基于Redux和Kotlin Multiplatform打造跨平台移动应用

在这里插入图片描述
客户端的跨平台技术早已屡见不鲜,在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安卓应用工程文件
|-- iOSiOS应用工程文件
|-- shared共享代码文件
   |-- commonMain共享逻辑
      |-- database本地数据管理
      |-- network远程数据管理
      |-- Reduxredux相关:action、reducer、middleware等
      |-- uiMVP的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本身的使用及原理的介绍不多,留待今后单独撰文深入分析,有兴趣的朋友可以持续关注。

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页