【译】未来的APP:声明式UI+Kotlin跨平台(D-KMP)(下)

1,578 阅读8分钟

The future of apps:Declarative UIs with Kotlin MultiPlatform (D-KMP) — Part 3/3
作者:Sahil Sharma
译者:不想翻身的鱼

原文链接

基于声明式UI,Kotlin跨平台和MVI模式,分三篇文章来讲述新的D-KMP架构。

第一篇:D-KMP架构和声明式UI

第二篇:Kotlin跨平台和MVI模式

第三篇:D-KMP的分层和团队组织

最近更新:2021年5月18日

1_qOiRVZEPsR5FrURz73XuBA.png

D-KMP架构下的ViewModel类

在我们的D-KMP架构下,ViewModel是一个Kotlin跨平台的类,它有5个组件:

class DKMPViewModel(repo: Repository) {
    val stateFlow: StateFlow<AppState>
        get() = stateManager.mutableStateFlow
    private val stateManager by lazy { StateManager(repo) }
    val navigation by lazy { Navigation(stateManager) }
    val stateProvider by lazy { StateProvider(stateManager) }
    val events by lazy { Events(stateManager) }
}

简单的描述一下这几个组件。

StateFlow

ViewModel里面的StateFlow是负责触发UI层进行重组的组件,每当它的值发生改变就会触发UI重组。

从上面的定义你可以发现,它的类型是一个叫AppState的数据类,定义的非常简单只持有了一个recompositionIndex的属性:

data class AppState (
    val recompositionIndex : Int = 0
)

StateFlow是一个只读组件,从读/写版本中读取自己的值(通过一个getter的属性计算),MutableStateFlow被定义为StateManager组件的一个属性。

StateManager

StateManager是我们ViewModel的核心类。它管理者屏幕的状态和协程的scope。它还持有了MutableStateFlow这个负责修改AppState这个StateFlow的值然后触发UI的重组。

class StateManager(repo: Repository) {
  internal val mutableStateFlow = MutableStateFlow(AppState())
  val screenStatesMap : MutableMap<ScreenIdentifier, ScreenState> = mutableMapOf()
  val screenScopesMap : MutableMap<ScreenIdentifier,CoroutineScope> = mutableMapOf()
  
  internal val dataRepository by lazy { repo }
  fun triggerRecomposition() {
      mutableStateFlow.value = AppState(mutableStateFlow.value.recompositionIndex+1)
  }
}

Navigation

Navigation被所有平台共享,很好的保证了多平台的一致性。 也正是因为这个,不同平台的页面可以用几乎相同的语法来定义。下面贴上了我们实例工程maste/detail里面的的页面定义(github仓库的链接在本文最后)。 Compose上面(Kotlin):

1_3tjBc_hNKZFSsMvDfxGPAA.png

SwiftUI上面(Swift):

1_FHoLB8muQjffBB-f48UmZA.png

StateProvider

StateProvider给页面提供状态。UI重组的时候会调用,并且从StateManager的screenStatesMap里面获取数据。

...
  Screen.CountriesList -> CountriesListScreen (
    countriesListState = stateProviders.get(screenIdentifier)
    ...
  )
...

Events

Events是定义在共享代码里面的函数,可以被各自平台的UI层调用。它们通常是执行一些能盖面APP状态的操作,然后触发新UI的重组。

...
  Screen.CountriesList -> CountriesListScreen (
      ...
      onFavoriteIconClick = { events.selectFavorite(countryName = it) }
  )
...

在文章的最后我贴了github仓库链接,可以把代码拉下来自己跑一下可能更容易理解这些组件。

1_bPZlgBPj5P7CZu22TumPvw.png

ViewModel 文件的组织

1_bpy6dfKU6MIAXOuYWzMWyQ.png

平台特性代码

关于平台特性的代码,首先我们需要通过平台特性的工厂方法创建一个DKMPViewModel的实例。

在Android侧,需要把applicationContext作为参数传进去。因为在Android的框架下很多东西没有应用上下文无法工作,比如sqlite数据库。

DKMPViewModel.Factory.getAndroidInstance(context)

在iOS侧,我们不需要任何参数。

DKMPViewModel.Factory.getIosInstance()

收集数据流(StateFlow)

我们架构里面非常重要的一个一部分就是StateFlow(跟平台无关的观察者),它可以通过监听AppState状态的变化,来触发UI层的重组(recomposition)。

在我们的框架里面你需要记住一点,AppState就是一个recompositionIndex的值,每次StateManager调用triggerRecomposition()会让它的值加+1。

每次进行重组,UI都能通过StateProvider拿到一个新的状态。

接下来我们看一下如何在各个平台上配置StateFlow。

Android侧

在Android上📱StateFlow非常的直接,一行代码就能搞定。是因为Android团队已经实现了StateFlow里面的collectAsState()方法作为Jetpack Compose这个库的一部分。

lass DKMPApp : Application() {
    lateinit var model: DKMPViewModel
    override fun onCreate() {
        super.onCreate()
        model = DKMPViewModel.Factory.getAndroidInstance(this)
    }
}
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val model = (application as DKMPApp).model
        setContent {
            MyTheme {
                MainComposable(model)
            }
        }
    }
}
@Composable
fun MainComposable(model: DKMPViewModel) {
    val appState by model.stateFlow.collectAsState()
    val dkmpNav = appState.getNavigation(model)
    dkmpNav.Router()
}

iOS侧

目前在iOS上收集数据流还需要写一些模板代码,但是我们希望JetBrains 能尽快优化一下。我们已经提了issue,你可以投个票。 同时我们需要初始化一个“listener”在平台特性的ViewModel上:

class AppObservableObject: ObservableObject {
    let model : DKMPViewModel = DKMPViewModel.Factory().getIosInstance()
    var dkmpNav : Navigation {
        return self.appState.getNavigation(model: self.model)
    }
   @Published var appState : AppState = AppState()
    init() {
        model.onChange { newState in
            self.appState = newState
        }
    }
}
@main
struct iosApp: App {
    @StateObject var appObj = AppObservableObject()
    var body: some Scene {
        WindowGroup {
            MainView(appObj: appObj)
        }
    }
}
struct MainView: View {
    @ObservedObject var appObj: AppObservableObject
    var body: some View {
        let dkmpNav = appObj.dkmpNav
        dkmpNav.router()
    }
}

onChange是作为一个扩展函数加到ViewModel上面,只有iOS需要。

fun DKMPViewModel.onChange(provideNewState: ((AppState) -> Unit)) : Closeable {
    val job = Job()
    stateFlow.onEach {
        provideNewState(it)
    }.launchIn(
        CoroutineScope(Dispatchers.Main + job)
    )
    return object : Closeable {
        override fun close() {
            job.cancel()
        }
    }
}

D-KMP数据层

在我们的D-KMP架构下,我们希望ViewModel和数据层是完全分离的。

1_CukRuyhOgE7pPXw1B7REcQ.png

数据层由Repository组成,Repository从各种数据源获取数据,比如webservice,运行时对象,平台配置,平台服务,sqlite数据库,实时数据库,本地文件等等。

Repository也负责管理本地的缓存机制,这样也能保证更好的做到只写一次,在各个平台上都能运行。不需要再各自平台再实现一遍。

Repository的角色是负责处理数据然后将未经格式化的数据给ViewModel,在ViewModel里面对数据进行格式化。

ViewModel不需要关心数据源,数据源或者缓存都是由Repository来负责管理。

KMP库

在KMP里面,我们当然不能用平台特有的库。但是也不用担心,因为新的KMP库会比旧的库做的更好。有的完全是用Kotlin重写了,有的可能是底层对原生库的封装。不管怎么样,你可以不用关心它具体是怎么实现的。

下面这个面,总结一些重要库目前的KMP支持情况:

1_MMObIMj_egbSAt83rYTZ-A.png

对于声明式UI,StateFlow和Coroutines。我们快速额过一下其他几个吧:

  • Ktor Http Client 由JetBrains开发,可以说是目前最好的KMP网路库。它在给各自平台封装了原生的Http client。
  • Serialization 由JetB开发,是一个非常简单的序列化数据的库。经常和Ktor Http client 一起使用,用来解析JSON数据。
  • SqlDelight,由Square开发。提供跨平台的本地SQLite数据库解决方案。
  • 跨平台设置,由Russell Wolf (TouchLab)开发。Android侧是对SharedPreferences的封装,iOS是NSUserDefaults,JS是Storage。

除了已经存在的KMP库,任何人都可以自己开发一个KMP的库。用expect/actual 的特性来实现,这样就能将各自平台特性给封装起来。

有一些KMP库可能马上会发布:

  • Location,对Android和iOS定位服务API的封装。
  • Bluetooth,对Android和iOS蓝牙服务API的封装(更新:本文发布的时候,BLE的KMP库已经发布了)
  • In-App-Purchases,包装Google Play和appStore的In-App-PurchasesAPI
  • Firebase,官方的KMP实现比如Analytics,FireStore,Authentication等。不过也不用担心已经有三方对整个Firebase做了封装。

现在让我们看一下D-KMP架构下怎么来组织开发团队,其实跟传统的APP开发还是有比较大的不同的。 我们有4个主要的角色:

  • 声明式UI 开发(JetpackCompose和SwiftUI)
  • ViewModel开发(KMP)
  • 数据层开发(KMP)
  • 后端开发(Kotlin/JVM? Gloang?)

1_4XIwe5-ASuUPmG_YKtM7RQ.png

声明式UI开发

我们坚信在D-KMP的团队里面,UI开发应该是跨平台的,负责实现Jetpack Compose和SwiftUI的实现。考虑从到声明式UI框架的简单特点,同一个开发者来负责两端完全是可行的(也是有趣的)。聚焦到两个框架上,可以让开发者能更好的理解UI的发展趋势,以及实现更好的用户体验。

ViewModel 开发

这个角色非常重要,某种程度上需要承担一些管理的任务。是整个开发过程中的中心位置,需要对整个工程很了解。ViewModel的开发者需要同UI开发者一起定义所有页面状态的对象,还要和数据层开发者一起定义需要的数据。 同时ViewModel开发者还需要组织国际化的问题。

数据层(DataLayer)开发

这是一个对技术要求非常高的角色。数据层开发需要处理所有跟数据有关的东西,包括数据的缓存机制等等。数据开发需要组织好所有的数据,有时候甚至包括平台特性的数据源,比如定位或者蓝牙服务。这个角色需要对Kotlin跨平台非常熟悉,有时候设置需要写一些自定义的跨平台库。

后端(Backend)开发

在D-KMP的团队里面,这个角色仍然重要,因为需要和所有的app团队进行合作(Android,iOS,Web),但是又没有平台特性开发那么重要。 在D-KMP架构下,后端开关直接和数据层开发打交道。数据层开发把APP需要哪些数据定好。后端开发不需要了解具体APP层发生了什么。Webservice可以用任意的语言开发,比如Golang。如果你对Kotlin全栈感兴趣的话,也可以用Ktor这个框架,用的Kotlin/JVM技术。在服务端用Kotlin来写还有另外一个好处,可以让APP的数据层和服务端共用数据类的定义。

文章到此就结束了,感谢大家的阅读。 示例工程