文章

企业级协同办公 Android 客户端架构复盘

企业级协同办公 Android 客户端架构复盘

这篇是对近一年在一款企业级 IM / 协同办公 Android 客户端上做的几项架构改造的系统复盘。产品形态可以概括为「IM 即时通讯 + 音视频会议 + 组织架构 + 轻量办公套件(日历 / 文档 / 邮箱 / 待办)」,代码按「应用宿主层 → 业务插件层 → 通用组件层 → 基础能力层」分层,采用 Kotlin + Jetpack Compose + MVI。

下面每个专题都按「背景与问题 → 技术难点 → 实现方案 → 延伸思考」来梳理,既是给自己留的复习笔记,也把其中通用的工程思路抽出来,便于迁移到其它项目。

音视频播放器从 ExoPlayer2 迁移到 Media3、自研缓存与完整文件导出这一块,已经单独写过一篇 《基于 Media3 的视频缓存与完整文件导出实践》,这里不再展开,只在下文「一图总览」里作为一条主线列出。


一、一年主线一图总览

把零散的需求归拢一下,大体是五条主线:

主线概述
基础设施升级播放器内核迁移(ExoPlayer2 → Media3)与自研缓存;路由框架整合(多套手写路由 → 统一门面 RouteEngine);Activity 栈管理 Java → Kotlin 重写。
合规与稳定性国产厂商推送 SDK 多通道适配;一系列内存泄漏、崩溃、多语言、键盘遮挡等长尾治理。
登录账号体系重构登录 / 新增账号两条验证码链路合并统一;邮箱验证码登录;账号注销;创建企业组织;账号状态标签统一管理。
邮箱产品从 0 到 1富文本编辑器(WebView + JsBridge)、收件人 / 附件交互、草稿自动保存与多端同步、离线草稿的 localId 架构迁移。
WebView 开放容器内嵌 H5 / 小程序容器、JsBridge 双向通信、深链 / 短链统一分发、聊天场景链接卡片。

这些专题横跨多个模块,很多是「先跑通、再治理」的迭代路径。下面挑几项工程上比较有代表性的展开。


二、路由框架整合与 Activity 栈管理重构

背景与问题

项目历史上并存好几套导航工具:手写的 Intent 链式构建器、较早的路由框架包装类、散落各处的路径字符串常量。底层仍基于注解式路由(跨多个模块上百个 Activity 在用),但上层调用方式不统一——新人不知道该用哪一套,路径常量重复且容易写错。

同时旧版 Activity 栈用 java.util.Stack 管理,在多窗口 / 分屏场景下「当前最上层可见 Activity」判断不准确。

技术难点

  1. 是「收敛」而不是「推翻重来」:统一门面本身仍然是底层路由框架之上的一层封装,buildRoute() 内部依旧走原有的注解式跳转。迁移不能破坏已有注解和依赖注入体系,只能在调用层做统一封装。
  2. 跨模块调用点迁移:把各模块对旧工具类的引用逐一替换,工作量分散在多次提交里,每次替换都要保证功能不回归。
  3. 多窗口场景下栈的正确性:旧版直接暴露 Stack 字段;新版用 ArrayDeque 替代,并额外维护一个「按 onResume 时机更新」的双端队列,专门修正分屏下「当前用户正在看哪个 Activity」的判断。

实现方案

  • 统一路径表:用嵌套 object 组织所有路由路径常量,替代散落各处的字符串。
  • 语义化门面RouteEngine 提供 routeTo() / routeToForResult() / routeToWithFlag() 等方法,默认 context 取栈顶 Activity,减少调用方每次传 Context 的样板代码。
  • Activity 栈:用 Kotlin object + ArrayDeque 重写,核心 API 为 currentActivity() / getTopResumeActivity() / finishAllActivityExcept() / isAppInForeground() 等,同时保留慢帧监控挂载点。
  • 迁移方式:逐模块、逐调用点替换(而非一次性全量替换),保留路径字符串语义不变,把回归风险控制在单次 diff 范围内。

延伸思考

  • 为什么用 ArrayDeque 替代 Stack Stack 继承自 Vector,所有方法都带同步锁,而 Activity 生命周期变化是高频单线程(主线程)操作,用非同步的 ArrayDeque 更轻量,且 API 更贴近「栈」与「最近使用队列」两种语义。
  • 为什么要额外维护一个 onResume 队列? 多窗口 / 分屏下,按创建顺序的栈顶不一定是当前可见的那个;单独维护一个按 onResume 时机更新的队列,才能准确回答「用户正在看哪个页面」。
  • 怎么保证不产生跳转回归? 关键在于「只收敛调用层、不改路由内核」——语义不变、逐点替换,而不是重写路由框架。

三、登录 / 验证码 / 账号体系全面重构

背景与问题

登录入口和「添加账号」入口各自维护了一套验证码发送、倒计时逻辑,代码重复且容易出现两类不一致问题:

  • 设备重启后倒计时失效
  • 接口已成功却没清理倒计时缓存,导致验证码状态错乱 / 复用

同时产品要新增邮箱验证码登录,以及「添加账号时可选择已登录的手机号或邮箱」。而原架构里手机号是唯一身份标识,承载不了多身份类型的选择与路由。

技术难点

  1. 倒计时状态的持久化与容灾:把倒计时能力下沉为公共基础组件 CountDownUtil,用 SystemClock.elapsedRealtime() 做基准时间戳并落库,同时显式检测「设备重启导致 elapsedRealtime 被重置」的场景,避免重启后倒计时错乱。
  2. 验证码消耗与缓存清理时机不一致:只有接口成功才应消耗验证码并清理倒计时缓存,但部分错误码分支也会消耗验证码——这是历史上验证码复用 / 失效 bug 的根源,属于需要逐个错误码分支补齐的治理型问题。
  3. 多身份类型的统一路由:倒计时的 key 从「单纯手机号」演进为「region + 手机号 / 邮箱 / SSO」,避免不同 region 下相同本地号码冲突;并新增身份验证方式选择页,处理「一个账号同时绑定手机号 + 邮箱 + SSO」时的选择逻辑。
  4. 接口安全升级:验证码 / 身份查询相关接口从 GET 改为 POST(避免手机号、邮箱等敏感信息出现在 URL、日志、代理里),并对敏感字段做加密。
  5. 两处 ViewModel 逻辑合并量大:核心迁移是把原本只属于「添加账号」的方法迁进登录侧共用的 ViewModel,需要保证原有登录场景(手机号登录、passkey)不回归。

实现方案

  • 倒计时能力下沉为公共层单例 CountDownUtil,以 StateFlow<Int> 对外暴露倒计时值,用 Room 做跨进程 / 重启持久化。
  • 身份选择页(Compose + MVI)根据后端返回的凭据动态渲染「手机 / 邮箱 / SSO」选项卡片,只有一种凭据时跳过选择页直接进入。
  • 共用 ViewModel 新增邮箱验证码发送 / 登录方法,与已有的手机号方法保持对称的成功 / 错误处理结构;通过场景枚举路由到不同业务处理,靠场景隔离而非物理隔离保证主流程不受影响。

延伸思考

  • 为什么把倒计时下沉到公共层? 登录和「添加账号」分属不同业务模块但都需要倒计时,各自实现导致逻辑重复且状态互不感知;下沉后统一了持久化、容灾与状态暴露方式,两处 UI 只需按 tag 消费。
  • 验证码接口为什么从 GET 改 POST? 手机号 / 邮箱是敏感信息,GET 参数会落在 URL / 日志 / 代理里;改成 POST + Body 并对敏感字段加密更安全。
  • 合并后怎么保证不影响原有登录主流程? 合并是「新增方法」而不是「重写已有方法」,新增方法通过场景枚举路由,与旧路径彼此隔离。

四、账号注销:验证 → 拿 Token → 删除的三段式安全流程

背景与问题

账号注销涉及身份二次验证、多路径数据清理(主动注销、组织所有者拦截、被动补偿清理)等复杂分支,旧实现基于 XML + Java,状态管理分散、维护成本高。重写目标是迁到 Compose + Kotlin + MVI,同时不破坏原有的全局网络错误码拦截行为

技术难点

  1. 三段式流程编排:身份验证 → 拿到短时效 Token → 用 Token 执行真正删除。这是一个必须保证顺序、中间状态不能被绕过的多步流程。Token 机制保证「验证」与「删除」的会话一致性,避免验证通过后请求参数被篡改直接删除任意账号。
  2. 架构迁移中的行为保真:从 XML + Java 迁到 MVI(Intent / State / Effect),需保证原有全局错误码拦截(账号已删除、Token 失效等统一提示)不因架构迁移回归。做法是先梳理旧实现覆盖的所有错误码分支和交互路径,再在新架构里逐一对应,而不是凭记忆重写。
  3. 多路径清理逻辑收敛:主动删除、组织所有者拦截、被动「数据未清理干净」补偿等多条路径,最终都收敛到统一的一个本地数据清理入口,避免各路径各写一套清理逻辑导致遗漏。

延伸思考

  • 为什么要三段式而不是一步到位? 删除不可逆,先做身份二次验证防盗号恶意注销,验证通过换取短时效 Token 再删除,Token 保证两步操作的会话一致性。
  • 组织所有者为什么不能直接注销? 所有者账号删除会导致整个组织归属 / 管理权真空,服务端用专门错误码拦截,客户端引导先转移所有权。
  • 「数据未清理干净」这个被动场景是什么? 指服务端已标记注销、但本地 / 关联数据未清干净的中间态(网络中断、多端并发等原因);登录时检测到该状态就走一次清理补偿,而不是当普通错误提示,是对分布式状态不一致的兜底。
  • 多路径清理为什么要收敛到一个入口? 未来任何一处清理范围调整(比如新增一类本地缓存)只需改一处,避免三处各改、容易遗漏。

五、账号状态 / 标签统一管理:从散落 if/else 到规则引擎

背景与问题

账号状态 / 内外部 / 身份标签的展示逻辑,历史上是每个页面各写一套 if/else(是否可信、是否外部、是否离职、激活状态逐条判断)。这带来两个问题:

  • 优先级容易写错:重构前的代码里就有真实 bug——一个「已注销的外部账号」被错误展示成 External 标签,而不是 Closed;
  • 规则一变满仓库找:同类逻辑分散在会议、日历、文档、IM 等二十多个场景里各自为政,PRD 一调整优先级就要满仓库改。

技术难点

  1. 多维度状态的互斥 / 优先级 / 并存关系:账号状态(已注销 / 不可用 / 未激活)与内外部标签(External / Trusted)互斥、只显示一个;身份标签(群主 / 群管理员)却可以和账号状态并存。规则本身是 PRD 定义的多层判断表,容易改错一处漏改多处。
  2. 模型解耦:调用方分散在各种具体业务模型上,字段命名和取值都不同。新增一个中立的「属性快照」作为统一输入,为每种业务模型写一个 adapter 做映射,让规则引擎不依赖具体模型、可独立单测。
  3. 场景 × 查看者的二维矩阵:同一状态在不同场景(定义了二十多种)展示规则不同,且对内部 / 外部查看者可见的标签集合也不同。这个矩阵用「规则表 + 映射」落地,作为「PRD 展示规则的唯一来源」。
  4. 渐进式迁移 / 预留设计:后端字段还没就绪的状态先在枚举里注释预留位置与优先级,但先不接线,保证旧逻辑不受影响,等后续分批迁移。

实现方案

四层架构:

1
2
3
4
5
6
7
8
9
统一属性快照(UserTagAttributes)
      ↓
规则引擎(AccountTagResolver + SceneRule)
  resolveAccountVisibility() —— 账号本身该不该展示
  resolvePrimaryTag()        —— 按互斥 + 优先级给出最终标签
      ↓
视觉规格(UiTagType)
      ↓
统一渲染函数 showTag()

业务侧调用从「各自 if/else 拼 UI」变成一行:

1
showTag(context, view, AccountTagResolver.resolvePrimaryTag(attr, TagScene.XXX))

延伸思考

  • 为什么不直接改每个 Adapter 的 if/else 顺序修 bug? 同样的逻辑在二十多个场景里各写一份,PRD 一变就要满仓库找,而且已经因为顺序写错出过真实 bug;用规则引擎把「规则」和「渲染」分层,PRD 变化只改一处映射表。
  • 属性快照这层解耦不做行不行? 不做的话规则引擎要直接依赖各种具体模型,跨模块引用混乱且难单测;用中立快照 + 每种模型一个 adapter,引擎可脱离业务模型单测。
  • 正确性怎么保证? 把 PRD 规则表直接翻译成数据类 + 映射表,写成显式布尔字段;目前主要靠集中到一处走查审阅,自动化测试覆盖是明确的待补强点——这类「规则集中化」重构最值得补的就是针对矩阵的单测。

六、国产厂商推送 SDK 多通道适配

背景与问题

产品主要走 FCM 分发,但目标市场里大量在售的华为 / 小米 / OPPO / vivo / 荣耀等设备缺少完整的 GMS,纯依赖 FCM 会导致这部分用户推送延迟、丢失甚至完全不可达。因此需要接入各厂商私有推送通道作为 FCM 的补充。

技术难点

  1. 多厂商 SDK 共存冲突:最初为每个厂商各建一个 flavor,需要在多个模块同步维护,flavor 矩阵随厂商数量线性膨胀;而且单厂商渠道包无法应对「设备实际厂商与安装包渠道不一致」的场景。
  2. 架构收敛为运行时判断:后来把多个厂商 flavor 合并成一个国内包,在同一安装包里内置全部厂商 SDK,改为运行时按机型选择要初始化的通道,同步在各模块 build.gradle 删除冗余 flavor 声明。再往后 push 这个 flavor 维度被彻底移除,向「单一产物、全部逻辑运行时判定」演进。
  3. 多环境厂商配置文件管理:某些厂商 push 需要按 dev / qa / prod 等多套环境各一份 key 配置,且配置文件要放在模块根目录(构建插件固定读取路径)。用自定义 Gradle Copy Task 在构建前把对应环境的配置拷贝改名到根目录,并显式声明与厂商服务插件任务的时序依赖,避免任务时序冲突。
  4. 网络安全策略适配:为新增厂商 push 域名和本地回环地址放开必要的明文流量许可,同时保留主域名的证书 pinning 不变——把安全豁免限定在必须访问的域名,而不是全局关闭 pinning。

延伸思考

  • 为什么先按厂商拆 flavor、后来又合并? 按厂商拆分时构建变体随渠道线性增长,且用户设备将来换机、是哪个厂商无法预知,单厂商包覆盖不全;运行时判断后一个包内置全部 SDK,构建矩阵大幅简化,也贴合应用商店「一次上传、全渠道适配」的诉求。
  • 多家 SDK 共存怎么不冲突? 各厂商 receiver / service 在 Manifest 中共存声明(多数由底层 IM SDK 的适配层承载);构建期通过 Gradle 任务时序声明避免不同厂商插件抢占同一份配置;运行时只激活当前设备厂商对应 SDK。
  • 动证书 pinning 会不会降低安全性? 新增的是针对特定域名和本地回环地址的例外,主域名 pinning 语义不变——这是接入第三方 SDK 时常见的「最小范围放宽」。
  • 重新设计会怎么做? 把「设备厂商探测 + 通道选择」抽成独立的 PushRouter 组件,对外暴露统一注册 / 注销接口,各厂商 SDK 依赖做成可插拔的 Gradle module,兼顾编译期裁剪体积与运行时容错。

七、WebView 开放容器与 JsBridge

背景与问题

「工作台」需要承载大量 H5 / 小程序业务,这些页面要调用原生能力(登录态、导航栏样式、分享等),同时存在短链系统用于分享邀请 / 应用跳转,聊天消息里分享应用 / 文档时要以富媒体卡片呈现。核心矛盾是:要暴露原生能力,但不能无限制信任前端调用;深链 / 短链种类繁多需要统一入口分发;链接预览要在聊天列表高频渲染下兼顾准确性与性能。

技术难点

  1. JsBridge 安全双重校验:H5 调 native 方法时,先按命名空间在 map 中查桥对象,再用反射查方法,找到后还必须校验该方法上标注了 @JavascriptInterface 注解才允许 invoke——两者缺一不可,避免业务对象的普通 public 方法被反射误暴露。
  2. WebView 生命周期与 Bridge 状态一致性:用 WeakReference<WebView> 持有页面,区分「已挂载 / 已回收」状态支持容器复用;loadUrl / reload 时重建回调队列和 callID,防止跨页面复用时回调串号或内存泄漏。
  3. Native → JS 调用的时序竞态:Native 侧在页面 JS 环境未就绪时先进入挂起队列,直到 JS 主动调用初始化方法后统一下发,解决「页面还没监听、Native 已经调用」导致消息丢失的问题。
  4. 短链解析的健壮性:用 HttpURLConnection 关闭自动重定向、手动读取 301/302 的 Location 头得到长链,并用 LruCache 缓存;解析失败时不能再把短链丢给系统浏览器打开,否则会陷入 App ↔ 浏览器的死循环——这是踩过坑后加的保护。
  5. 未登录态深链的补偿:点击深链时若未登录,先缓存 Uri、登录成功后重放,避免从深链进入时因未登录跳转失败。

实现方案

  • 深链 / 短链分发用责任链模式:依次尝试短链解析 → 工作台链接 → 加账号 / 组织邀请 → 即时通知,命中后统一通过 RouteEngine.routeTo() 打通到统一路由,无匹配时兜底跳系统浏览器。
  • 链接卡片按导航配置判断一条 URL 是否为自有链接,命中后请求后端拿标题 / 图标等元数据,用 LRU + 持久化两级缓存;聊天消息按优先级区分通用链接预览 / 文档卡片 / 应用卡片 / 会议邀请卡片分别渲染。

延伸思考

  • 怎么保证只有明确暴露的方法能被 H5 调用? 反射拿到方法后还要求显式标注 @JavascriptInterface 才放行,且按命名空间隔离,H5 必须知道正确的 namespace.method
  • Native 调 JS 时页面还没加载完怎么办? 用挂起队列缓存待发送调用,JS 侧初始化完成后统一 flush。
  • 短链为什么不直接交给浏览器? 要先在 App 内识别落地页业务类型再分发;直接交给浏览器会在解析失败时于 App 与浏览器间死循环跳转。

八、邮箱产品从 0 到 1:协调层拆分与 localId 架构

这是投入最大、复杂度最高的专题——在办公套件模块从 0 到 1 搭建完整的邮件收发闭环(列表、会话详情、富文本编辑器、附件、签名、草稿)。

背景与问题

邮件编辑页要同时满足富文本排版、多收件人输入与校验、附件上传下载、回复 / 转发正文与附件回显、离线可写草稿等多种互相耦合的复杂交互。若都堆在一个 ViewModel 里,状态机会迅速膨胀、难测难维护。

同时早期草稿以服务端返回的 emailId 作为主键,存在「离线 / 网络失败时拿不到草稿 ID、草稿无法落库、退出即丢失」的问题。

技术难点

1. WebView 富文本编辑器与原生双向通信

正文渲染在 WebView 内(富文本排版依赖 contenteditable / HTML 渲染引擎,原生实现成本高)。原生侧通过一个 EmailJsBridge 以「方法名 + JSON 参数」的轻量 RPC 驱动 JS(openEmailgetEmailBodyinsertImagessetFontSizetoggleBold 等),部分调用用 suspendCancellableCoroutine 把异步回调转成协程挂起函数;反向由 JS 侧事件统一归纳为一个 Action 枚举再分发给业务层,避免每个事件单独定义一套接口。

2. 协调层拆分

把复杂子领域从主状态机里拆出去,每个协调器只依赖自己需要的状态切片 + 一个回调:

  • RecipientCoordinator:管理 To / Cc 收件人流程(输入变化、退格删除规则、本地联系人合并、chip 折叠 / 展开);
  • DraftAttachmentBinder:专门处理附件与草稿 ID 的绑定时序——已保存附件重绑、签名内嵌附件、回复 / 转发回显附件绑定,并在远端草稿创建失败时统一清理回显附件并弹窗提示;
  • 另有 DraftCoordinator / AttachmentCoordinator / ContactSearchCoordinator 等各司其职。

3. 草稿 ID 从 emailId 迁移到 localId(核心架构升级)

把「本地草稿创建」和「远端草稿创建」拆成两个独立、非阻塞的步骤:

  • ensureLocalId()同步调用协议栈本地创建,必定成功、不依赖网络;
  • createRemoteDraftIfNeeded()异步调用远端创建拿服务端 emailId,仅在需要服务端能力(如附件上传绑定)时才触发。

保存草稿、发送邮件只需要 localId;emailIdlocalId 的映射由协议栈后台维护,上层不感知。这样即使完全离线编辑,也能持续本地保存与后续发送排队,网络恢复后再由协议层完成同步。

4. 并发与竞态控制

用一把 localIdCreationLock 防止多个入口并发触发本地草稿创建导致重复建草稿;用 initializeDraftJob?.join() 保证发送 / 保存前等待草稿初始化任务完成。

5. 正文本地持久化改用 localId 做文件名主键

正文文件命名从依赖 emailId 改为 {localId}_{digest}.txt,配合「先写临时文件再同目录 renameTo」实现原子写入、按 digest 去重。因为 emailId 可能在草稿生命周期中才被异步回填甚至变化,用它做文件名会导致路径漂移;localId 从创建起就稳定不变。

延伸思考

  • 为什么把收件人 / 附件从主 ViewModel 拆出来? 收件人的增删 / 校验 / 合并 / 布局,和附件与草稿 ID 的绑定时序,都是各自独立的子领域;混在一个大 Model 里会让状态转移分支爆炸、难以单测。拆成协调层后边界清晰、可独立测试。
  • localIdemailId 分别解决什么,为什么不能只用 emailId emailId 由服务端创建草稿接口返回、依赖网络;离线时用户输入无法产生任何可持久化标识,退出即丢失。localId 由协议栈本地同步生成、必定成功,让「草稿落库」与网络状态解耦。
  • 远端草稿创建失败(离线带着回显附件)怎么保证一致性? 把回显附件整体从列表移除、不参与后续保存,并弹窗提示哪些附件添加失败,避免草稿里保存了「看起来存在但实际未绑定成功」的附件引用。
  • 怎么防止本地草稿被并发重复创建? 用锁包裹 ensureLocalId(),并让发送 / 保存入口等待首次内容变化触发的初始化协程完成后再继续。
  • 这么大的专题怎么推进? 典型的「先跑通、再治理」:先把「列表 / 详情 / 发送」基础闭环跑通,再逐步叠加富文本、附件、协调层拆分;localId 迁移是在基础功能上线后,针对「离线丢草稿」这个具体问题做的专项架构升级。

九、几条可迁移的通用经验

抛开具体业务,这一年沉淀下来、可以带到别的项目的几条通用思路:

  1. 能力下沉,规则集中。凡是「多处各写一套」的逻辑(倒计时、标签判断、数据清理),都值得抽成单一数据源 / 单一入口——不是为了优雅,而是为了「规则变化只改一处」。
  2. 收敛优于推翻。路由整合、推送架构演进都不是重写内核,而是在调用层做统一封装、逐点迁移,把回归风险控制在最小 diff。
  3. 把不依赖网络的能力和依赖网络的能力解耦localId / emailId 的拆分是最典型的例子——本地必定成功的部分同步做,依赖网络的部分异步按需补齐,离线能力就自然出来了。
  4. 防御式编程针对生命周期。已释放资源、页面未就绪、设备重启导致的时钟重置……这些边界都要显式兜底,不能假设调用方一定按理想顺序调用。
  5. 安全豁免要最小范围。无论是 JsBridge 的注解校验,还是网络安全配置里对特定域名放开明文,原则都是「只放开必须的那一点,其余保持严格」。

本文所有实现细节都做了脱敏与泛化处理,类名、结构仅用于说明工程思路,不对应任何具体产品代码。

本文由作者按照 CC BY 4.0 进行授权