| π iOS | π€ Android |
|---|---|
demo_ios.mp4 |
demo_android.mp4 |
Newsfeed app with endless scrolling built for Kotlin Multiplatform (iOS + Android).
- Shared code: Kotlin Multiplatform, MVVM, Kodein (dependency injection), Ktor (network), SqlDelight (database)
- Android: Jetpack Compose, Android Architecture Components (LiveData, ViewModel)
- iOS: Swift, SwiftUI
The project setup is quite straightforward:
- Clone or download the repo.
- Install the latest Android Studio - at least version 2020.3.1 is required.
- Install the latest Kotlin Multiplatform plugin - at least version 0.3.0 is required.
- Install the latest Xcode - at least version 13.0.0 is required.
- Android:
- Open Android Studio ->
Preferences->Build, Execution, Deployment->Build Tools->Gradleand setGradle JDKto beEmbedded JDK. - Import the project.
- Build and run directly from Android Studio using the
appAndroidconfiguration.
- Open Android Studio ->
- iOS:
- Open
appIos.xcworkspacewith Xcode - Build and run directly from Xcode using the
appIosscheme.
- Open
- Shared KMM:
- The shared multiplatform code cannot be built or run by itself, so no further setup is required.
The project could also be used as a template for other multiplatform apps, providing a solid foundation to build on:
- Clone or download the repo
- Open Terminal and navigate to the repo
- Run
chmod +x new_app_setup.sh - Run
./new_app_setup.shand follow the instructions - Once the script finishes you should follow the instructions above to setup the project with Android Studio
Note that you might need to replace the API key for this project if this link doesn't work. To get a new key, go to this link and register a developer account. Once you get the key, replace the value for API_KEY in kmm/kmm-common-network/build.gradle.kts.
The project is built using CLEAN multi-module architecture consisting of feature, data and test-fixture modules both for the shared KMM code and the individual client targets. Each module contains classes and resources only related to its functionality and some modules can be reused within other modules. Definitions of the module types can be found under each platform below.
To allow easier differentiation between modules, there are separate root folders for each of the supported platforms:
kmm- shared code and related modulesappAndroid- Android app and related modulesappIos- iOS app and related modules
KMM is using Gradle as a build system so the project's build process is setup differently depending on the client target that is being compiled:
Ths shared multiplatform code consists of 4 different module types:
common- modules with common setup that are meant to be extended or used within other modules. Some examples include networking, dependency injection, persistence.data- modules that are responsible for data retrieval and persistence for a specific app feature. We usually have one data module per app feature.feature- modules that contain the view-models and core business logic for a specific app feature. We usually have one feature module per app feature.test-fixtures- modules that contain test fake's for a particular feature module. We usually have one test fixtures module per app feature.
To make creating new modules seamless, the KMM setup provides 3 dedicated Kotlin DSL Gradle plugins that should be applied to new modules depending on their type:
- new
commonmodules should apply theid("kmm-module-plugin")plugin, unless they are meant to be exposed throughkmm-module-pluginitself, in which case they should apply theid("kmm-platform-plugin")plugin to avoid circular dependencies; - new
datamodules should apply theid("kmm-data-plugin")plugin; - new
featuremodules should apply theid("kmm-feature-plugin")plugin; - all other modules should apply the
id("kmm-module-plugin")plugin;
You can create new modules using Android Studio's File -> New Module. Keep 3 things in mind when adding new KMM modules:
- The new module has to be defined inside the
kmmfolder. - The new module has to be prefixed with
kmm-. - Make sure to update
modules.gradle.ktsand add the new module to the list of modules.
The Android app is also using Gradle as a build system and consists of 3 different module types:
common- modules with common setup that are meant to be extended or used within other modules. Some examples include Jetpack Compose, the design system, tests.feature- modules that contain the UI for a specific app feature. We usually have one feature module per app feature.test-fixtures- modules that contain test robots for a particular feature module. We usually have one test fixtures module and robot per app feature.
Similarly to KMM, to make creating new modules seamless, the Android setup provides 2 dedicated Kotlin DSL Gradle plugins that should be applied to new modules depending on their type:
- new
commonmodules should apply theid("android-module-plugin")plugin, unless they are meant to be exposed throughandroid-module-pluginitself, in which case they should apply theid("android-library-plugin")plugin to avoid circular dependencies; - new
featuremodules should apply theid("android-feature-plugin")plugin; - all other modules should apply the
id("android-module-plugin")plugin;
You can create new modules using Android Studio's File -> New Module. Keep 3 things in mind when adding new Android modules:
- The new module has to be defined inside the
appAndroidfolder. - Make sure to update
modules.gradle.ktsand add the new module to the list of modules. - To link a KMM module to the new Android module, just add it as a
implementation(projects.MY_KMM_MODULE)dependency.
The iOS app has its own native build system using Xcode and is using the concept of a "workspace" to define a CLEAN multi-module setup consisting of 3 different module types with the exact same definitions as the ones for Android above.
The only real difference is around linking the KMM dependencies which are specified through an iOS .framework that has to be linked (or embedded) to Xcode so that it can be accessed correctly from Swift in the final app package. Since this process is a bit more involved, we have provided an overview of the 3 key concepts required to achieve this:
embedAndSignAppleFrameworkForXcodeRun ScriptsFramework Search Paths
This Gradle task is specifically designed to run as part of the Xcode build process and its core purpose is to compile the Kotlin source files into Swift, generate the .framework file, link and sign it with Xcode, as the name suggests. It should be invoked from a Run Script phase during every Xcode build to generate a .framework file for one Kotlin dependency module. For example, ./gradlew :kmm-umbrella:embedAndSignAppleFrameworkForXcode will compile all code in the shared kmm-umbrella module only and generate its framework based on the specs in kmm/kmm-umbrella/build.gradle.kts. This framework follows Gradle's rules for exporting dependencies and all api dependencies will be visible to Swift. This includes all api submodules that kmm-umbrella depends on.
A caveat with submodule api dependencies is that if a module (A) declares a dependency on module (B), the generated Swift code will prefix the classes from B with its fully qualified module name when they are exposed through A. For example, a class MyClass defined in B but exposed through A will be available as BMyClass when the A.framework is used in Swift. In addition, because the individual modules are exported as separate .frameworks, they do not share memory and resources, unlike Android, where they are all part of the same app memory model.
To work around this, the general KMM advice is to export an umbrella .framework for Xcode containing all modules that should be exposed to iOS and Swift. This method overcomes the two issues above:
- by using the
export()function, the module's transitiveapidependencies are correctly exported to Swift with their module-independent names, e.g. in our example above,MyClasswhich is exposed throughAbut defined inBwill be available asMyClassto swift when theA.frameworkis linked; - all exported module share the same memory pool which allows them to keep and access the same shared resources;
Run Scripts are custom scripts that can be invoked during an Xcode build during certain stages. In terms of KMM, we require a custom Run Script with which to invoke the embedAndSignAppleFrameworkForXcode Gradle task to generate and link the .framework file. A caveat here is that embedAndSignAppleFrameworkForXcode has to ideally be invoked from the main appIos target to ensure that the code is signed with the correct signature, otherwise Xcode will throw an error. For simple apps, this should be okay.
In this project, we have a multi-module setup so we have actually linked a Run Script phase both for the appIos target (to sign the code correctly) and for the relevant feature modules (where the KmmShared framework is used). This is because Xcode compiles the code using Dependency Order by default which means that the main app files will be compiled last. Therefore, if we do not have the Run Scripts in each feature module, we will get Swift compile errors related to a missing KmmShared module which is indeed the case because the app's Run Script will run after all sub-modules have been compiled first. Of course, having two Run Script phases means the KMM code will be compiled twice (or more times, depending on how many feature modules we have) when appIos is built, but luckily Gradle's cache makes subsequent compilations run almost instantly so there isn't much overhead.
Since the generated KmmShared.framework isn't directly linked to Xcode, the project needs a way to locate it when referenced from Swift. This is where Framework Search Paths have to be used to tell Xcode where the KmmShared framework is. An example value for Framework Search Paths is:
$(SRCROOT)/../../kmm/kmm-umbrella/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)
For this example, we will assume that we have a new shared KMM module in Kotlin under kmm/kmm-settings which is ready to be integrated with Xcode to build a settings feature for the newsfeed app (containing the view-model and all shared code to drive the UI).
- Since the new KMM module will be accessed from Swift we have to add it to the list of
exportedDependenciesin the umbrella framework underkmm/kmm-umbrella/build.gradle.kts, asprojects.kmmSettings. - Open the existing
appIos.xcworkspace. - Create a new
Frameworkproject calledsettings(without tests for now, more on that in the coming sections) and choose an appropriateBundle Identifier. - Link the project with the existing
appIosgroup+project so that it becomes part of the same workspace. - Open the new project and adjust common settings (iOS version etc).
- Click on the
settings.frameworktarget ->Build Settings. - Set
Framework Search Pathsto$(SRCROOT)/../../kmm/kmm-umbrella/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)and make sure to mark it asnon-recursive. - Go to
Build Phases->+->New Run Script Phaseand paste the following.
cd "$SRCROOT/../.."
./gradlew :kmm-umbrella:embedAndSignAppleFrameworkForXcode
- You should now be able to link your newly created Xcode
Settingsmodule to other modules asSettings.frameworkusing theFrameworks, Libraries and Embedded ContentXcode option under the relevant target you want to use it from.
Due to the nature of Kotlin Multiplatform and the interoperability with Native targets, standard dependency injection frameworks like Dagger or Hilt cannot be used within the shared KMM modules. Some reasons are:
- lack of KMM support within the DI frameworks;
- lack of multi-module support (works out-of-the-box on Android but not on Native);
- annotation processing limitations with KMM;
- differences in the Native target build process;
- Kotlin syntactic sugar features are unavailable for Native targets (e.g.
val dependency by inject<Dependency>()is not valid in Swift);
In order to build a multi-module CLEAN architecture with the above restrictions in mind, at the time of writing, we have a few options available to implement the DI pattern in KMM:
- Koin - requires custom initialisation for each target in order to start the Koin application using
startKoin; true multi-module setup seems possible only if one module knows about all exposed dependencies or if feature modules are linked/unliked dynamically when required, which don't seem to scale well. - kotlin-inject - Dagger-like, compile-time, annotation-based dependency injection library.
- Kodein - official Kotlin dependency injection tool; large community support; the choice for this project.
The preferred DI framework of choice is Kodein as it is in active development, purely Kotlin-based and offers out-of-the-box support for CLEAN multi-module setup. Below is a summary of the approach chosen for this project:
- The
kmm-common-dimodule contains an abstractDiModuleclass which allows code modules to register themselves as dependency providers. - In order to be eligible for injection, a feature (or data) code module must extend
DiModuleand provide a uniquenameand the dependencies it wishes to expose. Additionally, it can also specify its own upstream dependencies, if any, which will automatically be wired up. - Clients inject dependencies through custom
inject()methods which the code modules must specify for each dependency. For example, dependencyAmust have a correspondinginjectA(): Amethod within its code module. Although Kodein has dedicated syntax for injecting dependencies for Android and Native targets, having our owninjectmethods allows us to potentially replace the injection library altogether as well as have the same interface when accessing the module's dependencies directly from Kotlin. To make this easier,DiModulehas a convenienceinjector()method which exposes Kodein'sDirectDIclass which is used to expose the required dependency using Kodein's dedicated.instance(...)method family. - Clients import the relevant modules they need and use their custom injection methods to access dependencies in the same way with pure Kotlin syntax, e.g.
val viewModel = FeedModule.injectFeedViewModel()(Android) orFeedModule.shared.injectFeedViewModel()(iOS).
The chosen approach allows greater flexibility and scalability - replacing the DI framework is just a task of changing how the dependencies are provided through DiModule.
Following our previous example with the new kmm-settings module, lets say we are ready to expose its SettingsViewModel to our clients through DI:
- Create a new file under
kmm-settings/src/commonMain/kotlin/PACKAGE/calledSettingsModule. - Define the following class:
object SettingsModule : DiModule() {
override fun name() = "kmm-settings"
override fun build(builder: DI.Builder) {
builder.apply {
bindProvider {
SettingsViewModel()
}
}
}
fun injectSettingsViewModel(): SettingsViewModel = inject()
}
SettingsViewModelshould then be available to clients usingSettingsModule.injectSettingsViewModel()(Android) orSettingsModule.shared.injectSettingsViewModel()(iOS).
The clients support the following deeplinks:
- Home screen:
news://home; - Post details screen:
news://post?postId=b8;
Android supports deeplinks out-of-the-box using custom url schemes that Activities can register themselves for in the relevant AndroidManifest.xml.
Following our previous example with the new kmm-settings module, lets say we are ready to add a new deeplink to the new Settings screen on Android:
- Open the feature module's (
settings)AndroidManifest.xmlfile and add the following:
<application>
<activity android:name=".SettingsActivity" android:launchMode="singleTask">
<intent-filter> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="settings" android:scheme="@string/deeplink_url_scheme" />
</intent-filter>
</activity>
</application>
- Run the app and verify the new screen opens when navigating to:
adb shell am start -d "news://settings"
iOS also supports deeplinks out-of-the-box using custom url schemes handled by SwiftUi. Our common modules provide a handy View extension which can be used to register a struct as a deeplink receiver.
Following our previous example with the new kmm-settings module, lets say we are ready to add a new deeplink to it on iOS. Depending on where we'd like to launch the Settings screen from we can choose to put our SwiftUI link handler in the relevant place. Our common-swiftui package already includes a handy onDeepLink extension function that lets us register a rendered struct as a handler for a deeplinks. For the purposes of our app, we will just open the settings screen from the feed:
- Open
FeedScreen.swift, locateFeedScreenContentand a new property for controlling whether the settings screen is visible or not:
@State private var settingsOpened: Boolean = false
- Attach the following deep link handler:
.onDeepLink(deepLink: "settings") { queryItems in
settingsOpened = true
}
- Pass
settingsOpenedas a@Bindingparameter to theFeedStatestruct. - Add the actual action to perform:
if (settingsOpened) {
// Fake navigation link to handle programmatic selection of settings
NavigationLink(
destination: SettingsScreen(),
tag: settingsOpened,
selection: self.$settingsOpened
) {
EmptyView()
}
}
- Run the app and verify the new screen opens when navigating to:
xcrun simctl openurl booted "news://settings"
The project's testing framework includes unit tests for the shared KMM code (covering the view-model code), UI tests for all Android screens and UI tests for iOS (performing combined UI + view-model tests for both).
Additionally, we make use of the robot testing pattern on both client platforms though dedicated Robot classes and test-fixtures modules.
KMM supports both unit and UI testing through the standard testing framework with the following folder structure:
commonTest- unit tests for the common code;androidTest- unit tests for any Android-specific logic branched out fromcommonMain;androidAndroidTest- UI (instrumented) tests for any Android-specific logic branched out fromcommonMain;iosTest- unit tests for any iOS-specific logic branched out fromcommonMain;
In this project, we have added unit tests for all view-models in commonMain in the relevant module's commonTest folder which uses Fakes rather than mocks to verify correct behaviour.
To launch all unit tests (results available under path_to_your_project/module_name/build/reports/tests/), run:
./gradlew test
You can also launch the unit tests for a specific module directly from Android Studio by right clicking on its commonTest folder and then Run.
Since the KMM code is already covered by unit tests, the Android app only has UI (instrumented) tests. Each feature module is responsible for defining its own UI tests and they should be specified in the standard androidTest folder.
To launch all UI tests (results available under path_to_your_project/module_name/build/reports/androidTests/connected/), make sure to have an emulator instance running and then run:
./gradlew connectedAndroidTest
You can also launch the UI tests for a specific feature module directly from Android Studio by right clicking on its androidTest folder and then Run.
UI testing on iOS is made up of two components:
UI Testing Bundle- starting point for UI test. Separate testing bundles have to be created for each module we'd like to test;UI Test Host App- UI tests run within a host app, which can either be the main app target of the project or a "dummy" one just for tests;
In this project, we do not have access to the main appIos target from our feature modules, which means that we need to define our own "dummy" target for our unit tests to run in.
Since the KMM code is already covered by unit tests, the iOS app only has UI tests. Each feature module is responsible for defining its own UI tests and to differentiate them from the code we have chosen to put them under tests folders for the relevant feature modules. The naming pattern used here is <Module>UiTests and <Module>TestHostApp.
To launch all UI tests, run:
xcodebuild -workspace appIos.xcworkspace -scheme "appIos" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 11,OS=15.2' test | xcpretty
You can also launch the UI tests for a specific feature module directly from Xcode by following the steps below:
- Choose the scheme you want to run the tests for.
- Press and hold on the
Runbutton to see the dropdown menu and switch toTest.
Following our previous example with the new kmm-settings module, lets say we are ready to add a new UI test for the new settings feature module on iOS:
- Create a new
Frameworkproject calledsettings-test-fixturesand link it with theappIosworkspace group. - Set your required iOS version levels and basic project settings. You can also remove unnecessary files that Xcode creates during this step.
- Click on your
settings-test-fixtures.frameworktarget ->Build Settingsand set the following to allow the framework access to the standardXCTestsources:- set
Enable Testing Search PathstoyesforDebug; - add
$(PLATFORM_DIR)/Developer/Library/FrameworkstoRunpath Search Paths; - add
$(PLATFORM_DIR)/Developer/usr/libtoRunpath Search Paths;
- set
- Create a new file
SettingsRobot. You can follow examples from the other robots in the project. - Navigate to the
settingsmodule and create a newApptarget calledSettingsTestHost- this will be the app that runs the UI tests. - Select the new
SettingsTestHosttarget and underFrameworks, Libraries, and Embedded ContentlinkCommonSwiftUiTest.framework,Settings.frameworkandSettingsTestFixtures.framework. - Navigate to
Build Settingsand:- add
$(SRCROOT)/../../kmm/kmm-umbrella/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)toRunpath Search Paths(to give the app access to the KMM module); - set
$(SRCROOT)/../../kmm/kmm-umbrella/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)toFramework Search Paths(to make the KMM module visible from Swift);
- add
- Populate
SettingsTestHost.swiftwith the UI to be tested. You can use examples from other test host apps. - Select the
settings.frameworktarget and add a newUI Testing BundlemakingSettingsTestHostits host. - Populate the test file with some tests. You can use examples from other test files in the project.
- You should now be able to switch to the
settingsscheme and run the UI tests on the simulator. - Optional: if you want your new UI test to run as part of all UI tests:
- Select the
appIosscheme ->Edit Scheme->Test. - Add your new
SettingsUiTestto the list using the+.
- Select the