Android Jetpack Compose多平台用于Android和IOS
JetBrains和外部开源贡献者已经努力工作了几年时间来开发Compose Multiplatform,并最近发布了适用于iOS的Alpha版本。自然地,我们对其功能进行了测试,并决定通过使用该框架在iOS上运行我们的Dribbble复制音乐应用程序来进行实验,看看可能会出现什么挑战。
Compose Multiplatform面向桌面和iOS平台,利用Skia的功能,Skia是一个广泛应用于不同平台的开源2D图形库。Google Chrome、ChromeOS、Mozilla Firefox以及JetPack Compose和Flutter等广泛采用Skia作为其引擎。
Compose Multiplatform架构
为了理解Compose Multiplatform的方法,我们首先研究了JetBrains提供的概述,其中包括Kotlin Multiplatform Mobile(KMM)。
正如图表所示,Kotlin Multiplatform的一般方法包括:
- 为iOS特定的API(如蓝牙、CodeData等)编写代码。
- 为业务逻辑创建共享代码。
- 在iOS端创建UI。
Compose Multiplatform引入了共享UI代码的能力,不仅可以共享业务逻辑代码,还可以共享UI代码。您可以选择使用本机iOS UI框架(UIKit或SwiftUI),或直接将iOS代码嵌入Compose。我们希望查看我们在Android上复杂的本机UI在iOS上的工作情况,因此我们选择将本机iOS UI代码限制在最小范围内。目前,您只能使用Swift代码编写特定于平台的API,而对于特定于平台的UI,可以使用Kotlin和Jetpack Compose与Android应用程序共享所有其他代码。
如图所示,Kotlin Multiplatform的一般方法包括:
- 编写专门针对iOS API(如蓝牙和CodeData)的代码。
- 创建用Kotlin编写的共享业务逻辑代码。
- 在iOS端创建UI。
Compose Multiplatform扩展了代码共享的功能,现在您不仅可以共享业务逻辑代码,还可以共享UI代码。您仍然可以使用SwiftUI创建UI,或将UIKit直接嵌入Compose,我们将在下面进行讨论。通过这个新的开发方式,您只需要使用Swift代码来处理特定于平台的API和UI,而可以使用Kotlin和Jetpack Compose与Android应用程序共享其他所有代码。现在,让我们深入探讨启动所需的准备工作。
在iOS上运行的先决条件
获取iOS设置说明的最佳位置是官方文档本身。总结如下,以下是开始的所需条件:
- Mac电脑
- Xcode
- Android Studio
- Kotlin Multiplatform Mobile插件
- CocoaPods依赖管理器
此外,JetBrains存储库中提供了一个模板,可以帮助处理多个Gradle设置。
https://github.com/JetBrains/compose-multiplatform-ios-android-template/#readme
项目结构
设置了基础项目后,您将看到三个主要目录:
- androidApp
- iosApp
- shared
androidApp和shared是模块,因为它们与Android相关并使用build.gradle构建。iosApp是实际iOS应用程序的目录,您可以通过Xcode打开。androidApp模块只是Android应用程序的入口点。以下代码对于任何曾经为Android开发过的人来说都是熟悉的。
class MainActivity : AppCompatActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {MainView()}}
}
iosApp是iOS应用程序的入口点,其中包含一些样板式的SwiftUI代码:
import SwiftUI@main
struct iOSApp: App {var body: some Scene {WindowGroup {ContentView()}}
}
由于这是入口点,您应该在这里实现顶层的更改——例如,我们添加了ignoresSafeArea
修饰符,以在全屏显示应用程序:
struct ComposeView: UIViewControllerRepresentable {func makeUIViewController(context: Context) -> UIViewController {return Main_iosKt.MainViewController()}func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}struct ContentView: View {var body: some View {ComposeView().ignoresSafeArea(.all)}
}
上述代码已经可以在iOS上运行您的Android应用程序。在这里,您的ComposeUIViewController
被包装在一个UIKit的UIViewController
中,并呈现给用户。MainViewController()
位于名为main.ios.kt
的Kotlin文件中,而App()
包含了Compose应用程序的代码。
fun MainViewController() = ComposeUIViewController { App()}
以下是JetBrains提供的另一个示例。
https://github.com/JetBrains/compose-multiplatform/tree/master/examples/chat
如果您需要一些特定于平台的功能,可以使用UIKitView
将UIKit嵌入到Compose代码中。以下是JetBrains的一个地图视图示例。在使用UIKit时,与在Compose中使用AndroidView
非常相似,如果您已经熟悉该概念的话。
https://github.com/JetBrains/compose-multiplatform/blob/ea310cede5f08f7960957369247a6575f7bc5392/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/LocationVisualizer.ios.kt#L7
shared模块是这三个模块中最重要的一个。这个Kotlin模块实质上包含了Android和iOS实现的共享逻辑,促进了在两个平台上使用相同的代码库。在shared模块中,您会发现三个目录,每个目录都有自己的用途:commonMain、androidMain和iosMain。这是一个令人困惑的地方-实际上,实际共享的代码位于commonMain
目录中。其他两个目录用于编写特定于平台的Kotlin代码,在Android或iOS上会有不同的行为或外观。这是通过在commonMain
代码中编写expect fun
,并在相应的平台目录中使用actual Fun
来实现的。
Migration
在开始迁移时,我们确信会遇到一些需要特定修复的问题。尽管我们选择迁移的应用程序在逻辑上非常简单(基本上只有UI、动画和过渡),但如预期的那样,我们遇到了相当多的困难。以下是在迁移过程中可能遇到的一些问题。
Resource
我们首先要处理的是资源的使用。没有动态生成的R类,这仅适用于Android。相反,您需要将资源放在资源目录中,并将路径指定为字符串。以下是一个图像的示例:
import org.jetbrains.compose.resources.painterResourceImage(painter = painterResource(“image.webp”),contentDescription = "",
)
当以这种方式实现资源时,如果资源名称不正确,可能会发生运行时崩溃,而不是编译时崩溃。
此外,如果您在XML文件中引用Android资源,还需要摆脱与Android平台的链接。
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"android:height="24dp"
- android:tint="?attr/colorControlNormal"
+ android:tint="#000000"android:viewportWidth="24"android:viewportHeight="24">
- <path android:fillColor="@android:color/white"
+ <path android:fillColor="#000000"android:pathData="M9.31,6.71c-0.39,0.39 -0.39,1.02 0,1.41L13.19,12l-3.88" />
</vector>
Font
在编写本文时,Compose Multiplatform中没有使用iOS和Android上常用的标准字体加载技术的方法。据我们所见,Jetbrains建议使用字节数组来加载字体,如下所示的iOS代码:
private val cache: MutableMap<String, Font> = mutableMapOf()@OptIn(ExperimentalResourceApi::class)
@Composable
actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font {return cache.getOrPut(res) {val byteArray = runBlocking {resource("font/$res.ttf").readBytes()}androidx.compose.ui.text.platform.Font(res, byteArray, weight, style)}
}
然而,我们不喜欢异步的方法,也不喜欢在执行过程中阻塞主线程的runBlocking
的使用。因此,在Android上,我们决定采用一种更常见的方法,使用整数标识符:
@Composable
actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font {val context = LocalContext.currentval id = context.resources.getIdentifier(res, "font", context.packageName)return Font(id, weight, style)
}
使用时创建Font对象:
object Fonts {@Composablefun abrilFontFamily() = FontFamily(font("Abril","abril_fatface",FontWeight.Normal,FontStyle.Normal),)
}
使用Kotlin替换Java
在Compose Multiplatform中不可能使用Java代码,因为它使用Kotlin编译器插件。因此,我们需要重写使用到Java代码的部分。例如,在我们的应用中,一个时间格式化器将音乐曲目的时间从秒转换为更方便的分钟格式。我们不得不放弃使用java.util.concurrent.TimeUnit
,但事实证明这是好事,因为它给了我们重构代码并更优雅地编写代码的机会。
fun format(playbackTimeSeconds: Long): String {
- val minutes = TimeUnit.SECONDS.toMinutes(playbackTimeSeconds)
+ val minutes = playbackTimeSeconds / 60
- val seconds = if (playbackTimeSeconds < 60) {
- playbackTimeSeconds
- } else {
- (playbackTimeSeconds - TimeUnit.MINUTES.toSeconds(minutes))
- }
+ val seconds = playbackTimeSeconds % 60return buildString {if (minutes < 10) append(0)append(minutes)append(":")if (seconds < 10) append(0)append(seconds)}
}
Native Canvas
有时候,我们会使用Android Native画布来创建绘图。然而,在Compose Multiplatform中,我们无法在通用代码中访问Android原生画布,因此代码必须进行相应的调整。例如,我们有一个动画标题文本,它依赖于本机画布的measureText(letter)
函数,以实现逐字动画效果。我们不得不为这个功能寻找替代方法,所以我们使用了Compose画布来重写它,并使用TextMeasurer
代替Paint.measureText(letter)
。
fun format(playbackTimeSeconds: Long): String {
- val minutes = TimeUnit.SECONDS.toMinutes(playbackTimeSeconds)
+ val minutes = playbackTimeSeconds / 60
- val seconds = if (playbackTimeSeconds < 60) {
- playbackTimeSeconds
- } else {
- (playbackTimeSeconds - TimeUnit.MINUTES.toSeconds(minutes))
- }
+ val seconds = playbackTimeSeconds % 60return buildString {if (minutes < 10) append(0)append(minutes)append(":")if (seconds < 10) append(0)append(seconds)}
}
drawText方法也依赖于本机画布,因此必须进行重写:
Gestures
在Android上,BackHandler
始终可用 - 它处理后退手势或后退按钮按下,具体取决于设备可用的导航模式。但是这种方法在Compose Multiplatform中不起作用,因为BackHandler
是Android源集的一部分。相反,让我们使用expect fun
:
@Composable
expect fun BackHandler(isEnabled: Boolean, onBack: ()-> Unit)//Android implementation
@Composable
actual fun BackHandler(isEnabled: Boolean, onBack: () -> Unit) {BackHandler(isEnabled, onBack)
}
在iOS中,可以提出许多不同的方法来实现所需的结果。例如,您可以在Compose中编写自己的后退手势,或者如果应用中有多个屏幕,可以将每个屏幕包装在单独的UIViewController
中,并使用包含默认手势的本机iOS导航器UINavigationController
。
我们选择了一种在iOS侧处理后退手势的实现方式,而无需将单独的屏幕包装在相应的控制器中(因为我们的应用程序中的视图之间的过渡是高度定制的)。这是如何将这两种语言链接在一起的很好的示例。首先,我们添加了一个原生的iOS SwipeGestureViewController
来检测手势,并为手势事件添加了处理程序。完整的iOS实现可以在这里看到。
https://github.com/exyte/ComposeMultiplatformDribbbleAudio/blob/main/iosApp/iosApp/ContentView.swift
struct SwipeGestureViewController: UIViewControllerRepresentable {var onSwipe: () -> Voidfunc makeUIViewController(context: Context) -> UIViewController {let viewController = Main_iosKt.MainViewController()let containerController = ContainerViewController(child: viewController) {context.coordinator.startPoint = $0}let swipeGestureRecognizer = UISwipeGestureRecognizer(target:context.coordinator, action: #selector(Coordinator.handleSwipe))swipeGestureRecognizer.direction = .rightswipeGestureRecognizer.numberOfTouchesRequired = 1containerController.view.addGestureRecognizer(swipeGestureRecognizer)return containerController}func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}func makeCoordinator() -> Coordinator {Coordinator(onSwipe: onSwipe)}class Coordinator: NSObject, UIGestureRecognizerDelegate {var onSwipe: () -> Voidvar startPoint: CGPoint?init(onSwipe: @escaping () -> Void) {self.onSwipe = onSwipe}@objc func handleSwipe(_ gesture: UISwipeGestureRecognizer) {if gesture.state == .ended, let startPoint = startPoint, startPoint.x < 50 {onSwipe()}}func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {true}}
}
然后,在main.ios.kt文件中创建一个相应的函数:
fun onBackGesture() {store.send(Action.OnBackPressed)
}
我们可以在Swift中像这样调用这个函数:
public func onBackGesture() {Main_iosKt.onBackGesture()
}
我们实现了一个收集动作的存储库。
interface Store {fun send(action: Action)val events: SharedFlow<Action>
}fun CoroutineScope.createStore(): Store {val events = MutableSharedFlow<Action>()return object : Store {override fun send(action: Action) {launch {events.emit(action)}}override val events: SharedFlow<Action> = events.asSharedFlow()}
}
该存储库使用store.events.collect
方法累积动作。
@Composable
actual fun BackHandler(isEnabled: Boolean, onBack: () -> Unit) {LaunchedEffect(isEnabled) {store.events.collect {if(isEnabled) {onBack()}}}
}
这有助于解决两个平台上手势处理的差异,使iOS应用程序在后退导航方面具有原生和直观的体验。
最少Bug
在某些情况下,您可能会遇到一些次要问题,例如在iOS平台上,当点击时,项目会向上滚动以变得可见。您可以将期望的行为(Android)与下面的错误iOS行为进行比较:
这是因为Modifier.clickable在项目被点击时使其获得焦点,从而触发bringIntoView
滚动机制。Android和iOS上的焦点管理不同,导致了这种不同的行为。我们通过为项目添加.focusProperties { canFocus = false }
修饰符来解决这个问题。
结论
Compose Multiplatform是Kotlin语言在KMM之后的多平台开发的下一个阶段。这项技术为代码共享提供了更多的机会,不仅限于业务逻辑,还包括UI组件。尽管在多平台应用程序中可以结合使用Compose和SwiftUI,但目前看起来并不是很直观。
您应该考虑您的应用程序是否具有可从多个平台共享代码的业务逻辑、UI或功能能力。如果您的应用程序需要许多特定于平台的功能,KMM和Compose Multiplatform可能不是最佳选择。该存储库包含完整的实现。您还可以查看现有的库,以更加了解当前KMM的功能。
https://github.com/terrakok/kmm-awesome
至于我们,我们对Compose Multiplatform印象深刻,并认为一旦发布稳定版本,它可以在我们的实际项目中使用。它最适合于UI较重的应用程序,没有大量特定于硬件的功能。它可能是Flutter和原生开发的可行替代方案,但时间将证明一切。与此同时,我们将继续专注于原生开发-请查看我们的iOS和Android文章!