如何在 Kotlin Multiplatform 库的 API 中避免请求 Android Context
假设你正在进行 Kotlin Multiplatform 项目的开发。
你需要从通用代码中获取用户的 GPS 位置,并且目前没有现成的库可以实现该功能。
这时,你决定编写一个新的 Kotlin Multiplatform 库,以在 Android 和 iOS 上抽象 GPS 定位功能,因为你正在开发一个移动应用。
由于想不出好名字,你就给它起名为 KLocationManager
。
然后,你开始规划库所暴露的公共 API。可能是这样的:
private val kLocation: KLocationManager = KLocationManager()
fun showLocationUpdates() {
scope.launch {
kLocation.observeLocation().collect {
showMessage("Got (${it.lat}, ${it.lng})")
}
}
}
接着,你开始研究如何在这两个移动平台上实际获取 GPS 位置流。
iOS 实现
在 iOS 上,一切看起来都很简单。有一个名为 CLLocationManager
的类,你可以进行初始化,并附加一个委托(用于接收更新的回调),然后调用 startUpdatingLocation()
方法即可。
val locationManager = CLLocationManager()
fun observeLocation() = callbackFlow<KLocation> {
var mDelegate: CLLocationManagerDelegateProtocol = object : CLLocationManagerDelegateProtocol, NSObject() {
override fun locationManager(
manager: CLLocationManager,
didUpdateLocations: List<*>
) {
val locations = didUpdateLocations.map { it as CLLocation }
if (locations.isNotEmpty()) {
locations.last().coordinate.useContents {
trySend(this.toKLocation())
}
}
}
}
locationManager.apply {
delegate = mDelegate
startUpdatingLocation()
}
awaitClose {
locationManager.stopUpdatingLocation()
}
}
Android 实现
在 Android 上,你想使用 FusedLocationProviderClient,它对于这个用例来说足够简单。但是你会注意到从第一行代码开始就有问题:
val fusedLocationClient =
LocationServices.getFusedLocationProviderClient(context)
Context 的问题
正如你可能已经猜到的,我们正在编写一个多平台库,而我们已经需要一个非常依赖于平台的东西:Android Context。
你可以要求 Android 用户在使用库之前通过调用 init 方法来传递一些 Context:
KLocationManager.init(context)
然而,当在另一个多平台项目中使用该库时,这将增加一些复杂性,因为用户将需要添加特定于平台的代码来调用 init 方法,但这只适用于 Android。
在 StackOverflow 上,还有其他一些不太正规的解决方案,比如为每个平台创建不同的构造函数、创建一个通用的 Context 类(在 iOS 上无操作,在 Android 上是一个实际的 Context)、使用 DI 等等…
但是,有没有一种解决方案可以在库的内部处理所有这些问题,并且不需要用户付出努力,以便他们可以在两个平台和通用代码上使用完全相同的 API 呢?
Jetpack App Startup 解决方案
App Startup 是 Jetpack 中的一个库,用于在应用启动时初始化库的组件(从名称上就可以猜到)。
https://developer.android.com/topic/libraries/app-startup
对我们来说,这可能很有用,因为:
- 它会在任何库代码之前自动运行初始化步骤(因此我们确保我们的组件将始终在使用前被初始化);
- 它仅适用于 Android,不需要在 iOS 模块中进行任何修改;
- 它提供了在初始化阶段获取 Android Context 的方法。
正是因为最后一个原因,这对于我们来说可能很有用,因为我们正在寻找一种在不打扰用户的情况下向库代码注入 Android Context 的方法。
由于 App Startup 只需要在 Android 模块中使用,我们可以将其定义为 androidMain 的依赖项:
val androidMain by getting {
dependencies {
implementation("androidx.startup:startup-runtime:lastVersion")
[...]
}
}
可以在此处(https://developer.android.com/topic/libraries/app-startup)查看该库的最新发布版本。
AppStartup 将在启动时调用一个实现了 Initializer 接口的特殊类。
public interface Initializer<T> {
/**
* Initializes and a component given the application {@link Context}
*
* @param context The application context.
*/
@NonNull
T create(@NonNull Context context);
/**
* @return A list of dependencies that this {@link Initializer} depends on. This is
* used to determine initialization order of {@link Initializer}s.
* <br/>
* For e.g. if a {@link Initializer} `B` defines another
* {@link Initializer} `A` as its dependency, then `A` gets initialized before `B`.
*/
@NonNull
List<Class<? extends Initializer<?>>> dependencies();
}
第一个方法对我们来说非常有意义,请注意,通过实现此接口,我们可以在应用中免费获取一个 Context。
而第二个方法则在你的库依赖于其他库的初始化程序时有用,这样你就可以指定应用在启动时按照哪个顺序运行初始化程序。对于我们的用例,我们可以忽略这个方法。
因此,在我们的示例中,我们将从 create()
方法中获取 Context
,并将其存储在我们可以随后访问的地方。
import android.content.Context
import androidx.startup.Initializer
internal lateinit var applicationContext: Context
private set
public object KLocationContext
public class KLocationInitializer: Initializer<KLocationContext> {
override fun create(context: Context): KLocationContext {
applicationContext = context.applicationContext
return KLocationContext
}
override fun dependencies(): List<Class<out Initializer<*>>> {
return emptyList()
}
}
注意,
KLocationInitializer.kt
将位于 android/platform-specific 模块中,而不是我们库的通用代码中。
最后,App Startup 使用一个特殊的内容提供程序来发现和调用所有组件的初始化程序。在 AndroidManifest.xml 中增加一个条目将使我们的 Initializer 可被发现。
<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data android:name="dev.paolorotolo.klocation.KLocationInitializer"
android:value="androidx.startup" />
</provider>
[...]
</application>
我们可以放心地使用我们库的 AndoridManifest.xml,因为它最终将与使用它的应用的主 Manifest 合并。
到此为止,我们完成了!
每当我们在 Android 模块中需要一个 Context 时,我们实际上可以访问自动初始化的 applicationContext 变量。
现在,Android 的定位代码将会是这样的:
// injected context here from App Startup
val fusedLocationClient
= LocationServices.getFusedLocationProviderClient(applicationContext)
fun observeLocation(): Flow<KLocation> = callbackFlow {
val locationCallback = object: LocationCallback(){
override fun onLocationResult(locationResult: LocationResult) {
super.onLocationResult(locationResult)
trySend(locationResult)
}
}
fusedLocationClient.requestLocationUpdates(
buildLocationRequest(),
locationCallback,
Looper.getMainLooper()
).addOnFailureListener { close(it) }
awaitClose {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
}.map { it.asKLocation() }
结论
我们使用 App Startup 创建的个人上下文注入器将具有以下特点:
- 仅在 Android 上运行(因为 androidx-startup 是 androidMain 的依赖项);
- 仅在 Android 特定代码模块中提供
applicationContext
(因为它位于与 KLocationInitializer.kt 相同的模块中); - 对于库的用户来说是透明的,他们可以在通用代码和所有支持的目标中调用相同的 API
observeLocation()
。
这样,我们就完成了如何在 Kotlin Multiplatform 库的 API 中避免请求 Android Context 的介绍。希望本文对你有所帮助!