Cross-platform technology of client has long been common. At the UI level, native development still has advantages in user experience and other aspects. But at the logical level, throughKotlin MultiplatformSuch cross-platform technologies can indeed improve development efficiency by maintaining a single set of code.

With the introduction of cross-platform technology, how to choose a suitable development paradigm has become a new topic. Recently, a foreign peer put forward the idea of using ReduxKotlin to build Kotlin cross-platform App through a Sample App, which may be worth learning from.

Dreipol. ch/trash-dispo…

1. Example project


Through the analysis of a Sample App, this article introduces the realization advantages of kotlin cross-platform architecture based on Redux. Github.com/dreipol/mul…

There are various common pages in Sample, such as navigation page, Setting page, list page, etc. All pages can be divided into UI layer and Model layer in essence, and then the communication between UI and Model can be realized based on Redux

2. Project structure


**KMM (Kotlin Multiplatform Mobile) ** Project requirements:

Project
| – app Android application project files
| – iOS IOS application engineering files
| – Shared Sharing code files
| – commonMain A Shared logical
| – database Local data Management
| – network Remote data management
| – story Redux related: Action, Reducer, middleware, etc
| – UI MVP UI level logic: View, Presenter, etc
| – androidMain Expect needs to be implemented by Android
| – iosMain Expect’s Kotlin code needs to be implemented by ios
| – commonTest Multi-platform testing
| -…

Using Redux to decouple the UI and logic layers:

  • The business logic, data requests, and UI logic for some common functions (navigation/routing, etc.) are shared
  • UI refresh is implemented in Native

Logical layer: Redux & Presenter


In addition to Redux, Presenter is introduced to take care of UI refreshes. Redux and Presenter have the following roles:

  • Store: manages global State (AppState), including various substates, such as ViewState of each page, NavigationState used for page jump, etc. Reducer in Store will calculate new State according to Action
  • ViewState: The changed State is distributed to the Presenter on each page
  • Presenter: Subscribes to AppState changes as common logic in shared applications, and uses SubState to drive native UI refreshes
  • Navigator: can be regarded as a special Presenter that is responsible for switching between shared pages and drives Native to perform actual page jumps

Redux introduces Presenter with the following benefits:

  • Decentralize the management of State, reduce the burden of Store, and send SubState to the corresponding View
  • The UI doesn’t care about subscribing to state, just providing the Render method, which greatly improves reusability.

Presenter is only one option and can be replaced with ViewModel and other alternatives.

4. UI layer: Views


Take Setting page as an example to introduce the realization of View:

Shared

SettingsViewState contains all states of the Setting page and subViewState of the secondary page. Each Presenter subscribes to ViewState and calls the View’s corresponding method to refresh the UI when State changes.

//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(a) = settingsPresenter

    fun render(settingsViewState: SettingsViewState)
}

val settingsPresenter = presenter<SettingsView> {
    {
        select({ it.settingsViewState }) { render(state.settingsViewState) }
    }
}
Copy the code

Native: Android & iOS

The Android Fragment and iOS ViewController are responsible for the implementation of the page, providing the render method to render the UI for ViewState:

  • The Android side:
//SettingsFragment.kt
class SettingsFragment : BaseFragment<FragmentSettingsBinding, SettingsView>(), SettingsView {
    override val presenterObserver = PresenterLifecycleObserver(this)

    private lateinit var adapter: SettingsListAdapter

    override fun createBinding(a): 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()
    }
}
Copy the code
  • The iOS side:
//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(a) {
        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"}}Copy the code

5. Page skipping: Navigator


There are two types of page switching logic in Sample

  • When you first start up, you need to do initial setup (step by step) through the wizard page, which is a linear sequential page-jumping logic
  • After entering the main screen, switch tabs through BottomBar, which is the out-of-order page switching logic
  • Both logic support Back to the previous page

Both kinds of logic are common page jump scenarios in APP, and both can be realized by Redux’s state driver.

Shared

  • Screen: indicates the page type.
interface Screen {}
Copy the code
  • MainScreen: Use enumeration to define all pages after entering Home
enum class MainScreen : Screen {
    DASHBOARD,
    INFORMATION,
    SETTINGS,
    ZIP_SETTINGS,
    CALENDAR_SETTINGS,
    NOTIFICATION_SETTINGS,
    LANGUAGE_SETTINGS,
}
Copy the code
  • OnboardingScreen: Used in boot wizard page logic to mark the order in the wizard page by step
data class OnboardingScreen(val step: Int = 1) : Screen
Copy the code
  • NavigationState: The last position is the top of the stack (current page).
data class NavigationState(val screens: List<Screen>, val navigationDirection: NavigationDirection) {
    val currentScreen = screens.last()
}

enum class NavigationDirection {
    PUSH,
    POP
}
Copy the code
  • NavigationAction: Defines all actions that trigger page jumps
enum class NavigationAction {
    BACK,
    DASHBOARD,
    INFO,
    SETTINGS,
    ZIP_SETTINGS,
    CALENDAR_SETTINGS,
    NOTIFICATION_SETTINGS,
    LANGUAGE_SETTINGS,
    ONBOARDING_START,
    ONBOARDING_NEXT,
    ONBOARDING_END
}
Copy the code

In NavigationReducer, calculate the new state by action and current 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) } ... }}Copy the code

As above,

  • BACK: Go BACK to the previous page and remove the top screen.
  • SETTINGS: jump page, mainscreen. SETTINGS is pushed;
  • ONBOARDING_NEXT: OnboardingScreen is pressed and step increments

Native: Android & iOS

The Native side implements specific page hopping and rollback logic.

  • Android: Handles jumps in MainActivity
//MainActivity.kt
//updateNavigationState is the Navigator interface method
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()
    }
}

Copy the code

We expect all page switches to be state-driven, but some tripartite libraries on native (e.g., Navigation on Android) can automatically respond to Back events without the state driver. However, to ensure state correctness, we still need to update the state when we receive the Back event:

//MainActivity.kt
override fun onBackPressed(a) {
    super.onBackPressed()
    rootDispatch(NavigationAction.BACK)
}
Copy the code
  • IOS: The Coordinator design mode processes the page navigation
//NavigationCoordinator.swift
class NavigationCoordinator: Navigator.Coordinator {

    func getNavigationState(a) -> 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")}}}Copy the code
  • OnboardingCoordinator: Handles the display of the UIPageViewController in the wizard page
  • MainCoordinator: Handles the display of view controllers on the main interface
  • MainViewController: as aUITabBarControllerIs used only to update the navigation state

6. Data layer: Database & Networking


useSQLDelightLocal data management; usektorRemote data access. Asynchronous request passingThunksThe action of a

As above, Thunks’ actions are distributed to Middleware for asynchronous data requests.

Unit testing


Redux is naturally mono-friendly, as long as we care about whether State meets expectations.

class NavigationReducerTest {

    @Test
    fun testOnboardingNavigation(a) {
        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())
    }
}
Copy the code

For example, a test for Navigation, just write a test for NavigationState that doesn’t involve any mock at the UI layer

8. To summarize


Redux has been proven by the front end to be a very suitable development paradigm for UI-type apps. Based on ReduxKotlin, the state management of the core is shared, which can effectively reduce the amount of development and testing in the data layer and logic layer. UI layer in native side is only responsible for rendering without processing any business logic, which ensures user experience and can be flexibly replaced and taken.

This paper introduces the basic idea of ReduxKotlin’s cross-platform application through a Sample. There is not much introduction to the use and principle of ReduxKotlin itself, which will be left for a separate article for in-depth analysis in the future. Interested friends can continue to pay attention to it.