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 a
UITabBarController
Is used only to update the navigation state
6. Data layer: Database & Networking
useSQLDelight
Local data management; usektor
Remote data access. Asynchronous request passingThunks
The 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.