客户端设计(下):场景流派与实战设计方式 客户端架构为什么、什么时候、怎么做https://blog.csdn.net/mix39/article/details/161257993客户端设计上MVC/MVP/MVVM 与高内聚低耦合https://blog.csdn.net/mix39/article/details/161257807客户端设计中OOP、SOLID 与设计模式https://blog.csdn.net/mix39/article/details/148036409客户端设计下场景流派与实战设计方式https://blog.csdn.net/mix39/article/details/161322269?spm1001.2014.3001.5501前言ok我们前面三张文章讲了为什么要做架构、什么时候需要架构和怎么做、常用设计原则和设计模式感兴趣可以点击上方链接再看看再讲一下这些年遇到的具体的问题和解决策略。本文涉及到代码的地方均已微信为例提供想象空间并不是真的业务代码只抽象出这些年见过的一些常见公共通用架构设计能力。写过安卓app或者客户端app代码的人都会俺做一下整理归纳。8.6 大型互联网 App核心矛盾多团队并行 快速迭代 稳定性要求高一句话互联网 App 的设计重点是怎么让几百人同时改一个 App 还不炸。设计重点怎么做组件化业务组件 基础组件每个组件独立工程api 模块隔离路由方案统一路由表 拦截器链跨组件跳转降级可控配置中心所有开关动态下发支持灰度/AB/秒级回滚降级体系每个关键路径多层兜底动画→图→红点→无启动治理有向无环拓扑排序 懒加载 分级初始化监控体系启动/帧率/内存/网络/卡顿/ANR 全链路通信机制接口 路由 事件总线不直接引用实现类发版节奏组件独立发版 宿主集成热修复兜底有范围限制不能改资源和类结构互联网 App 的设计核心解耦——几百人改同一个 App改动不能互相影响可控——任何功能都能远程开关任何异常都能降级可观测——线上发生了什么必须看得见不然就是完全不可观测可恢复——出问题能秒级回滚不用发版8.7 厂商类型内置 App核心矛盾一套代码跑几十个机型/品牌/渠道 系统限制多 资源受限一句话厂商 App 的设计重点是一套代码跑几十个项目改一次全部生效。设计重点怎么做软件共版抽象平台差异层一套核心代码 多套平台适配多渠道构建变体 productFlavors渠道差异用配置驱动多 App同一系统上运行多个内置/外置应用厂商自行开发内置应用共享基础能力subModuleGradle 自定义构建变体flavorDimensions / productFlavors同一模块构建出不同变体音源策略不同设备/场景选不同音源策略模式 配置驱动资源管控设备分级低端机减功能减动画减资源兼容性API Level 适配封装层上层不感知版本差异包体积极致控制能内置就内置断网也要可用手绘图片/动画替代预制资源精打精算每个字节厂商 App 的设计核心通用——套代码适配所有差异不是每个场景写一套可插拔——功能模块可以按需组合不同 App 不同配置可配置——同一个二进制跑在不同渠道行为由配置驱动极致省——包体积、内存、功耗每一点都要抠8.8 具体设计方式详解8.6 和 8.7 定义了两种场景流派的设计重点下面展开每个设计方式的详细做法。21 种设计方式总览分类设计方式一句话编程范式8.8.1 现代化编程声明式 UI 状态管理 异步编程解耦通信8.8.2 生产者消费者队列解耦生产与消费8.8.9 事件驱动设计发出事件的人不知道谁在监听8.8.16 通信设计强依赖用接口弱依赖用事件并发资源8.8.3 线程池隔离不同业务用不同线程池8.8.10 内存防御设计对外不信任对内信任网络缓存8.8.4 抗弱网没网不是空白页慢网不是转圈页8.8.12 缓存设计请求→内存→磁盘→网络架构跨端8.8.5 Native壳跨端组件壳稳定、肉灵活8.8.6 场景切面AOP横切逻辑集中管理8.8.7 动态化设计编译期定死的越少越好8.8.8 组件化vs模块化按业务拆vs按功能拆稳定性8.8.11 降级设计L0→L5必须尽一切努力避免崩溃8.8.15 防御式编程假设所有外部输入都可能失败8.8.13 启动设计很大程度上取决于设计而非编码状态流程8.8.14 状态机设计行为取决于状态转换有规则8.8.18 设计思维从需求推导到设计厂商特有8.8.17 通用能力设计一次改动后续加需求改动量最小化8.8.19 音频焦点策略出声前申请焦点拿到才播放8.8.1 现代化编程三件套声明式 UI 状态管理 异步编程声明式 UI命令式告诉 UI 怎么做setText、setVisibility、setColor……声明式告诉 UI 是什么样state → UI 映射框架帮你刷新命令式Android View if (hasUnread) { badgeView.setVisibility(VISIBLE); badgeView.setText(3); } else { badgeView.setVisibility(GONE); } 声明式ArkTS if (this.hasUnread) { Badge({ count: this.unreadCount }) } // state 变了 UI 自动变 声明式Vue Badge v-ifhasUnread :countunreadCount / // state 变了 UI 自动变好处不用手动同步 UI 和数据——数据变了 UI 自动跟着变不会出现数据更新了 UI 忘了刷的 bug。代表ArkTS鸿蒙、ComposeKMP、React、Vue状态管理本质是观察者模式的工程化——UI 观察状态状态是唯一权威。原则 1. Single Source of Truth——一个状态只有一个写入者 2. 状态不可变——改状态 创建新状态不是原地改 3. 状态下沉事件上浮——子组件只读父组件的状态事件回调给父组件处理 ArkTSState / Prop / Link 装饰器AppStorage 全局状态 ReactuseState useReducer ContextRedux / Zustand 全局状态 Vueref / reactive Pinia 全局状态 KMP ComposeMutableStateFlow StateFlow ViewModel stateIn异步编程方式特点代表回调简单但容易嵌套地狱通用Promise/Future链式调用比回调好但异常处理分散Vue/React 的 async 操作async/await看起来像同步本质是协程ArkTS、Kotlin、JS/TS协程/FlowKotlin 首选结构化并发自动取消KMP链式编程 闭包链式的本质是流畅接口Fluent Interface——方法返回 this 实现链式调用Builder 模式是链式的一种应用。badgeManager .tab(chat) .show() .withNum(3) .withAnim(true) .onComplete { log(done) }闭包让行为参数化——把做什么当参数传进去而不是写死。代表ArkTS、React Hooks、KMP Compose、Vue Composition API8.8.2 生产者消费者模式生产者和消费者互不知道对方存在中间靠队列解耦。Producer → Queue → Consumer (未读数数据源) (消息队列) (未读数展示)要素说明生产者只管生产数据不管谁消费、什么时候消费消费者只管消费数据不管谁生产的、什么时候生产的队列缓冲区解耦生产和消费的速度差异背压生产速度 消费速度时的策略丢、等、批量消费客户端场景场景生产者消费者队列未读数更新产品/营销推送BadgeManager 展示未读数事件队列消息通知消息推送服务通知栏展示通知队列预加载任务启动流程各 Tab 初始化线程池任务队列埋点上报业务代码埋点上报服务埋点缓冲队列流式 RPC服务端流式推送客户端逐字消费流式数据缓冲队列会话消息长链接/推送聊天界面展示消息缓冲队列编解码相关应用音视频采集编码/解码模块帧/数据缓冲队列为什么用生产者消费者解耦——生产者和消费者各自独立演进异步——生产不等消费不阻塞主流程缓冲——峰值时队列兜住消费端按自己节奏处理可控——队列大小、消费速率、背压策略都可配置8.8.3 线程池隔离不同业务用不同线程池一个炸了不拖垮其他。有规模的 App 一般都需要特别是启动优化那块——不然一启动就噶或者请求个数据一直搁那转圈圈。需要线程管控、资源调度。大型 App 启动时如果不同业务同时开线程去进行耗时操作有可能整个 App 都在抢夺资源抢夺资源失败的业务直接 return 没有处理线程任务。且数量太多 App 可能直接噶掉。❌ 一个共用线程池 未读数加载 图片下载 数据同步 埋点上报 全挤在一起 图片下载阻塞了 → 未读数加载不了 → 用户体验炸 ✅ 隔离线程池 IO_POOL图片、文件 BADGE_POOL未读数计算 UPLOAD_POOL埋点上报 DB_POOL数据库读写 互相不影响线程切换应有且仅有一条规则主线程 → 后台统一一个入口如 Dispatchers.io() 或统一的 ThreadScheduler后台 → 主线程统一一个入口如 Dispatchers.main() 或统一的 UiHandler不允许业务代码直接 new Thread、直接 runOnUiThread设计要点说明核心线程数CPU 密集型 CPU 核数IO 密集型 CPU 核数 × 2队列大小有界队列不能无限排——内存会炸拒绝策略队列满了怎么办丢弃 打日志 / 调用者线程跑 / 丢弃最老线程命名必须命名出了问题看线程栈知道是谁监控队列积压量、活跃线程数、拒绝次数线程池分配原则关键路径独占启动、未读数展示这种用户可感知的用独立池非关键路径共享埋点上报、日志这种可以共享一个大池第三方 SDK 隔离SDK 用自己的池别跟你的业务挤在一起8.8.4 抗弱网核心思想没网不是空白页慢网不是转圈页层级策略网络层超时分级、重试策略指数退避、多路复用缓存层强缓存 协商缓存、离线数据兜底、过期数据先展示降级层接口失败 → 缓存 → 兜底默认值预加载WiFi 下预拉取关键数据增量更新只拉 diff减少传输量数据压缩请求/响应压缩请求合并多个请求合成一个减少 RTT断点续传大文件/长列表支持断点网络状态感知WiFi/4G/3G/无网策略自动切换客户端弱网设计的黄金法则每次网络请求都要问自己 1. 失败了怎么办→ 兜底值 2. 超时了怎么办→ 缓存值 3. 没网了怎么办→ 离线数据 4. 数据过期了怎么办→ 先展示旧的后台拉新的 推荐TTL 过期 读时刷新 组合8.8.5 Native 壳 跨端组件业务壳稳定、肉灵活——平台能力在 Native业务逻辑在跨端。设计要点说明壳只做壳的事生命周期、权限、平台能力不掺业务业务只做业务的事不直接调平台 API通过壳暴露的接口通信协议统一不管哪种跨端方案Native 侧接口统一封装业务可插拔不同业务选不同跨端方案降级兜底跨端页面加载失败降级到 Native 或 H5 兜底页调试体系跨端调试链路要通跨端框架的代价跨端框架省的是编写成本不省验证成本。选型时要问双端验证成本、能力边界、抽象泄漏、工具链成熟度、代码归属、三端差异文档化、语法基因。选型决策线业务复杂度低 组件够用 静态页面多 → 跨端框架收益大 业务复杂度高 需要动态能力 页面交互复杂 → 跨端框架成本 ≥ 原生8.8.6 场景切面AOP横切逻辑——日志、埋点、权限、性能监控、登录检查——从业务里抽出来集中管理。✅ 有 AOP RequireLogin Trace(chat_open) Log fun openChat() { chatManager.open(...) // 只写业务 }客户端怎么做 AOP方式原理适用场景限制注解 APT编译期生成代码路由、依赖注入编译期不能改逻辑注解 ASM编译期字节码插桩埋点、性能监控编译期构建变慢动态代理运行期代理接口调用接口层的横切逻辑只能代理接口Gradle Transform编译期改 class全埋点、方法耗时构建链路复杂Kotlin 协程拦截器协程上下文插入逻辑异步链路的横切只在协程内Lifecycle 感知生命周期回调页面级的横切只在生命周期内8.8.7 动态化设计编译期定死的越少越好运行时可变的越多越好。动态化三层L1 资源动态化运行时加载图片/动画/字体 → 最简单L2 布局动态化服务端下发 UI 描述客户端渲染 → 中等L3 逻辑动态化服务端下发脚本/规则客户端执行 → 最强但风险最大动态化设计原则降级兜底、版本兼容、安全校验、大小控制、离线可用、回滚能力。8.8.8 组件化 vs 模块化模块化组件化粒度按业务拆按功能拆关系模块之间有上下级依赖组件之间平级可独立运行复用模块通常只在一个 App 用组件跨 App 复用独立运行模块一般不能单独跑组件可以单独跑开发调试用组件化设计要点组件可独立运行、可插拔、组件间通信路由接口事件总线、组件隔离api/implementation分离、资源隔离、组件生命周期。8.8.9 事件驱动设计发出事件的人不知道谁在监听甚至不知道有没有人监听。解耦程度直接调用 观察者 事件驱动设计原则事件命名表达发生了什么不是要做什么事件携带数据不携带行为事件要有生命周期——谁注册谁反注册事件总线不是万能的——一对一强依赖用接口链路需要可追踪用接口防事件风暴——事件 A 触发 BB 触发 CC 又触发 A循环了8.8.10 内存防御设计内存泄漏不是写错了一行代码是架构设计没管好生命周期。防御点手段空值非空断言 兜底值越界集合操作前检查 size类型JSON 反序列化 try-catch并发共享变量加锁或用并发容器生命周期异步回调前判 isAdded / isFinishing配置所有远端配置配本地默认值第三方SDK 调用包 try-catch内存泄漏对称原则register 必有 unregisterOOM图片降采样 LRU 上限 onTrimMemory 主动释放防御原则对外不信任对内信任——模块边界全加防御模块内部正常写。8.8.11 降级设计L0: 完美体验 → 全部功能正常 L1: 功能降级 → 核心功能可用非核心关闭 L2: 内容降级 → 实时数据不可用用缓存数据 L3: 展示降级 → 富媒体不可用用文字/占位图 L4: 优雅失败 → 功能完全不可用提示用户 L5: 崩溃 ← 必须尽一切努力避免降级设计原则每个关键路径都要有降级方案降级是配置驱动的不是代码写死的降级要可观测埋点上报降级要可恢复开关关了自动恢复8.8.12 缓存设计缓存三层请求 → 内存缓存 → 磁盘缓存 → 网络决策点怎么选缓存什么只读数据、更新频率低的、请求成本高的缓存多久TTL 根据业务容忍度未读数 5 分钟配置 1 小时缓存大小内存 LRU 上限根据业务定图片缓存的经验值是可用内存 1/8其他场景需单独评估一致性大部分场景最终一致就行穿透加空值缓存防穿透击穿热点 key 加锁只让一个请求穿透雪崩TTL 加随机偏移客户端推荐TTL 过期 读时刷新 组合——先返回旧数据后台静默拉新。8.8.13 启动设计启动很大程度上取决于设计而非编码。类型什么时候可否延迟P0 必须同步阻塞❌P1 首帧首帧渲染前❌P2 首屏首屏展示后✅P3 后台空闲时✅设计原则有向无环拓扑排序——依赖不能成环核心路径预加载 非核心懒加载 空闲时预热每个阶段耗时打点启动慢了能定位任何任务失败都不能卡住启动8.8.14 状态机设计当一个对象的行为取决于它当前的状态且状态之间有严格的转换规则时。设计原则状态是有限的、明确的——枚举所有状态不允许未知转换是受限的——定义合法转换非法转换直接拒绝副作用在转换上不在状态上状态机优先单调只能往前走除非显式重置8.8.15 防御式编程假设所有外部输入都是恶意的所有外部调用都可能失败。过度防御每个方法每个参数都判空代码 50% 是防御逻辑 → 可读性炸适度防御模块边界防御内部信任 → 清晰 安全原则对外不信任对内信任。8.8.16 通信设计强依赖用接口弱依赖用事件。接口调用要超时。事件不能代替接口。跨进程通信最小化。8.8.17 通用能力设计一次改动后续再加需求改动量最小化。这是厂商类型 App 最看重的设计哲学——通用能力一旦写好新的 App、新的渠道、新的场景来了改动量最小化。通用能力 vs 专用能力专用能力通用能力写法if (渠道A) { ... } else if (渠道B) { ... }config.drive(mode)新渠道加 else if加一条配置测试每加一个渠道回归所有只测新配置维护代码越改越长配置表维护通用能力设计四步法第 1 步抽象共性 共性抽象成接口差异抽象成配置。 第 2 步配置驱动 所有差异用配置表达不用代码表达。 配置来源本地默认值 → 远程下发 → 运行时环境感知 第 3 步扩展点预留 不确定未来会不会变的地方留扩展点回调/策略/插件 确定不变的地方坚决不抽象 第 4 步验证套件 配置校验 → 兜底值 → 异常上报 → 日志追踪通用能力设计原则原则说明反例配置 代码差异用配置不用 if-else10 个渠道写 10 个 if接口 实现通用能力定义接口具体实现可替换直接 new 一个实现类策略 分支不同行为用策略模式不用 switchswitch(type) case A: ... case B: ...注册 硬编码新功能通过注册接入不修改通用代码通用代码里加新模块的引用事件 调用通用能力通知外部用事件不直接调用外部通用模块 import 业务模块扩展点 修改加功能通过扩展点不修改通用代码改通用代码加新逻辑过度通用的信号配置项比业务代码还多、为了通用引入了三层抽象、新接一个 App 还是要改通用代码、通用能力的开发比业务开发还慢。出现这些信号说明抽象过头了该收缩边界。8.8.18 设计思维从需求推导到设计需求 → 理解问题 → 识别变化点 → 选择模式 → 定义接口 → 写实现设计评审的核心问题问题回答不了 设计有问题这个类只有一个原因变吗回答不了 → 职责不单一加一种新类型要改几个文件3 → 扩展性差这个方法可能失败吗失败了怎么办不知道 → 没有错误处理这个方法跑在哪个线程不知道 → 线程模型缺失这个类的依赖能 mock 吗不能 → 不可测试这个模块挂了整不崩溃会的 → 没有容错这个配置写死了吗写死了 → 不够灵活三个月后你自己看得懂吗看不懂 → 命名/结构有问题8.8.19 音频焦点策略这是厂商类型 App 的核心设计问题之一也是观察者 状态 策略的综合实战。为什么需要音频焦点系统可能有几十种音源——音乐、导航、电话、倒车提示、系统通知……如果每种音源都自己播放几秒内可能变化十几次造成混音 chaos。音频焦点策略就是让所有音源遵守同一套规则出声前申请焦点拿到才播放。长焦点 vs 短焦点长焦点短焦点何时申请需要持续播放时需要短暂提示时持续时间持续持有直到主动释放播放完自动归还释放时机声音暂停后主动释放播放完成后系统自动归还给上一焦点持有者典型场景音乐、视频、电台、CarPlay提示语、电话铃声、倒车影像、开机音优先级一般较低一般较高短焦点可抢长焦点混音排查流程1. 两个音源同时播放了 → 混音了 2. 检查两个音源是否都申请了焦点 ├─ 没申请焦点就播放 → 那个业务的问题 └─ 都申请了焦点 3. 检查最后一次焦点在谁手里 ├─ 焦点在A但B也在播放 → B 的业务问题 └─ 焦点只有一个另一个没拿到焦点 4. 拿到焦点的音源正常播放但另一个音源也在响 ├─ 另一个业务调了播放接口 → 那个业务的问题 └─ 另一个业务根本没调播放接口 → Audio框架的问题音频焦点设计用到的模式模式怎么用的观察者焦点变化通知AudioManager.OnAudioFocusChangeListener状态音频焦点状态机持有 / 丢失 / 暂时丢失 / Duck策略不同音源类型申请不同焦点策略长焦点 / 短焦点 / Duck责任链焦点请求按优先级传递高优先级拦截8.8.20 Pipeline流水线编排把一个复杂流程拆成多个阶段每个阶段独立处理、独立线程池、阶段间用队列连接。生产者消费者是一个生产一个消费Pipeline 是一串阶段串起来每个阶段既是消费者又是生产者。输入 → Stage1(解析) → Queue1 → Stage2(校验) → Queue2 → Stage3(处理) → Queue3 → Stage4(输出) → 结果三要素Stage阶段、Queue队列、ThreadPool线程池实战例子直播推流管线Camera 采集 → OpenGL 渲染 → MediaCodec 编码 → 封包 → RTMP 推流 30fps GPU 处理 硬编耗时 快 受网络影响每个阶段速度不一样——Camera 30fps 稳定输出编码可能跟不上推流看网络。如果一条线程串下来推流卡了编码也卡了编码卡了相机也卡了。用 Pipeline每个阶段自己的队列兜住推流慢了编码队列积压背压传导回相机降帧而不是全线崩溃。背压下游处理不过来压力向上游传导。背压不是自动的——队列满了 BlockingQueue.put() 会阻塞生产者线程导致线程饥饿实际工程中需要显式选择背压策略丢帧offer 失败就丢、等待限时offer timeout、降速通知上游降频。不同场景选不同策略不能无脑用阻塞队列。什么时候用适合不适合多阶段处理阶段速度不一样简单请求-响应用不着需要背压保护阶段间速度差异很小收益不大IO 密集型流水线纯计算且阶段均匀的管线音视频/直播管线CRUD8.8.21 Hook钩子不修改原始代码在运行时插入自己的逻辑或者替换原始行为。Hook 不是 GoF 23 种设计模式之一但它是一种广泛使用的设计思想——预留扩展点外部插入逻辑。从温和到暴力Hook 分很多层层级方式例子设计层继承覆写 / 回调接口Activity.onCreate()、OnClickListener框架层拦截器 / 模板方法OkHttp Interceptor、RecyclerView.Adapter运行时反射替换实例Hook AMS 跳过 Activity 注册检查加载时ClassLoader 拦截插件化加载未注册的类实战例子1反射替换运行时 Hook正常流程App → IActivityManager.startActivity() → 系统检查 Manifest → 拒绝 Hook 后 App → AMSProxy.startActivity() → 替成占坑 Activity → 系统检查通过 → 换回真实 Activity三步走找到目标 → 构造替代品 → 偷梁换柱反射拿到系统的 AMS 代理对象创建自己的代理类持有原始对象在 startActivity 里把 Intent 替掉把代理塞回去以后系统调的都是你的代理本质上就是代理模式只不过代理对象是运行时偷偷塞进去的。实战例子2ClassLoader 拦截加载时 Hook场景插件化框架加载插件的类。正常加载ClassLoader.loadClass(PluginActivity) → 找不到 → 崩溃 Hook 后 ClassLoader.loadClass(PluginActivity) → 拦截 → 用插件 ClassLoader 加载 → 成功两种 Hook 对比反射替换ClassLoader 拦截Hook 时机运行时对象创建后类加载时对象创建前Hook 粒度替换一个实例/方法替换整个类Hook 范围精确只改一个对象全局所有用到这个类的地方代表应用插件化跳转、保活插件化加载、双开助手Hook 的设计模式本质Hook 是目的插入逻辑改变行为各种设计模式是实现手段。你在日常开发中写的 onCreate()、setOnClickListener()、Interceptor都是 Hook——只是你没叫它 Hook。