Android SDK 开发现代指南:架构与 API 设计(第 1 部分,共 3 部分)
- 原文链接:proandroiddev.com/the-modern-…
- 原文作者:Dmytro Petrenko
第 1 部分,共 3 部分 —— 资深工程师写给团队的「面向接入方」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 都需要清晰的入口。主流形态有两种:
Singleton(AnalyticsKit.getInstance())——熟悉且简单,但更难测试,并携带隐式全局状态。
Builder / Factory(AnalyticsKit.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/Fragment的Context。这是 SDK 泄漏的头号来源。 - 需要 synchronized,而不能只靠 Volatile:
Volatile保证读到最新写入,但不保证「检查后再赋值」这条路径原子化;两个线程同时调用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 可见性修饰符
包结构会直接刻画边界:AnalyticsKit、AnalyticsConfig、Event、AnalyticsState 这类公开 API 放在包根;接入方永远不该碰的东西放进 internal/ —— EventQueue、EventStore、EventDispatcher、RetryPolicy。
// 该类不属于公开 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:这是刻意选择,而不是冗余。
原则 6:依赖隔离
SDK 的依赖,会成为接入方的传递依赖。很多 SDK 在这里翻车。
规则:
- 尽量少依赖。 每个依赖都是潜在的版本冲突点。
implementation,不要用api。 不要把传递依赖泄漏出去。- 必要时 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 拖进可疑权限画像。
- **只声明你需要的。**分析 SDK:
INTERNET必需,ACCESS_NETWORK_STATE有帮助。到此为止。 - 绝不要在 SDK 里声明危险权限(定位、相机、通讯录等)。如果真需要,把它藏在宿主 App 调用后才触发的接口背后 —— 并且由宿主自行申请运行时权限。
- **在 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_CONTACTS、ACCESS_FINE_LOCATION或READ_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 point | SDK 对外初始化与获取实例的模式(单例、工厂等) |
| 传递依赖 | Transitive dependency | 你的 SDK 依赖的库会间接进入接入方工程 |
| Manifest 合并 | Manifest merger | Android 构建把 SDK manifest 权限与组件合并进宿主 manifest |
| 响应式状态 | Reactive state | 以流或可观察快照暴露运行时状态 |
| 合并(状态流) | Conflate | StateFlow 丢弃中间更新,只保留最新值的语义 |