用 Flutter 写论坛客户端:一次跨平台架构与性能实践

0x00 前言

这次的项目源自一位朋友的需求:他基于 Flarum 搭建了一个论坛,并希望拥有一个与其插件和定制功能深度适配的客户端应用。最初是将网站直接打包成的H5 App,但在服务器部署海外、缺乏针对性优化的情况下,加载速度和交互体验都难以接受,最终还是回到了客户端开发这一选择。

自然的项目的技术选型就要围绕 跨平台、性能和开发效率这三方面展开。结合声明式 UI 在开发效率上的优势,以及 Flutter 在稳定性、生态和性能上的综合表现,Flutter似乎是我能想到的最好选择。

在此之前,我并没Flutter的开发经验,只有曾用 Jetpack Compose 开发过Android 应用。因此,这次开发更像是一次从 Compose 迁移到 Flutter 的实践。很多关于 Flutter 的理解,都是在这个项目里建立起来的。

0x01 Flutter 初印象

同样是声明式 UI,Flutter 和 Compose 在核心理念上其实很接近,都是围绕“组合”来构建界面。Compose 用 @Composable 函数拼装 UI,Flutter 通过 Widget 的 build 方法组合组件。很多概念几乎可以一一对应,比如 Compose 里的 remember 和 Flutter 里的 StatefulWidget,都是对 UI State 的管理方式。有 Compose 经验的话,上手 Flutter 并不会太困难。

但真正写起来,差异感就慢慢明显了。Compose 的 Composable 是顶层函数,而 Flutter 的 UI 是 class 结构下的 build 重写方法,表达形式上会更偏 OOP,也更容易产生层层嵌套的感觉。代码视觉复杂度确实比 Compose 高一些。不过反过来看,Flutter 把 state 和逻辑收拢在 class 里,从结构清晰度上也有它的合理性。

Flutter vs Compose

Flutter 没有类似 Compose 那种统一的 Modifier 体系。样式、间距、装饰都需要通过不同的 Widget 或 Style 分别处理,比如 Padding、Container、TextStyle 等,这会导致 UI 层级更深。项目规模变大后,如果不提前做好组件拆分,维护成本会明显上升。

关于更新机制,两者差异也很大。Flutter 的 build 本质上只是普通 Dart 方法,State 变化后会重新执行 build,然后通过 Widget diff 机制决定真正更新哪些 Element。更新范围的控制更多依赖组件拆分和 const 优化,而不是 Stateful/Stateless 本身。Compose 则依赖编译器插桩和重组机制自动推断重组范围,理念更偏向“让框架帮你决定”。两种方式各有取舍,但 Flutter 确实需要更主动地关注 rebuild 粒度。

BuildContext 也是一个适应成本比较高的点。很多 UI 相关操作——比如弹窗、主题获取、导航——都依赖它。跨函数传递、异步场景下的安全使用都需要小心。不过从本质上看,这和 Compose 对 @Composable 作用域的限制是同一个问题:UI 操作的边界控制。两边其实都谈不上完美。

说完这些适应成本,也必须承认 Flutter 有不少让我惊喜的地方。热重载和热重启的效率非常高,在真实运行环境下调 UI 比 Preview 直观很多。配合 Dart 编译速度,反馈几乎是即时的,这种正向循环对开发体验提升很明显。相比之下,Gradle 那套流程就显得有点沉重了。

跨平台体验是 Flutter 很大的优势。生态里的库几乎默认就是为多端设计的,不太需要担心平台兼容问题。之前用 Compose Multiplatform 时,很多库实际上只适配 Android Compose,还得找替代方案。Flutter 在这方面一致性更好,pub 的包管理体验也比 Gradle/Maven 轻量不少。

Dart 语言本身没有 Kotlin 那么“现代感”强,但对 UI 开发是友好的。语法简洁、结构清晰,新版本的一些语法改进(例如 enum 简写)也在努力减少样板代码。虽然整体表达仍然偏啰嗦,但通过合理拆分组件,复杂度是可控的。

最后是性能。Flutter 的 Impeller 渲染引擎在移动端 Release 表现确实亮眼。自绘体系带来的可控性和稳定性,让动画和特效非常流畅。相比一些同样基于 Skia 的跨平台方案,启动和渲染体验都更成熟一些。这也是我最终觉得 Flutter 更适合作为客户端方案的重要原因。

0x02 开发过程

真正开始写 StarForum 之后,我才发现,耗精力的地方其实不在 UI,而是在那些不太显眼的工程细节上:API 封装、本地缓存,以及启动阶段的数据同步。

先说 API。Flarum 的接口文档谈不上完善,不过好在它遵循 JSON:API 规范,结构比较统一。抓几次包、自己试几组请求之后,基本能把参数和返回结构摸清楚。于是我按照功能模块把接口拆开封装,再抽出一层统一的请求包装,用来处理日志、异常、Cookie 管理这些通用逻辑。这样做的好处是后面改动不会牵一发而动全身。

API 封装结构

网络层用的是 Dio。拦截器机制很清晰,请求生命周期也比较可控。把 GET、POST 分开封装之后,会话管理就变得比较干净,后面调试问题也更容易定位。

认证这一块稍微麻烦一点。Flarum 默认没有单独的 refresh token 流程,所以目前的做法是:安全存储用户凭据,一旦接口返回 401,就重新登录获取新 Token。是否失效的判断方式也很直接——请求 /users,如果 401,就认为登录态过期。这显然算不上优雅,只是一个能工作的过渡方案。等后面做通知或者更复杂的登录态逻辑,肯定要把这块重构掉。

然后是本地缓存。贴文列表如果每次都全量请求,首屏体验会非常差,所以我用 sqflite 做了一个本地 SQLite 缓存。拿到贴文摘要之后直接落盘,下次启动优先展示本地数据,再做增量同步。

这里踩的一个点是:Flarum 的列表接口不会直接给“简介”,而是需要 include firstPost,然后自己截断。现在的实现是直接请求完整首帖内容再裁剪,逻辑简单,但请求体偏大。后续可以考虑字段精简或者延迟加载,否则列表页的网络负担始终偏重。

缓存带来的另一个问题是数据一致性。服务器删帖之后,本地还在。我的处理方式是增加一个“最后同步时间”字段,在每次同步时做比对;长时间未更新且服务器列表中已不存在的记录,会被标记为删除。逻辑上有点像 RSS 客户端的缓存策略——不是追求绝对实时,而是保证整体可控。

最后是启动阶段的数据同步。用户信息、标签列表、贴文数据、登录状态,这些东西之间多少有点依赖关系。如果处理不好,很容易出现 UI 先渲染、数据后到位的错位感。目前的实现比较保守:通过简单的加载页和请求排序保证状态完整,再放 UI 出来。

它能工作,但不够优雅。后续打算做请求合批和并发调度,把没有依赖关系的请求并行化,缩短冷启动时间。相比功能开发,这种“流程打磨”更花时间,也更考验耐心。

回头看这一部分,会发现它比 UI 复杂得多。UI 是可见的,能立刻看到结果;而这些工程层面的设计,更多是在为后面埋地基。看不见,但影响很长远。

0x03 调试和优化

真正开始优化,是在功能差不多齐了之后。那时候应用“能跑”,但还谈不上舒服。移动端偶尔掉帧,PC 端内存曲线也不太好看。说实话,我对 Flutter 的性能机制还谈不上熟悉,只能一边看 DevTools,一边一点点试。

调试器

慢慢会发现,Flutter 的优化其实没有那么神秘。很多问题都集中在一个点上:不必要的 rebuild 和 repaint。

一开始写 UI 时,为了图省事,会把结构堆得很大。等数据量一上来,帧率就开始波动。后来做的第一件事就是把 Widget 树拆小,把容易变化的部分单独抽出来。这样状态更新时,重建范围自然就被限制住了。const 也比想象中重要,它能让框架明确知道哪些节点是稳定的,从而跳过无意义的 diff。

RepaintBoundary 是后来才意识到要用的东西。并不是所有 rebuild 都会带来重绘,但一旦涉及复杂绘制区域,隔离 repaint 的效果还是很明显。尤其是列表中带图片或复杂布局的 item,如果不隔离,滚动时的压力会被放大。

长列表的优化是最直观的。最早用普通 Column 直接渲染全部数据,数据一多,帧率几乎是肉眼可见地下滑。改成 ListView.builder 之后情况立刻好转,本质上和 Compose 里的 LazyColumn 是一样的思路——只构建可见区域。那一刻会突然理解:声明式不等于可以忽略结构成本。

调试主要依赖 DevTools 看 rebuild 次数、Raster 时间和内存变化。Flutter 的工具已经够用,但相比 Android Studio 那套成熟的体系,细节上还是稍微粗糙一点。复杂 rebuild 的来源有时不太直观,需要自己多做几次实验才能摸清。

数据层的优化反而更像是在“收拾习惯”。控制缓存规模,及时关闭数据库连接和流监听,避免对象被意外长期持有。Dart 的 GC 不需要手动干预,但前提是你不要制造悬空引用。PC 端内存表现不理想,很大一部分其实来自自己写代码时的不够克制。

包体优化则相对直接一些。裁剪无用资源、开启 –tree-shake-icons、分 ABI 打包、开启混淆压缩。优化前 Android APK 在 50MB 左右,处理之后能压到 20MB 左右。对一个带本地数据库和网络模块的应用来说,这个体积已经比较理想了。

回过头看,优化的过程其实和前面做架构设计是连在一起的。如果结构本身混乱,后面再怎么调参数也救不了;如果结构清晰,很多性能问题只需要稍微打磨一下就能稳定下来。

Flutter 给我的一个感受是,它的性能下限其实不低。只要写法别太随意,Release 版本通常都能给出一个不错的表现。而真正影响体验的,更多是你对状态边界和数据流的控制能力,而不是某个“神秘优化技巧”。

0x04 总结

回过头看,这次从 Compose 转到 Flutter,其实没有想象中那么难。

同样是声明式 UI,很多设计理念是相通的。不同框架之间确实存在互相借鉴的痕迹,所以迁移时不会有太强的割裂感。更重要的是,Flutter 本身相对“独立”,不深度依赖某个平台生态,很多历史包袱天然就被规避了。这一点在跨平台开发时尤为明显——思维负担小很多。

如果目标是做一个高效率、同时又希望质量在线的跨平台 UI 应用,在当前的开源方案里,我依然会优先推荐 Flutter。它的成熟度、生态完整度和性能表现,综合下来非常均衡。至少在我这次的实际项目里,它确实给了一个很稳定的下限。

当然,它也并不完美。UI 树结构的复杂度、部分 API 的历史设计痕迹,都还有改进空间。Compose 在某些抽象层面上的表达更优雅,Flutter 未来如果能吸收这些优点,会更有竞争力。

对我来说,这次项目最大的意义,其实不只是学会了 Flutter。
它让我第一次完整地把一个跨平台 App 从架构、网络、缓存到优化全部走了一遍。过程中学到的,不只是框架本身,还有状态管理的边界、数据同步的取舍、性能与结构之间的平衡。

StarForum 现在算是一个能稳定运行的版本,但远谈不上完成。后续还会继续迭代、重构、补坑。某种程度上,这个项目也成了我持续学习和实践的载体。

写到这里,会有一种很踏实的感觉——跨平台不再只是概念,而是自己真正做过一次的事情。

上一篇
下一篇
跳至工具栏