不改变异步本质,只改变代码组织:Telegram 项目的 Kotlin 协程实践

回看四年前做过的一个项目,我使用 TDLib 获取 Telegram 群组数据。

底层 API 是典型的 Java 异步回调风格。

真正花时间的地方,不是发出某一个请求,而是把查群、加群、翻页、查用户、下载文件这些异步步骤组织成完整的业务流程。

后来,我用 Kotlin 协程重新组织了这套代码。

没有改变底层 API 的异步本质,只是改变了业务代码的组织方式。

这也是我后来对 Kotlin 协程越来越深的一个认识:

协程是对异步回调的一种线性化表达。

一、项目背景:业务不复杂,复杂的是异步流程

四年前,我做过一个基于 Telegram 的数据获取项目,底层使用的是 Telegram 官方提供的 TDLib。

项目涉及的业务并不算复杂,主要包括:

  • 根据群链接解析群组信息。
  • 搜索公开群组并加入。
  • 通过邀请链接加入群组。
  • 获取群组基本信息。
  • 分页获取历史消息。
  • 获取群成员列表。
  • 根据消息发送者查询用户资料。
  • 下载消息中的图片、文件以及用户头像。

如果只看业务需求,这些功能都很常规。

当时还有一个比较有意思的情况。

PM 更多关注的是业务设计。

比如,群组应该怎么查,加入群组之后应该获取哪些信息,历史消息需要获取多少,图片和文件应该如何处理。

这些讨论本身没有问题。

但有时候,业务流程在白板上画出来很简单,并不意味着技术实现也简单。

一个查询动作背后,可能涉及多个异步请求;一个看似普通的分页需求,实际上需要不断根据上一次请求的结果决定下一步;一次媒体下载,也可能涉及多个并发任务以及完成状态的管理。

我当时比较在意的是这些事情。

说得直白一点,业务流程可以设计得很漂亮,但如果没有考虑底层 API 的异步机制、请求之间的依赖关系以及并发处理方式,最终的技术实现就会变得非常复杂。

好的业务设计,不应该只停留在业务流程图上,还应该考虑技术实现的复杂度。

而这也是我当时真正想解决的问题。

TDLib 的 API 是异步回调风格,真正的复杂度不在某一个请求,而在这些请求之间的依赖关系。

例如,加入一个群组可能需要先解析链接,再判断群组类型,然后执行搜索或加入操作,最后获取群组详情。

获取历史消息也不是简单地调用一次接口。需要根据返回结果判断是否还有下一页,再继续请求,直到满足停止条件。

如果获取的消息中包含媒体,还需要继续发起文件下载请求。

这些步骤组合起来,就会出现大量异步操作。

当时团队更多关注单个 API 如何调用,而我关注的是这些 API 组合起来之后,整个业务流程会变得多么复杂。

我当时主要考虑两种实现方式:

  1. 继续使用 Callback,将业务逻辑写在回调里面。
  2. 使用 Lock、CountDownLatch 等 Java 同步工具,等待异步请求完成,再向下执行。

这两种方式都能解决问题,但都不够理想。

我希望找到一种方式,让业务代码按照正常的执行顺序组织,同时保留底层 API 的异步特性。

二、Java 异步编程:Callback Hell,还是线程等待?

TDLib 的 Java API 本身就是异步的。

调用接口时传入请求对象和回调,等底层处理完成后,再通过回调返回结果。

简化后的调用形式类似这样:

java 复制代码
client.send(function, new Client.ResultHandler() {
    @Override
    public void onResult(TdApi.Object result) {
        // 处理请求结果
    }
});

单个请求没有什么问题。

但当业务流程需要多个请求连续执行时,问题就出现了。

2.1 Callback Hell

假设我们需要根据邀请链接加入群组,然后获取群组详情。

业务流程大概是:

如果全部使用 Callback,代码就会变成这样:

java 复制代码
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 的同步工具。

例如,发起请求之后,等待回调返回结果,再继续执行后续逻辑。

简化示意:

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();
}

这种方式看起来解决了回调嵌套的问题。

调用方可以按照顺序写代码:

java 复制代码
TdApi.Object result1 = sendFunction(function1);

TdApi.Object result2 = sendFunction(function2);

TdApi.Object result3 = sendFunction(function3);

但它并没有真正解决异步问题。

它只是让调用线程等待异步结果。

如果调用发生在主线程,就可能造成 ANR;如果大量请求都采用这种方式,也会带来线程资源和并发管理方面的问题。

可以把两种方式放在一起看:

一个让代码陷入回调嵌套,一个让线程陷入等待。

2.3 历史消息分页:等待下一页结果

获取历史消息需要不断根据上一页的结果,决定是否继续请求。

例如:

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

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

先看一个简化实现:

kotlin 复制代码
suspend fun <T : TdApi.Object> sendFunctionAsync(
    function: TdApi.Function<T>
): T = suspendCoroutine { continuation ->

    Context.client.send(function) { result ->
        continuation.resume(result as T)
    }
}

这段代码做的事情很简单:

  1. 调用 TDLib 的 send() 方法。
  2. 注册结果回调。
  3. 当前协程挂起。
  4. 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() 等阻塞等待方式最重要的区别。

从调用方看,原来的异步回调被封装成了一个可以直接返回结果的挂起函数。

例如:

kotlin 复制代码
val chat = sendFunctionAsync(
    TdApi.GetChat(chatId)
)

val messages = sendFunctionAsync(
    TdApi.GetChatHistory(
        chatId,
        0,
        0,
        100,
        false
    )
)

代码按照顺序执行。

但底层仍然是 TDLib 异步请求。

我们没有改变异步本质,只是改变了代码组织方式。

3.2从 Callback 到顺序代码

对比一下两种写法。

Java Callback:

java 复制代码
client.send(function1, result1 -> {
    client.send(function2, result2 -> {
        client.send(function3, result3 -> {
            // 最终处理
        });
    });
});

Kotlin Coroutine:

kotlin 复制代码
val result1 = sendFunctionAsync(function1)

val result2 = sendFunctionAsync(function2)

val result3 = sendFunctionAsync(function3)

// 最终处理

两段代码表达的是同一条业务流程。

区别在于,Kotlin 版本不需要把后续业务逻辑不断嵌套在回调里面。

对于需要按顺序执行的任务,代码自然就可以按照业务顺序组织。

四、业务重构:让异步流程回归业务本身

完成基础封装之后,真正有价值的是业务层的变化。

4.1 搜索群组与加入群组

例如,用户提供一个 Telegram 群组链接。

根据链接类型,需要走不同的处理流程:

使用协程之后,业务代码可以这样组织:

kotlin 复制代码
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 历史消息分页

分页逻辑也可以从回调递归转换成普通循环。

kotlin 复制代码
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 并发查询用户资料

kotlin 复制代码
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 限制最大并发数量:

kotlin 复制代码
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 并发下载文件

同样的思路也可以用在媒体下载上:

kotlin 复制代码
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 这样的请求不同。

一次性请求通常是:

text 复制代码
发起请求 → 返回结果 → 结束

持续性事件则是:

text 复制代码
注册监听 → 持续接收事件 → 取消监听

对于前者,使用 suspend 更合适。

对于后者,使用 Flow 更自然。

例如,可以使用 callbackFlow 将回调事件转换成 Flow:

kotlin 复制代码
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 隔离在底层。

text 复制代码
业务层
   |
   v
Coroutine API
   |
   v
Callback Adapter
   |
   v
TDLib

业务层不需要知道底层究竟使用的是 Callback、线程池,还是其他异步机制。

中间通过一个适配层完成转换。

这实际上也是一种适配器设计思想。

回头看,当时我们有几种选择:

实现方式 主要问题
Callback 多层回调嵌套,业务流程容易分散
Lock / Wait 将异步等待变成线程阻塞
CountDownLatch 需要手动管理并发任务完成状态
Kotlin Coroutine 用挂起函数和结构化并发组织业务流程

协程并没有让底层请求变成真正的同步调用。

也没有让 TDLib 的异步机制消失。

它只是提供了一种更自然的表达方式:

  • 顺序任务使用挂起函数。
  • 并发任务使用 async。
  • 等待多个任务使用 awaitAll()。
  • 持续性事件使用 Flow。
  • 任务生命周期通过结构化并发管理。

以前需要通过回调嵌套表达的业务流程,现在可以用顺序代码表达。

以前需要手动维护的并发等待,现在可以交给协程管理。

这也是我认为 Kotlin 协程最有价值的地方。

异步本身不是问题,如何组织异步操作才是问题。

协程没有消灭 Callback,它只是把 Callback 隔离在了适配层。

当我们不再把注意力放在回调、锁和计数器上,而是回到业务流程本身,代码才真正变得清晰。

就像人与人交流一样。

我们希望把一件事情按照正常的顺序讲清楚,而不是每讲一句话,就需要等待另一个回调告诉我们接下来该说什么。

协程没有改变异步的本质,只是让异步代码终于可以按照人的思维方式组织起来。

参考资料

  1. TDLib GitHub 仓库

    Telegram 官方 TDLib 开源项目,包含源码、Java 接口及使用示例。

  2. Getting Started with TDLib

    TDLib 官方入门文档,介绍 TDLib 的基本使用方式以及异步请求机制。

相关推荐
好的佩奇9 小时前
蓝牙之广播与扫描
android·flutter·蓝牙
传奇开心果编程11 小时前
【Jetpack Compose娓娓道来】第36课:焦点管理与键盘输入——让遥控器、键盘和输入法都“找得到北”
android·kotlin·移动开发·交互·android jetpack·ui框架·声明式ui
hai_android11 小时前
AudioTrack 深度解析:从原理到实战
android·java·kotlin
红尘伴蝶舞12 小时前
从秒开优化到连环崩溃:深入剖析 ViewPager2 在常驻 DialogFragment 中的复用陷阱与自愈方案
android·kotlin
巫山老妖16 小时前
Android 应用架构演进:MVC / MVP / MVVM / MVI (2026)
android·android jetpack
木头左20 小时前
【架构设计】教育类 App 服务端化改造:AI 上移、题库分层与个人题库归属
android·架构设计·系统设计·后端开发·数据库设计
墨狂之逸才21 小时前
USB 的 HOST 和 DEVICE 到底有什么区别?ADB 调试应该选哪个?
android
奶油话梅糖1 天前
苹果的内核跑上了安卓手机:2,631 Star 的 HakiOS 让 XNU 在 Adreno GPU 上画出 118 帧
android·macos·智能手机
恋猫de小郭1 天前
Flutter THLC 的 WebView 滚动终于从 70 FPS 提升到 120 FPS
android·前端·flutter