基於ReduxKotlin打造KMM跨平臺移動應用

語言: CN / TW / HK

在這裡插入圖片描述 客戶端的跨平臺技術早已屢見不鮮,在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本身的使用及原理的介紹不多,留待今後單獨撰文深入分析,有興趣的朋友可以持續關注。