%title插图%num

7月底 Compose for Android 1.0 刚刚发布,紧接着 8月4日 JetBrains 就宣布了 Compose Multiplatform 的*新进展,目前已进入 alpha 阶段。

Compose 作为一个声明式UI框架,除了渲染部分需借助平台能力以外,其他大部分特性可以做到平台无关。尤其是 Kotlin 这样一门跨平台语言,早就为日后的 UI 跨平台奠定了基础。

Compose Multiplatform 将整合现有的三个 Compose 项目:Android、Desktop、Web,未来可以像 Kotlin Multiplatform Project 一样,在一个工程下开发跨端应用,统一的声明式范式让代码在*大程度上实现复用,真正做到write once,run anywhere 。如今进入 alpah 阶段标志着其 API 也日渐成熟,相信不久的未来正式版就会与大家见面。

我们通过官方 todoapp 的例子,提前体验一下 Compose Multiplatform 的魅力 https://github.com/JetBrains/compose-jb/tree/master/examples/todoapp

%title插图%num

image.png

%title插图%num

todoapp 工程

  • todoapp
    • compose-ui :UI层可复用代码(兼容 Android 与 Desktop)
    • main:逻辑层可复用代码(首页)
    • edit:逻辑层可复用代码(编辑)
    • root:逻辑层入口、导航管理(main 与 eidt 间页面跳转)
    • utils:工具类
    • database:数据库
    • common:平台无关代码
    • android:平台相关代码,Activity等
    • desktop:平台相关代码,application等
    • web:平台相关,index.html等
    • ios:compose-ui 尚不支持 ios,但通过KMM配合SwiftUI可以实现iOS端代码

项目基于 Model-View-Intent(aka MVI) 打造,Model层、ViewModel层 代码几乎可以 100% 复用,View层在 desktop 和 Android 也可实现大部分复用,web 有一定特殊性需要单独适配。

%title插图%num

除了 Jetpack Compose 以外,项目中使用了多个基于 KM 的三方框架,保证了上层的开发范式在多平台上的一致体验:

%title插图%num

%title插图%num

todoapp 代码

平台入口代码

对比一下 Android端 与 Desktop端 的入口代码

  1. //todoapp/android/src/main/java/example/todo/android/MainActivity.ktclass MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
  2. val root = todoRoot(defaultComponentContext())
  3. setContent { ComposeAppTheme { Surface(color = MaterialTheme.colors.background) { TodoRootContent(root) } } } }
  4. private fun todoRoot(componentContext: ComponentContext): TodoRoot = TodoRootComponent( componentContext = componentContext, storeFactory = LoggingStoreFactory(TimeTravelStoreFactory(DefaultStoreFactory())), database = DefaultTodoSharedDatabase(TodoDatabaseDriver(context = this)) )}
  1. //todoapp/desktop/src/jvmMain/kotlin/example/todo/desktop/Main.kt
  2. fun main() { overrideSchedulers(main = Dispatchers.Main::asScheduler)
  3. val lifecycle = LifecycleRegistry() val root = todoRoot(DefaultComponentContext(lifecycle = lifecycle))
  4. application { val windowState = rememberWindowState() LifecycleController(lifecycle, windowState)
  5. Window( onCloseRequest = ::exitApplication, state = windowState, title = “Todo” ) { Surface(modifier = Modifier.fillMaxSize()) { MaterialTheme { DesktopTheme { TodoRootContent(root) } } } } }}
  6. private fun todoRoot(componentContext: ComponentContext): TodoRoot = TodoRootComponent( componentContext = componentContext, storeFactory = DefaultStoreFactory(), database = DefaultTodoSharedDatabase(TodoDatabaseDriver()) )
  • TodoRootContent:根Composable,View层入口
  • TodoRootComponent:根状态管理器,ViewModel层入口
    • DefaultStoreFactory:创建 Store,管理状态
    • DefaultTodoShareDatabase:M层,数据管理

TodoRootContent 和 TodoRootComponent 分别是 View 层和 ViewModel 层的入口,TodoRootComponent 管理着全局状态,即页面导航状态。

可以看到,Android 与 Desktop 在 View 、 VM 、M等各层都进行了大面积复用,

VM层代码

MVI 中虽然没有 ViewModel,但是有等价概念,从习惯出发我们暂且称之为 VM 层。VM层其实就是状态的管理场所,我们以首页的 mian 为例

  1. //todoapp/common/main/src/commonMain/kotlin/example/todo/common/main/integration/TodoMainComponent.kt
  2. class TodoMainComponent( componentContext: ComponentContext, storeFactory: StoreFactory, database: TodoSharedDatabase, private val output: Consumer<Output>) : TodoMain, ComponentContext by componentContext {
  3. private val store = instanceKeeper.getStore { TodoMainStoreProvider( storeFactory = storeFactory, database = TodoMainStoreDatabase(database = database) ).provide() }
  4. override val models: Value<Model> = store.asValue().map(stateToModel)
  5. override fun onItemClicked(id: Long) { output(Output.Selected(id = id)) }
  6. override fun onItemDoneChanged(id: Long, isDone: Boolean) { store.accept(Intent.SetItemDone(id = id, isDone = isDone)) }
  7. override fun onItemDeleteClicked(id: Long) { store.accept(Intent.DeleteItem(id = id)) }
  8. override fun onInputTextChanged(text: String) { store.accept(Intent.SetText(text = text)) }
  9. override fun onAddItemClicked() { store.accept(Intent.AddItem) }}

了解 MVI 的朋友对上面的代码应该非常熟悉,store 管理状态并通过 models 对UI暴露,所有数据流单向流动。Value<Model> 是 Decompose 库中的类型,可以理解为跨平台的 LiveData

View层代码

@Composablefun TodoRootContent(component: TodoRoot) {    Children(routerState = component.routerState, animation = crossfadeScale()) {        when (val child = it.instance) {            is Child.Main -> TodoMainContent(child.component)            is Child.Edit -> TodoEditContent(child.component)        }    }}

TodoRootContent内部很简单,就是根据导航切换不同的页面。

具体看一下TodoMainContent

  1. @Composablefun TodoMainContent(component: TodoMain) { val model by component.models.subscribeAsState()
  2. Column { TopAppBar(title = { Text(text = “Todo List”) })
  3. Box(Modifier.weight(1F)) { TodoList( items = model.items, onItemClicked = component::onItemClicked, onDoneChanged = component::onItemDoneChanged, onDeleteItemClicked = component::onItemDeleteClicked ) }
  4. TodoInput( text = model.text, onAddClicked = component::onAddItemClicked, onTextChanged = component::onInputTextChanged ) }}

subscribeAsState() 在 Composable 中订阅了 Models 的状态,从而驱动 UI 刷新。Column 、Box 等 Composalbe 在 Descktop 和 Android 端会分别进行平台渲染。

web端代码

*后看一下web端实现。

Compose For Web 的 Composalbe 大多基于 DOM 设计,无法像 Android 和 Desktop 的 Composable 那样复用,但是 VM 和 M 层仍然可以大量复用:

  1. //todoapp/web/src/jsMain/kotlin/example/todo/web/App.ktfun main() { val rootElement = document.getElementById(“root”) as HTMLElement
  2. val lifecycle = LifecycleRegistry()
  3. val root = TodoRootComponent( componentContext = DefaultComponentContext(lifecycle = lifecycle), storeFactory = DefaultStoreFactory(), database = DefaultTodoSharedDatabase(todoDatabaseDriver()) )
  4. lifecycle.resume()
  5. renderComposable(root = rootElement) { Style(Styles)
  6. TodoRootUi(root) }}

将 TodoRootComponent 传给 UI, 协助进行导航管理

  1. @Composablefun TodoRootUi(component: TodoRoot) { Card( attrs = { style { position(Position.Absolute) height(700.px) property(“max-width”, 640.px) top(0.px) bottom(0.px) left(0.px) right(0.px) property(“margin”, auto) } } ) { val routerState by component.routerState.subscribeAsState()
  2. Crossfade( target = routerState.activeChild.instance, attrs = { style { width(100.percent) height(100.percent) position(Position.Relative) left(0.px) top(0.px) } } ) { child -> when (child) { is TodoRoot.Child.Main -> TodoMainUi(child.component) is TodoRoot.Child.Edit -> TodoEditUi(child.component) } } }}
  3. TodoMainUi 的实现如下:
  1. @Composablefun TodoMainUi(component: TodoMain) { val model by component.models.subscribeAsState()
  2. Div( attrs = { style { width(100.percent) height(100.percent) display(DisplayStyle.Flex) flexFlow(FlexDirection.Column, FlexWrap.Nowrap) } } ) { Div( attrs = { style { width(100.percent) property(“flex”, “0 1 auto”) } } ) { NavBar(title = “Todo List”) }
  3. Ul( attrs = { style { width(100.percent) margin(0.px) property(“flex”, “1 1 auto”) property(“overflow-y”, “scroll”) } } ) { model.items.forEach { item -> Item( item = item, onClicked = component::onItemClicked, onDoneChanged = component::onItemDoneChanged, onDeleteClicked = component::onItemDeleteClicked ) } }
  4. Div( attrs = { style { width(100.percent) property(“flex”, “0 1 auto”) } } ) { TodoInput( text = model.text, onTextChanged = component::onInputTextChanged, onAddClicked = component::onAddItemClicked ) } }}

%title插图%num

*后

我曾介绍过 Compose 跨平台的技术基础,如今配合各种 KM 三方库,使得开发生态更加完整。Compose Multiplatform 全程基于 Kotlin 打造,上下游同构,相对于 Flutter 和 RN 更具优势,未来可期。