从零实现一个 IM + 直播 App:Kotlin + Compose 多模块架构全流程记录
Kotlin 2.0 / Jetpack Compose / Clean Architecture / Hilt / Room / Media3 / Paging 3。
本文适合已经做过几年 Android、想把 IM、直播、Compose 性能优化、工程化这些点一次打通的读者。
效果截图
一、写这篇文章(和这个项目)的起因
最近在看高级 Android 岗位,岗位描述里 IM、直播、音视频、Compose、多模块架构,基本是标配。面试问问题好答,但要真拿出能"讲半小时"的代码来却不太容易——大部分在职项目要么有保密约束,要么技术点太零散。
所以我干脆花了一个长周末,自己搭了一个 LiveTalk:一个同时具备 IM 聊天 + 直播观看的 App。目标很明确:
- 不做花架子,每个模块都对应一个真实岗位考察点;
- 代码里每个关键抽象都写一段"为什么这样写"而不是"做了什么"的注释;
- 开箱即可装机演示,面试现场不要求联网或外部服务。
这篇文章把我搭它的过程整理出来,重点是几个技术决策背后的权衡,而不是 API 使用手册。有贴代码的地方都尽量贴完整上下文,避免让读者去翻仓库。
二、整体架构:多模块 + Clean Architecture
LiveTalk/
├── build-logic/ Convention Plugins(构建配置统一)
├── app/ 壳工程 + Navigation + Hilt Application
├── core/
│ ├── common/ 调度器、Clock、AppResult、日志
│ ├── designsystem/ M3 主题、品牌色、通用组件
│ ├── model/ 纯 JVM 数据模型
│ ├── network/ OkHttp/Retrofit/WebSocket 封装
│ ├── database/ Room / DataStore
│ └── ui/ 通用 UI(弹幕引擎、礼物 banner)
├── data/
│ ├── im/ IM 长连接、协议、outbox、路由、repository
│ ├── live/ 直播 API + Repository + Paging Source
│ └── user/ 用户登录 / Token / SessionStore
└── feature/
├── auth/ 登录
├── home/ 直播列表
├── conversation/ 会话列表
├── chat/ 聊天
└── liveroom/ 直播间
依赖方向严格单向:app -> feature -> data -> core。core:model 是纯 JVM 模块,禁止依赖任何 Android API,保证领域层可以在纯 JVM 单元测试里跑。
2.1 Version Catalog + Convention Plugins
模块一多,每个模块的 build.gradle.kts 都重复配 compileSdk、minSdk、java 17、Compose 依赖一套,很快就会变"复制粘贴地狱"。
build-logic/convention/ 里写了 7 个 Convention Plugin:
// build-logic/convention/src/main/kotlin/.../AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
with(pluginManager) {
apply("livetalk.android.library")
apply("livetalk.android.library.compose")
apply("livetalk.android.hilt")
}
dependencies {
add("implementation", project(":core:common"))
add("implementation", project(":core:designsystem"))
add("implementation", project(":core:model"))
add("implementation", project(":core:ui"))
add("implementation", libs.findLibrary("androidx-lifecycle-runtime-compose").get())
add("implementation", libs.findLibrary("androidx-navigation-compose").get())
add("implementation", libs.findLibrary("hilt-navigation-compose").get())
}
}
}
一个 feature 模块的 build.gradle.kts 就只剩这些:
plugins {
alias(libs.plugins.livetalk.android.feature)
}
android { namespace = "com.qwfy.livetalk.feature.home" }
dependencies {
implementation(project(":data:live"))
implementation(libs.coil.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.paging.runtime)
}
升级 Kotlin / AGP / SDK 的时候只改一处。依赖版本全部集中在 gradle/libs.versions.toml,IDE 能自动补全,改名/重构都比字符串坐标稳。
三、核心亮点 1:IM 长连接状态机
高级岗面 IM,基本都会从"你的 WebSocket 怎么管理的"开始。所以我把状态机显式写出来了,而不是藏在回调里。
3.1 显式状态
enum class ConnectionState { Idle, Connecting, Authenticating, Connected, Backoff, Failed }
状态流(用 ASCII 画一下):
Idle --connect--> Connecting --open--> Authenticating --auth_ok--> Connected
^ |
| |
+------ Backoff <----- disconnect / io_error / pong_timeout ---------+
|
+---- attempt (jittered) --> Connecting
3.2 双心跳 + Pong 超时
OkHttp 本身支持 pingInterval,但这个只是 TCP 层保活,遇到运营商 NAT 半开连接(socket 看起来还在但实际对端早没了)是抓不到的。
所以应用层再加一层 Ping/Pong,带 nonce 时间戳,追踪 lastPongAtMs:
private val pingIntervalMs = 20_000L
private val pongTimeoutMs = 45_000L
private var lastPongAtMs = 0L
private suspend fun heartbeatLoop(webSocket: WebSocket) {
while (_state.value == ConnectionState.Connected) {
delay(pingIntervalMs)
val now = clock.elapsedRealtime()
if (now - lastPongAtMs > pongTimeoutMs) {
AppLog.w("IM") { "pong timeout; forcing reconnect" }
webSocket.cancel() // 主动断开,触发 onFailure/onClosed
return
}
sendMutex.withLock {
webSocket.send(codec.encode(ImFrame.Ping(seq = nextSeq(), nonce = now)))
}
}
}
clock.elapsedRealtime() 用的是 SystemClock.elapsedRealtime(),不会被用户改系统时间或 NTP 同步影响,比 System.currentTimeMillis() 可靠得多。
3.3 Decorrelated Jitter 重连退避
普通的"指数退避 + 均匀抖动"在大量客户端同时被断开(服务端重启、交换机抖动)时会产生重连风暴。AWS 架构博客推荐的 Decorrelated Jitter 算法效果更好:
class BackoffPolicy(
private val baseMs: Long = 500,
private val capMs: Long = 30_000,
private val random: Random = Random.Default,
) {
private var prev: Long = baseMs
fun nextDelayMs(): Long {
val upper = (prev * 3).coerceAtLeast(baseMs + 1)
val next = random.nextLong(baseMs, upper)
prev = min(capMs, next)
return prev
}
fun reset() { prev = baseMs }
}
公式很简单:delay(n) = min(cap, random(base, prev * 3))。
3.4 单写线程消除竞争
OkHttp 的 WebSocket.send() 本身是线程安全的,但我们要保证出站帧的顺序(比如同一个会话消息的顺序),所以用 Dispatchers.IO.limitedParallelism(1) 造一个单写调度器:
@Provides
@Singleton
@AppDispatcher(Dispatcher.ImNetwork)
fun providesImNetworkDispatcher(): CoroutineDispatcher =
Dispatchers.IO.limitedParallelism(parallelism = 1)
所有出站 send() 都 withContext(netDispatcher) + sendMutex.withLock,消息顺序和线程安全都保证了。
四、核心亮点 2:消息可靠性(不丢 / 不重 / 有序 / 重试)
这是 IM 系统的"灵魂"。我按需求拆成三层:OutboundMessageQueue(出站)、AckReconciler(对账)、InboundMessageRouter(入站)。
4.1 Outbox 持久化
客户端生成 UUID 作为 clientMessageId,这是幂等键。消息一写就同时落两张表:messages(UI 观察)和 outbox(worker 观察):
suspend fun enqueue(
conversationId: String,
senderId: String,
body: MessageBody,
): String = withContext(ioDispatcher) {
val clientId = UUID.randomUUID().toString()
val now = clock.currentMillis()
val bodyJson = json.encodeToString(body)
// 立刻可见:UI 观察 Room Flow 就会显示 PENDING 气泡
messageDao.upsert(MessageEntity(
clientMessageId = clientId,
conversationId = conversationId,
serverMessageId = null,
senderId = senderId,
bodyJson = bodyJson,
createdAtMillis = now,
serverSequence = null,
status = MessageStatus.PENDING,
))
outboxDao.upsert(OutboxEntity(
clientMessageId = clientId,
conversationId = conversationId,
payload = bodyJson,
attempts = 0,
firstEnqueuedAtMillis = now,
nextAttemptAtMillis = now,
))
clientId
}
为什么分两张表? 因为"UI 需要什么"和"重试需要什么"形状不一样:
- UI 需要所有消息(已发、已读、已撤回等)
- 重试只关心未 ACK 的,还需要
attempts、nextAttemptAtMillis这类调度字段
合并成一张表反而会让查询索引和生命周期管理变复杂。
4.2 Worker 调度 + 指数退避
Worker 只在连接 Connected 时工作,平时 park 住不吃 CPU:
private suspend fun runWorker() {
while (true) {
// Park 直到连接起来
connection.state.filter { it == ConnectionState.Connected }.first()
val now = clock.currentMillis()
val due = outboxDao.dueForSend(now, limit = 32)
if (due.isEmpty()) { delay(500); continue }
for (row in due) trySend(row)
}
}
trySend 之后,把 nextAttemptAtMillis 推到 now + ackTimeoutMs:
outboxDao.upsert(
row.copy(
attempts = row.attempts + 1,
nextAttemptAtMillis = clock.currentMillis() + ackTimeoutMs, // 15s
),
)
如果 15 秒内没收到 ACK,下次 worker 扫表就又把这行拉出来重发。重发的延迟按指数退避:
private suspend fun rescheduleRetry(row: OutboxEntity, reason: String) {
val attempts = row.attempts + 1
val backoff = min(maxRetryDelayMs, baseRetryDelayMs * (1L shl (attempts - 1).coerceAtMost(5)))
outboxDao.upsert(row.copy(
attempts = attempts,
nextAttemptAtMillis = clock.currentMillis() + backoff,
lastErrorReason = reason,
))
if (attempts >= MAX_ATTEMPTS_BEFORE_FAILED) {
messageDao.findByClientId(row.clientMessageId)?.let {
messageDao.update(it.copy(status = MessageStatus.FAILED))
}
}
}
6 次失败后标 FAILED,UI 出现重试按钮。
4.3 ACK 对账与去重
AckReconciler 只监听 ImFrame.Ack,专门做"把 outbox 行 + 本地消息升级为 SENT":
private suspend fun handle(ack: ImFrame.Ack) {
val status = runCatching { MessageStatus.valueOf(ack.status) }
.getOrDefault(MessageStatus.SENT)
messageDao.applyServerAck(
clientMessageId = ack.clientMessageId,
serverMessageId = ack.serverMessageId,
serverSequence = ack.serverSequence,
status = status,
)
outboxDao.deleteByClientId(ack.clientMessageId)
}
为什么不和 Queue 合一个类? Queue 管"把消息发出去",Reconciler 管"收到服务端确认"。拆开好处:每个类 < 150 行,单测互不干扰;一个类跑飞了另一个还能继续跑。
去重怎么做? 服务端要保证同一个 clientMessageId 总是返回同一个 serverMessageId。客户端这里用 MessageDao.applyServerAck 对着 clientMessageId 找行更新,天然幂等。
五、核心亮点 3:离线补拉与序列断层检测
短暂网络中断后,客户端怎么知道"我错过了哪些消息"?答案是每个会话维护一个本地 ackedSequence,对比推送的 serverSequence:
private suspend fun maybePullGap(conversationId: String, incomingSeq: Long) {
val convo = conversationDao.findById(conversationId) ?: return
val expected = convo.ackedSequence + 1
if (incomingSeq > expected) {
AppLog.i("IM/Inbound") { "gap in $conversationId: $expected..${incomingSeq - 1}" }
connection.send(
ImFrame.PullSince(
seq = connection.nextSeq(),
conversationId = conversationId,
afterSequence = convo.ackedSequence,
limit = BACKFILL_PAGE_SIZE,
),
)
}
}
服务端响应 Backfill,可能分页(hasMore 标志),客户端就继续追:
if (backfill.hasMore) {
connection.send(ImFrame.PullSince(
seq = connection.nextSeq(),
conversationId = backfill.conversationId,
afterSequence = maxSeq,
limit = BACKFILL_PAGE_SIZE,
))
}
这里 INSERT OR IGNORE 非常关键——push 和 backfill 可能同时把同一条消息送到客户端:
@Transaction
suspend fun mergeServerBatch(messages: List<MessageEntity>) {
val inserts = insertIfAbsent(messages) // OnConflictStrategy.IGNORE
// ...
}
同时 serverMessageId 建了唯一索引,真要 insert 两次会触发 IGNORE 走 no-op 分支,不会污染状态。
六、核心亮点 4:高帧率弹幕(Compose)
直播间弹幕是一个"看起来简单,做好很难"的东西。一场礼物风暴下弹幕能瞬间冲到 100+ 条并发,任何一点额外分配都会放大成卡顿。
6.1 为什么不用 AnimatedVisibility 堆叠
最直觉的做法是把每条弹幕放一个 AnimatedVisibility 或 Modifier.offset。问题:
- 每条弹幕注册独立动画 clock,100 条就是 100 个 clock,每帧触发大量重组;
- 滚动速度很难对齐(不同弹幕进场时机不同,感觉"一卡一卡")。
6.2 解法:共享 clock + drawWithContent
核心思路:所有弹幕共享同一个 withFrameMillis 时间戳,X 位置由 (nowMs - startMs) * speedPxPerMs 在 draw 阶段计算。这样每帧的开销是 O(active),而且 draw 阶段连 Compose 的重组都不需要。
@Composable
fun DanmakuHost(
stream: Flow<DanmakuItem>,
trackCount: Int = 6,
modifier: Modifier = Modifier,
maxConcurrent: Int = 96,
) {
val density = LocalDensity.current
var nowMs by remember { mutableLongStateOf(0L) }
val active = remember { mutableStateListOf<LivePlacement>() }
// 一个 coroutine 驱动所有弹幕的时间
LaunchedEffect(Unit) {
while (true) {
nowMs = withFrameMillis { it }
val iter = active.listIterator()
while (iter.hasNext()) {
val p = iter.next()
if (nowMs >= p.endAtMs) iter.remove()
}
}
}
Layout(
modifier = modifier.drawWithContent {
drawContent()
val trackHeight = size.height / trackCount
for (live in active) {
val p = live.placement
val age = nowMs - p.startAtMs
val x = size.width - age * 0.18f
val y = trackHeight * p.track + trackHeight / 2
drawIntoCanvas { canvas ->
val paint = android.graphics.Paint().apply {
color = p.item.color.toArgb()
textSize = with(density) { p.item.fontSize.toPx() }
isAntiAlias = true
setShadowLayer(4f, 0f, 1f, android.graphics.Color.BLACK)
}
canvas.nativeCanvas.drawText(p.item.text, x, y + paint.textSize / 3f, paint)
}
}
},
content = {},
) { _, constraints ->
layout(constraints.maxWidth, constraints.maxHeight) {}
}
}
6.3 Track-based 调度
弹幕挤在同一行会重叠,得分轨。DanmakuEngine 记录每条轨道的"尾部离开时间",新弹幕分配到最先腾出来的轨道。高权重弹幕(礼物触发的、系统通知)优先走上半区,避免被正文淹没:
private fun pickTrack(nowMs: Long, weight: DanmakuItem.Weight): Int? {
val preferTop = weight != DanmakuItem.Weight.Normal
val range = if (preferTop) 0 until (trackCount / 2).coerceAtLeast(1)
else 0 until trackCount
var best = -1
var bestClearAt = Long.MAX_VALUE
for (i in range) {
val clearAt = trackTailClearAtMs[i]
if (clearAt <= nowMs) return i
if (clearAt < bestClearAt) { bestClearAt = clearAt; best = i }
}
return if (preferTop && best >= 0) best else null
}
6.4 背压
弹幕源头用 SharedFlow + DROP_OLDEST:
class DanmakuDispatcher(bufferCapacity: Int = 256) {
private val channel = MutableSharedFlow<DanmakuItem>(
replay = 0,
extraBufferCapacity = bufferCapacity,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val stream: Flow<DanmakuItem> = channel
fun emit(item: DanmakuItem): Boolean = channel.tryEmit(item)
}
礼物风暴时扔旧的,宁可丢弹幕也不能积压导致 OOM 或视觉错乱。
七、Demo 模式:让一个需要后端的项目"单机可跑"
面试展示项目的痛点:没有后端。手写一个 Mock Server 也是一天工作量,得不偿失。
我选的方案:在 core/common 加一个全局开关:
object AppConfig {
@Volatile var demoMode: Boolean = true
}
然后在边界层(Repository 和 Entry Point)判断:
7.1 登录绕过
// UserRepository.kt
suspend fun signIn(phone: String, password: String): AppResult<Session> = runCatchingResult {
val resp = if (AppConfig.demoMode) {
demoSignInResponse(phone) // 本地捏一个
} else {
api.signIn(SignInRequest(phone, password))
}
sessionStore.setSession(...)
Session(...)
}
7.2 直播列表返回内置 fixture
// LiveRepository.kt
fun pagedRooms(tag: String? = null, pageSize: Int = 24): Flow<PagingData<LiveRoom>> =
Pager(config = PagingConfig(pageSize = pageSize, enablePlaceholders = false)) {
if (AppConfig.demoMode) DemoRoomPagingSource()
else RoomPagingSource(api, tag, pageSize)
}.flow
直播间播放用 Apple 的公开 HLS 测试流,稳定不过期,播放器真的在解码视频,不是贴静态图。
7.3 IM 假 Push
DemoRuntime 在 Application.onCreate 启动后,直接往 Room 写假消息,模拟"服务端 Push 落到本地"的最终效果:
private suspend fun simulatePushes() {
var seq = DemoPersonas.all.size.toLong() + 1
while (true) {
delay(6_000L + Random.nextLong(2_000L))
val persona = DemoPersonas.all.random()
val text = DemoMessageScript.next(persona)
messageDao.upsert(MessageEntity(
clientMessageId = UUID.randomUUID().toString(),
conversationId = persona.conversationId,
serverMessageId = "push-${persona.conversationId}-$seq",
senderId = persona.userId,
bodyJson = json.encodeToString<MessageBody>(MessageBody.Text(text)),
createdAtMillis = clock.currentMillis(),
serverSequence = seq++,
status = MessageStatus.DELIVERED,
))
// 更新 conversation 的 unread count / lastMessage
// ...
}
}
UI 是观察 Room Flow 的,完全不用知道这是"假 push"。
这个设计的好处:生产代码路径和 demo 路径共享同一层 UI 和 ViewModel。面试时我可以一边演示一边讲"这里走真实路径会发生什么",而不是有两套代码。
八、踩过的几个坑
8.1 Room KSP 的 MissingType
最开始我用 @TypeConverters 把 MessageBody(sealed class)转 JSON 存进 Room:
// 出错的版本
class Converters {
@TypeConverter fun encodeBody(body: MessageBody): String = json.encodeToString(body)
@TypeConverter fun decodeBody(raw: String): MessageBody = json.decodeFromString(raw)
}
@Entity
data class MessageEntity(
// ...
val body: MessageBody, // Room 在处理这里时会报 MissingType
)
KSP 报:
e: [ksp] [MissingType]: Element 'com.qwfy.livetalk.core.database.Converters'
references a type that is not present
e: androidx.room.RoomKspProcessor was unable to process
'com.qwfy.livetalk.core.database.LiveTalkDatabase'
这是 Room KSP 对跨模块 sealed 类型解析的一个已知坑。修法很直接:不让 Room 碰领域类型,Entity 只存 bodyJson: String,MessageBody 和 String 的转换放到 Repository 层:
// 修好的版本
@Entity
data class MessageEntity(
// ...
val bodyJson: String,
)
// ImRepository.kt
private fun MessageEntity.toDomain(): Message {
val body = runCatching { json.decodeFromString<MessageBody>(bodyJson) }
.getOrElse { MessageBody.System("[消息解析失败]", kind = "decode_error") }
return Message(...)
}
附带好处:Entity 零跨模块依赖,Room schema 更容易迁移。
8.2 Dagger/Hilt 的循环依赖
OkHttpClient --needs--> TokenAuthenticator --needs--> TokenProvider
TokenProvider --impl--> SessionTokenProvider --needs--> UserApi
UserApi --from--> Retrofit --needs--> OkHttpClient <-- cycle
Hilt 报错非常长,但核心就是这个环。修法是把 UserApi 注入改成 Provider<UserApi>:
@Singleton
class SessionTokenProvider @Inject constructor(
private val sessionStore: SessionStore,
private val apiProvider: Provider<UserApi>, // 不是 UserApi 本身
) : TokenProvider {
private val api: UserApi get() = apiProvider.get()
// ...
}
Provider<T> 是懒加载:构造 SessionTokenProvider 的时候不 resolve UserApi,等第一次真的 refresh 调用时才 get。这时候 Retrofit 已经构造完了,环破了。
8.3 Kotlin 2.0 泛型 smartcast 不推断
这个写法在 Kotlin 1.9 是 OK 的:
inline fun <T, R> AppResult<T>.map(transform: (T) -> R): AppResult<R> = when (this) {
is AppResult.Success -> AppResult.Success(transform(data))
is AppResult.Error -> this // Kotlin 2.0 这里报 Type mismatch
AppResult.Loading -> this
}
Kotlin 2.0 的 K2 编译器不再自动把 AppResult<Nothing> 协变推断为 AppResult<R>。修法是显式 cast(安全,因为 out T):
is AppResult.Error -> @Suppress("UNCHECKED_CAST") (this as AppResult<R>)
AppResult.Loading -> @Suppress("UNCHECKED_CAST") (this as AppResult<R>)
8.4 KDoc 里的 /* 把注释吃掉了
/**
* Tag is prefixed with "LT/" so filtered logcat sessions (`adb logcat LT/*`)
* only surface this app's output.
*/
LT/* 里的 /* 启动了嵌套块注释,编译器找不到匹配的 */ 就会一路吃到文件结尾,报 "Unclosed comment"。改成 LT:D 就好(这也是 logcat 的真实 filter 语法)。
九、一些值得聊的设计决策
这些不是"最佳实践",只是我做选择时的权衡:
| 决策 | 为什么 |
|---|---|
| Protobuf 还是 JSON? | JSON。因为仓库要能开箱即用,不想拉 protoc 工具链。envelope 形状和 Protobuf 是一致的,升级时不破坏协议。 |
| Message body 存 String 还是对象? | String。见上面的 KSP 坑。 |
| Outbox 和 messages 合并? | 不合并。UI 查询和重试调度是两类工作负载,索引都不一样。 |
| 用 Ktor Client 替代 OkHttp? | 没。Ktor 对 Android 的生态(certificate pinning、cache、authenticator)还没 OkHttp 成熟。 |
| Compose vs XML? | 纯 Compose。但播放器用的是 PlayerView(AndroidView 包一层),因为自己重写没必要。 |
| Kotlin Serialization vs Moshi? | Kotlin Serialization。和 Compose、协程都是 JetBrains 自家产品,升级节奏一致。 |
十、测试与可测性
可测性不是"写测试",而是"让代码可以被写测试"。
几个关键抽象:
Clock接口 +SystemClock实现 +FakeClock:IM 所有时间判断(心跳、退避、ACK 超时)都能在单测里确定性推进。WebSocketFactory接口:测试里注入假 factory,手动触发onMessage/onFailure。Dispatcherqualifier:测试用StandardTestDispatcher替换Dispatchers.IO,协程调度完全可控。
所以可以写这种测试:
@Test fun `first delay is at or above base`() {
val policy = BackoffPolicy(baseMs = 500, capMs = 30_000, random = Random(seed = 42))
val first = policy.nextDelayMs()
first shouldBeInRange 500L..1_500L
}
@Test fun `premium items can queue on the earliest-clearing track`() {
val engine = DanmakuEngine(trackCount = 2, containerWidthPx = 1080)
engine.tryPlace(item("a"), 1_000, 0).shouldNotBeNull()
engine.tryPlace(item("b"), 1_000, 0).shouldNotBeNull()
val premium = engine.tryPlace(
item("p", DanmakuItem.Weight.Premium), 200, 10
)
premium.shouldNotBeNull()
}
完全脱离 Android、OkHttp、协程实际调度,跑得飞快。
十一、装机体验
DEMO 模式默认开启,装完直接用:
- 克隆仓库,改
local.properties里sdk.dir= ./gradlew :app:assembleDebugadb install -r app/build/outputs/apk/debug/app-debug.apk
打开后登录页已经预填好默认账号密码(13800138000 / demo1234),一点直接进:
- 直播 tab:6 张假封面,点任何一张进入直播间,能看到 Apple 测试流 + 自动弹幕
- 消息 tab:5 个预置会话,每 6-8 秒来一条假推送
- 聊天页:发任何消息都会立刻显示
SENT
十二、总结 + 仓库地址
这个项目我想传达的能力,按重要性排:
- IM 核心系统设计:长连接状态机、心跳、退避、ACK、去重、幂等、离线补拉、持久化 outbox
- Compose 性能意识:共享 clock、
drawWithContent避开重组、Stable/Immutable、对象分配 - Android 工程化:多模块划分、Convention Plugins、Version Catalog、依赖倒置、KSP
- 可测性设计:Clock / Dispatcher / Factory 抽象,配合 Kotest + Turbine + MockK
- Demo 模式构造:在不污染生产代码路径的前提下让仓库可演示
README 里还有更详细的"代码导览"章节,按推荐阅读顺序列了 IM 五大核心类的路径。欢迎 star、issue、PR。
如果你正在准备高级 Android 的面试,这个仓库里的代码基本能覆盖绝大部分考察点——更重要的是,每个决策背后的"为什么"我都写在注释里了,可以拿来直接当面试素材用。
🧑💻 顺便求职
目前正在找工作,前端优先,全栈也可以胜任,坐标 厦门。
案例集(前端 / 全栈):my.feishu.cn/wiki/XUmGw8…
有合适岗位欢迎评论或私信,感谢。