在 SwiftUI 中使用 Compose 共享业务逻辑

737 阅读4分钟

前言

看到标题也许会一头雾水,SwiftUI、Compose、业务逻辑这几个词分开看得懂,连起来是个什么玩意?

首先,关于 Compose 和业务逻辑的关系,在之前的文章提到过:Compose 和 Compose UI 有些不同,Compose 可以完全不包含 UI,结合 Compose 的特性,你甚至拿 Compose 来写业务逻辑也完全没有问题。有不了解的朋友可以先看看之前的文章,在这里就不赘述了。
那么更进一步,Compose 编写的业务逻辑能不能用在 SwiftUI 中呢?
答案是可以的!
下面就来讲讲如何在 iOS 甚至其他更多平台使用 Compose 编写的业务逻辑代码,从而达到多个平台共享同一套业务逻辑代码。

如何在 SwiftUI 中使用 Compose 编写的业务逻辑

之前的文章有提到 Jake Wharton 大佬编写的 Molecule,简单说就是一个使用 Compose 的特性来生成 Flow 的一个工具,在这个工具的帮助下,我们就可以编写一套共享的业务逻辑。
还是举 Counter 的例子来说,我们首先定义一个 CounterState 和CounterPresenter:

@Composable
fun counterPresenter(): CounterState {
    var count by remember { mutableStateOf(0) }
    return object : CounterState(count.toString()) {
        override fun increment() {
            count++
        }
    }
}

abstract class CounterState(
    val count: String,
) {
    abstract fun increment()
}

当然,在 iOS 中,counterPresenter 是无法直接调用的,这时候就需要使用 Molecule 来进行桥接,输出一个 Flow 给 iOS 使用:

class CounterPresenter {
    private val scope = CoroutineScope(Dispatchers.Main + DisplayLinkClock)

    actual val models: StateFlow<CounterState> by lazy {
        scope.launchMolecule(mode = RecompositionMode.ContextClock) {
            counterPresenter()
        }
    }
}

在 iOS 中,Flow 并不是很容易直接调用,这时候需要 SKIE 来帮我们将 Flow 的调用简化,这样我们就可以在 iOS 中这样调用 CounterPresenter

@Observable
class CounterViewModel {
    private let presenter = CounterPresenter()
    var model: CounterState
    init() {
        // 初始化 model的值
        model = presenter.models.value
    }
    @MainActor
    func activate() async {
        // 借助 SKIE 完成对 CounterPresenter.models 的监听
        for await model in presenter.models {
            self.model = model
        }
    }
}

struct CounterView: View {
    @State var viewModel = CounterViewModel()
    var body: some View {
        VStack {
            // 直接使用 CounterState 的值
            Text("Counter: \(viewModel.model.count)")
            Button(action: {
                // 直接调用 CounterState 内的方法
                viewModel.model.increment()
            }, label: {
                Text("Increment")
            })
        }
        // 确保 viewModel 监听正常 Presenter 的 Flow
        .task {
            await viewModel.activate()
        }
    }
}

而在 Android 中,我们可以直接在 Compose 中使用 counterPresenter,相关的内容已经在前文有介绍过,就不再赘述了。

限制

当然这样的做法也是有一些限制的,毕竟 Kotlin/Native 还是一个很初期的阶段

范型

Kotlin/Native 在 iOS 上还不是直接与 Swift 进行交互,还只是通过 Objective-C 来进行的,这个过程中泛型就很容易出问题,几乎所有的 interface 定义的泛型都无法使用,但是 class 可以,举个例子:

interface SomeInterface<T> {}
val i: SomeInterface<T> // 在 Swift 中 i 的泛型会被抹除

class SomeClass<T> {}
val c: SomeClass<T> // 而 c 的泛型能够正确使用

最典型的例子就是 List<T>,我们需要返回一个 class 来避免泛型类型被抹除,需要手写一个类似 ListWrapper 的 class,并且不能让这个 class 继承 List<T>

生命周期

SwiftUI 和 Compose UI 的生命周期还是有一些差别的,比如在 iOS 中,当页面返回之后,我们的 CounterPresenter 似乎会被重新创建,导致数据会被重新获取,如果是严格使用单一数据源的架构的话,这倒不是什么大问题。不过我对于 SwiftUI 的了解还是非常浅显的,有可能是我理解错误,这里就抛砖引玉,还请各位 iOS 开发来解答比较好。

FAQ

Q:这样做有什么好处吗?
一定会有人遇到因为平台表现不一致而导致的问题,相同的一套业务逻辑代码能够同时在多端共享,在保证业务逻辑一致的情况下还能拥有 Native 级别的性能,并且还能与 Swift 代码交互。

Q:都用上 Compose 了,我直接用 Compose 画 UI 不是更好吗?
确实,这更多是一种选择。如果更偏向体验一致可以选择直接用 Compose 画 UI,如果想要 Native 的体验,还需要多端业务逻辑保持一致,那么文章提到的做法也不失为一种选择。

Q:这些限制这么多,感觉会有很多坑,而且不稳定
确实,Kotlin Multiplatform 甚至目前为止都还不是正式版,仍然有不少基础组件缺失,写一些业务逻辑其实也会遇到因为基础组件的缺失而导致很别扭的情况,比如 HTML 解析,所以文章提到的更多是一种尝试。

Q:说了这么些,有没有什么实际项目在这样用呢?
您好,有的。这是我最近一直在写的一个项目:github.com/DimensionDe… ,这是一个跨平台的 Mastodon/Misskey/Bluesky 的社交客户端,支持 Android/iOS/macOS,代码层面完全是按照文章所说的实现的,核心业务逻辑完全使用 Kotlin 编写,在 Android 上使用 Jetpack Compose 实现 UI,在 iOS/macOS 上使用 SwiftUI,目前也进入了公开测试阶段,欢迎体验。

是的,为了写这篇文章,我特地写了一个项目(狗头)。
另外这篇文章从去年9月拖到现在终于写完了。