稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

揭秘原生View与Jetpack Compose之间的传送门

芳菲随春去,碧绿入夏来,不知不觉中Compose专题已经写了近40篇文章了,从Compose各组件的使用方法,到Compose的编程思想,再到内部原理和最佳实践。通过这一系列的文章相信对Compose已经有了足够的理解,能够在项目中进行实战和运用。学无止境,今天将继续学习,重点探讨如何在已有的项目中使用Compose。

缘起

无疑Jetpack Compose是一个优秀的声明式UI框架,它与原生的View方式最大的区别,在于思考问题的方式上并不一样。声明式框架能把开发者从繁杂的命令式的UI细节中解放出来,重点思考一个好的体验应该是什么样子的,而具体的UI细节由框架来处理。尽管如此,毕竟Compose是近几年来发展起来的,现今大量的项目仍是原生View主导的。此外,Compose也还在发展中,有些特定业务领域如Camera,视频,3D渲染,还没有能力支持。因此,整合原生View和Compose是项目中很现实的一个难题,本文将重点讨论两个议题:一个是如何在原生View中嵌入Compose,另一个就是如何在Compose中嵌入原生View。

注意: 本文中提到的两个组件ComposeView和AndroidView都仅在Jetpack Compose(for Android)生效,并不适用于跨平台的Compose Multiplatform。

在原生View中嵌入Compose

第一个传送门是如何进入Compose的世界。相信现在绝大多数项目都是基于原生View的,借助ComposeView就可以进入到Compose的世界。

1
2
3
4
5
val composeView = ComposeView(context).apply {
            setContent {
                // 这里调用Composables
            }
        }

ComposeView是View的一个子类,能够作为Compose的容器,在其setContent方法)中提供一个Composable即可。ComposeView与其他View一样,可以用在View tree中,用在Fragment里和Activity里面。实际上作为平台的入口ComponentActivity用的也是ComposeView。

在View层级中直接嵌入

ComposeView就是一个普通的Android View,跟其他View的子类是一样的,所以可以把它放在任何可以使用View的地方,比如一个布局里面,作为一个页面的一部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="WidgetButton"
        />
</LinearLayout>

注意: 尽管可以把ComposeView当成普通的View,直接嵌入到布局中,作为页面中的一部分,但这并不是一个好的做法,一来是不能发挥Compose的优势,另外Compose本身是有特定的生命周期的(重组),它需要知道平台的生命周期,以管控它自己的生命周期。而常规的View tree之中是没有平台生命周期的,因为常规的View tree并不关心平台的生命周期,view tree主要受窗口影响(attachToWindow,detachFromWindow),这个与平台组件的生命周期没有关系。

用在Fragment中

想要在某个Fragment中集成Compose的方式就是把ComposeView作为Fragment的根View即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ExampleFragmentNoXml : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            // 当View的宿主destroy时销毁组合
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
            setContent {
                MaterialTheme {
                    // 进入到Compose世界
                    Text("Hello Compose!")
                }
            }
        }
    }
}

用在Activity中

这其实是最好的方式,在一个新的页面窗口中使用Compose,这就能与其余view独立开来,是最为理想的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ExampleActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MaterialTheme {
                Greeting(name = "compose")
            }
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

使用建议

虽然ComposeView可以当成一个普通的View来使用,但最为合理的方式就是在一个新的Activity中才使用Compose,也就是说当有一个全新的页面时,考虑使用Compose来开发,这样才能发挥出它的价值。

除非有特别的需求,否则不要把ComposeView作为现有页面的一部分嵌入到View tree中(也就是作为页面的一部分)。

至于在Fragment中使用,如果是一个全新的页面,而非现有布局的一部分,那也可以考虑使用Compose。

注意: 其根本原因在于,我们使用Jetpack Compose并不是图它能实现什么特别的UI效果,Compose能做的事情View都能做,甚至它不能做的事情View也能做。用Compose是因为它是声明式的UI框架,在开发效率和可扩展性上面有巨大的优势。所以,只应该在想要发挥声明式框架优势的时候,才考虑使用它,并且应该从一个全新的页面开始。

在Compose中嵌入原生View

Jetpack Compose提供了足够丰富的组件,足以应对常规的UI,但它毕竟还不是特别的成熟,总会遇到一些场景,发现Compose无法胜任,而且并不是通过自定义组件就能够解决的,比如一些特定领域的UI,如camera,如视频,如3D渲染。或者说,已经有了自定义好的View,并不想重复开发。再或者说对于一些三方的库,它并没有对应的Compose组件。这些场景就需要把原生的View嵌入到Compose之中。

AndroidView)就是专门用于把原生View嵌入到Compose中的一个特殊composable。它就像一个传送门一样,能把原生的View,无论是一个现成的自定义View,还是特定领域的View或者三方库的View,带入到Compose中,变成一个composable。

AndroidView的使用方法

AndroidView是一个composable,把它放在想要的位置即可。它有三个参数,一个是常规的Modifier用以约束这个composable的;另两个是lambda,一个是用于创建View的,返回一个View的实例,只会被调用一次;另一个就是用于更新View的,会被调用多次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Composable
fun CustomView() {
    var selectedItem by remember { mutableStateOf(0) }

    // 添加原生View到Compose
    AndroidView(
        modifier = Modifier.fillMaxSize(),
        factory = { context ->
            // 创建View的实例
            MyView(context).apply {
                // 设置View的点击事件,更新状态,这会触发重组
                setOnClickListener {
                    selectedItem = 1
                }
            }
        },
        update = { view ->
            // 更新View的状态
            // 这里读了状态,所以重组时update会被再次调用,view能拿到最新的状态
            view.selectedItem = selectedItem
        }
    )
}

@Composable
fun ContentExample() {
    Column(Modifier.fillMaxSize()) {
        Text("Look at this CustomView!")
        CustomView()
    }
}

注意: factory仅会被调用一次,用于创建View实例,update会被调用多次,用于更新view的状态,包括初次组合时,也就是factory执行之后,就会调用update。AndroidView函数会帮助提供View需要的参数context,以及管理View的实例,所以update中会把view当作参数传给我们,所以我们完全没有必要再用额外的状态(remember)去缓存View的实例了。

使用建议

虽然AndroidView是一个传送门,可以连接两个世界,但是能不用还是不要用,非必要不使用。如果能用Compose搞定的事情,还是要用Compose来搞,比如用Canvas去实现自定义组件。

需要使用AndroidView的场景只有三个:一是有现成的自定义View,拿过来就可以用,不想二次开发;二是三方库的View;三就是Compose确实搞不定的特定领域,如WebView,如视频,如SurfaceView或者3D渲染(OpenGL ES)等等。除以之外,不建议使用。

还需要特别注意的是,如果原生的View交互比较复杂,不光是点击,还涉及Touch事件处理,处理事件的同时还要不断更新View的状态,那也不应该使用它。比较理想的情况是,嵌入的这个View是一个比较纯粹的生产者,比如它只产生事件,不需要再往回更新状态;或者是一个比较纯粹的消费者,比如它就负责展示,只需要塞数据就行了。

总结

网上的教程或者Demo中的世界是很美好的,往往都是一个新建的项目,一个新的页面,直接就进入了Compose世界,也都在讲Compose能做的事情。但现实的世界往往不是这样子的,极少情况下是全新开始的项目,往往需要与遗留代码打交道,需要实现的需求也是多种多样的。本文中介绍了两个传送门,ComposeView和AndroidView可以方便地连接原生View和Compose两个世界,为现实项目中遇到的问题提供了一个可行的解决方案。

让Compose支持OpenGL ES

References

Comments