回看四年前做过的一个项目,我使用 TDLib 获取 Telegram 群组数据。
底层 API 是典型的 Java 异步回调风格。
真正花时间的地方,不是发出某一个请求,而是把查群、加群、翻页、查用户、下载文件这些异步步骤组织成完整的业务流程。
后来,我用 Kotlin 协程重新组织了这套代码。
没有改变底层 API 的异步本质,只是改变了业务代码的组织方式。
这也是我后来对 Kotlin 协程越来越深的一个认识:
协程是对异步回调的一种线性化表达。
一、项目背景:业务不复杂,复杂的是异步流程
四年前,我做过一个基于 Telegram 的数据获取项目,底层使用的是 Telegram 官方提供的 TDLib。
项目涉及的业务并不算复杂,主要包括:
- 根据群链接解析群组信息。
- 搜索公开群组并加入。
- 通过邀请链接加入群组。
- 获取群组基本信息。
- 分页获取历史消息。
- 获取群成员列表。
- 根据消息发送者查询用户资料。
- 下载消息中的图片、文件以及用户头像。
如果只看业务需求,这些功能都很常规。
当时还有一个比较有意思的情况。
PM 更多关注的是业务设计。
比如,群组应该怎么查,加入群组之后应该获取哪些信息,历史消息需要获取多少,图片和文件应该如何处理。
这些讨论本身没有问题。
但有时候,业务流程在白板上画出来很简单,并不意味着技术实现也简单。
一个查询动作背后,可能涉及多个异步请求;一个看似普通的分页需求,实际上需要不断根据上一次请求的结果决定下一步;一次媒体下载,也可能涉及多个并发任务以及完成状态的管理。
我当时比较在意的是这些事情。
说得直白一点,业务流程可以设计得很漂亮,但如果没有考虑底层 API 的异步机制、请求之间的依赖关系以及并发处理方式,最终的技术实现就会变得非常复杂。
好的业务设计,不应该只停留在业务流程图上,还应该考虑技术实现的复杂度。
而这也是我当时真正想解决的问题。
TDLib 的 API 是异步回调风格,真正的复杂度不在某一个请求,而在这些请求之间的依赖关系。
例如,加入一个群组可能需要先解析链接,再判断群组类型,然后执行搜索或加入操作,最后获取群组详情。
获取历史消息也不是简单地调用一次接口。需要根据返回结果判断是否还有下一页,再继续请求,直到满足停止条件。
如果获取的消息中包含媒体,还需要继续发起文件下载请求。
这些步骤组合起来,就会出现大量异步操作。
当时团队更多关注单个 API 如何调用,而我关注的是这些 API 组合起来之后,整个业务流程会变得多么复杂。
我当时主要考虑两种实现方式:
- 继续使用 Callback,将业务逻辑写在回调里面。
- 使用 Lock、CountDownLatch 等 Java 同步工具,等待异步请求完成,再向下执行。
这两种方式都能解决问题,但都不够理想。
我希望找到一种方式,让业务代码按照正常的执行顺序组织,同时保留底层 API 的异步特性。
二、Java 异步编程:Callback Hell,还是线程等待?
TDLib 的 Java API 本身就是异步的。
调用接口时传入请求对象和回调,等底层处理完成后,再通过回调返回结果。
简化后的调用形式类似这样:
client.send(function, new Client.ResultHandler() {
@Override
public void onResult(TdApi.Object result) {
// 处理请求结果
}
});
单个请求没有什么问题。
但当业务流程需要多个请求连续执行时,问题就出现了。
2.1 Callback Hell
假设我们需要根据邀请链接加入群组,然后获取群组详情。
业务流程大概是:
如果全部使用 Callback,代码就会变成这样:
client.send(
new TdApi.CheckChatInviteLink(inviteLink),
result -> {
if (result instanceof TdApi.ChatInvite) {
client.send(
new TdApi.JoinChatByInviteLink(inviteLink),
joinResult -> {
if (joinResult instanceof TdApi.Chat) {
TdApi.Chat chat = (TdApi.Chat) joinResult;
client.send(
new TdApi.GetChat(chat.id),
chatResult -> {
// 处理最终结果
}
);
}
}
);
}
}
);
这里只展示了三个步骤。
如果后面继续增加错误处理、状态判断、消息分页、用户信息查询,代码嵌套就会越来越深。
更麻烦的是,业务逻辑被拆散在多个回调里面。
开发人员需要不断在不同的回调之间切换,才能理解完整的业务流程。
业务流程本来是线性的,代码却被回调拆成了多层。
2.2 使用同步等待工具,把异步调用包装成阻塞调用
另一种方式是使用 Java 的同步工具。
例如,发起请求之后,等待回调返回结果,再继续执行后续逻辑。
简化示意:
public TdApi.Object sendFunction(
TdApi.Function function) {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<TdApi.Object> result =
new AtomicReference<>();
client.send(function, response -> {
result.set(response);
latch.countDown();
});
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
return result.get();
}
这种方式看起来解决了回调嵌套的问题。
调用方可以按照顺序写代码:
TdApi.Object result1 = sendFunction(function1);
TdApi.Object result2 = sendFunction(function2);
TdApi.Object result3 = sendFunction(function3);
但它并没有真正解决异步问题。
它只是让调用线程等待异步结果。
如果调用发生在主线程,就可能造成 ANR;如果大量请求都采用这种方式,也会带来线程资源和并发管理方面的问题。
可以把两种方式放在一起看:
一个让代码陷入回调嵌套,一个让线程陷入等待。
2.3 历史消息分页:等待下一页结果
获取历史消息需要不断根据上一页的结果,决定是否继续请求。
例如:
public List<TdApi.Message> getMessages(
long chatId,
long fromMessageId,
int limit) {
List<TdApi.Message> messages = new ArrayList<>();
long messageId = fromMessageId;
while (true) {
TdApi.Messages result =
(TdApi.Messages) sendFunction(
new TdApi.GetChatHistory(
chatId,
messageId,
0,
limit,
false
)
);
if (result.messages.length == 0) {
break;
}
for (TdApi.Message message : result.messages) {
messages.add(message);
}
messageId = result.messages[
result.messages.length - 1
].id;
if (result.messages.length < limit) {
break;
}
}
return messages;
}
这里的重点不是分页算法本身。
而是每次请求下一页,都需要等待上一页返回结果,然后才能决定下一步。
如果没有同步包装,分页逻辑就需要放在回调里面不断递归或继续发起请求。
2.4 多媒体下载:CountDownLatch
再看一个场景。
获取一批消息之后,可能需要下载其中的图片或文件。
如果希望多个文件并发下载,并在全部完成后继续执行,就需要处理任务完成状态。
Java 中可以使用 CountDownLatch:
CountDownLatch latch =
new CountDownLatch(files.size());
for (TdApi.File file : files) {
client.send(
new TdApi.DownloadFile(
file.id,
32,
0,
0,
true
),
result -> {
// 处理下载结果
latch.countDown();
}
);
}
latch.await();
// 所有下载任务完成后继续执行
这种写法能够表达等待多个任务完成的需求。
但它也引入了额外的同步管理:
- 需要维护计数。
- 需要确保每个任务都能正确执行
countDown()。 - 需要考虑异常、超时和任务失败。
await()会阻塞当前线程。
业务逻辑本身并不复杂,却需要额外处理不少并发细节。
这也是我后来决定使用协程重新组织代码的原因。
三、Kotlin 协程:把 Callback 封装成挂起函数
协程并不会自动把 TDLib 的异步 API 变成同步 API。
它需要一个适配层,将 Callback 转换成 Kotlin 的挂起函数。
当时项目中最核心的封装就是这一层。
3.1 从 Callback 到 Continuation
先看一个简化实现:
suspend fun <T : TdApi.Object> sendFunctionAsync(
function: TdApi.Function<T>
): T = suspendCoroutine { continuation ->
Context.client.send(function) { result ->
continuation.resume(result as T)
}
}
这段代码做的事情很简单:
- 调用 TDLib 的
send()方法。 - 注册结果回调。
- 当前协程挂起。
- TDLib 返回结果后,通过
continuation.resume()恢复协程。
这里有一个非常重要的概念:
挂起的是协程,不是执行线程。
在等待 TDLib 返回结果期间,当前执行线程可以继续处理其他任务。
可以用一张时序图看一下这个过程:
sequenceDiagram
participant C as Coroutine
participant A as Adapter
participant T as TDLib
participant R as ResponseReceiver
C->>A: sendFunctionAsync()
A->>T: client.send()
Note over C: 协程挂起
T-->>R: 异步返回结果
R-->>A: 执行 ResultHandler
A-->>C: continuation.resume()
Note over C: 协程恢复执行
这就是它和 CountDownLatch.await()、Object.wait() 等阻塞等待方式最重要的区别。
从调用方看,原来的异步回调被封装成了一个可以直接返回结果的挂起函数。
例如:
val chat = sendFunctionAsync(
TdApi.GetChat(chatId)
)
val messages = sendFunctionAsync(
TdApi.GetChatHistory(
chatId,
0,
0,
100,
false
)
)
代码按照顺序执行。
但底层仍然是 TDLib 异步请求。
我们没有改变异步本质,只是改变了代码组织方式。
3.2从 Callback 到顺序代码
对比一下两种写法。
Java Callback:
client.send(function1, result1 -> {
client.send(function2, result2 -> {
client.send(function3, result3 -> {
// 最终处理
});
});
});
Kotlin Coroutine:
val result1 = sendFunctionAsync(function1)
val result2 = sendFunctionAsync(function2)
val result3 = sendFunctionAsync(function3)
// 最终处理
两段代码表达的是同一条业务流程。
区别在于,Kotlin 版本不需要把后续业务逻辑不断嵌套在回调里面。
对于需要按顺序执行的任务,代码自然就可以按照业务顺序组织。
四、业务重构:让异步流程回归业务本身
完成基础封装之后,真正有价值的是业务层的变化。
4.1 搜索群组与加入群组
例如,用户提供一个 Telegram 群组链接。
根据链接类型,需要走不同的处理流程:
使用协程之后,业务代码可以这样组织:
suspend fun joinChat(
inviteLink: String
): TdApi.Chat {
val invite = sendFunctionAsync(
TdApi.CheckChatInviteLink(inviteLink)
)
return when (invite) {
is TdApi.ChatInvite -> {
sendFunctionAsync(
TdApi.JoinChatByInviteLink(inviteLink)
)
}
else -> {
throw IllegalStateException(
"Unsupported invite result"
)
}
}
}
这里仅展示邀请链接流程的基本结构。
公开群组可以通过 SearchPublicChat 获取群组,再通过 JoinChat 加入。
如果已经是群组成员,也需要根据 TDLib 返回的结果进行相应处理,再获取最终的群组信息。
重点是:这些业务步骤不再需要通过 Callback 一层层嵌套。
每一步都可以独立调用,也可以根据业务需要组合起来。
4.2 历史消息分页
分页逻辑也可以从回调递归转换成普通循环。
suspend fun getChatMessages(
chatId: Long,
limit: Int
): List<TdApi.Message> {
val result = mutableListOf<TdApi.Message>()
var fromMessageId = 0L
while (true) {
val response = sendFunctionAsync(
TdApi.GetChatHistory(
chatId,
fromMessageId,
0,
limit,
false
)
)
if (response.messages.isEmpty()) {
break
}
result.addAll(response.messages)
val nextMessageId = response.messages.last().id
// 防止分页游标没有推进
if (nextMessageId == fromMessageId) {
break
}
fromMessageId = nextMessageId
if (response.messages.size < limit) {
break
}
}
return result
}
原来需要在回调中继续发起下一页请求。
现在只需要一个 while 循环。
每次请求都会挂起当前协程,等结果返回后再继续执行。
当然,实际项目还需要根据 TDLib 返回的数据处理分页边界、重复页、请求异常以及业务停止条件。
但至少,分页逻辑终于回到了它本来应该有的样子。
五、并发处理:CountDownLatch 变成 async / awaitAll
顺序执行的问题解决之后,还有一个重要场景:并发。
例如,获取一批群消息之后,需要根据消息发送者 ID 查询用户资料。
如果逐个查询,效率会比较低。
如果使用 Callback,就需要维护多个回调以及任务完成状态。
如果使用 CountDownLatch,又需要手动管理计数。
Kotlin 协程可以直接使用 async 和 awaitAll。
5.1 并发查询用户资料
suspend fun getUsers(
userIds: List<Long>
): List<TdApi.User> = coroutineScope {
userIds
.distinct()
.map { userId ->
async {
sendFunctionAsync(
TdApi.GetUser(userId)
)
}
}
.awaitAll()
}
这段代码表达的意思很清楚:
map为每个用户创建一个协程任务。async发起并发请求。awaitAll()等待所有任务完成。- 最后返回所有用户资料。
不需要手动维护计数器,也不需要自己管理线程等待。
不过,这里还有一个工程问题。
如果 userIds 有几万个,直接创建几万个并发任务,并不一定合理。
协程降低了并发编排的复杂度,但不意味着可以忽略并发数量的控制。
可以使用 Semaphore 限制最大并发数量:
suspend fun getUsers(
userIds: List<Long>
): List<TdApi.User> = coroutineScope {
val semaphore = Semaphore(10)
userIds
.distinct()
.map { userId ->
async {
semaphore.withPermit {
sendFunctionAsync(
TdApi.GetUser(userId)
)
}
}
}
.awaitAll()
}
这里最多允许 10 个请求同时进入实际请求阶段。
具体并发数量还需要根据 TDLib 的请求能力、服务端限制以及业务需求决定。
5.2 并发下载文件
同样的思路也可以用在媒体下载上:
suspend fun downloadFiles(
files: List<TdApi.File>
) = coroutineScope {
files.map { file ->
async {
sendFunctionAsync(
TdApi.DownloadFile(
file.id,
32,
0,
0,
true
)
)
}
}.awaitAll()
}
相比 CountDownLatch,协程版本更容易表达任务之间的关系。
我们不再需要关心计数器如何变化,也不需要自己管理等待和唤醒。
只需要描述:
启动这些任务,并等待它们全部完成。
六、不是所有 Callback 都应该转换成 suspend
前面主要讨论的是一次性请求。
但 TDLib 还有另一种异步交互方式:持续性的 Updates 事件。
例如:
- 新消息到达。
- 群组信息发生变化。
- 文件下载进度更新。
- 用户状态发生变化。
这些事件和 GetChat、GetUser 这样的请求不同。
一次性请求通常是:
发起请求 → 返回结果 → 结束
持续性事件则是:
注册监听 → 持续接收事件 → 取消监听
对于前者,使用 suspend 更合适。
对于后者,使用 Flow 更自然。
例如,可以使用 callbackFlow 将回调事件转换成 Flow:
fun observeUpdates(): Flow<TdApi.Update> =
callbackFlow {
val listener = object : UpdateListener {
override fun onUpdate(update: TdApi.Update) {
trySend(update)
}
}
client.addUpdateListener(listener)
awaitClose {
client.removeUpdateListener(listener)
}
}
这里的 UpdateListener 只是示意接口,实际需要根据 TDLib 使用的监听机制实现。
通过 callbackFlow,底层持续性的 Callback 就可以转换成 Kotlin Flow。
两者的职责可以这样理解:
| 异步操作 | Kotlin 表达方式 | 适用场景 |
|---|---|---|
| 一次性请求 | suspend | 查询群组、查询用户 |
| 持续性事件 | Flow | 新消息、状态更新 |
| 并发任务 | async | 并发查询、并发下载 |
| 等待多个任务 | awaitAll | 等待任务全部完成 |
不是所有 Callback 都应该改成 suspend,而是应该根据异步操作的语义选择合适的表达方式。
七、回过头看:协程真正改变了什么?
回顾这个项目,最初的问题其实并不是 TDLib 的 API 不好用。
TDLib 本身就是一个异步库,它通过请求回调和 Updates 机制处理数据交互。
问题出在业务代码如何组织这些异步操作。
通过协程适配层,我们可以把 Callback 隔离在底层。
业务层
|
v
Coroutine API
|
v
Callback Adapter
|
v
TDLib
业务层不需要知道底层究竟使用的是 Callback、线程池,还是其他异步机制。
中间通过一个适配层完成转换。
这实际上也是一种适配器设计思想。
回头看,当时我们有几种选择:
| 实现方式 | 主要问题 |
|---|---|
| Callback | 多层回调嵌套,业务流程容易分散 |
| Lock / Wait | 将异步等待变成线程阻塞 |
| CountDownLatch | 需要手动管理并发任务完成状态 |
| Kotlin Coroutine | 用挂起函数和结构化并发组织业务流程 |
协程并没有让底层请求变成真正的同步调用。
也没有让 TDLib 的异步机制消失。
它只是提供了一种更自然的表达方式:
- 顺序任务使用挂起函数。
- 并发任务使用
async。 - 等待多个任务使用
awaitAll()。 - 持续性事件使用 Flow。
- 任务生命周期通过结构化并发管理。
以前需要通过回调嵌套表达的业务流程,现在可以用顺序代码表达。
以前需要手动维护的并发等待,现在可以交给协程管理。
这也是我认为 Kotlin 协程最有价值的地方。
异步本身不是问题,如何组织异步操作才是问题。
协程没有消灭 Callback,它只是把 Callback 隔离在了适配层。
当我们不再把注意力放在回调、锁和计数器上,而是回到业务流程本身,代码才真正变得清晰。
就像人与人交流一样。
我们希望把一件事情按照正常的顺序讲清楚,而不是每讲一句话,就需要等待另一个回调告诉我们接下来该说什么。
协程没有改变异步的本质,只是让异步代码终于可以按照人的思维方式组织起来。
参考资料
-
Telegram 官方 TDLib 开源项目,包含源码、Java 接口及使用示例。
-
TDLib 官方入门文档,介绍 TDLib 的基本使用方式以及异步请求机制。