7.
App Architecture
Written by Saeed Taheri
In the previous chapter, you started creating the Organize app. However, you didn’t make it to the organization part. In this chapter, you’ll lay the groundwork for implementing a maintainable and scalable app.
Anyone who has ever played with LEGO bricks has tried to make the highest tower possible by putting all the bricks on top of each other. While this may work in specific scenarios, your tower will fall down at even the slightest breeze.
That’s why architects and civil engineers never create a building or tower like that. They plan extensively, so their creations stay stable for decades. The same applies to the software world.
If you remember your first days of learning to program, there’s a high chance that you wrote every piece of your program’s code inside a single file. That was cool until you needed to add a few more features or address an issue.
Although the term software architecture is relatively new in the industry, software engineers have applied the fundamental principles since the mid-1980s.
Design Patterns
The broad heading of software architecture consists of numerous subtopics. One of these is architectural styles — otherwise known as software design patterns. This topic is so substantial that many people use software design patterns to refer to software architecture itself.
Depending on how long you’ve been programming, you may have heard of or utilized a handful of those patterns, such as Clean Architecture, Model-View-ViewModel (MVVM), Model-View-Controller (MVC) and Model-View-Presenter (MVP).
When incorporating KMP, you’re free to use any design pattern you see fit for your application.
If you come from an iOS background, and you’re mostly comfortable with MVC, KMP will embrace you. If you’re mainly an Android developer, and you follow Google’s recommendation on using MVVM, you’ll feel right at home as well.
There is no best or worst way to do it.
Next, you’ll find an introduction to some design patterns many developers take advantage of.
Model-View-Controller
The MVC pattern’s history goes back to the 1970s. Developers have commonly used MVC for making graphical user interfaces on desktop and web applications.
In the mobile world, Apple made MVC mainstream when it introduced the iPhone SDK in 2008. If you did iOS development before SwiftUI, you may have noticed that one of the base components was a UIViewController. It speaks for itself how Apple heavily invested in this pattern.
For long years before Google became opinionated about Android development patterns and architectures, developers used Model-View-Presenter, or MVP, which is a close deviation of MVC.
In MVC, you partition your code into three separate camps:
- Model: The central component of the pattern. It’s completely independent of the UI and handles the logic and rules of the application.
- View: Any representation of information, such as lists, grids, etc. This section is usually platform- and framework-dependent. You can use UIKit and SwiftUI on iOS and Views or Jetpack Compose on Android.
- Controller: Accepts input and converts it to commands for model or view. It also receives feedback from the model and reflects the changes to the view. It’s somehow the know-it-all of the pattern.
The diagram below shows the relationship between the different partitions:
Model-View-ViewModel
As the name implies, MVVM is a great fit for applications with views or user interfaces. Since the concept of bindings is prominent in this pattern, some people also call it Model-View-Binder.
It’s much newer than MVC. John Gossman, one of Microsoft’s engineers, announced MVVM in his blog in 2005. Microsoft embraced MVVM in .NET frameworks and made this pattern very popular.
Google introduced the Architecture Components at Google I/O 2017. This marked the first time Google recommended a design pattern for developing Android applications. Over the years, Google has also introduced various tools and components centered around the concept of MVVM. Today, MVVM is the preferred choice for most Android developers when creating an app.
The components of MVVM are as follows:
- Model: It’s much like the model layer of MVC. It represents the app data and rules.
- View: Pretty much similar to the component with the same name in MVC. It represents the model, receives input from the user and forwards the handling of the input to the ViewModel via a link between View and ViewModel.
- ViewModel: ViewModel is basically the state of data in the model. It exposes some public methods and properties to which the View subscribes and receives the changes automatically. People call this mechanism Data Binding, or simply Binding.
Android developers are no strangers to using LiveData, or recently, Kotlin Flow or StateFlow, as the Binder inside ViewModel.
After Apple introduced the Combine framework and SwiftUI as a first-party solution to reactive programming and declarative UI, iOS developers began adopting the MVVM design pattern and the binding mechanism more and more. Additionally, Apple introduced the Observation framework in iOS 17 to simplify the utilization of this pattern.
Clean Architecture
In 2012, Robert C. Martin, also known as Uncle Bob, published a post in his blog at https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html explaining the details of a new design pattern he came up with based on Hexagonal Architecture, Onion Architecture and many more.
Clean Architecture has a steeper learning curve because it has more components. However, because of its extensibility and its ability to handle various problems in software development, it’s very popular among professionals — especially when they want to create large applications.
The circles above represent different levels of software in an app. There are two principles to bear in mind about this graph:
- The center circle is the most abstract, and the outer circle is the most concrete. This is called the Abstraction Principle. The Abstraction Principle specifies that inner circles should contain business logic, and outer circles should contain implementation details. In other words, the closer you are to the center, the less dependency on a specific platform you have.
- Another principle of Clean Architecture is the Dependency Rule. This rule specifies that each circle can depend solely on the nearest inward circle — this is what makes the architecture work. This makes code based on Clean Architecture pretty decoupled and hence testable.
The basic components of Clean Architecture are as follows, explained from outer circles inward:
- Presentation and Framework: The outermost layer generally contains frameworks and tools specific to a platform. Using SwiftUI or Jetpack Compose for making interfaces? Here’s the place. Using SwiftData or Room for database? They also belong here. You usually can’t share code in this layer between platforms.
- Controllers or Presenters: This is the layer you used to have in MVC as Controller or ViewModel in MVVM. They receive input from the outer layer and pass them to the next layer. You can combine MVVM and MVC with Clean Architecture. It’s also a good thing to do since the responsibilities of your controllers or ViewModels will decrease.
- Use Cases or Interactors: This layer defines the actions the user can trigger. The objects in the previous layer have access to use cases and can only call into the defined interactions. In the original definition of Clean Architecture, this is the layer you put your business logic in. As you’re free to add your layers, you can delegate this responsibility to inner layers as well.
- Entities: Abstract definitions of all the data sources. It can contain some business logic.
While creating the Organize app, you’re going to use the MVVM design pattern. You’re free to choose any other pattern you like better for your applications.
Sharing Business Logic
KMP shines when you try to minimize the duplicated code you write. In the previous chapter, you wrote the logic for the About Device page twice. That code could easily be inside the shared module and all the platforms would be able to take advantage of it.
Creating ViewModels
Open the starter project in Android Studio. It’s mostly the final project of the previous chapter.
Inside the presentation directory of the commonMain folder in the shared module, create a new file and name it BaseViewModel.kt.
Fill the file with this line:
expect abstract class BaseViewModel()
You’re familiar with this line. This time, though, it’s defining an abstract class, which all our app’s ViewModels would extend. Next, you’re going to implement the actual implementations of this class on all three platforms.
Put the cursor in the middle of the class name and press Alt+Enter and select Add missing actual declarations.
Then, select desktopMain, iosMain and androidMain. Click OK. Android Studio will help you create all the needed actual files.
Note: If Android Studio fails to automatically create the needed actual files for you, don’t worry. Create a file in the same package with the same name inside the missing platform’s folder.
Open the Android version of BaseViewModel.kt and replace the content with this line:
actual abstract class BaseViewModel : ViewModel()
Don’t forget to import the needed package:
import androidx.lifecycle.ViewModel
On Android, the ViewModels should extend the Lifecycle version of ViewModel, so they can survive the configuration changes on the devices.
If this is confusing to iOS developers, here’s a small explanation:
On Android, when a configuration change occurs — for example, the device rotates or the user changes the system-wide theme or locale — the system recreates all the view components. However, the system will keep the same instance of the ViewModel extending from the Lifecycle package of AndroidX in memory. Hence, you can keep the view data inside the ViewModel and apply them to the newly created view components and the user won’t notice anything.
For iOS and desktop, you don’t need to extend anything. What Android Studio did for the actual files on those platforms is more than enough. They look like this:
actual abstract class BaseViewModel actual constructor()
Creating AboutViewModel
Now that you have a base viewmodel, it’s time to create the concrete versions. Start by creating a file named AboutViewModel.kt in the commonMain folder inside the presentation directory.
Define the class and subclass from the BaseViewModel you created earlier.
class AboutViewModel: BaseViewModel() {
}
Inside the class, create an instance of the Platform class and press Alt + Enter to import it.
private val platform = Platform()
Define a data class inside the AboutViewModel class to hold the data you show in each row of the About page:
data class RowItem(
val title: String,
val subtitle: String,
)
Next, create a function that generates the items for the About page. You wrote the same logic three times — once for each platform — in the previous chapter. You’ll remove them all later.
private fun makeRowItems(platform: Platform): List<RowItem> {
val rowItems = mutableListOf(
RowItem("Operating System", "${platform.osName} ${platform.osVersion}"),
RowItem("Device", platform.deviceModel),
RowItem("CPU", platform.cpuType),
)
platform.screen?.let {
rowItems.add(
RowItem(
"Display",
"${
max(it.width, it.height)
}×${
min(it.width, it.height)
} @${it.density}x"
),
)
}
return rowItems
}
In the end, create an instance property to store the result of this function to avoid recreating the data. After all, these data would never change and are rather static.
val items: List<RowItem> = makeRowItems(platform)
This will be the public API of the ViewModel.
Using AboutViewModel in the View Layer
Android
Open AboutView.kt inside the androidApp module.
Remove the makeItems() method, as a similar implementation now exists inside AboutViewModel.
Next, edit the definition of the AboutView method as follows to account for the viewmodel:
@Composable
fun AboutView(
viewModel: AboutViewModel = AboutViewModel(),
onUpButtonClick: () -> Unit
)
For now, provide a default value for the viewModel parameter. In later chapters, you’ll introduce dependency injection to your code and improve this instantiation.
Following that, update the ContentView method as follows:
@Composable
private fun ContentView(items: List<AboutViewModel.RowItem>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(items) { row ->
RowView(title = row.title, subtitle = row.subtitle)
}
}
}
You injected the items this function needs for rendering, as a parameter. You also removed the now-unnecessary makeItems method invocation.
Now that the ContentView method requires a parameter, go back to the implementation of AboutView to pass the items in. Replace ContentView() with this:
ContentView(items = viewModel.items)
Build and run the Android app. It works just like before, but this time it uses a viewmodel.
iOS
Open the Xcode project and switch to AboutView.swift.
At the top of the file, make sure to import the shared module by adding this line:
import Shared
Next, inside the AboutView struct, add a property for the viewmodel:
@State private var viewModel = AboutViewModel()
You annotate the property with the @State directive to make SwiftUI create and hold an instance of AboutViewModel for the lifetime of AboutView.
Next, open AboutListView.swift. Remove the RowItem struct as well as the items property. Then, add a property to hold the items this view shows as follows:
let items: [AboutViewModel.RowItem]
Don’t forget to import the shared module.
Inside the AboutListView_Previews struct, change AboutListView() invocation to the following:
AboutListView(items: [AboutViewModel.RowItem(title: "Title", subtitle: "Subtitle")])
In the code above, you are using a hardcoded row item to fix the UI preview inside Xcode.
Go back to AboutView.swift and pass the needed parameter to AboutListView:
AboutListView(items: viewModel.items)
Build and run to see the result of the refactoring you just did.
Desktop
You’re now familiar with the process. Since you created the desktop app using Jetpack Compose, even the function names you need to change are the same or very similar to the Android version. Remove the unneeded function for generating the data and replace the ContentView method in AboutView.kt in the desktopApp module.
The result will look like this:
@Composable
fun AboutView(viewModel: AboutViewModel = AboutViewModel()) {
ContentView(items = viewModel.items)
}
@Composable
private fun ContentView(items: List<AboutViewModel.RowItem>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(items) { row ->
RowView(title = row.title, subtitle = row.subtitle)
}
}
}
Build and run the desktop app and see the changes…or the lack thereof!
Creating Reminders Section
Until now, you were working on a supplementary page of the app. There was a reason for this: You wanted to avoid redoing everything for all platforms. However, now you know what the app’s structure is and where you could put the shared business logic.
Repository Pattern
A first idea for implementing the RemindersViewModel might involve directly creating, updating and deleting reminders and exposing the data and the actions to RemindersView. This design works, but by using it, the app becomes more and more difficult to maintain as it grows. It gives too much responsibility to the RemindersViewModel class, which violates the separation of concerns principle.
For instance, when you start integrating a database into the app in later chapters, you would need to update many things in the viewmodel.
One way to mitigate this issue is to use a Repository Pattern. A repository is an object that sits in between the viewmodel and the source of your data, whether it’s a remote server, a local database or even a cache in memory.
Create a new directory as a sibling to presentation deep inside the commonMain folder of the shared module and name it data. Then, create a new Kotlin class named RemindersRepository inside the data directory.
First, add a property to hold the Reminders objects internally:
private val _reminders: MutableList<Reminder> = mutableListOf()
You’ll get a compiler error stating that the Reminder type is unresolved. Reminder will be a data model for our app. To keep things more organized, you’re going to create the Reminder class inside a directory named domain, which is another sibling of presentation and data. If you pay close attention, you’ll notice that there are some cues from the Clean Architecture here. But don’t worry — you’ll only use some naming conventions and won’t dig deeper than that.
Create the Reminder.kt file and add this block of code:
data class Reminder(
val id: String,
val title: String,
val isCompleted: Boolean = false,
)
Each reminder will have an identifier, a title and a value for whether it’s completed or not.
Next, in RemindersRepository, add the import for the Reminder class.
Then, add this function to create a new reminder:
fun createReminder(title: String) {
val newReminder = Reminder(
id = UUID().toString(),
title = title,
isCompleted = false
)
_reminders.add(newReminder)
}
UUID is a class used to create random identifiers with the expect/actual mechanism. It’s already there in the starter project. Take a look at its implementation if you’re interested.
Next, add a function to update the isCompleted status of a reminder:
fun markReminder(id: String, isCompleted: Boolean) {
val index = _reminders.indexOfFirst { it.id == id }
if (index != -1) {
_reminders[index] = _reminders[index].copy(isCompleted = isCompleted)
}
}
It first checks if an item with the id exists. If the answer is yes, it updates the isCompleted value.
In the end, create a public getter property for all the reminders. Later, you’ll change this to a Kotlin Flow to be able to propagate live changes to the viewmodel and view. Since using Flows on iOS is a bit tricky, you’ll stick to plain properties for now.
val reminders: List<Reminder>
get() = _reminders
You’ve created a nice-looking API for the repository. Good job!
Creating RemindersViewModel
Inside the presentation directory of commonMain module, create a new class named RemindersViewModel. Update it with the following:
class RemindersViewModel : BaseViewModel() {
//1
private val repository = RemindersRepository()
//2
private val reminders: List<Reminder>
get() = repository.reminders
//3
var onRemindersUpdated: ((List<Reminder>) -> Unit)? = null
set(value) {
field = value
onRemindersUpdated?.invoke(reminders)
}
//4
fun createReminder(title: String) {
val trimmed = title.trim()
if (trimmed.isNotEmpty()) {
repository.createReminder(title = trimmed)
onRemindersUpdated?.invoke(reminders)
}
}
//5
fun markReminder(id: String, isCompleted: Boolean) {
repository.markReminder(id = id, isCompleted = isCompleted)
onRemindersUpdated?.invoke(reminders)
}
}
Here’s what this class includes:
-
A property to keep a strong reference to the repository.
-
A property that accesses reminders from the repository.
-
Views can connect to this property to find out about changes in reminders. For now, it’s the link or the binding component of MVVM. You make sure to call the lambda, or closure in Swift terms, with the current state of
remindersat its setter block. -
A method for creating a reminder after safeguarding against reminders with empty titles. When
viewModelasks the repository to create a new reminder, it propagates the changes throughonRemindersUpdated. -
A method for changing the
isCompletedproperty of a specific reminder.
Updating View on Android
Open RemindersView.kt inside the androidApp module. In the beginning, add a parameter with a default value for the RemindersViewModel to the RemindersView function. Then, pass viewModel into the ContentView method.
@Composable
fun RemindersView(
viewModel: RemindersViewModel = RemindersViewModel(),
onAboutButtonClick: () -> Unit,
) {
Column {
Toolbar(onAboutButtonClick = onAboutButtonClick)
ContentView(viewModel = viewModel)
}
}
You’ll give the ContentView function a massive upgrade. First, remove the existing code in the function. Next, change the function signature to accept a parameter of type RemindersViewModel.
@Composable
private fun ContentView(viewModel: RemindersViewModel) {
}
Add a variable for remembering the state of reminders. Jetpack Compose re-renders, or recomposes the function whenever this state changes.
var reminders by remember {
mutableStateOf(listOf<Reminder>(), policy = neverEqualPolicy())
}
In the code above, by is a delegate syntax. Doing so delegates the get() and set() methods to the remember method.
Add the following imports to resolve the IDE warnings and errors:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.neverEqualPolicy
import androidx.compose.runtime.remember
To connect the underlying changes of reminders in viewModel to this function, add this line after initializing reminders:
viewModel.onRemindersUpdated = {
reminders = it
}
Whenever the viewmodel calls the onRemindersUpdated lambda, you set the new value of the reminders list to the reminders state variable. This makes the component react to changes. The policy you set in an earlier step will make sure the recomposition always happens, regardless of the equality status of new and old values.
Subsequently, create a LazyColumn to show the reminders in a list:
LazyColumn(modifier = Modifier.fillMaxSize()) {
//1
items(items = reminders) { item ->
//2
val onItemClick = {
viewModel.markReminder(id = item.id, isCompleted = !item.isCompleted)
}
//3
ReminderItem(
title = item.title,
isCompleted = item.isCompleted,
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = true, onClick = onItemClick)
.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
}
- Using the
itemscomposable function, you provide theremindersstate variable to theLazyColumnfunction. LazyColumn is an efficient version of List that renders only the subset of items that can be displayed on the screen. - Store a lambda, that calls into
viewModelto update theisCompletedstatus of a particular reminder. - Use the already provided
ReminderItemfunction for each row of the list. You are welcome to take a look at its implementation.
After the items block, you add an item that will contain the text field to add new reminders.
item {
//1
val onSubmit = {
viewModel.createReminder(title = textFieldValue)
textFieldValue = ""
}
//2
NewReminderTextField(
value = textFieldValue,
onValueChange = { textFieldValue = it },
onSubmit = onSubmit,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
-
An
onSubmitlambda that creates a new reminder, and clears the text field when the user presses the Return or the Done key on their phone’s keyboard. -
A customized
NewReminderTextFieldis inside the starter project. You bind thevalueand theonValueChangeto thetextFieldValuestate variable.
Finally, add the textFieldValue state variable at the top of the ContentView function as follows:
var textFieldValue by remember { mutableStateOf("") }
Build and run the app. Add a couple of reminders and mark a few of them as done.
Updating the View on iOS
For the reactive nature of data binding to function effectively, SwiftUI heavily relies on the Combine framework or, as of iOS 17, the Observation framework. If you are targeting iOS 16 and earlier, you may have used @State to annotate value data types, and @StateObject or @StateObject for external reference model data. With the advent of iOS 17, Apple has simplified state management. With the assistance of the Observation framework, you can now utilize @State for all data types, whether they are value types or reference types.
However, there’s a catch in using RemindersViewModel. Since you defined the viewmodel inside the KMP Shared module, you weren’t able to use Combine or Observation there, as they’re Swift-only.
One way to address the issue is to create a wrapper around the viewmodel and expose the properties for SwiftUI to use.
Open the iosApp.xcodeproj and create a new Swift file by pressing Command-N. Name it RemindersViewModelWrapper.swift and place it in the Reminders directory.
Add the following code to the file:
//1
import Observation
import Shared
//2
@Observable
final class RemindersViewModelWrapper {
//3
let viewModel = RemindersViewModel()
//4
private(set) var reminders: [Reminder] = []
init() {
//5
viewModel.onRemindersUpdated = { [weak self] items in
self?.reminders = items
}
}
}
- You should import
Observationas well as theSharedframework. - By annotating your class with the
@Observablemacro, the class becomes observable by SwiftUI view. - Here, you hold a strong reference to the real viewmodel.
- You expose a property out of this class. SwiftUI will re-render the body of the view when this property changes.
- At initialize, you subscribe to
onRemindersUpdatedclosure ofviewModeland update your published property accordingly. By using[weak self], you break a potential memory cycle.
Open RemindersView.swift and replace the struct with the following code. It’s rather long, but it looks and behaves a lot like what you did with Jetpack Compose:
struct RemindersView: View {
//1
@State private var viewModelWrapper = RemindersViewModelWrapper()
//2
@State private var textFieldValue = ""
var body: some View {
//3
List {
//4
if !viewModelWrapper.reminders.isEmpty {
Section {
ForEach(viewModelWrapper.reminders, id: \.id) { item in
//5
ReminderItem(title: item.title, isCompleted: item.isCompleted)
.onTapGesture {
//6
withAnimation {
viewModelWrapper.viewModel.markReminder(
id: item.id,
isCompleted: !item.isCompleted
)
}
}
}
}
}
//7
Section {
NewReminderTextField(text: $textFieldValue) {
withAnimation {
viewModelWrapper.viewModel.createReminder(title: textFieldValue)
textFieldValue = ""
}
}
}
}
.navigationTitle("Reminders")
}
}
-
Using the
@Stateannotation, you create an instance of the wrapper you defined in the previous step. -
Using the
@Stateannotation, you create a property to hold the text field’s text value. You even used the same variable name in Jetpack Compose. -
Listin SwiftUI is essentially the equivalent ofLazyColumnin Jetpack Compose. -
If the
remindersproperty contains values, you create a section with reminder items. -
For each row of the list in the first section, you use an instance of
ReminderItemthat’s inside the starter project. -
When the user taps on each row, you call into
viewModelto mark the reminder as completed or uncompleted. ThewithAnimatonfunction makes the transition look smooth. -
This section is there to create a text field for adding new items. You bind it to the
textFieldValueproperty.
Build and run, and take a look at the Reminders page in all its glory!
Updating View on Desktop
Since the desktop app is using Jetpack Compose, you can literally copy and paste the code from RemindersView.kt in the androidApp module to the same file in the desktopApp module.
After doing so, build and run the desktop app.
One point to mention is that the apps forget your reminders whenever you relaunch them or navigate to another page and come back. This is because you’re storing the reminders in a property inside the repository. In later chapters, when you integrate a database, you’ll fix this.
In the final project, there are a couple of touches for improving keyboard support — such as focus switch. For brevity’s sake, they weren’t in this chapter.
Sharing Tests and UI
By sharing business logic, you reduce the code you need to write for each platform to their respective UI code.
In the next chapter, you’re going to add tests to the project. Since all the business logic now resides in a single place, you’ll write a single set of tests. Hence, you’ll write fewer test codes — which means you’re secretly rejoicing!
You might have thought it was a little weird to copy and paste code between Android and desktop. And, you might be thinking of a way to share these pretty similar pieces of code. Since you’ve been using Jetpack Compose for both of these platforms, there are a couple of ways to share these codes. You’ll learn more about one option in Appendix C.
One thing to notice is that sharing UI code may not always be a good decision for a couple of reasons:
- You may have noticed that the desktop app looks a bit unusual aesthetically. It’s adhering to Material Design guidelines, which isn’t a typical approach on the desktop. It doesn’t look like other native apps on Windows or macOS, either. Many would prefer to stick to the native UI toolkit of each platform instead of using Jetpack Compose, which uses Java Swing under the hood. For that matter, many developers wouldn’t create their desktop app using the approach you saw in this book. If you don’t do that, you won’t have Jetpack Compose for Desktop, and therefore, no code to share between Android and desktop.
- Each platform has its differences. A desktop app would usually need a different design than the Android app. For instance, it doesn’t need to have large touch targets, it doesn’t have multitouch, it mostly uses a mouse and keyboard instead of touch as input, etc. If you want to create a great app for each platform, you need to take these into consideration.
All these explanations apply to UI testing as well. If you somehow share UI code, you can have shared UI tests. If you don’t, you’ll need to create UI tests for each platform separately.
Challenge
Here’s a challenge for you to see if you mastered this chapter. The solution is waiting for you inside the materials for this chapter.
Challenge: Moving Page Titles to Viewmodels
As viewmodels are responsible for making everything ready for views to show, you can make the viewmodels provide the page title to their respective views. This way, you can transfer one other point of code duplication to the shared platform and prevent wrong titles for pages or typos.
You can add the title property to both AboutViewModel and ReminderViewModel and then utilize it in the respective views across all platforms.
Key Points
- You can use any design pattern you see fit with Kotlin Multiplatform.
- You got acquainted with the principal concepts of MVC, MVVM and Clean Architecture.
- Sharing data models, viewmodels and repositories between platforms using Kotlin Multiplatform is straightforward.
- You can share business logic tests using Kotlin Multiplatform.
- Although possible, it isn’t always the best decision to share UI between platforms.