【翻译】Android SDK 开发现代指南:架构与 API 设计(第 1 部分,共 3 部分)

10 阅读15分钟

Android SDK 开发现代指南:架构与 API 设计(第 1 部分,共 3 部分)

第 1 部分,共 3 部分 —— 资深工程师写给团队的「面向接入方」Android SDK 实践指南

Android SDK 架构示意图

作为一名 Android 工程师,我们职业生涯大部分时间都在消费 SDK —— 网络用 Retrofit,图片用 Glide,持久化用 Room。但要做一个被别人依赖的 SDK,则是完全不同的学科。约束变了。你的 API 表面是一份契约。你的失误会变成别人的编译错误。你的破坏性变更会变成别人的周末加班。

在多年构建和维护被多个团队消费的 SDK(其中包括通过 React Native 的跨平台接入方)之后,我把「精工细作 SDK」与「大号工具类」区分开的原则提炼了出来。本三部曲覆盖完整生命周期:本篇讲架构与 API 设计,第 2 部分讲测试与分发,第 3 部分讲跨平台交付与长期维护。

我们从零开始做一个 SDK,全文示例与架构决策都围绕这条主线展开。

💡 本系列全部示例代码见配套仓库:github.com/sailsdima/A…

我们要造什么

为便于讨论落到地面,我们会设计一个 AnalyticsKit SDK:负责捕获、批量聚合、持久化并把宿主 App 的分析事件投递到后端。把它看成一种轻量且自带观点的 Segment 风格 SDK 的替代方案亦可 —— 它封装更低层的传输细节,并向接入方暴露干净的 API。

这是一种很典型的 SDK 形态:你在抽象复杂度(批量、重试、离线持久化),管理状态(事件队列、flush 生命周期),并提供一个对「永远不会读你源码」的开发者也必须直观的表面。

原则 1:先设计 API,再写实现

SDK 开发是 API 优先。在写任何业务逻辑之前,先定义接入方会与之交互的表面 —— 要像你就是接入方那样写公开接口。

先从接入方视角写出理想调用点长什么样;下面的 Kotlin 片段只是把推论固化成可讨论的代码形态。

// 接入方代码理想上应该长这样
class MainActivity : ComponentActivity() {    
    
    private val analytics by lazy {        
        AnalyticsKit.initialize(            
            context = applicationContext,            
            config = AnalyticsConfig(                
                apiKey = "key_abc123",                
                environment = Environment.PRODUCTION            
            )        
        )    
    }    
    
    override fun onCreate(savedInstanceState: Bundle?) {        
        super.onCreate(savedInstanceState)        
        
        // 跟踪一个简单事件        
        analytics.track("screen_viewed", mapOf("screen" to "home"))        
        
        // 跟踪一个结构化事件        
        analytics.track(            
            Event(                
                name = "item_added_to_cart",                
                properties = mapOf(                   
                    "item_id" to "SKU-1234",                    
                    "price" to 29.99,                   
                    "currency" to "USD"                
                )            
            )        
        )        
        
        // 以响应式方式观察投递状态        
        lifecycleScope.launch {            
            analytics.state.collect { state ->                
                Log.d("Analytics", "Queue: ${state.queuedEvents}, State: ${state.deliveryStatus}")            
            }        
        }    
    }    
    
    override fun onStop() {        
        super.onStop()        
        analytics.flush() // 强制投递队列中的事件    
    }
}

这个调用点会把我们对公开 API 的需求说清楚:

下面把这些需求逐项映射到具体实现层面。

  • 显式初始化的类单例入口
  • 可读且可扩展的配置对象
  • 简单与结构化并存的上报方法(便捷 API + 强类型模型)
  • 用于 SDK 状态的响应式流(Kotlin Flow)
  • 生命周期关键时刻可用的显式 flush 机制
  • 告别回调地狱 —— 面向协程

下面把它们逐项映射到实现层面。

原则 2:入口形态(Entry Point)

每个 SDK 都需要清晰的入口。主流形态有两种:

SingletonAnalyticsKit.getInstance())——熟悉且简单,但更难测试,并携带隐式全局状态。

Builder / FactoryAnalyticsKit.initialize(…))——显式且可配置,但需要接入方自己保管实例。

我强烈推荐混合路线:显式初始化返回可管理的实例,同时可选提供便捷的单例访问。

public class AnalyticsKit private constructor(    
    private val config: AnalyticsConfig,    
    private val store: IEventStore,    
    private val eventDispatcher: IEventDispatcher
) {    
    
        private val scope = CoroutineScope(        
            SupervisorJob() + Dispatchers.Default + CoroutineName("AnalyticsKit")    
        )    
        
        @Volatile     
        private var destroyed = false    
        
        public companion object {        
        
            @Volatile        
            private var instance: AnalyticsKit? = null        
            
            @JvmStatic        
            public fun initialize(            
                context: Context,            
                config: AnalyticsConfig        
            ): AnalyticsKit {            
                // synchronized 让「检查—赋值」整体原子化            
                // 仅靠 @Volatile 只能保证可见性:两个线程可能都在赋值完成前通过检查            
                synchronized(this) {                
                    check(instance == null) { "AnalyticsKit is already initialized" }                       return AnalyticsKit(                    
                        config = config,                    
                        store = EventStore(context.applicationContext),                 
                        eventDispatcher = EventDispatcher(config)                
                    ).also { instance = it }            
                }        
            }        
            
            @JvmStatic        
            public fun getInstance(): AnalyticsKit {            
                return checkNotNull(instance) {                
                    "AnalyticsKit is not initialized. Call AnalyticsKit.initialize() first."            
                }        
            }    
        }    
        
        /** 释放所有资源;调用后 SDK 不可用 */    
        public fun destroy() {        
            destroyed = true        
            scope.cancel()        
            // 与 initialize() 使用同一把锁,并用引用相等判断        
            // 避免在新实例创建后又错误地把全局实例置空        
            synchronized(Companion) {            
                if (instance === this) instance = null        
            }    
        }
    }

这里的几项关键取舍:

  • context.applicationContext:永远用它。不要长期持有 Activity / FragmentContext。这是 SDK 泄漏的头号来源。
  • 需要 synchronized,而不能只靠 VolatileVolatile 保证读到最新写入,但不保证「检查后再赋值」这条路径原子化;两个线程同时调用 initialize() 可能都在赋值前通过检查。把临界区包进 synchronized 可修复这一点;保留 Volatile 也很重要 —— 它让 getInstance() 多数读取不必每次进锁。
  • destroy() 也用同一把锁:实例方法里直接把 instance = null 容易产生架构异味 —— 对象不该独自管理自己在共享容器里的存储位。正确做法是回到 companion 的锁上,并用引用相等判断,避免 destroy() 与一次全新的 initialize() 竞态时误伤新实例。
  • @JvmStatic:只要有 Java 接入方(或通过 Java 互操作桥到 React Native),它就值得保留。
  • 失败要吵闹check() / checkNotNull() 配以清晰文案。SDK 里的静默失败对接入方来说是调试噩梦。

相比 companion 自己保管实例,另一种更干净的做法是使用专门的 InstanceHolder —— 单独的单例对象只负责持有引用,消解「实例对象反向管理容器」的环状依赖,代价是多一个类。对 AnalyticsKit 而言 companion 足够简单;如果你的 SDK 需要多个命名实例(例如按 API Key 分桶),再考虑 holder 模式。

原则 3:把配置当成数据契约

SDK 配置应当 声明式可校验,并且在尽量不引入破坏性变更的前提下 可扩展

public data class AnalyticsConfig(    val apiKey: String,    val environment: Environment = Environment.PRODUCTION,    val batching: BatchConfig = BatchConfig(),    val logging: LogLevel = LogLevel.NONE) {    init {        require(apiKey.isNotBlank()) { "API key must not be blank" }    }}public data class BatchConfig(    val maxBatchSize: Int = 25,    val flushInterval: Duration = 30.seconds,    val maxQueueSize: Int = 1000,    val persistOfflineEvents: Boolean = true) {    init {        require(maxBatchSize in 1..100) { "Batch size must be between 1 and 100" }        require(maxQueueSize >= maxBatchSize) { "Queue size must be >= batch size" }    }}public enum class Environment {    PRODUCTION,    STAGING}public enum class LogLevel {    NONE,    ERROR,    DEBUG,    VERBOSE}

为什么这里用 data class,而不是 Builder?

值得知道的坑:data class 并不适合追求「严格的二进制稳定公开 API」。新增一个构造函数参数 —— 即便带默认值 —— 也会改变生成的 copy() 签名以及 componentN() 解构函数;任何调用 config.copy(apiKey = "x") 的接入方在你加字段后都可能编译失败。若你的 SDK 以二进制形态分发,且团队不一定总能重新编译,这点就很刺眼。

但对配置对象而言,实践中风险往往偏低:接入方通常在 Application.onCreate() 创建一次,很少再对配置做 copy() 或解构。这也是为什么多数 SDK 仍然 pragmatic 地使用 data class。

如果你需要更强的 API 稳定性保证 —— 或者你的接入方主要是直接用构造函数的 Java 开发者 —— 就用普通类 + Builder:

public data class AnalyticsConfig(    
    val apiKey: String,    
    val environment: Environment = Environment.PRODUCTION,    
    val batching: BatchConfig = BatchConfig(),    
    val logging: LogLevel = LogLevel.NONE
) {    
    init {        
        require(apiKey.isNotBlank()) { "API key must not be blank" }    
    }    
    
    /** 面向 Java 互操作的 Builder */    
    public class Builder(private val apiKey: String) {        
        private var environment: Environment = Environment.PRODUCTION        
        private var batching: BatchConfig = BatchConfig()        
        private var logging: LogLevel = LogLevel.NONE        
        
        public fun environment(env: Environment) = apply { this.environment = env }     
        public fun batching(config: BatchConfig) = apply { this.batching = config }     
        public fun logging(level: LogLevel) = apply { this.logging = level }        
        
        public fun build(): AnalyticsConfig = AnalyticsConfig(            
            apiKey = apiKey,            
            environment = environment,            
            batching = batching,            
            logging = logging        
        )    
    }
}

可扩展性的打法:给新字段加上默认值,对使用命名参数的 Kotlin 接入方通常是源码兼容的 —— 他们不必改动初始化代码。严格意义上它不等于二进制兼容(copy() 会变),但对配置对象而言这笔权衡几乎总是被接受。

原则 4:用 Kotlin Flow 暴露响应式状态

管理异步行为的 SDK,应该把状态做成响应式暴露。Kotlin Flow 是合适的抽象,并且能与生命周期叙事对齐。

public class AnalyticsKit private constructor(/* ... */) {    

    private val _state = MutableStateFlow(        
        AnalyticsState(queuedEvents = 0, deliveryStatus = DeliveryStatus.Idle)    
    )    
    
    /** 分析管线的当前状态 */    
    public val state: StateFlow<AnalyticsState> = _state.asStateFlow()    
    
    // ...
}

对公开模型,使用 sealed interface 搭配清晰的 data class —— 能迫使接入方处理所有分支:

public data class AnalyticsState(    
    val queuedEvents: Int,    
    val deliveryStatus: DeliveryStatus
)

public sealed interface DeliveryStatus { 

    /** 当前没有进行 flush */    
    data object Idle : DeliveryStatus    
    
    /** 正在投递事件 */    
    public data class Flushing(val batchSize: Int) : DeliveryStatus    
    
    /** 上一次 flush 失败 */    
    public data class Failed(        
        val error: AnalyticsError,        
        val retryIn: Duration    
    ) : DeliveryStatus
}

public data class Event(    
    val name: String,    
    val properties: Map<String, Any> = emptyMap(),    
    val timestamp: Long = System.currentTimeMillis()
) {    
    init {        
        require(name.isNotBlank()) { "Event name must not be blank" }    
    }
}

public enum class AnalyticsError {    
    NETWORK_ERROR,    
    INVALID_API_KEY,    
    RATE_LIMITED,    
    PAYLOAD_TOO_LARGE,    
    UNKNOWN
}

原则 3 里关于 data class 的权衡,对 Event 同样成立 —— 接入方会构造它,因此加参数多半是源码兼容但会改变 copy()AnalyticsState 其实更安全:SDK 创建并发射它,接入方只观察;他们不会对状态对象做 config.copy() 这类操作,因此实践中即便继续用 data class,给 AnalyticsState 加字段往往也不构成破坏性变更。

为什么 StateFlow,而不是 SharedFlow?

StateFlow 总有当前值。对「队列长度、投递状态」这类状态数据,这是正确的语义 —— 新订阅者应立即拿到最新快照,而不是干等下一次发射。对「事件型」数据(不该回放的一次性通知),用 SharedFlow

还需要知道:StateFlow 会对发射做合并(conflate) —— 如果 SDK 更新状态的速度快于订阅者读取速度,中间状态会被丢弃。对分析状态而言这通常是正确行为:接入方要的是最新快照,而不是每一次中间变化的回放。如果你需要每一次发射(例如审计日志),改用带缓冲的 SharedFlow

原则 5:可见性就是一切

SDK 开发中最关键的架构决策,是你暴露什么。每一个 public 的类、方法与属性,都是你要长期维护的契约。

激进使用 Kotlin 可见性修饰符

包结构会直接刻画边界:AnalyticsKitAnalyticsConfigEventAnalyticsState 这类公开 API 放在包根;接入方永远不该碰的东西放进 internal/ —— EventQueueEventStoreEventDispatcherRetryPolicy

Kotlin 包可见性示意图

// 该类不属于公开 API
internal class EventQueue(    
    private val config: BatchConfig,    
    private val store: IEventStore,    
    private val dispatcher: IEventDispatcher
) {    

    val size: Int get() = store.count()    
    
    fun enqueue(event: InternalEvent) {        
        // 队列满时丢弃最旧事件 —— 绝不阻塞调用方        
        if (store.count() >= config.maxQueueSize) {            
            store.drain(1)        
        }        
        store.persist(event)    
    }    
    
    suspend fun flushAll() {       
        while (store.count() > 0) {            
            val batch = store.drain(config.maxBatchSize)            
            if (batch.isEmpty()) break            
            dispatcher.dispatch(batch)        
        }    
    }
}

Kotlin 的 internal 关键词非常关键:它表示「仅模块内可见」—— SDK 内部可以随意使用,但接入方无法引用,于是你可以在不大面积破坏别人的前提下重构内部实现。

用 explicit API() 做补强

在模块的 build.gradle.kts 里:

kotlin {    
    explicitApi()
}

这会强迫你为每个类与函数写出可见性。第一个小时很烦,长期非常有价值 —— 再也不会不小心公开 API。

说明:在标准 Kotlin 里 public 是默认值,可以省略;启用 explicitApi() 后,public 变成强制 —— 编译器会对省略报错。这就是为什么文中所有公开声明都写了显式 public:这是刻意选择,而不是冗余。

SDK 边界示意图

原则 6:依赖隔离

SDK 的依赖,会成为接入方的传递依赖。很多 SDK 在这里翻车。

规则:

  1. 尽量少依赖。 每个依赖都是潜在的版本冲突点。
  2. implementation,不要用 api 不要把传递依赖泄漏出去。
  3. 必要时 shade / relocate。 如果你必须打包特定版本的库(例如固定某一版 OkHttp),考虑 relocation。

Context 陷阱

永远不要强迫接入方传入你的依赖。这是错的:

// 不要这样 —— 接入方不该被迫了解你的 OkHttpClient
fun initialize(context: Context, client: OkHttpClient, config: Config)

相反,接受抽象,或者什么都不额外要求:

// 把依赖藏在 SDK 内部
fun initialize(context: Context, config: Config)

如果接入方确实需要定制网络(证书锁定、代理等),暴露一个干净的接口:

public fun interface EventInterceptor {    
    public suspend fun intercept(events: List<Event>): List<Event>
}

public data class AnalyticsConfig(    
    val apiKey: String,    
    // ...    
    val eventInterceptor: EventInterceptor? = null
)

这给接入方能力(在投递前丰富、过滤或变换事件),却不把他们绑定到你的实现细节。

权限同样是「传递」的

你不只在静默地推送依赖:SDK AndroidManifest.xml 里声明的每一个权限,都会通过 manifest merger 合并进宿主 App。接入方可能根本不知道 —— 直到安全审计把它标红。

SDK 权限声明务必克制:只覆盖投递链路所需的最小集合,避免把宿主 App 拖进可疑权限画像。

  1. **只声明你需要的。**分析 SDK:INTERNET 必需,ACCESS_NETWORK_STATE 有帮助。到此为止。
  2. 绝不要在 SDK 里声明危险权限(定位、相机、通讯录等)。如果真需要,把它藏在宿主 App 调用后才触发的接口背后 —— 并且由宿主自行申请运行时权限。
  3. **在 README 与发布说明里文档化每个权限。**接入方需要知道他们要把什么带给最终用户。
<!-- analyticskit/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">    
    <!-- 必需:通过网络投递事件 -->    
    <uses-permission android:name="android.permission.INTERNET" />    
    
    <!-- 可选但推荐:离线时跳过无意义的 flush 尝试 -->    
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

</manifest>

二者都是普通权限 —— 安装时自动授予,不需要运行时弹窗。这对分析 SDK 来说是合适的「脚印」。

SDK 审计里的红旗:如果你在第三方 SDK 的 manifest 里看到 READ_CONTACTSACCESS_FINE_LOCATIONREAD_PHONE_STATE —— 它越界了。拒绝它,或把它隔离出去。

原则 7:防御式初始化与线程安全

SDK 跑在别人的进程里。你无法控制线程模型、生命周期或初始化顺序。

public class AnalyticsKit private constructor(    
    private val config: AnalyticsConfig,    
    private val store: IEventStore,    
    private val eventDispatcher: IEventDispatcher
) {    

    private val scope = CoroutineScope(        
        SupervisorJob() + Dispatchers.Default + CoroutineName("AnalyticsKit")    
    )    
    
    @Volatile     
    private var destroyed = false    
    
    public companion object {        
        @Volatile        
        private var instance: AnalyticsKit? = null        
        
        @JvmStatic        
        public fun initialize(            
            context: Context,            
            config: AnalyticsConfig        
        ): AnalyticsKit {            
            // synchronized 让「检查—赋值」整体原子化            
            // 仅靠 @Volatile 只能保证可见性:两个线程可能都在赋值完成前通过检查            
            synchronized(this) {                
                check(instance == null) { "AnalyticsKit is already initialized" }                       return AnalyticsKit(                    
                    config = config,                    
                    store = EventStore(context.applicationContext),                    
                    eventDispatcher = EventDispatcher(config)                
                ).also { instance = it }            
            }        
        }        
        
        @JvmStatic        
        public fun getInstance(): AnalyticsKit {            
            return checkNotNull(instance) {                
                "AnalyticsKit is not initialized. Call AnalyticsKit.initialize() first."            
            }        
        }    
    }    
    
    /** 释放所有资源;调用后 SDK 不可用 */    
    public fun destroy() {       
        destroyed = true        
        scope.cancel()        
        // 与 initialize() 使用同一把锁,并用引用相等判断        
        // 避免在新实例创建后又错误地把全局实例置空        
        synchronized(Companion) {            
            if (instance === this) instance = null        
        }    
    }
}

关键细节:

  • SupervisorJob():一个子协程失败不该拖垮整个 SDK 作用域;一次失败的 flush 也不能让后续 track() 永久不可用。
  • CoroutineName("AnalyticsKit"):接入方看协程 dump 时能分清哪些是你的。
  • 绝不吞 CancellationException:这是结构化并发规则;违反它会破坏取消语义。
  • destroy() 之后再 track() 要抛错:取消作用域会让 scope.launch {} 静默丢工作 —— 没有异常、没有日志,事件就这样消失。用 destroyed 标记配合显式 check(),能给接入方清晰错误,而不是静默丢数据。
  • 队列用 lazy(LazyThreadSafetyMode.NONE):默认 lazy {}SYNCHRONIZED,首次访问会拿锁;既然对 queue 的读写都发生在 SDK 协程作用域内,这把锁没必要,NONE 去掉开销且行为一致。
  • destroy() 走 companion 锁:实例方法里直接 instance = null 有风险;引用相等判断(instance === this)避免 scope.cancel() 与置空之间竞态创建新实例时被误伤。
  • 自动 flush 必须扛住单次失败:如果 performFlush()while (isActive) 循环里抛异常却缺少 try/catch,协程会悄悄死掉,自动 flush 永久停摆。要捕获、记录日志,然后继续循环。
  • 生命周期集成要可选:在接入方不知情时注册 ProcessLifecycleOwner 观察者是反模式 —— 他们无法退出,也可能根本不知道发生过。把它藏在配置开关后面(例如 flushOnAppBackground)。

自动 flush

生产级分析 SDK 还应定时 flush,并在 App 进入后台时 flush:

private fun startAutoFlush() {    
    scope.launch {        
        while (isActive) {            
            delay(config.batching.flushInterval)            
            if (queue.size > 0) {                
                try {                    
                    performFlush()                
                } catch (e: CancellationException) {                   
                    throw e                
                } catch (e: Exception) {                   
                    log(LogLevel.ERROR, "Auto-flush failed: ${e.message}")             
                    // 吞掉异常并继续 —— 一次失败绝不能杀死循环                
                }            
            }        
        }    
    }  
    
    // 可选:只有接入方启用时才注册生命周期观察者    
    // 在接入方不知情时静默注册 ProcessLifecycleOwner 属于 SDK 反模式:他们无法退出,也不知道发生过    
    if (config.batching.flushOnAppBackground) {        
        scope.launch(Dispatchers.Main.immediate) {            
            ProcessLifecycleOwner.get().lifecycle.addObserver(                
                object : DefaultLifecycleObserver {                    
                    override fun onStop(owner: LifecycleOwner) {                        
                        flush()                    
                    }                
                }            
            )        
        }    
    }
}

模块结构落地

完整目录结构见 companion repository。SDK 模块(analyticskit/)是标准 Android Library:公开 API 在包根,internal/ 下放内部实现,单元测试并排摆放,并为 ProGuard 接入方准备 consumer-rules.pro。完整的 build.gradle.kts 也值得看一眼:

// 模块 Gradle 构建脚本示例(文件名通常为 build.gradle.kts)
plugins {    
    id("com.android.library")    
    id("org.jetbrains.kotlin.android")
}

android {    
    namespace = "com.analyticskit"    
    compileSdk = 36    
    
    defaultConfig {        
        minSdk = 26        
        consumerProguardFiles("consumer-rules.pro")    
    }
}

kotlin {    
    explicitApi()
}

dependencies {    
    implementation(libs.kotlinx.coroutines.android)    
    implementation(libs.okhttp)    
    implementation(libs.androidx.lifecycle.process)    
    testImplementation(libs.junit)    
    testImplementation(libs.kotlinx.coroutines.test)    
    testImplementation(libs.turbine)
}

接下来是什么

第 2 部分会覆盖测试与分发:用 Turbine 测试重度协程 SDK 代码、模拟接入方环境、GitHub Actions CI,以及按语义化版本发布到 Maven Central

第 3 部分会走向跨平台:React Native Bridge、Kotlin 与 TypeScript 的 API 对齐、弃用策略,以及服务多个下游时的长期维护手册。

💡 本系列全部示例代码见配套仓库:github.com/sailsdima/A…

这是关于现代 Android SDK 开发的 第 1 部分(共 3 部分) 系列文章。

术语表(本篇命中)

术语英文释义
入口形态Entry pointSDK 对外初始化与获取实例的模式(单例、工厂等)
传递依赖Transitive dependency你的 SDK 依赖的库会间接进入接入方工程
Manifest 合并Manifest mergerAndroid 构建把 SDK manifest 权限与组件合并进宿主 manifest
响应式状态Reactive state以流或可观察快照暴露运行时状态
合并(状态流)ConflateStateFlow 丢弃中间更新,只保留最新值的语义