HarmonyOS应用《民族图鉴》开发第40篇:服务层架构总结——设计模式与最佳实践全景

一、回顾:我们讲了哪些服务
这十篇(第31-40篇)我们深入了「数据与服务篇」,从数据模型到各个服务,构建了完整的服务层架构。让我们先回顾一下:
| 序号 | 文章主题 | 核心内容 |
|---|---|---|
| 第31篇 | 数据模型设计 | TypeScript 接口、类型、枚举,API 模型规范 |
| 第32篇 | Mock 数据设计 | 56 个民族 Mock 数据的真实性、完整性、多样性 |
| 第33篇 | StorageService | Preferences 封装、对象存储、用户行为记录 |
| 第34篇 | TTSEngineService | 语音引擎封装、状态管理、降级模拟模式 |
| 第35篇 | MusicService | 音乐播放、监听者模式、播放进度管理 |
| 第36篇 | ThemeService | 主题切换、AppStorage 全局状态、三层结构 |
| 第37篇 | I18nService | 多语言国际化、双语数据模型、UI 适配 |
| 第38篇 | AIService | AI 对话服务、上下文管理、Mock 回复 |
| 第39篇 | ApiService | 统一 API 封装、双模式切换、防腐层、降级策略 |
| 第40篇 | 架构总结 | 设计模式汇总、最佳实践、架构思想 |
这一篇,我们把这些内容串起来,从更高的视角看整个服务层的架构。
二、服务层的整体架构
2.1 七大服务完整架构图
让我们用更详细的架构图展示所有服务的分层关系和职责:
┌──────────────────────────────────────────────────────────────────────────────┐
│ 页面层(Pages) │
│ 首页 / 列表 / 详情 / 地图 / 测验 / 个人中心 / 设置 / AI对话 / 音乐播放器 │
└───────────────────────────────────────┬──────────────────────────────────────┘
│ 调用(统一服务接口)
┌───────────────────────────────────────▼──────────────────────────────────────┐
│ 服务层(Services) │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ ThemeService │ │ I18nService │ │ ApiService │ │
│ │ 主题模式管理 │ │ 多语言切换 │ │ 数据访问门面 │ │
│ │ 内容模式管理 │ │ 格式化工具 │ │ Repository 模式 │ │
│ │ 系统主题监听 │ │ RTL 适配 │ │ 缓存/降级/重试 │ │
│ └─────────┬────────┘ └─────────┬────────┘ └─────────┬────────┘ │
│ │ │ │ │
│ ┌─────────▼────────┐ ┌─────────▼────────┐ ┌─────────▼────────┐ │
│ │ AIService │ │ MusicService │ │ TTSEngineService │ │
│ │ 对话管理 │ │ 播放控制 │ │ 语音合成 │ │
│ │ 上下文管理 │ │ 播放列表 │ │ 状态管理 │ │
│ │ 知识库检索 │ │ 进度管理 │ │ 长文本朗读 │ │
│ │ 流式输出 │ │ 音频焦点 │ │ 多语言支持 │ │
│ └─────────┬────────┘ └──────────────────┘ └──────────────────┘ │
│ │ │
│ ┌─────────▼──────────────────────────────────────────────────────────┐ │
│ │ StorageService │ │
│ │ Preferences 封装 / 对象序列化 / 浏览历史 / 用户偏好 / 数据迁移 │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────┬──────────────────────────────────────┘
│ 依赖
┌───────────────────────────────────────▼──────────────────────────────────────┐
│ 数据层(Data Layer) │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 数据模型 │ │ Mock 数据 │ │ 远程 API │ │
│ │ EthnicModels │ │ EthnicMockData │ │ HttpClient │ │
│ │ MusicModels │ │ MusicMockData │ │ 拦截器/重试 │ │
│ │ AIModels │ │ AIMockData │ │ 错误处理 │ │
│ │ ThemeModels │ │ DictMockData │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
七层架构解读:
| 层级 | 职责 | 关键技术 |
|---|---|---|
| 页面层 | UI 渲染、用户交互 | ArkTS 组件、状态驱动 |
| 业务服务层 | 业务逻辑封装、状态管理 | ThemeService、I18nService、ApiService |
| 能力服务层 | 专项能力提供 | AIService、MusicService、TTSEngineService |
| 基础服务层 | 通用基础能力 | StorageService |
| 数据模型层 | 类型定义、接口契约 | Interface、Type、Enum |
| 数据源层 | 数据来源抽象 | Mock 数据、远程 API |
| 基础设施层 | 系统能力调用 | Preferences、Http、Core Speech Kit |
2.2 服务分层与分类
七大服务可以按职责分为三类:
第一类:基础服务(底层)
- StorageService:所有服务的基石,负责数据持久化
第二类:业务服务(中层)
- ThemeService:主题管理
- I18nService:国际化
- ApiService:数据访问
第三类:能力服务(上层)
- AIService:AI 对话
- MusicService:音乐播放
- TTSEngineService:语音合成
分层原则:
- 上层服务可以调用下层服务
- 下层服务不能调用上层服务
- 同层服务之间尽量不要互相调用
- StorageService 是最底层,被所有服务依赖
2.3 服务之间的依赖关系
服务之间不是完全独立的,它们有依赖关系。让我们用更详细的依赖图展示:
┌──────────────────┐
│ Pages │
│ 页面层 │
└────┬─────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ThemeService │ │ I18nService │ │ ApiService │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└───────────┬───────────┘ │
│ │
▼ ┌───────▼───────┐
┌──────────────┐ │ AIService │
│ StorageService │◄─────────────────┤ │
└──────────────┘ └───────────────┘
▲
│
┌─────────┴─────────┐
│ │
┌────────────────┐ ┌──────────────────┐
│ MusicService │ │ TTSEngineService │
│ (独立管理状态) │ │(独立管理引擎) │
└────────────────┘ └──────────────────┘
ApiService ──依赖──→ Mock 数据 / HttpClient
AIService ──依赖──→ Mock 数据 / 远程 LLM API
详细依赖清单:
| 服务 | 依赖的服务 | 依赖的数据/模块 | 依赖原因 |
|---|---|---|---|
| ThemeService | StorageService | - | 持久化主题设置 |
| I18nService | StorageService | - | 持久化语言设置 |
| ApiService | StorageService | Mock 数据、HttpClient | 缓存、降级、网络请求 |
| AIService | StorageService | Mock 数据、远程 API | 持久化历史、对话能力 |
| MusicService | - | 音频播放器 | 独立管理播放状态 |
| TTSEngineService | - | Core Speech Kit | 独立管理引擎状态 |
| StorageService | - | Preferences | 最底层,无服务依赖 |
依赖原则:
- 单向依赖:A 依赖 B,B 就不要反过来依赖 A,避免循环依赖
- 底层服务不依赖上层:StorageService 是底层,不应该依赖 ThemeService
- 依赖越少越好:一个服务依赖的其他服务越少,越独立、越好维护
- 依赖注入:通过构造函数或方法注入依赖,不硬编码在服务内部
💡 架构思想:分层架构(Layered Architecture)——上层可以依赖下层,下层不能依赖上层。每一层只和相邻层打交道。
2.4 服务的生命周期管理
服务不是凭空存在的,它有自己的生命周期。管理好生命周期,能避免很多问题。
服务生命周期阶段:
| 阶段 | 时机 | 做什么 | 谁负责 |
|---|---|---|---|
| 创建 | 第一次调用 getInstance() | 分配内存、初始化成员变量 | 单例模式懒加载 |
| 初始化 | init() 方法调用 | 加载配置、建立连接、注册监听 | 应用启动时主动调用 |
| 运行 | 业务方法调用 | 处理业务逻辑、状态变化 | 正常使用期间 |
| 休眠 | 应用退到后台 | 暂停定时器、释放非必要资源 | 前后台切换 |
| 唤醒 | 应用回到前台 | 恢复定时器、重新监听 | 前后台切换 |
| 销毁 | 应用退出 | 释放资源、保存状态、取消监听 | 应用退出时 |
为什么需要生命周期管理?
- 资源泄漏:定时器、事件监听、引擎实例不释放,内存泄漏
- 状态丢失:应用被杀掉前没保存数据,回来就丢了
- 性能浪费:后台还在跑定时器、发请求,耗电耗流量
- 初始化失败:启动顺序不对,依赖还没准备好就用了
推荐的生命周期接口设计:
interface IService {
init(): Promise<void>; // 初始化
onForeground(): void; // 进入前台
onBackground(): void; // 进入后台
shutdown(): Promise<void>; // 销毁
}
**应用启动时,按顺序初始化所有服务:
// 伪代码:服务启动顺序
async function initAllServices() {
// 1. 先初始化底层服务
await StorageService.getInstance().init();
// 2. 再初始化中层服务
await ThemeService.getInstance().init();
await I18nService.getInstance().init();
await ApiService.getInstance().init();
// 3. 最后初始化上层服务
await MusicService.getInstance().init();
await TTSEngineService.getInstance().init();
await AIService.getInstance().init();
}
初始化顺序原则:
- 被依赖的先初始化,依赖的后初始化
- 底层先初始化,上层后初始化
- 销毁顺序反过来:上层先销毁,底层后销毁
⚠️ 常见坑:循环依赖导致初始化死锁——A 等 B 初始化,B 等 A 初始化,结果都卡住。解决办法:理清依赖关系,坚决避免循环依赖。
三、六大设计模式汇总
我们在这些服务中反复用到了几个经典的设计模式。让我们系统地梳理一下。
3.1 单例模式(Singleton Pattern)
应用于:所有服务(StorageService、TTSEngineService、MusicService、ThemeService、I18nService、AIService、ApiService)
核心思想:确保一个类只有一个实例,并提供全局访问点。
为什么服务都用单例?
- 全局统一状态(播放状态、主题、语言…)
- 避免重复初始化,节省资源
- 方便跨页面共享数据
关键代码特征:
class SomeService {
private static instance: SomeService; // 私有静态实例
private constructor() {} // 私有构造函数
static getInstance(): SomeService { // 公共静态获取方法
if (!SomeService.instance) {
SomeService.instance = new SomeService();
}
return SomeService.instance;
}
}
适用场景:
- 全局状态管理
- 资源共享(数据库连接、网络客户端、引擎实例)
- 统一入口(日志、配置、服务定位)
3.2 监听者模式(Observer Pattern)
应用于:MusicService、ThemeService(通过 AppStorage)、I18nService(通过 AppStorage)
核心思想:定义对象间的一对多依赖关系,当一个对象状态改变时,所有依赖它的对象都收到通知并自动更新。
别名:观察者模式、发布-订阅模式、事件模式
关键代码特征:
class Subject {
private listeners: Set<Listener> = new Set();
addListener(listener: Listener): void { /* 注册 */ }
removeListener(listener: Listener): void { /* 注销 */ }
private notifyListeners(): void { /* 通知所有 */ }
}
适用场景:
- 状态变化需要通知多个对象
- 事件驱动架构
- 解耦:发布者不知道订阅者是谁
3.3 策略模式(Strategy Pattern)
应用于:ApiService(Mock / Remote)、TTSEngineService(真实引擎 / Demo Mode)
核心思想:定义一系列算法,把它们封装起来,并且使它们可以相互替换。
关键代码特征:
// 同一个接口,不同实现
if (mode === Mode.MOCK) {
return this.getFromMock();
} else {
return this.getFromRemote();
}
适用场景:
- 多种方式实现同一个功能
- 需要在运行时切换实现
- 隔离不同算法的实现细节
3.4 外观模式(Facade Pattern)
应用于:所有服务(都是对底层能力的封装)
核心思想:为子系统中的一组接口提供一个统一的高层接口,使得子系统更容易使用。
典型例子:
- StorageService 封装了 Preferences 的复杂 API
- TTSEngineService 封装了 TTS 引擎的创建、监听、状态管理
- ApiService 封装了 HTTP 请求、数据转换、错误处理
关键代码特征:
// 简单的对外接口
storageService.saveObject(key, value);
// 内部做了很多事
// → JSON.stringify
// → preferences.put
// → preferences.flush
// → try-catch 错误处理
适用场景:
- 复杂系统的简化访问
- 分层架构中的每一层
- 封装第三方库/系统能力
3.5 适配器模式(Adapter Pattern)
应用于:ApiService 的数据映射(防腐层)
核心思想:将一个类的接口转换成客户期望的另一个接口,让原本不兼容的类可以一起工作。
在本项目中的体现:
- 后端 API 返回的是 EthnicApiItem(蛇形命名等)
- 前端业务用的是 EthnicGroup(小驼峰等)
- mapApiItemToModel 函数把"不兼容的接口"转成"期望的接口"
关键代码特征:
function adapter(source: ApiModel): BusinessModel {
return {
id: source.id,
name: source.name,
nameEn: source.name_en, // 字段名转换
// ...
};
}
适用场景:
- 对接外部系统(第三方 API、旧系统)
- 数据结构转换
- 防腐层实现
3.6 状态模式(State Pattern)
应用于:TTSEngineService(IDLE/SPEAKING/ERROR)、MusicService(播放状态)
核心思想:允许对象在内部状态改变时改变它的行为,对象看起来好像修改了它的类。
简化版实现:
enum TTSState { IDLE, SPEAKING, ERROR }
class TTSEngineService {
private currentState: TTSState = TTSState.IDLE;
speak(text: string): void {
if (this.currentState === TTSState.SPEAKING) {
return; // 状态决定行为
}
// ... 真正的播放逻辑
this.currentState = TTSState.SPEAKING;
}
}
适用场景:
- 对象有多个状态,状态变化影响行为
- 大量条件判断(if-else / switch)可以用状态模式重构
3.7 装饰器模式(Decorator Pattern)
应用于:ApiService(缓存、日志、重试可以层层叠加)、HttpClient(拦截器链)
核心思想:动态地给一个对象添加一些额外的职责。就增加功能来说,装饰器模式比生成子类更灵活。
在本项目中的体现:
- HttpClient 的拦截器链:每加一个拦截器,就是给请求"装饰"了一层能力
- ApiService 的缓存、日志、重试:可以按需叠加,而不是写在一个大方法里
关键代码特征:
// 拦截器链就是装饰器模式的体现
// 原始请求 → 加 Token → 加日志 → 加错误处理 → 发出去
// 每一步都是在"装饰"请求
适用场景:
- 需要动态地给对象添加功能,这些功能还可以动态撤销
- 功能可以自由组合,而不是用继承堆叠
- 不想用大量子类来扩展功能
3.8 模板方法模式(Template Method Pattern)
应用于:所有服务的初始化流程、错误处理流程
核心思想:定义一个操作中的算法骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
在本项目中的体现:
- 每个服务的 init() 流程:先加载配置 → 再初始化依赖 → 最后注册监听
- 每个服务的错误处理:先打日志 → 再降级 → 最后抛异常/返回默认值
关键代码特征:
abstract class BaseService {
// 模板方法:定义了初始化的骨架
async init(): Promise<void> {
await this.loadConfig(); // 步骤1
await this.initDependencies(); // 步骤2
this.registerListeners(); // 步骤3
}
protected abstract loadConfig(): Promise<void>;
protected abstract initDependencies(): Promise<void>;
protected abstract registerListeners(): void;
}
适用场景:
- 有多个子类有共同的流程,但具体步骤实现不同
- 需要控制子类的扩展点
- 重构时把重复代码抽到父类
🎯 设计模式的本质:
设计模式不是"必须套进去"的模板,而是对"常见问题的经典解决方案"的总结。当你遇到类似问题时,可以参考这些模式,但不要为了用模式而用模式。
好的代码是"解决了问题,用了合适的模式",而不是"用了很多模式,问题不一定解决得好"。
3.9 设计模式选择指南
这么多模式,什么时候用哪个?给你一个简单的决策指南:
| 问题场景 | 推荐模式 | 一句话总结 |
|---|---|---|
| 全局唯一实例 | 单例模式 | 只能有一个 |
| 状态变化通知别人 | 监听者模式 | 有事喊一声 |
| 多种实现可切换 | 策略模式 | 换个算法 |
| 复杂系统简化使用 | 外观模式 | 统一入口 |
| 接口不兼容要对接 | 适配器模式 | 转个接头 |
| 状态多行为复杂 | 状态模式 | 状态决定行为 |
| 功能可自由叠加 | 装饰器模式 | 一层层套 |
| 流程固定步骤不同 | 模板方法 | 骨架不变填细节 |
记住:模式是工具,不是目的。解决问题才是目的。
四、五大架构原则
除了设计模式,这些服务的设计还体现了几个重要的架构原则。
4.1 单一职责原则(SRP)
定义:一个类/模块应该只有一个改变的理由。
体现:
- StorageService 只管存储,不管主题
- ThemeService 只管主题,不管音乐
- MusicService 只管播放,不管 AI 对话
为什么重要:
- 改一个功能只需要改一个地方
- 每个服务都很小,容易理解
- 修改一个功能不会影响其他不相关的功能
4.2 开闭原则(OCP)
定义:软件实体应该对扩展开放,对修改关闭。
体现:
- 加新的服务,不用改现有的服务
- ApiService 加新接口,不用改旧接口
- 数据映射加新字段,只改 map 函数
为什么重要:
- 新增功能风险小(不改旧代码)
- 不会改着改着把老功能改坏了
- 代码越来越多,但不会越来越乱
4.3 依赖倒置原则(DIP)
定义:高层模块不应该依赖低层模块,两者都应该依赖抽象。
体现:
- 页面依赖 ApiService(抽象接口),不依赖 HttpClient(具体实现)
- 页面依赖 ThemeService 接口,不依赖 AppStorage
- Mock 和 Remote 对外接口一样,可以互相替换
为什么重要:
- 底层实现可以随便换,上层不用改
- 单元测试可以用 Mock 替代真实依赖
- 系统各部分可以独立开发
4.4 迪米特法则(LoD)
定义:一个对象应该对其他对象有最少的了解。又叫"最少知识原则"。
体现:
- 页面只知道 Service,不知道 Service 内部用了 Preferences 还是 AppStorage
- Service 只知道自己的直接依赖,不深入依赖的依赖
为什么重要:
- 耦合度低,改一个地方影响范围小
- 每个模块都很"独立",好维护、好测试
- 新人上手快,不用了解整个系统才能改一个功能
4.5 防御性编程
定义:编程时预先考虑各种异常情况,确保程序在异常情况下也能合理运行。
体现:
- 每个方法开头检查参数(空值、越界等)
- 所有可能失败的操作都 try-catch
- 初始化失败有降级方案(Demo Mode、Mock 降级)
- 持久化数据有校验,防止脏数据
为什么重要:
- 程序健壮,不容易崩
- 用户体验好(出错了有提示,不是白屏)
- 容易排查问题(错误日志打清楚)
📚 SOLID 原则:
单一职责(SRP)、开闭原则(OCP)、里氏替换(LSP)、接口隔离(ISP)、依赖倒置(DIP)。这五个原则合起来叫 SOLID,是面向对象设计的五大原则。
本项目的服务层设计,虽然没有刻意"按 SOLID 来写",但自然而然地符合其中大部分原则。因为好的设计总是殊途同归的。
五、服务层的最佳实践
5.1 命名规范
| 元素 | 规范 | 示例 |
|---|---|---|
| 服务类 | 大驼峰 + Service 后缀 | MusicService |
| 单例获取 | getInstance() |
MusicService.getInstance() |
| 获取数据 | getXxx() |
getPlaylist() |
| 设置数据 | setXxx() |
setPlayMode() |
| 动作方法 | 动词开头 | play() / pause() / stop() |
| 监听器注册 | addListener / removeListener |
addStateListener() |
| 状态查询 | isXxx() / hasXxx() |
isPlaying() / isDarkMode() |
好的命名是最好的文档——看方法名就知道它干什么,不用读实现。
5.2 错误处理三原则
- 不崩溃:任何异常都不能让 App 崩掉
- 有日志:出错了要打日志,方便排查
- 有反馈:用户要知道出问题了(但不用知道技术细节)
// ✅ 好的错误处理
try {
await riskyOperation();
} catch (e) {
console.error('操作失败:', e); // 2. 有日志
showToast('操作失败,请重试'); // 3. 有反馈(友好文案)
return defaultValue; // 1. 不崩溃,返回兜底值
}
5.3 状态管理最佳实践
- 单一数据源:状态集中在服务里,页面不存状态
- 状态只读:页面只能读状态,要改必须通过服务方法
- 变化可追踪:所有状态变化都通过方法调用,好找
- 响应式更新:状态变了,UI 自动更新(通过监听器/AppStorage)
为什么页面不要自己存状态?
因为多个页面共享的状态,每个页面存一份,必然会不一致。统一存在服务里,就不会有这个问题。
5.4 初始化与销毁
| 方法 | 时机 | 做什么 |
|---|---|---|
init() |
应用启动时 | 初始化依赖、加载配置、创建实例 |
| 业务方法 | 用户操作时 | 处理业务逻辑 |
shutdown() |
应用退出时 | 释放资源、保存状态、销毁实例 |
资源一定要释放:
- 定时器要 clear
- 事件监听要移除
- 引擎实例要 shutdown
- 网络连接要关闭
不释放的后果:内存泄漏、性能下降、奇怪的 bug。
5.5 降级与容错
| 策略 | 应用场景 |
|---|---|
| Mock 降级 | ApiService 后端挂了用 Mock |
| Demo Mode | TTSEngineService 引擎不可用用模拟 |
| 默认值 | 读存储失败返回默认值 |
| 空数据 | 列表加载失败返回空数组 |
| 重试 | 网络请求失败自动重试 |
核心思想:优雅降级(Graceful Degradation)——核心功能尽量可用,非核心功能用不了也不影响整体。
5.6 接口设计原则
服务对外暴露的接口,设计得好不好直接影响使用体验。
接口设计原则:
- 最小够用:只暴露必要的接口,内部方法不对外暴露
- 参数校验:入口处校验参数,不符合要求直接报错
- 返回值明确:返回值类型清晰,不要返回 any
- 错误可预期:可能抛什么错误要说清楚
- 向后兼容:加新字段不影响老调用者
反例:
// ❌ 不好:返回 any,调用者不知道返回什么
getData(key: string): any
// ❌ 不好:参数太随意,啥都能传
saveData(key: string, value: any): void
正例:
// ✅ 好:类型明确,一目了然
getUserInfo(userId: string): Promise<UserInfo>
// ✅ 好:参数明确,错误说明清楚
saveUserInfo(user: UserInfo): Promise<void>
// @throws {ValidationError} 用户信息不合法时抛出
5.7 日志规范
日志是排查问题的关键。好的日志能让问题定位事半功倍。
日志级别使用指南:
| 级别 | 使用场景 | 示例 |
|---|---|---|
| ERROR | 出错了,影响功能 | 存储写入失败、网络请求失败 |
| WARN | 有问题但不影响用 | 降级到 Mock、缓存失效 |
| INFO | 关键流程节点 | 服务初始化完成、播放开始 |
| DEBUG | 调试用的详细信息 | 参数值、状态变化、中间结果 |
日志内容要包含什么?
- 时间戳:什么时候发生的
- 级别:ERROR / WARN / INFO / DEBUG
- 模块:哪个服务/哪个方法
- 内容:发生了什么事
- 上下文:相关的参数、ID 等
// ✅ 好的日志
console.error('[MusicService] play failed, trackId:', trackId, 'error:', e);
// ❌ 不好的日志
console.log('错了');
console.log(e);
5.8 性能考量
服务层是性能的关键路径之一。设计时要考虑性能。
服务层性能优化点:
- 避免重复计算:能用缓存就用缓存,不要每次都重新算
- 批量操作:存数据尽量批量存,不要循环里一条条存
- 异步不阻塞:耗时操作异步做,别卡住主线程
- 懒加载:用的时候再初始化,不用就别创建
- 防抖节流:高频操作(搜索、滚动)要防抖节流
反例:
// ❌ 不好:循环里调存储,性能差
for (const item of list) {
storageService.save(`key_${item.id}`, item);
}
正例:
// ✅ 好:批量保存,性能好
storageService.saveBatch(list.map(item => ({
key: `key_${item.id}`,
value: item
})));
5.9 安全性考虑
服务层处理的数据可能包含敏感信息,要注意安全。
安全注意事项:
- 敏感数据不打日志:密码、Token、身份证号… 不要打到日志里
- 数据脱敏:需要展示敏感数据时,中间打码(138****8888)
- 输入校验:外部传入的数据都要校验,防止注入攻击
- 本地存储加密:敏感数据存本地时加密
- 不硬编码密钥:密钥、密码不要写死在代码里
5.10 可测试性设计
好的服务应该容易写测试。怎么设计才能好测?
可测试性设计要点:
- 依赖注入:依赖从外面传进来,测试时可以传 Mock
- 纯函数优先:能写成纯函数的就写成纯函数,好测
- 状态可观测:内部状态能通过接口读到,方便断言
- 副作用隔离:把副作用(IO、网络)集中在少数地方
- 单一职责:一个服务只干一件事,测试范围小
// ✅ 依赖注入,好测
class UserService {
constructor(private storage: IStorage) {}
}
// 测试时传 Mock 进去
const mockStorage = new MockStorage();
const userService = new UserService(mockStorage);
💡 最佳实践的本质:
最佳实践不是"必须遵守的教条",而是"前人踩过坑总结出来的经验"。理解为什么要有这些实践,比死记硬背更重要。
项目小的时候可以灵活一些,项目大了这些实践的价值就会体现出来。
六、服务层的测试策略
测试是质量的保障。服务层作为业务逻辑的核心,更需要好好测试。
6.1 为什么服务层要测试
服务层承上启下,是业务逻辑最集中的地方:
- 逻辑复杂:状态管理、业务规则、错误处理… 都在这里
- 影响面广:所有页面都调用服务,服务出问题影响所有页面
- 容易测:服务层不依赖 UI,比页面好测多了
所以服务层是测试投入产出比最高的地方。
6.2 测试金字塔
经典的测试金字塔:
/\
/ \ E2E 测试(少而精)
/----\
/ \ 集成测试(适量)
/--------\
/ \ 单元测试(最多)
/------------\
各层测试对比:
| 测试层级 | 测什么 | 数量 | 速度 | 稳定性 | 难度 |
|---|---|---|---|---|---|
| 单元测试 | 单个方法、工具函数 | 多 | 快 | 高 | ⭐⭐ |
| 集成测试 | 服务整体流程、服务间协作 | 中 | 中 | 中 | ⭐⭐⭐ |
| E2E 测试 | 从页面到服务完整流程 | 少 | 慢 | 低 | ⭐⭐⭐⭐ |
对于服务层,重点是单元测试 + 集成测试。
6.3 单元测试怎么写
适合单元测试的内容:
- 工具函数
- 数据映射函数
- 纯逻辑方法
- 状态转换逻辑
单元测试示例:播放列表测试
describe('MusicService - Playlist', () => {
let service: MusicService;
beforeEach(() => {
service = MusicService.getInstance();
service.clearPlaylist();
});
it('should add track to playlist', () => {
const track = { id: '1', name: 'test' };
service.addTrack(track);
expect(service.getPlaylist().length).toBe(1);
expect(service.getPlaylist()[0].id).toBe('1');
});
it('should not add duplicate track', () => {
const track = { id: '1', name: 'test' };
service.addTrack(track);
service.addTrack(track);
expect(service.getPlaylist().length).toBe(1);
});
it('should remove track by id', () => {
service.addTrack({ id: '1', name: 'test1' });
service.addTrack({ id: '2', name: 'test2' });
service.removeTrack('1');
expect(service.getPlaylist().length).toBe(1);
expect(service.getPlaylist()[0].id).toBe('2');
});
});
6.4 集成测试怎么写
集成测试测服务的整体流程,可能涉及多个方法、多个状态变化。
适合集成测试的场景:
- 完整的业务流程
- 状态机的状态转换
- 服务间的交互
- 降级策略
集成测试示例:播放流程
describe('MusicService - Play Flow', () => {
let service: MusicService;
beforeEach(() => {
service = MusicService.getInstance();
service.clearPlaylist();
});
it('should play track correctly', async () => {
// 1. 添加歌曲
service.addTrack({ id: '1', name: 'test', url: 'xxx' });
// 2. 开始播放
await service.play('1');
// 3. 验证状态
expect(service.isPlaying()).toBe(true);
expect(service.getCurrentTrack()?.id).toBe('1');
});
it('should pause and resume correctly', async () => {
service.addTrack({ id: '1', name: 'test', url: 'xxx' });
await service.play('1');
// 暂停
service.pause();
expect(service.isPlaying()).toBe(false);
// 恢复
service.resume();
expect(service.isPlaying()).toBe(true);
});
});
6.5 Mock 与依赖注入
测试时,我们不想真的发网络请求、真的读磁盘。这时候就需要 Mock。
Mock 的作用:
- 隔离外部依赖
- 模拟各种场景(成功、失败、超时)
- 测试跑得更快
- 测试结果更稳定
怎么设计才能好 Mock?——依赖注入!
// 依赖注入:依赖从外面传进来
class ApiService {
constructor(
private httpClient: IHttpClient,
private storage: IStorage
) {}
}
// 测试时传 Mock 进去
const mockHttp = new MockHttpClient();
const mockStorage = new MockStorage();
const apiService = new ApiService(mockHttp, mockStorage);
// 模拟网络错误
mockHttp.setShouldFail(true);
// 验证降级策略是否生效
const result = await apiService.getEthnicList();
expect(result.length).toBeGreaterThan(0); // 应该降级到 Mock 了
6.6 测试覆盖率
测试覆盖率不是越高越好,但太低肯定不行。
合理的覆盖率目标:
| 模块 | 覆盖率目标 | 说明 |
|---|---|---|
| 工具函数 | > 90% | 纯函数,容易测,尽量全覆盖 |
| 数据映射 | > 80% | 防腐层很重要,要测透 |
| 核心服务 | > 70% | 主要流程覆盖到 |
| 整体项目 | > 60% | 及格线 |
⚠️ 不要为了覆盖率而写测试:
覆盖率只是参考指标,不是目标。为了凑覆盖率写一堆没有意义的测试,是自欺欺人。
好的测试是"覆盖了关键业务场景",而不是"覆盖了所有行"。
七、从服务层看架构演进
7.1 小项目阶段:单体页面
页面 ──直接调用──→ 系统 API
特点:
- 简单直接,怎么快怎么来
- 没有服务层的概念
- 适合:原型、Demo、很小的项目
问题:
- 代码重复
- 难维护
- 改起来到处改
7.2 服务层阶段:集中管理
页面 ──调用──→ Service ──封装──→ 系统 API / 数据
特点:
- 有服务层,封装业务逻辑
- 页面更薄,逻辑集中在服务
- 适合:中等规模项目
这就是本项目当前的阶段。
7.3 分层架构阶段:职责分离
页面层 → 服务层 → 数据访问层 → 数据库 / API
↓ ↓ ↓
UI 逻辑 业务逻辑 数据存取
特点:
- 分层更细,每层职责明确
- 有数据访问层、业务逻辑层、表现层
- 适合:大型项目、团队协作
7.4 模块化阶段:业务域拆分
┌─────────────────┐ ┌─────────────────┐
│ 民族百科模块 │ │ 个人中心模块 │
│ 页面+服务+数据 │ │ 页面+服务+数据 │
└────────┬────────┘ └────────┬────────┘
│ │
└─────────┬──────────┘
▼
┌───────────────┐
│ 共享内核 │
│ 通用工具/组件 │
└───────────────┘
特点:
- 按业务域拆分模块
- 每个模块可以独立开发
- 模块之间通过接口通信
- 适合:中大型项目、多团队协作
7.5 从单体到微服务的演进思路
很多人一上来就想搞微服务,但微服务不是银弹。让我们梳理一下演进路径。
什么时候该从单体往微服务演进?
| 信号 | 说明 |
|---|---|
| 团队规模 | 超过 10 人,多人协作冲突频繁 |
| 代码规模 | 单模块超过 10 万行,编译慢、改动风险大 |
| 发布频率 | 不同业务想独立发布,但互相耦合 |
| 性能瓶颈 | 某个模块压力大,需要单独扩容 |
| 技术异构 | 不同模块想用不同的技术栈 |
如果这些信号都没有,老老实实做单体,比微服务舒服多了。
演进步骤:
-
第一步:模块化(最重要)
- 先在单体内部按业务域拆分模块
- 模块之间通过接口通信,不直接依赖实现
- 这一步做不好,微服务只会更乱
-
第二步:服务化
- 把成熟的、独立的模块拆成独立服务
- 先拆一个,跑通了再拆下一个
- 不要一上来全拆了
-
第三步:微服务化
- 每个服务可以独立部署、独立扩容
- 有完善的服务治理(注册发现、限流熔断、监控告警)
- 这时候才是真正的微服务
⚠️ 微服务的代价:
微服务不是免费的,它会带来很多复杂度:
- 分布式事务怎么办?
- 服务之间怎么调用?
- 出了问题怎么排查?
- 部署运维复杂度飙升
没有银弹,只有取舍。
💡 架构没有最好,只有最合适:
不要上来就搞最复杂的架构。项目小的时候,简单直接最好;项目变大了,再逐步演进。
好的架构是"演进式"的——根据项目规模和需求变化,逐步调整,而不是一开始就设计成"最终形态"。
八、「民族图鉴」v2.0 服务层规划
站在 v1.0 的基础上,我们来规划一下 v2.0 的服务层可以怎么演进。
8.1 v1.0 现状回顾
当前 v1.0 的服务层:
- ✅ 七大服务,职责基本清晰
- ✅ 单例模式,全局统一状态
- ✅ 监听者模式,状态变化通知
- ✅ Mock 降级,保证可用性
- ✅ StorageService 统一存储
但也有不足:
- ❌ 服务之间直接依赖,耦合度偏高
- ❌ 没有统一的服务初始化和生命周期管理
- ❌ 缺少依赖注入,测试不太方便
- ❌ 缓存策略比较简单
- ❌ 没有数据库,数据量大了之后性能会有问题
8.2 v2.0 架构规划
┌─────────────────────────────────────────────────────────┐
│ 页面层(Pages) │
└───────────────────────────┬─────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────┐
│ 服务门面层(Facade) │
│ ServiceManager / 服务定位器 │
└───────────────────────────┬─────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 内容服务域 │ │ 用户服务域 │ │ 系统服务域 │
│ ApiService │ │ UserService │ │ ThemeService │
│ AIService │ │ FavoriteSvc │ │ I18nService │
│ MusicService │ │ HistorySvc │ │ StorageSvc │
│ TTSService │ │ │ │ │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
└──────────────────┼──────────────────┘
▼
┌───────────────┐
│ 数据访问层 │
│ Repository │
└───────┬───────┘
│
┌───────────┴───────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ 本地数据库 │ │ 远程 API │
│ (RDB) │ │ │
└───────────────┘ └───────────────┘
8.3 v2.0 新增能力
| 能力 | 说明 | 价值 |
|---|---|---|
| 服务管理器 | 统一管理所有服务的生命周期、依赖关系 | 初始化顺序、优雅启停 |
| 依赖注入 | 用 DI 容器管理服务依赖 | 解耦、好测试、易扩展 |
| 数据访问层 | Repository 模式统一数据访问 | 数据源可切换、好测 |
| 本地数据库 | 引入关系型数据库(RDB) | 大量数据查询性能好 |
| 用户服务 | 登录、收藏、历史记录等用户相关 | 用户体系完整 |
| 服务监控 | 服务健康检查、性能埋点 | 问题排查、性能优化 |
8.4 分阶段演进路线
第一阶段:打基础(1-2 个月)
- 引入 BaseService 基类,统一生命周期
- 完善错误处理和日志规范
- 补充单元测试覆盖率到 60%+
第二阶段:解耦(2-3 个月)
- 引入依赖注入容器
- 按业务域重新组织服务
- 提取 Repository 数据访问层
- 服务之间通过接口通信
第三阶段:增强(3-6 个月)
- 引入本地数据库(RDB)
- 完善缓存策略(多级缓存)
- 增加用户服务、收藏服务
- 服务监控和性能埋点
第四阶段:模块化(6 个月+)
- 按业务域拆分独立模块
- 每个模块可以独立编译
- 模块间通过约定接口通信
8.5 演进原则
- 不破坏现有功能:每一步都要保证现有功能正常
- 小步快跑:一次只改一点,改完验证,再改下一点
- 可回退:每一步都要有回退方案
- 价值驱动:每一步演进都要有明确的价值,不为了演进而演进
🎯 架构演进的智慧:
架构不是设计出来的,是演进出来的。
不要想着"一步到位"做出完美的架构——完美的架构不存在,合适的架构才是最好的。
好的架构师,不是一开始就设计出最复杂的架构,而是能根据项目的发展,在合适的时候做出合适的调整。
九、常见的坑与避坑指南
9.1 服务变"上帝类"
症状:一个服务什么都管,几千行代码,改哪都怕。
原因:单一职责没做好,什么都往里塞。
解决:按职责拆分服务。一个服务超过 300 行就要警惕了,超过 500 行就该考虑拆了。
9.2 循环依赖
症状:A 依赖 B,B 依赖 A,初始化顺序搞不定。
原因:设计没理清,职责交叉。
解决:
- 把共同依赖的部分抽到第三个服务
- 用事件/消息解耦,不直接调用
- 重新审视职责划分
9.3 监听器泄漏
症状:页面销毁了还在收回调,内存泄漏。
原因:只 addListener 不 removeListener。
解决:
- 注册和注销配对出现
- 在 aboutToAppear 注册,在 aboutToDisappear 注销
- 用成员变量保存监听器引用,保证 add/remove 是同一个
9.4 状态不一致
症状:A 页面显示的状态和 B 页面不一样。
原因:多个地方存状态,改了一个忘了改另一个。
解决:单一数据源——状态只存在服务里,页面只读。
9.5 过度设计
症状:简单功能搞了一堆接口、抽象、模式,代码绕来绕去。
原因:为了用模式而用模式,为了架构而架构。
解决:KISS 原则(Keep It Simple, Stupid)——能用简单方案就用简单方案。等真正需要复杂架构的时候再加。
9.6 初始化顺序混乱
症状:启动时偶发报错,有时候好使有时候不好使。
原因:服务之间有依赖,但初始化顺序没保证,依赖的服务还没初始化就用了。
解决:
- 统一管理初始化顺序(ServiceManager)
- 被依赖的先初始化,依赖的后初始化
- 初始化失败要有明确报错
9.7 日志乱打
症状:日志一大堆,真出问题了找不到有用的信息。
原因:日志没有规范,想打就打,级别不分。
解决:
- 统一日志格式(时间、级别、模块、内容)
- 合理使用日志级别(ERROR/WARN/INFO/DEBUG)
- 敏感数据不打日志
- 生产环境关闭 DEBUG 日志
⚖️ 架构的艺术在于平衡:
太简单 → 后期维护痛苦
太复杂 → 前期开发太慢找到那个"刚刚好"的点,是架构师的核心能力。
十、小结
这十篇"数据与服务篇",我们从数据模型讲到 Mock 数据,又逐个拆解了 7 个服务,最后汇总了设计模式和架构原则。
核心收获:
| 维度 | 要点 |
|---|---|
| 七大服务 | Storage、Theme、I18n、API、AI、Music、TTS |
| 设计模式 | 单例、监听者、策略、外观、适配器、状态、装饰器、模板方法 |
| 架构原则 | SOLID、迪米特法则、防御性编程、分层架构 |
| 最佳实践 | 10 条:命名、错误处理、状态管理、生命周期、降级、接口、日志、性能、安全、可测试 |
| 测试策略 | 单元测试为主,集成测试为辅,测试金字塔 |
| 演进思路 | 小步快跑、演进式架构、从单体到模块化 |
| 核心思想 | 封装变化、单一职责、面向接口、优雅降级 |
服务层是连接 UI 和数据的桥梁。这层设计得好不好,直接决定了整个应用的:
- 可维护性:改需求好不好改
- 可扩展性:加功能好不好加
- 稳定性:容不容易出 bug
- 开发效率:新人上手快不快
希望这十篇的内容,能让你对服务层设计有更系统的理解。
接下来我们将进入「架构与工程篇」,从更高的视角看整个项目的架构设计、工程化、性能优化等内容。
更多推荐



所有评论(0)