不止 Android!Compose Multiplatform 來了

fundroid發表於2021-08-10

這是我參與8月更文挑戰的第2天,活動詳情檢視:8月更文挑戰

7月底 Compose for Android 1.0 剛剛釋出,緊接著 8月4日 JetBrains 就宣佈了 Compose Multiplatform 的最新進展,目前已進入 alpha 階段。

Compose 作為一個宣告式UI框架,除了渲染部分需藉助平臺能力以外,其他大部分特性可以做到平臺無關。尤其是 Kotlin 這樣一門跨平臺語言,早就為日後的 UI 跨平臺奠定了基礎。

Compose Multiplatform 將整合現有的三個 Compose 專案:AndroidDesktopWeb,未來可以像 Kotlin Multiplatform Project 一樣,在一個工程下開發跨端應用,統一的宣告式正規化讓程式碼在最大程度上實現複用,真正做到write once,run anywhere 。如今進入 alpah 階段標誌著其 API 也日漸成熟,相信不久的未來正式版就會與大家見面。

我們通過官方 todoapp 的例子,提前體驗一下 Compose Multiplatform 的魅力 github.com/JetBrains/c…

image.png

todoapp 工程

  • todoapp
    • common:平臺無關程式碼
      • compose-ui :UI層可複用程式碼(相容 Android 與 Desktop)
      • main:邏輯層可複用程式碼(首頁)
      • edit:邏輯層可複用程式碼(編輯)
      • root:邏輯層入口、導航管理( main 與 eidt 間頁面跳轉)
      • utils:工具類
      • database:資料庫
    • 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 有一定特殊性需要單獨適配。

除了 Jetpack Compose 以外,專案中使用了多個基於 KM 的三方框架,保證了上層的開發正規化在多平臺上的一致體驗:

KM三方庫說明
Decompose資料通訊(BLoC)
MVIKotlin跨平臺MVI
Rektive非同步響應式庫
SQLDelight資料庫

todoapp 程式碼

平臺入口程式碼

對比一下 Android端 與 Desktop端 的入口程式碼

//todoapp/android/src/main/java/example/todo/android/MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val root = todoRoot(defaultComponentContext())

        setContent {
            ComposeAppTheme {
                Surface(color = MaterialTheme.colors.background) {
                    TodoRootContent(root)
                }
            }
        }
    }

    private fun todoRoot(componentContext: ComponentContext): TodoRoot =
        TodoRootComponent(
            componentContext = componentContext,
            storeFactory = LoggingStoreFactory(TimeTravelStoreFactory(DefaultStoreFactory())),
            database = DefaultTodoSharedDatabase(TodoDatabaseDriver(context = this))
        )
}
複製程式碼
//todoapp/desktop/src/jvmMain/kotlin/example/todo/desktop/Main.kt

fun main() {
    overrideSchedulers(main = Dispatchers.Main::asScheduler)

    val lifecycle = LifecycleRegistry()
    val root = todoRoot(DefaultComponentContext(lifecycle = lifecycle))

    application {
        val windowState = rememberWindowState()
        LifecycleController(lifecycle, windowState)

        Window(
            onCloseRequest = ::exitApplication,
            state = windowState,
            title = "Todo"
        ) {
            Surface(modifier = Modifier.fillMaxSize()) {
                MaterialTheme {
                    DesktopTheme {
                        TodoRootContent(root)
                    }
                }
            }
        }
    }
}

private fun todoRoot(componentContext: ComponentContext): TodoRoot =
    TodoRootComponent(
        componentContext = componentContext,
        storeFactory = DefaultStoreFactory(),
        database = DefaultTodoSharedDatabase(TodoDatabaseDriver())
    )
複製程式碼
  • TodoRootContent:根Composable,View層入口
  • TodoRootComponent:根狀態管理器,ViewModel層入口
    • DefaultStoreFactory:建立 Store,管理狀態
    • DefaultTodoShareDatabase:M層,資料管理

TodoRootContentTodoRootComponent 分別是 View 層和 ViewModel 層的入口,TodoRootComponent 管理著全域性狀態,即頁面導航狀態。

可以看到,Android 與 Desktop 在 View 、 VM 、M等各層都進行了大面積複用,

VM層程式碼

MVI 中雖然沒有 ViewModel,但是有等價概念,從習慣出發我們暫且稱之為 VM 層。 VM層其實就是狀態的管理場所,我們以首頁的 mian 為例

//todoapp/common/main/src/commonMain/kotlin/example/todo/common/main/integration/TodoMainComponent.kt

class TodoMainComponent(
    componentContext: ComponentContext,
    storeFactory: StoreFactory,
    database: TodoSharedDatabase,
    private val output: Consumer<Output>
) : TodoMain, ComponentContext by componentContext {

    private val store =
        instanceKeeper.getStore {
            TodoMainStoreProvider(
                storeFactory = storeFactory,
                database = TodoMainStoreDatabase(database = database)
            ).provide()
        }

    override val models: Value<Model> = store.asValue().map(stateToModel)

    override fun onItemClicked(id: Long) {
        output(Output.Selected(id = id))
    }

    override fun onItemDoneChanged(id: Long, isDone: Boolean) {
        store.accept(Intent.SetItemDone(id = id, isDone = isDone))
    }

    override fun onItemDeleteClicked(id: Long) {
        store.accept(Intent.DeleteItem(id = id))
    }

    override fun onInputTextChanged(text: String) {
        store.accept(Intent.SetText(text = text))
    }

    override fun onAddItemClicked() {
        store.accept(Intent.AddItem)
    }
}
複製程式碼

瞭解 MVI 的朋友對上面的程式碼應該非常熟悉,store 管理狀態並通過 models 對UI暴露,所有資料流單向流動。 Value<Model> Decompose 庫中的型別,可以理解為跨平臺的 LiveData

View層程式碼

@Composable
fun 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

@Composable
fun TodoMainContent(component: TodoMain) {
    val model by component.models.subscribeAsState() 

    Column {
        TopAppBar(title = { Text(text = "Todo List") })

        Box(Modifier.weight(1F)) {
            TodoList(
                items = model.items,
                onItemClicked = component::onItemClicked,
                onDoneChanged = component::onItemDoneChanged,
                onDeleteItemClicked = component::onItemDeleteClicked
            )
        }

        TodoInput(
            text = model.text,
            onAddClicked = component::onAddItemClicked,
            onTextChanged = component::onInputTextChanged
        )
    }
}
複製程式碼

subscribeAsState() 在 Composable 中訂閱了 Models 的狀態,從而驅動 UI 重新整理。ColumnBox 等 Composalbe 在 Descktop 和 Android 端會分別進行平臺渲染。

web端程式碼

最後看一下web端實現。

Compose For Web 的 Composalbe 大多基於 DOM 設計,無法像 Android 和 Desktop 的 Composable 那樣複用,但是 VM 和 M 層仍然可以大量複用:

//todoapp/web/src/jsMain/kotlin/example/todo/web/App.kt
fun main() {
    val rootElement = document.getElementById("root") as HTMLElement

    val lifecycle = LifecycleRegistry()

    val root =
        TodoRootComponent(
            componentContext = DefaultComponentContext(lifecycle = lifecycle),
            storeFactory = DefaultStoreFactory(),
            database = DefaultTodoSharedDatabase(todoDatabaseDriver())
        )

    lifecycle.resume()

    renderComposable(root = rootElement) {
        Style(Styles)

        TodoRootUi(root)
    }
}
複製程式碼

TodoRootComponent 傳給 UI, 協助進行導航管理

@Composable
fun 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()

        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)
            }
        }
    }
}
複製程式碼

TodoMainUi 的實現如下:

@Composable
fun TodoMainUi(component: TodoMain) {
    val model by component.models.subscribeAsState()

    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")
        }

        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
                )
            }
        }

        Div(
            attrs = {
                style {
                    width(100.percent)
                    property("flex", "0 1 auto")
                }
            }
        ) {
            TodoInput(
                text = model.text,
                onTextChanged = component::onInputTextChanged,
                onAddClicked = component::onAddItemClicked
            )
        }
    }
}
複製程式碼

最後

Jetpack Compose Runtime : 宣告式 UI 的基礎 一文中,我曾介紹過 Compose 跨平臺的技術基礎,如今配合各種 KM 三方庫,使得開發生態更加完整。 Compose Multiplatform 全程基於 Kotlin 打造,上下游同構,相對於 Flutter 和 RN 更具優勢,未來可期。

相關文章