客户端的跨平台技术早已屡见不鲜,在UI层面,native开发在用户体验等方面仍然占据优势;但是在逻辑层,通过Kotlin Multiplatform等跨平台技术确实可以通过维护一套代码提高开发效率。
引入跨平台技术后,该如何选择一个适合的开发范式也成为了新的课题。近期有国外同行通过一个Sample App提出了使用ReduxKotlin打造Kotlin跨平台APP的思路,或许值得大家借鉴。
原文地址:https://blog.dreipol.ch/trash-disposal-with-kotlin-multiplattform-12abb5b5eb2c
1. 实例项目
文章里通过对一个Sample App的分析,介绍基于Redux打造kotlin跨平台架构的实现即优势。https://github.com/dreipol/multiplatform-redux-sample
Sample中有导航、Setting页、列表页等多种常见页面,各页面本质上都可以拆分为UI层和Model层,然后基于Redux实现UI与Model间的通信
2. 项目结构
目录结构符合标准的KMM(kotlin multiplatform mobile)项目要求:
Project | ||
---|---|---|
\ | -- app | 安卓应用工程文件 |
\ | -- iOS | iOS应用工程文件 |
\ | -- shared | 共享代码文件 |
\ | -- commonMain | 共享逻辑 |
\ | -- database | 本地数据管理 |
\ | -- network | 远程数据管理 |
\ | -- Redux | redux相关:action、reducer、middleware等 |
\ | -- ui | MVP的UI层逻辑:View、Presenter等 |
\ | -- androidMain | 需要由android实现的expect |
\ | -- iosMain | 需要由ios实现的expect的kotlin代码 |
\ | -- commonTest | 多平台测试 |
\ | -- ... |
依托Redux对UI层和逻辑层进行解耦:
- 业务逻辑、数据请求以及一部分共通功能的UI逻辑(navigation/routing等)下沉shared
- UI的刷新在native中实现
3. 逻辑层:Redux & Presenter
除了Redux外,引入了Presenter负责UI的刷新。Redux与Presenter的分工如下:
- Store:管理全局状态(AppState),包含各种subState,例如各页面的ViewState、页面跳转用的NavigationState等,Store中的Reducer会根据Action计算新的State
- ViewState:变化后的State被分发到各页面对应的Presenter
- Presenter:作为共同逻辑在shared中,订阅AppState变化,针对性的使用SubState驱动native端UI刷新
- Navigator:可以看作是一个特殊的Presenter,在shared负责页面切换,驱动native进行实际的页面跳转
Redux引入Presenter有以下好处:
- 对State分散管理,减轻Store的负担,将SubState针对性地发送给对应的View
- UI不关心state的订阅,只提供render方法即可,复用性大大提高。
Presenter只是选项之一,也可替换为ViewModel等其他方案。
4. UI层:Views
以Setting页为例介绍一下View的实现:
Shared
SettingsViewState
中包含了Setting页的所有状态以及二级页面的subViewState
。各Presenter订阅ViewState,当State变化时调用View的对应方法刷新UI。
//SettinsView.kt
data class SettingsViewState(
val titleKey: String = "settings_title",
val settings: List<SettingsEntry> = listOf(
SettingsEntry("settings_zip", NavigationAction.ZIP_SETTINGS),
SettingsEntry("settings_notifications", NavigationAction.NOTIFICATION_SETTINGS),
SettingsEntry("settings_calendar", NavigationAction.CALENDAR_SETTINGS),
SettingsEntry("settings_language", NavigationAction.LANGUAGE_SETTINGS)
),
val zipSettingsViewState: ZipSettingsViewState = ZipSettingsViewState(),
val calendarSettingsViewState: CalendarSettingsViewState = CalendarSettingsViewState(),
val notificationSettingsViewState: NotificationSettingsViewState = NotificationSettingsViewState(),
val languageSettingsViewState: LanguageSettingsViewState = LanguageSettingsViewState(),
)
data class SettingsEntry(val descriptionKey: String, val navigationAction: NavigationAction)
interface SettingsView : BaseView {
override fun presenter() = settingsPresenter
fun render(settingsViewState: SettingsViewState)
}
val settingsPresenter = presenter<SettingsView> {
{
select({ it.settingsViewState }) { render(state.settingsViewState) }
}
}
Native: Android & iOS
Android的Fragment以及iOS的ViewController负责页面的具体实现,提供render
方法针对ViewState渲染UI:
- Android侧:
//SettingsFragment.kt
class SettingsFragment : BaseFragment<FragmentSettingsBinding, SettingsView>(), SettingsView {
override val presenterObserver = PresenterLifecycleObserver(this)
private lateinit var adapter: SettingsListAdapter
override fun createBinding(): FragmentSettingsBinding {
return FragmentSettingsBinding.inflate(layoutInflater)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
adapter = SettingsListAdapter(listOf(), requireContext())
viewBinding.settings.adapter = adapter
return view
}
override fun render(settingsViewState: SettingsViewState) {
viewBinding.title.text = requireContext().getString(settingsViewState.titleKey)
adapter.settings = settingsViewState.settings
adapter.notifyDataSetChanged()
}
}
- iOS侧:
//SettingsViewController.swift
class SettingsViewController: PresenterViewController<SettingsView>, SettingsView {
override var viewPresenter: Presenter<SettingsView> { SettingsViewKt.settingsPresenter }
private let titleLabel = UILabel.h2()
private let settingsTableView = UIStackView.autoLayout(axis: .vertical)
private var allSettings: [SettingsEntry] = []
override init() {
super.init()
vStack.addSpace(kUnit3)
titleLabel.textAlignment = .left
vStack.addArrangedSubview(titleLabel)
vStack.addSpace(kUnit3)
let backgroundView = UIView.autoLayout()
backgroundView.backgroundColor = .white
backgroundView.layer.cornerRadius = kCardCornerRadius
settingsTableView.layer.addShadow()
settingsTableView.addSubview(backgroundView)
backgroundView.fitSuperview()
vStack.addArrangedSubview(settingsTableView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func render(settingsViewState: SettingsViewState) {
titleLabel.text = settingsViewState.titleKey.localized
allSettings = settingsViewState.settings
settingsTableView.removeAllArrangedSubviews()
//Since we hide the licence item, there is one item less
let lastIndex = allSettings.count - 2
for (index, item) in allSettings.enumerated() where item.navigationAction != NavigationAction.licences {
let control = SettingsEntryControl(model: item, isLast: index == lastIndex)
settingsTableView.addArrangedSubview(control)
}
}
}
extension SettingsViewController: TabBarCompatible {
var tabBarImageName: String { "ic_30_settings" }
}
5. 页面跳转:Navigator
Sample中有两种页面切换逻辑
- 首次启动时,需要通过向导页进行初始设定(step by step),这是一个线性有序的页面跳转逻辑
- 进入主界面后,通过BottomBar,进行选项卡切换,这是无序的页面跳转逻辑
- 两种逻辑都支持Back回到前一页面
两种逻辑都是APP中常见的页面跳转场景,都可以通过Redux的state驱动实现。
Shared
- Screen:代表页面类型;
interface Screen {}
- MainScreen: 使用枚举定义进入Home之后的所有页面
enum class MainScreen : Screen {
DASHBOARD,
INFORMATION,
SETTINGS,
ZIP_SETTINGS,
CALENDAR_SETTINGS,
NOTIFICATION_SETTINGS,
LANGUAGE_SETTINGS,
}
- OnboardingScreen:用于开机向导页逻辑中,通过step标记向导页中的顺序
data class OnboardingScreen(val step: Int = 1) : Screen
- NavigationState:使用List代表回退栈,last位置即栈顶(当前页面)
data class NavigationState(val screens: List<Screen>, val navigationDirection: NavigationDirection) {
val currentScreen = screens.last()
}
enum class NavigationDirection {
PUSH,
POP
}
- NavigationAction: 定义所有触发页面跳转的actions
enum class NavigationAction {
BACK,
DASHBOARD,
INFO,
SETTINGS,
ZIP_SETTINGS,
CALENDAR_SETTINGS,
NOTIFICATION_SETTINGS,
LANGUAGE_SETTINGS,
ONBOARDING_START,
ONBOARDING_NEXT,
ONBOARDING_END
}
NavigationReducer
中,通过action与当前state计算新的state:
//NavigationReducer.kt
val navigationReducer: Reducer<NavigationState> = { state, action ->
when (action) {
NavigationAction.BACK -> {
val screens = state.screens.toMutableList()
if (screens.size == 1) {
return state
}
screens.removeAt(screens.lastIndex)
state.copy(screens = screens, navigationDirection = NavigationDirection.POP)
}
NavigationAction.SETTINGS -> {
val screens = state.screens.toMutableSet()
val screens = screens.add(MainScreen.SETTINGS)
state.copy(screens = screens, navigationDirection = NavigationDirection.PUSH)
}
NavigationAction.ONBOARDING_NEXT -> {
val screens = state.screens.toMutableList()
val lastScreen = screens.last() as OnboardingScreen
screens.add(OnboardingScreen(lastScreen.step + 1))
state.copy(screens = screens, navigationDirection = NavigationDirection.PUSH)
}
...
}
}
如上,
- BACK:返回前一页,移除栈顶的screen;
- SETTINGS:跳转页面,MainScreen.SETTINGS被压栈;
- ONBOARDING_NEXT:OnboardingScreen压栈,step递增
Native:Android & iOS
Native侧实现具体的页面跳转和回退逻辑。
- Android: 在MainActivity中负责跳转
//MainActivity.kt
//updateNavigationState是Navigator接口的方法
override fun updateNavigationState(navigationState: NavigationState) {
if (navigationState.screens.isEmpty()) {
return
}
val navController = findNavController(R.id.main_nav_host_fragment)
val backStack = navController.getBackStackList()
val expectedScreen = navigationState.screens.last()
val expectedDestinationId = screenToResourceId(expectedScreen)
if (navController.currentDestination?.id != expectedDestinationId) {
navController.navigate(
expectedDestinationId, createBundle(expectedScreen),
buildNavOptions(expectedDestinationId, navigationState, backStack)
)
}
}
private fun screenToResourceId(screen: Screen): Int {
if (screen is OnboardingScreen) {
return R.id.onboardingNavigatorFragment
}
return when (screen) {
MainScreen.CALENDAR, MainScreen.INFORMATION, MainScreen.SETTINGS -> R.id.mainFragment
MainScreen.CALENDAR_SETTINGS -> R.id.disposalTypesFragment
MainScreen.ZIP_SETTINGS -> R.id.zipSettingsFragment
MainScreen.NOTIFICATION_SETTINGS -> R.id.notificationSettingsFragment
MainScreen.LANGUAGE_SETTINGS -> R.id.languageSettingsFragment
MainScreen.LICENCES -> R.id.licenceFragment
else -> throw IllegalArgumentException()
}
}
我们希望所有的页面切换是经过state驱动的,但是native端的一些三方库(例如Android端的Navigation)无需state驱动也可自动响应Back事件。虽然如此,为了保证state正确性,仍然需要在收到Back事件时,更新状态:
//MainActivity.kt
override fun onBackPressed() {
super.onBackPressed()
rootDispatch(NavigationAction.BACK)
}
- iOS: 使用Coordinator设计模式处理页面导航
//NavigationCoordinator.swift
class NavigationCoordinator: Navigator, Coordinator {
func getNavigationState() -> NavigationState {
return store.appState.navigationState
}
let store: Store
lazy var onboardingCoordinator: OnboardingCoordinator = {
OnboardingCoordinator(root: self)
}()
lazy var mainCoordinator: MainCoordinator = {
MainCoordinator(root: self)
}()
var state: NavigationState {
return getNavigationState()
}
var window: UIWindow?
var windowStrong: UIWindow {
guard let window = window else {
fatalError("Window is nil")
}
return window
}
var rootViewController: UIViewController? {
get { windowStrong.rootViewController }
set {
windowStrong.rootViewController = newValue
windowStrong.makeKey()
}
}
init(store: Store) {
self.store = store
}
func setup(window: UIWindow?) {
self.window = window
NavigatorKt.subscribeNavigationState(self)
updateNavigationState(navigationState: state)
}
func updateNavigationState(navigationState: NavigationState) {
print(navigationState)
switch navigationState.screens.last {
case is OnboardingScreen:
onboardingCoordinator.updateNavigationState(navigationState: navigationState)
case is MainScreen:
mainCoordinator.updateNavigationState(navigationState: navigationState)
default:
fatalError("Implement")
}
}
}
- OnboardingCoordinator:处理向导页中的UIPageViewController的显示
- MainCoordinator:处理主界面各个ViewController的显示
- MainViewController:作为
UITabBarController
,仅用来更新导航的state
6. 数据层:Database & networking
使用SQLDelight
进行本地数据管理;使用ktor
进行远程数据访问。异步请求通过Thunks
的action发起
如上,Thunks的actions被分发到Middleware后,进行异步数据请求。
7. 单元测试
Redux天然对单测友好,我们只要关心State是否符合预期即可。
class NavigationReducerTest {
@Test
fun testOnboardingNavigation() {
var navigationState = initialTestAppState.navigationState
navigationState = navigationReducer(navigationState, NavigationAction.ONBOARDING_START)
assertEquals(1, navigationState.screens.size)
var lastScreen = navigationState.screens.last() as OnboardingScreen
assertEquals(1, lastScreen.step)
navigationState = navigationReducer(navigationState, NavigationAction.ONBOARDING_NEXT)
assertEquals(2, navigationState.screens.size)
lastScreen = navigationState.screens.last() as OnboardingScreen
assertEquals(2, lastScreen.step)
navigationState = navigationReducer(navigationState, NavigationAction.BACK)
assertEquals(1, navigationState.screens.size)
lastScreen = navigationState.screens.last() as OnboardingScreen
assertEquals(1, lastScreen.step)
navigationState = navigationReducer(navigationState, NavigationAction.ONBOARDING_END)
assertEquals(1, navigationState.screens.size)
assertEquals(MainScreen.CALENDAR, navigationState.screens.last())
}
}
例如对Navigation的测试,只要编写NavigationState的测试,不涉及UI层的任何mock
8. 总结
Redux已经被前端证明了,是非常适合UI类型的APP的开发范式。基于ReduxKotlin,将核心的状态管理放在shared进行,可以有效降低数据层、逻辑层的开发量以及测试方面的工足量。UI层在native侧仅仅负责渲染而不处理任何业务逻辑,保证了用户体验的同时,可以灵活的替换和服用。
本文通过一个Sample介绍了ReduxKotlin打造跨平台应用的基本思路,对于ReduxKotlin本身的使用及原理的介绍不多,留待今后单独撰文深入分析,有兴趣的朋友可以持续关注。