如何在 Kotlin Multiplatform 库的 API 中避免请求 Android Context

如何在 Kotlin Multiplatform 库的 API 中避免请求 Android Context_第1张图片

如何在 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.
     * 
* 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 的介绍。希望本文对你有所帮助!

你可能感兴趣的:(kotlin多平台,Kotlin进阶,android,kotlin,cocoa)