从零实现一个 IM + 直播 App:Kotlin + Compose 多模块架构全流程记录

从零实现一个 IM + 直播 App:Kotlin + Compose 多模块架构全流程记录

仓库地址:github.com/qwfy5287/Li...

Kotlin 2.0 / Jetpack Compose / Clean Architecture / Hilt / Room / Media3 / Paging 3。

本文适合已经做过几年 Android、想把 IM、直播、Compose 性能优化、工程化这些点一次打通的读者。

效果截图

一、写这篇文章(和这个项目)的起因

最近在看高级 Android 岗位,岗位描述里 IM、直播、音视频、Compose、多模块架构,基本是标配。面试问问题好答,但要真拿出能"讲半小时"的代码来却不太容易------大部分在职项目要么有保密约束,要么技术点太零散。

所以我干脆花了一个长周末,自己搭了一个 LiveTalk:一个同时具备 IM 聊天 + 直播观看的 App。目标很明确:

  • 不做花架子,每个模块都对应一个真实岗位考察点;
  • 代码里每个关键抽象都写一段"为什么这样写"而不是"做了什么"的注释;
  • 开箱即可装机演示,面试现场不要求联网或外部服务。

这篇文章把我搭它的过程整理出来,重点是几个技术决策背后的权衡,而不是 API 使用手册。有贴代码的地方都尽量贴完整上下文,避免让读者去翻仓库。


二、整体架构:多模块 + Clean Architecture

arduino 复制代码
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 -> corecore:model 是纯 JVM 模块,禁止依赖任何 Android API,保证领域层可以在纯 JVM 单元测试里跑。

2.1 Version Catalog + Convention Plugins

模块一多,每个模块的 build.gradle.kts 都重复配 compileSdkminSdkjava 17、Compose 依赖一套,很快就会变"复制粘贴地狱"。

build-logic/convention/ 里写了 7 个 Convention Plugin:

kotlin 复制代码
// 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 就只剩这些:

kotlin 复制代码
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 显式状态

kotlin 复制代码
enum class ConnectionState { Idle, Connecting, Authenticating, Connected, Backoff, Failed }

状态流(用 ASCII 画一下):

rust 复制代码
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

kotlin 复制代码
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 算法效果更好:

kotlin 复制代码
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) 造一个单写调度器:

kotlin 复制代码
@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 观察):

kotlin 复制代码
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 的,还需要 attemptsnextAttemptAtMillis 这类调度字段

合并成一张表反而会让查询索引和生命周期管理变复杂。

4.2 Worker 调度 + 指数退避

Worker 只在连接 Connected 时工作,平时 park 住不吃 CPU:

kotlin 复制代码
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

kotlin 复制代码
outboxDao.upsert(
    row.copy(
        attempts = row.attempts + 1,
        nextAttemptAtMillis = clock.currentMillis() + ackTimeoutMs,  // 15s
    ),
)

如果 15 秒内没收到 ACK,下次 worker 扫表就又把这行拉出来重发。重发的延迟按指数退避:

kotlin 复制代码
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":

kotlin 复制代码
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

kotlin 复制代码
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 标志),客户端就继续追:

kotlin 复制代码
if (backfill.hasMore) {
    connection.send(ImFrame.PullSince(
        seq = connection.nextSeq(),
        conversationId = backfill.conversationId,
        afterSequence = maxSeq,
        limit = BACKFILL_PAGE_SIZE,
    ))
}

这里 INSERT OR IGNORE 非常关键------push 和 backfill 可能同时把同一条消息送到客户端:

kotlin 复制代码
@Transaction
suspend fun mergeServerBatch(messages: List<MessageEntity>) {
    val inserts = insertIfAbsent(messages)   // OnConflictStrategy.IGNORE
    // ...
}

同时 serverMessageId 建了唯一索引,真要 insert 两次会触发 IGNORE 走 no-op 分支,不会污染状态。


六、核心亮点 4:高帧率弹幕(Compose)

直播间弹幕是一个"看起来简单,做好很难"的东西。一场礼物风暴下弹幕能瞬间冲到 100+ 条并发,任何一点额外分配都会放大成卡顿。

6.1 为什么不用 AnimatedVisibility 堆叠

最直觉的做法是把每条弹幕放一个 AnimatedVisibilityModifier.offset。问题:

  1. 每条弹幕注册独立动画 clock,100 条就是 100 个 clock,每帧触发大量重组;
  2. 滚动速度很难对齐(不同弹幕进场时机不同,感觉"一卡一卡")。

6.2 解法:共享 clock + drawWithContent

核心思路:所有弹幕共享同一个 withFrameMillis 时间戳 ,X 位置由 (nowMs - startMs) * speedPxPerMs 在 draw 阶段计算。这样每帧的开销是 O(active),而且 draw 阶段连 Compose 的重组都不需要。

kotlin 复制代码
@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 记录每条轨道的"尾部离开时间",新弹幕分配到最先腾出来的轨道。高权重弹幕(礼物触发的、系统通知)优先走上半区,避免被正文淹没:

kotlin 复制代码
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

kotlin 复制代码
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 加一个全局开关:

kotlin 复制代码
object AppConfig {
    @Volatile var demoMode: Boolean = true
}

然后在边界层(Repository 和 Entry Point)判断:

7.1 登录绕过

kotlin 复制代码
// 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

kotlin 复制代码
// 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 落到本地"的最终效果:

kotlin 复制代码
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

最开始我用 @TypeConvertersMessageBody(sealed class)转 JSON 存进 Room:

kotlin 复制代码
// 出错的版本
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 报:

vbnet 复制代码
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 层:

kotlin 复制代码
// 修好的版本
@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 的循环依赖

rust 复制代码
OkHttpClient --needs--> TokenAuthenticator --needs--> TokenProvider
TokenProvider --impl--> SessionTokenProvider --needs--> UserApi
UserApi --from--> Retrofit --needs--> OkHttpClient   <-- cycle

Hilt 报错非常长,但核心就是这个环。修法是把 UserApi 注入改成 Provider<UserApi>

kotlin 复制代码
@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 的:

kotlin 复制代码
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):

kotlin 复制代码
is AppResult.Error -> @Suppress("UNCHECKED_CAST") (this as AppResult<R>)
AppResult.Loading -> @Suppress("UNCHECKED_CAST") (this as AppResult<R>)

8.4 KDoc 里的 /* 把注释吃掉了

kotlin 复制代码
/**
 * 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。但播放器用的是 PlayerViewAndroidView 包一层),因为自己重写没必要。
Kotlin Serialization vs Moshi? Kotlin Serialization。和 Compose、协程都是 JetBrains 自家产品,升级节奏一致。

十、测试与可测性

可测性不是"写测试",而是"让代码可以被写测试"。

几个关键抽象:

  • Clock 接口 + SystemClock 实现 + FakeClock:IM 所有时间判断(心跳、退避、ACK 超时)都能在单测里确定性推进。
  • WebSocketFactory 接口:测试里注入假 factory,手动触发 onMessage/onFailure
  • Dispatcher qualifier :测试用 StandardTestDispatcher 替换 Dispatchers.IO,协程调度完全可控。

所以可以写这种测试:

kotlin 复制代码
@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 模式默认开启,装完直接用:

  1. 克隆仓库,改 local.propertiessdk.dir=
  2. ./gradlew :app:assembleDebug
  3. adb install -r app/build/outputs/apk/debug/app-debug.apk

打开后登录页已经预填好默认账号密码13800138000 / demo1234),一点直接进:

  • 直播 tab:6 张假封面,点任何一张进入直播间,能看到 Apple 测试流 + 自动弹幕
  • 消息 tab:5 个预置会话,每 6-8 秒来一条假推送
  • 聊天页 :发任何消息都会立刻显示 SENT

十二、总结 + 仓库地址

这个项目我想传达的能力,按重要性排:

  1. IM 核心系统设计:长连接状态机、心跳、退避、ACK、去重、幂等、离线补拉、持久化 outbox
  2. Compose 性能意识 :共享 clock、drawWithContent 避开重组、Stable/Immutable、对象分配
  3. Android 工程化:多模块划分、Convention Plugins、Version Catalog、依赖倒置、KSP
  4. 可测性设计:Clock / Dispatcher / Factory 抽象,配合 Kotest + Turbine + MockK
  5. Demo 模式构造:在不污染生产代码路径的前提下让仓库可演示

仓库:github.com/qwfy5287/Li...

README 里还有更详细的"代码导览"章节,按推荐阅读顺序列了 IM 五大核心类的路径。欢迎 star、issue、PR。

如果你正在准备高级 Android 的面试,这个仓库里的代码基本能覆盖绝大部分考察点------更重要的是,每个决策背后的"为什么"我都写在注释里了,可以拿来直接当面试素材用。

🧑‍💻 顺便求职

目前正在找工作,前端优先,全栈也可以胜任 ,坐标 厦门

案例集(前端 / 全栈):my.feishu.cn/wiki/XUmGw8...

有合适岗位欢迎评论或私信,感谢。

相关推荐
方白羽3 天前
《被封印的六秒:大厂外包破解 Android 启动流之谜》
android·app·android studio
音视频牛哥4 天前
鸿蒙 NEXT 下 RTSP/RTMP 播放器如何实时调节音量、亮度、对比度与饱和度?
harmonyos·音视频开发·直播
冬奇Lab5 天前
音视频同步与渲染:PTS、VSYNC 与 SurfaceFlinger 的协作之道
android·音视频开发
欧达克5 天前
vibe coding:2 天用 AI 鼓捣一个 APP
flutter·app
Kingexpand_com5 天前
APP开发选型指南:模板与原生定制技术对比及中小企业适配方案
app·app开发·app定制开发·app定制开发公司
私人珍藏库6 天前
[Android] 蓝叠模拟器工具箱v1.1
android·智能手机·app·工具·软件·多功能
私人珍藏库7 天前
【Android】Shizuku升级版-Stellar-提高软件权限
android·app·工具·软件·多功能
冬奇Lab7 天前
MediaPlayer 播放器架构:NuPlayer 的 Source/Decoder/Renderer 三驾马车
android·音视频开发·源码阅读
私人珍藏库8 天前
【Android】一键硬核锁手机
android·智能手机·app·工具·软件