A pattern for code sharing in Kotlin Multiplatform

In this article, Douglas Hoskins shows how the MVP pattern can be used to great effect when creating a robust, maintainable cross-platform app with Kotlin Multiplatform.

With Kotlin Multiplatform we can create a library of shared business logic and use it in our iOS and Android apps. It’s possible to use any class in that library, anywhere within our native code. Sounds powerful! 

But in reality, we need to be more organised.

We need a pattern for architecting our system which will:

  • clearly define the boundaries of our shared codebase
  • make the shared code more structured and easier to develop
  • make it easier for the entire development team to work with shared code
  • make the shared code easier to test

The MVP pattern works really well in Kotlin Multiplatform apps for maximum code sharing in a structured fashion. It lets us clearly define our interfaces to our shared library. This makes native implementation simpler, and makes testing a breeze.

In this blog we are going to see how to create a simple weather display screen in the MVP pattern.

Let’s get started!

The View

As we can see from the diagram, the Presenter and Models components of our pattern live in the shared Kotlin code, while the Views are native.

But in order for the pattern to work on shared code, the Views need to be the same on both platforms. So let’s start by creating an interface for our native View.

This interface encapsulates anything the presenter would want to send to the view. Don’t forget that the shared logic should be doing all of the heavy lifting. The views should be “dumb”: data passed into the view should be ready to display to the user without further processing.

The next step is to implement that view on the iOS and Android native sides.

Let’s look at iOS first. Kotlin Native maps Kotlin interfaces to protocols which can be accessed from Swift or Objective-C.

The iOS view is in place, and we’re making use of a Kotlin interface from Swift. Very nice! The Android side is more predictable:

Nothing out of the ordinary here, we declare our Activity in Kotlin and implement the View interface in the usual manner. Life with Kotlin Multiplatform is very easy for Android developers!

Our native views are in place. We can see that specifying our views as Kotlin interfaces works really well because:

  • The only points of interaction between shared and native code are those defined in the contract
  • iOS and Android developers can easily see what they need to do to interact with shared code (by implementing the functions we have defined)
  • Changing our view interface will cause a compilation error on our native views. We can be sure that the native side is up-to-date with the shared code at all times

Now let’s see how to implement a Presenter that can be used by our native views.

The Presenter

We’ve specified an interface for our View already, so now let’s do the same for our Presenter. 

This completes our WeatherContract we defined earlier, with a specification of both the View and the Presenter. It’s pretty simple: we have functions to associate and dissociate the view with the presenter, and another function to refresh the weather (possibly to be triggered by a refresh button or a Pull-to-Refresh mechanism).

Specifying an interface for the presenter will help us immensely when it comes to mocking it for unit testing.

For now, let’s create a real implementation of the presenter. 

The presenter’s job here is to accept actions performed by the user and ensure the correct app functionality happens as a result. In this case we have abstracted location and weather retrieval into dependencies that are provided to the presenter in the constructor.

This will help us with testing as we’ll see later. But for now we’ve provided default implementations of them, giving us a concrete presenter which we can instantiate from our native Views and hook up to our UI.

Let’s look at iOS first.

We instantiate our presenter in viewDidLoad, and pass it a reference to the view. Additionally, we call presenter.dropView() in our dealloc method — this is the presenter’s chance to do any cleanup required when the user navigates away from the screen. 

However, in order for this method to get called, we need to ensure the presenter is storing a weak reference to our view, not a strong reference.

If we don’t do this, then we will have a memory leak. The presenter’s strong reference to the view will prevent the view from being deallocd.

Both iOS and Android support weak references, but Kotlin Multiplatform has no built-in notion of them. We need to add it ourselves. Fortunately this is very easy!

First of all we declare a WeakReference in shared code:

The expect keyword here tells us that the implementation of this class will be provided separately for each target platform.

We then need to implement it separately for iOS and Android using actual.

Here’s what it looks like for Android:

And on iOS:

That’s it – we now have a WeakReference definition we can use from our shared code. So let’s edit our Presenter implementation to use it:

Very nice! We are now safely storing a weak reference to our iOS native view.

Finally let’s add the presenter to our Android view:

That’s all we need! We now have a functioning MVP interface for our native views to communicate with the presenter.

Testing

The final piece of the puzzle is testing. Kotlin Multiplatform forces us to keep our view and business logic code cleanly separated, and therefore much easier to test. We can use the kotlin.test library with MockK to simulate and test all the scenarios we care about.

Here’s an example of a test which ensures the happy path behaves correctly and ensures that values make their way from the location and weather providers to the native views.

This approach can be extended to cover a wide variety of success and error scenarios. 

The MVP pattern means our views are very lightweight, so the majority of our testing effort should be directed towards testing our shared code like this. Future articles will also look at snapshot and interaction tests for our native views.

Wrap up

In this article we’ve seen how to use MVP to specify contracts between native and shared code. Using this pattern, it’s very clear exactly how a shared library should be used from iOS or Android code. It scales well for simple or complex screens and can be used across your project.

We’ve also seen how to use the expect/actual mechanism to avoid memory leaks when using this pattern on iOS.

And we have seen how our presenter logic can be tested, adding confidence and robustness to our shared code and providing a high level of test coverage to both our iOS and Android apps.

This approach puts us well on our way to creating a shared codebase which will make our iOS and Android apps more consistent, and reduce the effort required for development, testing and maintenance.

In future articles, we will look more closely at the presenter implementation, and at some of the interesting libraries available for use in Kotlin Multiplatform projects.

Leave a Reply

Your email address will not be published. Required fields are marked *