【翻译】Android SDK 开发现代指南:测试与分发(第 2 部分,共 3 部分)

16 阅读8分钟

Android SDK 开发现代指南:测试与分发(第 2 部分,共 3 部分)

第 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 间隔在测试里耗费的「真实毫秒」是零。

StandardTestDispatcherrunTest 的默认调度器)不会急着执行协程;它们会排队,直到你调用 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 秒后,才发现事件在会话剩余时间里悄悄不再上报。

两个测试都依赖 buildTestInstanceFakeEventDispatcher。下面是最小实现思路:

为了让 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.ktFakeEventDispatcher 就很直接:

// 测试脚手架(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 }
}

buildTestInstanceTestScope 的扩展函数,因此能拿到 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.propertiesfindProperty() 读到的是同一套机制。不需要条件分支,也不需要平台探测。

四个密钥放在 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.propertiesandroid.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_USERNAMEMAVEN_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 surfaceSDK 对接入方承诺的可调用 API 与可观察行为
黑盒测试Black-box testing只通过公开 API 驱动系统、不看内部实现
虚拟时钟Virtual clockrunTest 中用 advanceTimeBy 等推进的测试时钟
接入方模拟Consumer simulation像真实接入方一样只依赖公开 API 的测试层
消费者 ProGuard 规则consumer Proguard files随 AAR 分发、由 R8 在接入方工程中自动合并的规则
二进制兼容性Binary compatibility升级 SDK 二进制后,既有编译产物无需改源码仍可链接通过
BCVBinary Compatibility ValidatorKotlin 官方生态中用于生成/校验公开 API 快照的工具链
语义化版本Semver以主/次/补丁版本号表达兼容性含义的约定