Android SDK 开发现代指南:测试与分发(第 2 部分,共 3 部分)
- 原文链接:proandroiddev.com/the-modern-…
- 原文作者:Dmytro Petrenko
第 2 部分,共 3 部分 —— 协程测试、接入方环境模拟、二进制兼容性,以及 Maven Central
第 1 部分讲的是更难的设计抉择:API 优先、线程安全的初始化、用 StateFlow 做响应式状态,以及把公开契约与私有实现隔开的可见性边界。架构已经站得住脚。现在我们要证明它真的可用、防止意外回归,并把它交付出去。
💡 全部代码见配套仓库:github.com/sailsdima/A…
原则 1:测契约,而不是测实现
测 SDK 时,第一反应往往是伸手去摸内部 —— 直接测 EventQueue、验证 RetryPolicy 行为、断言 EventDispatcher 收到了什么。这不算错,但会让测试套件跟实现细节绑在一起,而不是跟接入方真正依赖的契约绑在一起。
对 SDK 而言,契约就是公开 API 表面。如果 track() 接受 Event,并且状态流会迁到 DeliveryStatus.Flushing,这就是你承诺过的。队列背后是 SharedPreferences 还是 SQLite 表,并不在承诺范围内。
AnalyticsKit 需要两类测试:
- 内部单元测试覆盖不依赖 Android 的纯逻辑 ——
RetryPolicy退避计算、BatchConfig校验、事件名约束等。它们快、可封闭,适合按单元测。放在src/test/,跑在 JVM 上。 - 接入方测试只使用公开 API 表面,完全像外部开发者那样写。不要
internal.*import,不要注入 fake。SDK 是黑盒。这类测试对防止回归最重要。 - 对内部单元测试,fake 优于 mock:手写的
FakeEventDispatcher把每次派送的批次记在列表里,比 Mockito mock 稳定 —— 后者在你改名方法时很容易碎。fake 还能传达意图,它本身就是「真实实现应当如何表现」的可执行文档。
原则 2:给重度协程的 SDK 写单元测试
AnalyticsKit 的行为 —— track()、flush()、自动 flush 循环 —— 几乎都由协程驱动。测试它需要 kotlinx-coroutines-test 提供的两样东西。
runTest 会创建一个由虚拟时钟驱动的 TestScope。生产代码里的 delay() 不会真的睡眠 —— 它们推进虚拟时间。30 秒的 flush 间隔在测试里耗费的「真实毫秒」是零。
StandardTestDispatcher(runTest 的默认调度器)不会急着执行协程;它们会排队,直到你调用 advanceUntilIdle() 或 advanceTimeBy()。正是这种可控性,让与时间相关的行为变得确定。
class RetryPolicyTest {
@Test
fun `backoff delay increases exponentially`() {
val policy = RetryPolicy(baseDelay = 1.seconds, maxDelay = 30.seconds)
assertThat(policy.delayFor(attempt = 1)).isEqualTo(2.seconds)
assertThat(policy.delayFor(attempt = 2)).isEqualTo(4.seconds)
assertThat(policy.delayFor(attempt = 3)).isEqualTo(8.seconds)
}
@Test
fun `delay caps at maximum`() {
val policy = RetryPolicy(baseDelay = 1.seconds, maxDelay = 5.seconds)
assertThat(policy.delayFor(attempt = 10)).isEqualTo(5.seconds)
}
@Test
fun `shouldRetry is false beyond max attempts`() {
val policy = RetryPolicy(maxRetries = 3)
assertThat(policy.shouldRetry(attempt = 1)).isTrue()
assertThat(policy.shouldRetry(attempt = 3)).isTrue()
assertThat(policy.shouldRetry(attempt = 4)).isFalse()
}
}
异步行为用带虚拟时间的 runTest:
class AutoFlushTest {
@Test
fun `auto-flush fires after the configured interval`() = runTest {
val analytics = buildTestInstance(
flushInterval = 30.seconds,
flushOnAppBackground = false
)
repeat(3) { analytics.track("event_$it") }
advanceUntilIdle() // 让 track() 相关协程先跑完
assertThat(analytics.state.value.queuedEvents).isEqualTo(3)
advanceTimeBy(30_001) // 把虚拟时钟拨过间隔
advanceUntilIdle()
assertThat(analytics.state.value.queuedEvents).isEqualTo(0)
analytics.destroy()
}
@Test
fun `auto-flush loop survives a single network failure`() = runTest {
val fakeDispatcher = FakeEventDispatcher(failOnFirstCall = true)
val analytics = buildTestInstance(dispatcher = fakeDispatcher)
analytics.track("will_fail_first_attempt")
advanceUntilIdle()
// 第一次自动 flush 失败 —— 循环绝不能就此死掉
advanceTimeBy(30_001)
advanceUntilIdle()
// 第二次自动 flush 成功
advanceTimeBy(30_001)
advanceUntilIdle()
assertThat(analytics.state.value.queuedEvents).isEqualTo(0)
analytics.destroy()
}
}
第二个测试非常值得写。第 1 部分里自动 flush 循环中的 try/catch 专门用来防止一次失败的 flush 永久杀死循环。这个测试就是在证明这条契约成立。没有它,你可能要等到接入方设备断网 30 秒后,才发现事件在会话剩余时间里悄悄不再上报。
两个测试都依赖 buildTestInstance 和 FakeEventDispatcher。下面是最小实现思路:
为了让 SDK 在测试中不必打真实网络,给 AnalyticsKit 增加一个包内可见的工厂方法,直接接收 IEventDispatcher,绕过真正的 EventDispatcher。它是 internal 的 —— 对接入方不可见,但 SDK 自己的 test source 能访问。
这个工厂有意绕过第 1 部分的单例检查 —— 测试需要在一次运行里创建并销毁多个实例。每个测试类的 tearDown() 负责在测试间清理状态。这是仅用于测试的模式;在生产代码里,单例守卫始终生效。
// 位于 AnalyticsKit 的 companion object 内
internal fun initializeForTest(
config: AnalyticsConfig,
dispatcher: IEventDispatcher
): AnalyticsKit = AnalyticsKit(
config = config,
store = InMemoryEventStore(),
eventDispatcher = dispatcher
)
把 IEventDispatcher 从具体的 EventDispatcher 中抽出来,才能让 dispatcher 在测试里可替换 —— 这是标准的依赖倒置:
internal interface IEventDispatcher {
suspend fun dispatch(events: List<InternalEvent>)
}
AnalyticsException 是 SDK 内与 EventDispatcher 放在一起的类型化投递错误:
internal class AnalyticsException(
val error: AnalyticsError,
message: String
) : Exception(message)
有了这三块积木,TestHelpers.kt 和 FakeEventDispatcher 就很直接:
// 测试脚手架(TestHelpers.kt)
internal fun TestScope.buildTestInstance(
flushInterval: Duration = 30.seconds,
flushOnAppBackground: Boolean = false,
failNextFlush: Boolean = false,
dispatcher: FakeEventDispatcher = FakeEventDispatcher(failOnFirstCall = failNextFlush)
): AnalyticsKit = AnalyticsKit.initializeForTest(
config = AnalyticsConfig(
apiKey = "test_key",
environment = Environment.STAGING,
batching = BatchConfig(
flushInterval = flushInterval,
flushOnAppBackground = flushOnAppBackground
)
),
dispatcher = dispatcher
)
internal class FakeEventDispatcher(
private val failOnFirstCall: Boolean = false
) : IEventDispatcher {
val batches = mutableListOf<List<InternalEvent>>()
private var callCount = 0
override suspend fun dispatch(events: List<InternalEvent>) {
callCount++
if (failOnFirstCall && callCount == 1) {
throw AnalyticsException(AnalyticsError.NETWORK_ERROR, "Simulated network failure")
}
batches.add(events)
}
val totalEventCount get() = batches.sumOf { it.size }
}
buildTestInstance 是 TestScope 的扩展函数,因此能拿到 runTest 的虚拟时钟。FakeEventDispatcher 记录每一次派送的批次,并在 failOnFirstCall 时于首次调用抛错 —— 足以在不碰网络的情况下测试重试与「循环存活」行为。
用 Turbine 测 StateFlow 的发射
断言 state.value 只能告诉你最终停在哪里。对 AnalyticsKit 来说,状态迁移顺序很重要 —— Flushing 必须出现在 Idle 之前,Failed 必须带上正确的错误码。Turbine 能把这一点说精确。
testImplementation(libs.turbine)
class StateEmissionTest {
@Test
fun `flush transitions through Flushing then settles at Idle`() = runTest {
val analytics = buildTestInstance()
analytics.track("purchase_completed")
advanceUntilIdle()
analytics.state.test {
analytics.flush()
advanceUntilIdle()
val flushing = awaitItem()
assertThat(flushing.deliveryStatus).isInstanceOf(DeliveryStatus.Flushing::class.java)
val idle = awaitItem()
assertThat(idle.deliveryStatus).isEqualTo(DeliveryStatus.Idle)
assertThat(idle.queuedEvents).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
}
analytics.destroy()
}
@Test
fun `failed flush emits DeliveryStatus Failed with the correct error`() = runTest {
val analytics = buildTestInstance(failNextFlush = true)
analytics.track("purchase_completed")
advanceUntilIdle()
analytics.state.test {
analytics.flush()
advanceUntilIdle()
skipItems(1) // Flushing —— 这里不是要断言的重点
val failed = awaitItem()
assertThat(failed.deliveryStatus).isInstanceOf(DeliveryStatus.Failed::class.java)
val status = failed.deliveryStatus as DeliveryStatus.Failed
assertThat(status.error).isEqualTo(AnalyticsError.NETWORK_ERROR)
cancelAndIgnoreRemainingEvents()
}
analytics.destroy()
}
}
三条 Turbine 用法值得记住:
awaitItem()会挂起直到下一次发射或超时。如果流不发,测试就失败 —— 常见原因是你把advanceUntilIdle()放在了test {}块之前,而不是块内。skipItems(n)会消费掉 n 次发射而不断言。你只关心最终状态时用它。cancelAndIgnoreRemainingEvents()在仍有未读发射时结束收集且不让测试失败。没有它的话,Turbine 会在消费数量少于发射数量时判失败。
每个测试结束都要调用 analytics.destroy()。SDK 在 init {} 里启动协程;不取消就会在测试之间泄漏,产生依赖执行顺序的 flaky 失败。
原则 3:模拟接入方环境
接入方模拟测试是大多数 SDK 作者会跳过的一层,却也是能在发布前抓住最尴尬 bug 的一层。
规则很硬:这些文件只能 import com.analyticskit。不要 internal.* 包,不要注入 fake。SDK 被当作来自 Maven 的依赖 —— 只能通过公开表面沟通的黑盒。
ApplicationProvider.getApplicationContext() 需要真实的 Android Context。这些测试借助 Robolectric 跑在 JVM 上 —— 不是设备/模拟器上的 instrumented 测试 —— 因此加上依赖与 Runner 注解:
testImplementation(libs.robolectric)
// 接入方黑盒测试(ConsumerSimulationTest.kt)
// 规则:只允许公开 API 的 import。不要 com.analyticskit.internal.*
@RunWith(RobolectricTestRunner::class)
class ConsumerSimulationTest {
@After
fun tearDown() {
runCatching { AnalyticsKit.getInstance().destroy() }
}
@Test
fun `consumer initializes SDK and tracks events`() = runTest {
val analytics = AnalyticsKit.initialize(
context = ApplicationProvider.getApplicationContext(),
config = AnalyticsConfig(
apiKey = "key_test_abc",
environment = Environment.STAGING,
batching = BatchConfig(flushOnAppBackground = false),
logging = LogLevel.DEBUG
)
)
analytics.track("screen_viewed", mapOf("screen" to "home"))
analytics.track(
Event(
name = "item_added_to_cart",
properties = mapOf("item_id" to "SKU-9988", "price" to 49.99)
)
)
advanceUntilIdle()
assertThat(analytics.state.value.queuedEvents).isEqualTo(2)
}
@Test
fun `EventInterceptor filters events before delivery`() = runTest {
val analytics = AnalyticsKit.initialize(
context = ApplicationProvider.getApplicationContext(),
config = AnalyticsConfig(
apiKey = "key_test",
batching = BatchConfig(flushOnAppBackground = false),
eventInterceptor = EventInterceptor { events ->
events.filter { it.name != "debug_noise" }
}
)
)
analytics.track("purchase_completed")
analytics.track("debug_noise") // 应当被过滤
advanceUntilIdle()
assertThat(analytics.state.value.queuedEvents).isEqualTo(1)
}
@Test
fun `double initialization throws`() {
AnalyticsKit.initialize(
context = ApplicationProvider.getApplicationContext(),
config = AnalyticsConfig(apiKey = "first")
)
assertThrows<IllegalStateException> {
AnalyticsKit.initialize(
context = ApplicationProvider.getApplicationContext(),
config = AnalyticsConfig(apiKey = "second")
)
}
}
@Test
fun `track after destroy throws`() {
val analytics = AnalyticsKit.initialize(
context = ApplicationProvider.getApplicationContext(),
config = AnalyticsConfig(apiKey = "key")
)
analytics.destroy()
assertThrows<IllegalStateException> {
analytics.track("post_destroy_event")
}
}
@Test
fun `Java Builder produces equivalent config to Kotlin DSL`() {
val kotlinStyle = AnalyticsConfig(
apiKey = "key",
environment = Environment.STAGING,
logging = LogLevel.DEBUG
)
val javaStyle = AnalyticsConfig.Builder("key")
.environment(Environment.STAGING)
.logging(LogLevel.DEBUG)
.build()
assertThat(javaStyle.environment).isEqualTo(kotlinStyle.environment)
assertThat(javaStyle.logging).isEqualTo(kotlinStyle.logging)
}
}
EventInterceptor 这个测试能抓住一整类白盒测试完全看不到的 bug:配置里接好了拦截器,却忘了在 track() 里调用。注入 fake dispatcher 的单元测试永远看不到;接入方模拟测试走真实生产路径端到端。
Java Builder 测试存在的原因是 Java 互操作属于公开契约的一部分。你给 AnalyticsConfig 加字段却忘了更新 Builder,Kotlin 接入方可能没事 —— Java 接入方会直接编译失败。这个测试让你在发布前就能看到这一点。
原则 4:ProGuard / R8 校验
consumerProguardFiles 会把你的规则打进 AAR。接入方开启压缩时,R8 会自动应用它们。
// SDK 模块构建脚本(analyticskit/build.gradle.kts)
defaultConfig {
consumerProguardFiles("consumer-rules.pro")
}
「打包进去」不等于「规则正确」。你必须显式保留接入方可能调用、继承或反射的一切:
# consumer-rules.pro
# 公开入口与配置
-keep public class com.analyticskit.AnalyticsKit { public *; }
-keep public class com.analyticskit.AnalyticsConfig { *; }
-keep public class com.analyticskit.AnalyticsConfig$Builder { *; }
-keep public class com.analyticskit.BatchConfig { *; }
-keep public class com.analyticskit.Event { *; }
-keep public class com.analyticskit.AnalyticsState { *; }
# 带 @JvmStatic 的 companion object
-keepclassmembers class com.analyticskit.AnalyticsKit$Companion {
public static <methods>;
}
# sealed interface 与 enum —— R8 默认会删掉未使用变体
-keep class com.analyticskit.DeliveryStatus { *; }
-keep class com.analyticskit.DeliveryStatus$* { *; }
-keep class com.analyticskit.AnalyticsError { *; }
-keep class com.analyticskit.Environment { *; }
-keep class com.analyticskit.LogLevel { *; }
# EventInterceptor —— 接入方会以 lambda 或匿名类实现
-keep interface com.analyticskit.EventInterceptor { *; }
# Kotlin data class 合成方法:copy()、componentN()、equals()、hashCode()
-keepclassmembers class com.analyticskit.** {
synthetic <methods>;
}
验证方式:用开启压缩的示例 App 构建,并对 release 构建跑接入方模拟测试。在 app/build.gradle.kts 增加 release 构建类型:
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
如果 release 构建后调用 AnalyticsKit.initialize() 会在运行时崩溃,说明 consumer-rules.pro 有洞。最常见元凶:DeliveryStatus 子类型被 R8 剥掉(修复:保留 DeliveryStatus$*)、Builder 被当成未使用而移除(修复:显式 keep)、Kotlin 的 @Metadata 被剥掉(修复:-keep class kotlin.Metadata)。
尽早验证这一点。发布之后才发现 ProGuard 问题,意味着要打补丁版本,并在变更日志里公开道歉。
原则 5:GitHub Actions CI
两个工作流:一个在每次 PR 时做校验;一个在版本 tag 上发布。
# 持续集成工作流(.github/workflows/ci.yml)
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- uses: gradle/actions/setup-gradle@v3
- name: 运行单元测试
run: ./gradlew :analyticskit:test --no-daemon
- name: 检查二进制兼容性
run: ./gradlew apiCheck --no-daemon
- name: 构建 release AAR
run: ./gradlew :analyticskit:bundleReleaseAar --no-daemon
# 发布工作流(.github/workflows/publish.yml)
name: Publish
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- uses: gradle/actions/setup-gradle@v3
- name: 发布到 Maven Central
run: ./gradlew publishAndroidReleasePublicationToMavenCentralRepository --no-daemon
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }}
ORG_GRADLE_PROJECT_ 前缀会把环境变量直接映射成 Gradle 项目属性 —— 与本地开发时 gradle.properties 里 findProperty() 读到的是同一套机制。不需要条件分支,也不需要平台探测。
四个密钥放在 GitHub → Settings → Secrets and variables → Actions。
原则 6:二进制兼容性校验器(BCV)
ProGuard 保护运行时。Kotlin Binary Compatibility Validator 保护编译期契约。
没有它,你可能改了公开方法名、所有测试都过、打了 tag 发布,却在静默中弄坏每一个升级的接入方。BCV 通过维护一份 .api 快照(记录所有公开声明)并在 CI 里强制执行,来避免这种情况。
在根 build.gradle.kts 配置:
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false // BCV 检测需要在这里声明
alias(libs.plugins.compose.compiler) apply false
id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.17.0"
}
apiValidation {
ignoredProjects += setOf("app") // 只校验 SDK 模块
}
生成初始基线:
./gradlew apiDump
这会创建 analyticskit/api/analyticskit.api。下面是 AnalyticsKit 片段示例:
public final class com/analyticskit/AnalyticsKit {
public static final field Companion Lcom/analyticskit/AnalyticsKit$Companion;
public final fun destroy ()V
public final fun flush ()V
public final fun getState ()Lkotlinx/coroutines/flow/StateFlow;
public final fun track (Lcom/analyticskit/Event;)V
public final fun track (Ljava/lang/String;Ljava/util/Map;)V
}
public final class com/analyticskit/AnalyticsKit$Companion {
public final fun getInstance ()Lcom/analyticskit/AnalyticsKit;
public static fun initialize (Landroid/content/Context;Lcom/analyticskit/AnalyticsConfig;)Lcom/analyticskit/AnalyticsKit;
}
提交这个文件。从此 ./gradlew apiCheck —— 作为 check 的一部分 —— 会在公开声明变化但 .api 未更新时失败。
有意引入破坏性变更时:刻意重新生成快照,审阅 diff,并与版本号提升一起提交:
./gradlew apiDump
git diff analyticskit/api/analyticskit.api # 精确查看变了什么
git add analyticskit/api/analyticskit.api
git commit -m "API: rename flushAsync() to awaitFlush() — MAJOR version bump required"
这种「被迫认真」正是关键。你没法意外弄坏 ABI —— 你必须有意识地更新快照,并在提交信息里解释。在升主版本之前,这种摩擦是你想要的。
原则 7:发布到 Maven Central
Maven Central 是生产级 SDK 的正途。JitPack 适合实验;团队要在生产里依赖的东西,默认就期待在 Central。能发上去,说明你已经把签名、POM 元数据、暂存与提升这套发布工程跑通了。
现代做法是用 vanniktech gradle-maven-publish 插件,在一个插件里覆盖 POM 生成、签名与 Central Portal 流程。
步骤 1:Sonatype 账号
在 central.sonatype.com 注册。对 GitHub 项目来说,groupId 可以是 io.github.sailsdima。Sonatype 会自动验证 io.github.* 命名空间与 GitHub 所有权。
步骤 2:GPG 密钥
gpg --gen-key
gpg --list-secret-keys --keyid-format LONG
gpg --export-secret-keys --armor YOUR_KEY_ID > signing-key.asc
gpg --keyserver keyserver.ubuntu.com --send-keys YOUR_KEY_ID
# 把 signing-key.asc 内容存成 GitHub Secret:SIGNING_KEY
# 把口令存成:SIGNING_KEY_PASSWORD
别跳过 send-keys 那一步。Maven Central 在验签时会向公钥服务器校验你的密钥。密钥没发布出去,发布会被拒,并提示签名验证错误。
步骤 3:插件配置
// Maven 发布配置(analyticskit/build.gradle.kts)
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
id("com.vanniktech.maven.publish") version "0.31.0"
}
mavenPublishing {
coordinates(
groupId = "io.github.sailsdima",
artifactId = "analyticskit",
version = "1.0.0"
)
pom {
name.set("AnalyticsKit")
description.set("A modern, lightweight analytics SDK for Android")
url.set("https://github.com/sailsdima/Analytics-Kit")
licenses {
license {
name.set("MIT License")
url.set("https://opensource.org/licenses/MIT")
}
}
developers {
developer {
id.set("sailsdima")
name.set("Dmytro Petrenko")
}
}
scm {
url.set("https://github.com/sailsdima/Analytics-Kit")
connection.set("scm:git:github.com/sailsdima/Analytics-Kit.git")
developerConnection.set("scm:git:ssh://github.com/sailsdima/Analytics-Kit.git")
}
}
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
signAllPublications()
}
automaticRelease = true 会在上传后自动把暂存仓库提升到 Central。第一次发布或心里没底时,把它设成 false,改在 Central Portal UI 里手动提升。这样你能在 artifact 公开前检查 POM、AAR 内容与 GPG 签名。
还要在 gradle.properties 加 android.useAndroidX=true —— 在 AGP 8.x 下依赖 AndroidX 时需要:
# gradle.properties
android.useAndroidX=true
本地凭据放在 ~/.gradle/gradle.properties —— 永远不要进仓库:
# ~/.gradle/gradle.properties
mavenCentralUsername=your_sonatype_token_username
mavenCentralPassword=your_sonatype_token_password
signingInMemoryKey=-----BEGIN PGP PRIVATE KEY BLOCK-----\n...
signingInMemoryKeyPassword=your_passphrase
在 Central Portal 生成 User Token —— 得到的用户名/密码就是你要存成 MAVEN_CENTRAL_USERNAME 和 MAVEN_CENTRAL_PASSWORD 的那一对。
步骤 4:语义化版本纪律
第 6 部分的 BCV 让版本决策变得显式:如果你在发布前跑 apiDump 产生了 diff,diff 会告诉你该升哪一级:
任何删除或重命名的公开声明 → 主版本。带默认值的新方法或字段 → 次版本。API 表面完全不变 → 补丁版本。
一个经常让人意外的情况:给接口加一个没有默认实现的新方法,是主版本 bump,而不是次版本 —— 每个既有实现者现在都会编译失败。这与第 1 部分的 EventInterceptor 直接相关:给这个接口加新方法,会弄坏所有用匿名类实现它、而不是用 lambda 的接入方。
发布流程:
# 更新 build.gradle.kts 里的版本,更新 CHANGELOG.md,提交
git tag v1.0.0
git push origin v1.0.0
GitHub Actions 会捕获 tag 并运行发布工作流。同步完成后,接入方只需:
dependencies {
implementation("io.github.sailsdima:analyticskit:1.0.0")
}
一行就够。Manifest 权限会自动合并。AAR 里的 ProGuard 规则会自动应用。第 1 部分里用 api() 声明的协程依赖会传递进来。接入方写初始化代码,然后把你忘掉 —— 这正是做工良好的 SDK 应有的体验。
接下来是什么
第 3 部分会讲如何交付到 React Native 并让 SDK 长期存活:在原生 Android SDK 之上搭 bridge、在 Kotlin 与 TypeScript 之间保持 API 对齐、处理 Kotlin sealed interface 与 JavaScript 类型系统之间的阻抗失配,以及长期维护手册 —— 版本策略、弃用策略与迁移指南。
进行中 —— 关注作者以便在更新时收到通知。
💡 本系列全部代码见配套 GitHub 仓库:github.com/sailsdima/A…。
这是关于现代 Android SDK 开发的 第 2 部分(共 3 部分) 系列文章。第 1 部分:架构与 API 设计
处理记录
proandroiddev.com与直连urllib/curl在本环境返回 403/超时,无法用ensure_article_source_md.py拉取<main><article>HTML,因此check_source_link_fidelity.py未传--html-url(仅做 Markdown 侧链路与结构质检)。- 英文
source由 Medium 公开页可读文本与作者配套仓库示例对齐重建;对外source_url仍以 ProAndroidDev 为准。 - 头图沿用本系列第 1 部分已使用的 Medium 头图 URL(同系列视觉一致);正文未插入 Medium「点击查看大图」占位图(无稳定可引用的外链
src)。
术语表(本篇命中)
| 术语 | 英文 | 释义 |
|---|---|---|
| 契约 / 公开表面 | Contract / public API surface | SDK 对接入方承诺的可调用 API 与可观察行为 |
| 黑盒测试 | Black-box testing | 只通过公开 API 驱动系统、不看内部实现 |
| 虚拟时钟 | Virtual clock | runTest 中用 advanceTimeBy 等推进的测试时钟 |
| 接入方模拟 | Consumer simulation | 像真实接入方一样只依赖公开 API 的测试层 |
| 消费者 ProGuard 规则 | consumer Proguard files | 随 AAR 分发、由 R8 在接入方工程中自动合并的规则 |
| 二进制兼容性 | Binary compatibility | 升级 SDK 二进制后,既有编译产物无需改源码仍可链接通过 |
| BCV | Binary Compatibility Validator | Kotlin 官方生态中用于生成/校验公开 API 快照的工具链 |
| 语义化版本 | Semver | 以主/次/补丁版本号表达兼容性含义的约定 |