Skip to content

frankois944/kmp-mvvm-exploration

Repository files navigation

Exploration of KMP MVVM and other useful features for iOS developer

I'm trying to find a good solution for using the MVVM pattern with the KMP ViewModel on SwiftUI/UIKit.

It's not that simple, I've been working on it for some time, and with the advancement of KMP, it sounds easier, but not so much :)

The KMP ViewModel approach on Android is fully supported, using Kotlin multiplatform or not, it's the same implementation.

Otherwise, on iOS, it's kind of experimental (really ?); the KMP ViewModel is not made to work on this target; we need to find some workarounds for correctly using it; and the main issue is the lifecycle.

Other features presented in this repository are optional for using the MVVM pattern, but I think they're kind of useful.

You will find inside this repo :

Requirement

So, the most interesting thing is about the MVVM and what do we need :

Inspiration from this repository https://github.com/joreilly/FantasyPremierLeague and this issue joreilly/FantasyPremierLeague#231

  • First step is exporting the Kotlin MVVM dependency for accessing the KMP ViewModel from Swift

shared Gradle file

it.binaries.framework {
//...
    export(libs.androidx.lifecycle.viewmodel)
}
commonMain.dependencies {
//...
    api(libs.androidx.lifecycle.viewmodel)
}
  • Then import SKIE to fully access the Kotlin Flow from Swift

And activate some useful features and expand the SwiftUI capabilities.

shared Gradle file

skie {
    features {
        // https://skie.touchlab.co/features/flows-in-swiftui (>= iOS15)
        enableSwiftUIObservingPreview = true // (>= iOS15)
        // https://skie.touchlab.co/features/combine
        enableFutureCombineExtensionPreview = true
        enableFlowCombineConvertorPreview = true
    }
}
  • Finally, create a SwiftUI class for managing the KMP ViewModel lifecycle

By wrapping the KMP ViewModel inside an ObservableObject, the shared class is now aligned with the lifecycle of the SwiftUI view.

class SharedViewModel<VM: ViewModel>: ObservableObject {

    private let key = String(describing: type(of: VM.self))
    private let viewModelStore = ViewModelStore()

    // Injecting the viewmodel
    init(_ viewModel: VM = .init()) {
        viewModelStore.put(key: key, viewModel: viewModel)
    }

    var instance: VM {
        (viewModelStore.get(key: key) as? VM)!
    }

    deinit {
        viewModelStore.clear()
    }
}
  • The ViewModel

Based on this shared Kotlin ViewModel.

Also, you can find the Android integration here.

Note: This viewmodel doesn't represent of what a ViewModel MUST BE.

The most important is a Kotlin Flow (Flow/StateFlow/SharedFlow) is transformed in Swift async. Also, SKIE makes it easier to implement with Swift.

  • SwiftUI SKIE observable (iOS 15 and later)

Example with SKIE.

This approach uses the SKIE flows for SwiftUI capability, which use the .task SwiftUI modifier.

  • SwiftUI SKIE observable (iOS 14 and earlier)

Example with a customized SKIE, a copy of SKIE collect methods that use the .onAppear SwiftUI modifier.

This approach uses the SKIE Flow capability and reproduces the SKIE flows for SwiftUI

  • MVVM using Macro

Example with a macro, more like an iOS dev will commonly use.

This approach uses a macro I made to automatically wrap a KMP ViewModel inside an ObservableObject, almost like a SwiftUI ViewModel.

You can see an example of a generated code here.

  • Pure SwiftUI MVVM

Example with a common SwiftUI ViewModel

There is no usage of KMP MVVM here, just like in an MVVM SwiftUI class, but we need to use the Koin injection capacities, or you can directly use the class instead of injection.

  • UIKit

Example with a UIViewController

UIKit is not dead, we can use the SharedViewModel class and the SKIE combine support

Thinking

The goal of this playground is to align the behavior between the Android ViewModel and the SwiftUI ViewModel. It's not that simple, as the ViewModel model must be the same, but the lifecycle of the Viewholder is different.

Look at the logs I added to verify the lifecycle, it should be exactly the same on each approach.

Getting the ViewModel or any instance from Swift and Koin

As this playground is using Koin, I want to get my ViewModel from it, not directly from the constructor (it's still working, but it's not great).

So we can use Koin qualifiers and parameters, like Koin for Android.

  • We need to export two Kotlin methods that resolve the ObjC class/protocol to the Kotlin Class from the Swift Application

  • Store the Kotlin Koin Application instance somewhere and make it accessible everywhere in the Swift App

// For example: store in swift singleton the koin application
AppContext.shared.koinApplication = // instance of initialized koinapplication
private class KoinQualifier: Koin_coreQualifier {
    init(value: String) {
        self.value = value
    }
    var value: String
}

extension Koin_coreKoinApplication {

    // reproducing the koin `get()` method behavior
    // we can set qualifier and parameters
    func get(qualifier: String? = nil, parameters: [Any]? = nil) -> T {
        let ktClass: KotlinKClass?
        // check if T is a Class or a Protocol and get the linked kotlin class
        if let protocolType = NSProtocolFromString("\(T.self)") {
            ktClass = Shared.getOriginalKotlinClass(objCProtocol: protocolType)
        } else {
            ktClass = Shared.getOriginalKotlinClass(objCClass: T.self)
        }

        guard let ktClass else {
            // no Kotlin Class found, it's an critical error
            fatalError("Cant resolve objc class \(T.self)")
        }

        var koinQualifier: Koin_coreQualifier?
        if let qualifier = qualifier {
            koinQualifier = KoinQualifier(value: qualifier)
        }

        var koinParameters: (() -> Koin_coreParametersHolder)?
        if let parameters {
            koinParameters = {
                .init(_values: .init(array: parameters), useIndexedValues: nil)
            }
        }

        guard let instance = koin.get(clazz: ktClass,
                                      qualifier: koinQualifier,
                                      parameters: koinParameters) as? T else {
            fatalError("Cant resolve Koin Injection \(self)")
        }
        return instance
    }
}

/// propertyWrapper like `by inject()` koin method
@propertyWrapper struct KoinInject {
    var qualifier: String?
    var parameters: [Any]?

    init(qualifier: String? = nil, parameters: [Any]? = nil) {
        self.qualifier = qualifier
        self.parameters = parameters
    }

    lazy var wrappedValue: T = {
        return koinGet(qualifier: qualifier, parameters: parameters)
    }()
}

/// like the `get()` koin method
func koinGet(qualifier: String? = nil, parameters: [Any]? = nil) -> T {
    return AppContext.shared.koinApplication.get(qualifier: qualifier,
                                                 parameters: parameters)
}
  • Finally, get the instance from different ways
// lazy loading of any instance
@KoinInject private var accountService
// direct loading of any instance
private let logger: KermitLogger = koinGet(parameters: ["FirstScreenDataStore"])

// get the ViewModel as example
@StateObject private var viewModel: SharedViewModel
init(param1: String? = nil) {
    _viewModel = StateObject(wrappedValue: { .init(parameters: ["IOS-MyFirstScreenWithoutMacro"]) }())
}
// or
@StateObject private var viewModel: SharedViewModel = .init(koinGet())
// and many other ways