Compose for iOS:kotlin 与 swift 互操作

前言

类似于 Android 上的 compose,在 iOS 上的 compose 同样支持嵌套显示 compose UI 和 swiftUI 或是 uikit 。

但是不同于 Android 原生就是使用 kotlin 作为开发语言,iOS 的开发语言是 swift 或者 object-c 。虽然大多数业务逻辑都可以直接使用 kotlin 实现,但是有时候有些逻辑无法直接使用 kotlin 实现,必须调用 iOS 原生代码,例如关于 iOS 原生平台的 API。

因此,本文将以实际项目为例,说明如何在 Compose for iOS 实现业务逻辑的互操作。

swift 调用 kotlin

没错,这次又双叒用 calculator-Compose-MultiPlatform 项目举例子,哈哈哈,谁叫我现在手头就这个完整的跨平台项目呢,而且恰好上次移植这个项目支持 iOS 时留下了一些关于 iOS 平台未解决的问题,正好这次一并解决了。

关于这个项目,第一个要解决的问题就是需要监听屏幕的旋转事件,当监听到屏幕旋转时动态的改变当前显示键盘为标准键盘或程序员键盘。

但是监听屏幕旋转属于是 iOS 的平台特有代码,无法直接在 kotlin 中实现,所以只能在 iOS 原生代码中实现监听后,调用 kotlin 代码更改 Compose 界面逻辑。

在开始之前还是得说明一下,毕竟我不是 iOS 开发者,只是 Android 开发,所以对于 iOS 原生代码一窍不通,下文中提到的大多数 iOS 代码都是我从网上 copy 下来修改的,难免会有所错误,各位大佬发现了欢迎指正。

那么,我们正式开始我们的适配之路吧~

首先,我们需要在 iOS 原生代码也就是 swift 中实现对于屏幕旋转事件的监听。

使用 Xcode 打开我们的 Compose MultiPlatform 项目的 iosApp 目录。

然后找到 ContentView.swift 文件并打开,在其中添加下面一些函数:

struct DetectOrientation: ViewModifier { 
    func body(content: Content) -> some View {
        content
            .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
                // 触发屏幕方向改变事件
            }
    }
}

extension View {
    func detectOrientation() -> some View {
        modifier(DetectOrientation())
    }
}

上述代码在 DetectOrientation 中订阅了接收屏幕方向改变事件,然后又定义了一个 detectOrientation 扩展函数,用于将这个订阅函数绑定到特定的 view 中。

接着,我们在实际使用的 view 中添加这个扩展函数即可:

struct ContentView: View {
    var body: some View {
        VStack {
            ComposeView()
                    .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
        }.detectOrientation()
    }
}

上面的 ContentView 即我们实际用于放置 Compose UI 的界面代码,所以我们就把检测屏幕方向改变的扩展函数加到这里。

此时只要 iOS 设备的屏幕方向发生改变就会触发 DetectOrientation 中的事件,所以我们只要在其中调用我们的 kotlin 代码实现更改键盘逻辑即可。

在这里调用 kotlin 代码非常简单。

我们将 IDE 切换回 AndroidStudio,并打开项目的 shared 模块的 iosMain 包下的 main.ios.kt 文件,在其中直接添加一个函数:

/**
 * @param orientation 0 竖,1 横
 * */
fun onScreenChange(orientation: Int) {
    if (orientation == 0) {
        homeChannel?.trySend(
            HomeAction.OnScreenOrientationChange(
                changeToType = KeyboardTypeStandard
            )
        )
    }
    else {
        homeChannel?.trySend(
            HomeAction.OnScreenOrientationChange(
                changeToType = KeyboardTypeProgrammer
            )
        )
    }
}

这个函数逻辑也很简单,接收一个参数 orientation 当其为 0 时表示切换到标准键盘,为 1 时表示切换到程序员键盘。

在这个函数被调用后会发送一个 Action 通知 Compose 更改布局。

那么,怎么在刚才的 swift 代码中调用这个代码呢?

其实也很简单:

struct DetectOrientation: ViewModifier {
    func body(content: Content) -> some View {
        content
            .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
                // 触发屏幕方向改变事件
                if (UIDevice.current.orientation.isLandscape) {
                    Main_iosKt.onScreenChange(orientation: 1)
                }
                else {
                    Main_iosKt.onScreenChange(orientation: 0)
                }
            }
    }
}

没错,就是这么简单,直接 Main_iosKt.onScreenChange() 就可以了。

需要注意的是,这里的这个 onScreenChange 大概率会报错,不用害怕,重新编译一下即可。

这是因为在 iosMain 包下的 kt 函数都会被直接编译成 iOS 的 native 代码,并通过 shared 映射给 iOS ,所以直接调用即可。

kotlin 调用 swift

其实大多数的业务逻辑已经完全可以直接使用 kotlin 来编写而无需调用 swift 了,除了一些平台特定 API 除外。

此时又有了两种解决方案,一种是 kotlin MultiPlatform 已经封装了大多数的 iOS 平台特定代码到 kotlin 中,我们直接调用即可。

例如关于蓝牙操作的 API 就封装在了 platform.CoreBluetooth 包中,我们需要使用 iOS 的蓝牙时只需要在 kotlin 中导入这个包然后使用即可,例如申请蓝牙权限:

import platform.CoreBluetooth.CBCentralManager
import platform.CoreBluetooth.CBManagerAuthorizationAllowedAlways
import platform.CoreBluetooth.CBManagerAuthorizationDenied
import platform.CoreBluetooth.CBManagerAuthorizationNotDetermined
import platform.CoreBluetooth.CBManagerAuthorizationRestricted

internal class BluetoothPermissionDelegate : PermissionDelegate {
    override fun getPermissionState(): PermissionState {
        return when (CBCentralManager.authorization) {
            CBManagerAuthorizationNotDetermined -> {
                // 未授予权限
            }
            CBManagerAuthorizationAllowedAlways, CBManagerAuthorizationRestricted -> {
                // 权限已授予
            }
            CBManagerAuthorizationDenied -> {
                // 权限已被拒绝
            }
            else -> {
                // 其他
            }
        }
    }

    override suspend fun providePermission() {
        CBCentralManager().authorization()
    }

    override fun openSettingPage() {
        // 打开设置界面
    }
}

只是,虽然 kotlin MultiPlatform 已经封装了大多数的平台特定 API ,但是还是会有一些没有封装到的,我们不得不只能通过调用 swift 来使用的 API 。

例如,上文中我们提到了目前项目中移植到 iOS 缺失的部分是关于屏幕方向改变监听的,其实与之对应的还缺失了直接强制更改当前屏幕方向的代码。

因为在程序中不仅支持旋转屏幕切换键盘类型,也支持直接点击切换按钮切换键盘类型,但是只切换类型而不强制旋转屏幕的话 UI 将会变得非常奇怪,所以就必须在更改 UI 的同时更改屏幕方向。

而更改屏幕方向的 API 显然在 kotlin 中并不存在,所以只能我们自己在 swift 中实现:

func changeOrientation(to orientation: UIInterfaceOrientation) {
    if #available(iOS 16.0, *) {

        let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene

        if (orientation.isPortrait) {
            windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait))
        }
        else {
            windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: .landscape))
        }
    }
    else {
        UIDevice.current.setValue(orientation.rawValue, forKey: "orientation")
    }
}

在上述的 swift 代码中我们还对旋转屏幕做了一个适配,因为在 iOS 16 以后,原本直接使用 UIDevice.current.setValue 设置屏幕方向的方法被弃用了,所以需要额外适配一下。

那么,现在在 swift 中旋转屏幕的代码已经有了,该怎么从 kotlin 中调用呢?

答案是,我不知道,我找了很久的资料,也没找到怎么从 kt 中直接调用 swift 函数的方法,也许就是不支持吧。

但是,别慌,虽然没有直接支持的方法,但是我们可以曲线支持。

即然上文我们已经知道了 swift 可以直接调用 kt 函数,而且最重要的是,kt 和 swift 都支持匿名函数以及把匿名函数作为函数的参数。

那么,答案这不就出来了吗?

我们首先在 kt 中定义一个匿名函数: var changeScreenOrientationFunc: ((to: Int) -> Unit)? = null

然后在 Compose 中点击按钮后需要旋转屏幕时调用这个匿名函数:

fun changeKeyBoardType(changeTo: Int, isFromUser: Boolean) {
    if (changeTo == KeyboardTypeStandard) {
        changeScreenOrientationFunc?.invoke(0)
    }
    else {
        changeScreenOrientationFunc?.invoke(1)
    }
}

接下来,我们需要在 kt 中定义一个函数用于设置这个匿名函数,然后提供给 swift 调用:

fun changeScreenOrientation(callBack: (to: Int) -> Unit) {
    changeScreenOrientationFunc = callBack
}

最后,我们只需要在 swift 中初始化时调用这个函数设置相应的匿名函数实现即可:

Main_iosKt.changeScreenOrientation { KotlinInt in
    if (KotlinInt == 0) {
        changeOrientation(to: UIInterfaceOrientation.portrait)
    }
    else {
        changeOrientation(to: UIInterfaceOrientation.landscapeLeft)
    }
}

总结

以上就是在 compose iOS 中 swift 与 kotlin 互操作的全部内容,完整代码可见 calculator-Compose-MultiPlatform 项目。

本来今天是准备写在 kotlin jvm 平台调用 jni 实现和 c/c++ 的互操作的,但是遇到一点啸问题,忙活了一整天都没解决,所以就临时改为写一篇 compose iOS 中 swift 与 kotlin 互操作了。