HarmonyOS应用《民族图鉴》开发第86篇:端云一体化——云数据库/云存储/Serverless

📖 引言
想象一下这个场景:
你用「民族图鉴」收藏了十几个感兴趣的民族,历史记录里存着几十条浏览记录。某天你换了一部新手机,重新安装 App 后,发现收藏和历史记录全都没了——因为它们都存在本地。
这就是纯本地应用的痛点:数据不跨设备、换手机数据丢失、无法多端同步、复杂逻辑端侧跑不动。
怎么解决?答案是端云一体化。
你可能会问:
- 什么是端云一体化?它和传统的前后端分离有什么区别?
- 鸿蒙云服务都提供了哪些能力?云数据库、云存储、云函数分别是什么?
- 「民族图鉴」这种应用,哪些功能适合放到云上?
- 云数据库怎么设计 Schema?权限怎么管理?实时同步是什么原理?
- 云函数是 Serverless 吗?冷启动问题怎么解决?
- 数据安全和隐私合规怎么保障?会不会很花钱?
这些问题非常关键。端云一体化不是简单地"把数据放到云上",而是一套完整的架构理念——端侧负责展示和交互,云端负责存储和计算,两者协同工作,给用户带来无缝体验。
本文将带你深入理解端云一体化的核心概念,系统学习鸿蒙云服务的四大能力(云数据库、云存储、云函数、云托管),并以「民族图鉴」项目为例,完成收藏和历史记录从本地到云端的迁移实战。
🎯 学习目标
完成本文后,你将能够:
- ✅ 深入理解端云一体化的核心理念与架构模式
- ✅ 掌握鸿蒙云服务四大组件:云数据库、云存储、云函数、云托管
- ✅ 学会云数据库 Schema 设计、权限管理、实时同步的使用
- ✅ 掌握云存储的文件上传下载、访问控制、CDN 加速
- ✅ 理解云函数 Serverless 架构的原理与适用场景
- ✅ 学会端云协同设计:本地缓存、离线可用、冲突解决
- ✅ 了解云服务的成本控制与数据安全最佳实践
- ✅ 能够独立完成「民族图鉴」收藏和历史记录的云端迁移
- ✅ 解决端云一体化开发中的常见问题
💡 需求分析
为什么需要端云一体化?
在讲技术之前,我们先搞清楚一个问题:好好的本地应用,为什么要搞端云一体化?
纯本地应用的痛点
| 痛点 | 说明 | 影响 |
|---|---|---|
| 数据不跨设备 | 手机上收藏的,平板上看不到 | 用户体验差 |
| 换设备数据丢 | 换手机、重装 App,数据全没了 | 用户流失 |
| 计算能力有限 | 复杂 AI 模型、大数据分析跑不动 | 功能受限 |
| 存储容量有限 | 大量图片音视频存不下 | 内容少 |
| 无法多人协作 | 没有账号体系,不能社交 | 缺粘性 |
| 内容更新困难 | 每次更新内容都要发版 | 迭代慢 |
对于「民族图鉴」这样的应用来说,前三个痛点尤为突出:
- 收藏和历史记录只存在本地,换手机就没了
- 56个民族的图片和音频占了不少包体积,而且想加新内容必须发版
- AI 问答功能目前是 Mock 的,真要做大模型,端侧跑不动
端云一体化能带来什么?
端云一体化不是"把所有东西都搬到云上",而是端和云各尽其责,协同工作:
端侧(用户设备) 云端(服务器)
┌───────────────────┐ ┌───────────────────┐
│ │ │ │
│ UI 展示与交互 │◄────────►│ 数据存储与管理 │
│ 本地缓存 │ │ 复杂计算 │
│ 离线可用 │ │ 大数据分析 │
│ 轻量计算 │ │ 内容分发 │
│ │ │ 账号与权限 │
└───────────────────┘ └───────────────────┘
▲ ▲
│ │
└──────────── 协同 ────────────┘
端侧负责:
- 用户界面和交互(必须在端侧做)
- 本地缓存,提升速度(数据先读本地,再同步云端)
- 离线可用(没网也能正常使用基础功能)
- 轻量计算(简单的过滤、排序、格式化)
云端负责:
- 数据持久化存储(不会因为换手机而丢失)
- 复杂计算(AI 模型、大数据统计)
- 多端同步(手机、平板、智慧屏数据一致)
- 内容分发(图片、音视频 CDN 加速)
- 账号体系和权限管理
💡 核心思想:端云一体化不是"云取代端",而是"端云协同,1+1>2"。端侧做端侧擅长的,云端做云端擅长的。
端云一体化 vs 传统前后端分离
你可能会说:这不就是前后端分离吗?有什么不一样的?
| 维度 | 传统前后端分离 | 端云一体化 |
|---|---|---|
| 架构理念 | 前端展示 + 后端 API | 端云协同,一体化体验 |
| 数据同步 | 手动调用接口同步 | 自动实时同步 |
| 离线能力 | 基本没有 | 完整的离线支持 |
| 开发模式 | 前后端两个团队,两套栈 | 一体化开发,统一工具链 |
| 运维模式 | 自己搭服务器、运维 | Serverless,免运维 |
| 数据一致性 | 容易出现不一致 | 端云协同,冲突解决 |
简单说:传统前后端分离是"端调用云",端云一体化是"端云融合"。
鸿蒙云服务概览
鸿蒙7 提供了完整的云服务能力,主要包括四大组件:
| 云服务 | 作用 | 类似什么 |
|---|---|---|
| 云数据库 | 结构化数据存储,支持实时同步 | Firebase Firestore / 阿里云云开发数据库 |
| 云存储 | 文件存储(图片、音视频等),支持 CDN | 阿里云 OSS / AWS S3 |
| 云函数 | Serverless 函数计算,按需运行 | AWS Lambda / 阿里云函数计算 |
| 云托管 | 容器化部署,运行完整的服务端 | 阿里云容器服务 / Heroku |
这四个服务不是孤立的,而是可以互相配合:
┌─────────────────────────────────────────────────────────┐
│ 鸿蒙云服务 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 云数据库 │◄──►│ 云函数 │◄──►│ 云存储 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ │ │
│ ┌──────────┐ │
│ │ 云托管 │ │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
对于「民族图鉴」这样的应用,我们的使用策略是:
- 云数据库:存用户数据(收藏、历史记录、用户信息)、民族基础数据
- 云存储:存民族封面图、音频文件、视频文件
- 云函数:复杂业务逻辑(AI 问答、内容推荐、数据统计)
- 云托管:暂时用不上,等业务复杂了再考虑
💡 选择原则:优先用云数据库+云存储+云函数这套 Serverless 组合,运维成本最低、扩缩容最灵活。只有当 Serverless 满足不了需求时,才考虑云托管。
「民族图鉴」端云一体化改造规划
让我们来规划一下「民族图鉴」的端云一体化改造路线。
改造前的架构
┌─────────────────────────────────────────────────┐
│ 端侧(手机) │
│ │
│ UI层 → 业务逻辑层 → 数据层(本地Mock+Preferences)│
│ │
│ 所有数据都在本地,换手机就没了 │
└─────────────────────────────────────────────────┘
改造后的架构
┌──────────────────────┐ ┌──────────────────────┐
│ 端侧(手机) │ │ 云端服务 │
│ │ │ │
│ UI 层 │ │ │
│ │ │ │ │
│ 业务逻辑层 │◄─────►│ 云函数(复杂逻辑) │
│ │ │ │ │
│ 数据访问层 │ │ 云数据库(结构化数据)│
│ (本地缓存+同步) │◄─────►│ │
│ │ │ │ 云存储(文件资源) │
│ 本地存储(兜底) │◄─────►│ │
│ │ │ │
└──────────────────────┘ └──────────────────────┘
分阶段改造计划
| 阶段 | 内容 | 优先级 |
|---|---|---|
| 第一阶段 | 收藏、历史记录迁移到云数据库 | 最高 |
| 第二阶段 | 民族图片迁移到云存储 | 高 |
| 第三阶段 | AI 问答迁移到云函数 | 中 |
| 第四阶段 | 民族音频迁移到云存储 | 中 |
| 第五阶段 | 内容推荐、数据统计等高级功能 | 低 |
本文我们重点讲第一阶段:收藏和历史记录迁移到云数据库。这是最基础、也是用户感知最强的功能。
🛠️ 核心实现
步骤1:云数据库入门——Schema 设计与数据模型
1.1 什么是云数据库?
云数据库是鸿蒙提供的Serverless 化的云端数据库服务。你不需要自己搭数据库服务器、不需要运维、不需要考虑扩缩容——直接用就行。
它的核心特点:
- Serverless:不用管服务器,按读写量付费
- 实时同步:数据变化自动推送到端侧
- 离线支持:没网时也能读写,联网后自动同步
- 权限管理:灵活的数据访问权限控制
- 多端一致:手机、平板、智慧屏数据实时同步
1.2 数据模型设计
在创建云数据库之前,我们先要设计数据 Schema——也就是数据长什么样。
对于「民族图鉴」的收藏和历史记录功能,我们需要两个集合(Collection):
收藏表(favorites):
| 字段 | 类型 | 说明 |
|---|---|---|
| _id | string | 文档ID(自动生成) |
| userId | string | 用户ID |
| ethnicId | string | 民族ID(如 ‘han’、‘zhuang’) |
| ethnicName | string | 民族名称(冗余存储,方便查询展示) |
| ethnicCover | string | 民族封面图URL(冗余存储) |
| createdAt | number | 收藏时间(时间戳) |
历史记录表(view_history):
| 字段 | 类型 | 说明 |
|---|---|---|
| _id | string | 文档ID(自动生成) |
| userId | string | 用户ID |
| ethnicId | string | 民族ID |
| ethnicName | string | 民族名称 |
| ethnicCover | string | 民族封面图URL |
| viewedAt | number | 浏览时间(时间戳) |
| duration | number | 浏览时长(秒) |
💡 为什么要冗余存储 ethnicName 和 ethnicCover? 因为查询收藏列表时,我们需要展示民族名称和封面图。如果只存 ethnicId,每次都要关联查询民族表,性能差。冗余存储虽然多占了一点存储空间,但查询更快、用户体验更好。这是 NoSQL 数据库的常见设计思路——以空间换时间。
1.3 在「民族图鉴」中定义数据模型
在代码中,我们先定义好数据模型:
// models/CloudModels.ets
/** 收藏记录 */
export interface FavoriteRecord {
_id?: string; // 文档ID(云端自动生成)
userId: string; // 用户ID
ethnicId: string; // 民族ID
ethnicName: string; // 民族名称(冗余)
ethnicCover: string; // 民族封面图URL(冗余)
createdAt: number; // 收藏时间
}
/** 浏览历史记录 */
export interface ViewHistoryRecord {
_id?: string; // 文档ID
userId: string; // 用户ID
ethnicId: string; // 民族ID
ethnicName: string; // 民族名称
ethnicCover: string; // 民族封面图URL
viewedAt: number; // 浏览时间
duration: number; // 浏览时长(秒)
}
1.4 Schema 设计的最佳实践
设计云数据库 Schema 时,有几个最佳实践需要注意:
1. 用什么样的主键?
云数据库会自动生成 _id 作为文档的唯一标识,但业务查询往往是按 userId + ethnicId 来查的。所以我们需要建立复合索引。
2. 数据冗余到什么程度?
冗余多了:数据一致性难维护,占存储空间
冗余少了:查询慢,需要多次关联
原则:
- 经常一起查询的数据 → 冗余在一起
- 很少变化的数据 → 可以冗余(比如民族名称不会变)
- 经常变化的数据 → 不要冗余(比如用户头像可能会变)
3. 分页怎么处理?
NoSQL 数据库不像 SQL 那样有 offset,一般用游标分页(基于上一页最后一条的某个字段来翻页)。收藏列表可以按 createdAt 倒序排列,用时间戳作为游标。
步骤2:权限管理——谁能读、谁能写
2.1 为什么需要权限管理?
数据存到云端后,一个很重要的问题是:谁可以读?谁可以写?
如果不做权限控制,那任何人都能读写任何人的数据,这显然是不行的。
云数据库提供了灵活的权限规则配置,你可以精确控制每张表、每个文档的读写权限。
2.2 常见的权限模式
| 权限模式 | 说明 | 适用场景 |
|---|---|---|
| 所有人可读可写 | 完全开放 | 公共数据(如民族基础数据) |
| 所有人可读,仅创建者可写 | 公开内容,只能改自己的 | 评论、帖子 |
| 仅创建者可读可写 | 完全私有 | 收藏、历史记录、个人设置 |
| 自定义规则 | 按条件判断 | 复杂的业务场景 |
对于「民族图鉴」的数据:
- 收藏表、历史记录表:仅创建者可读可写(用户只能看自己的收藏)
- 民族基础数据表:所有人可读,仅管理员可写
2.3 权限规则的配置方式
云数据库的权限规则是用类似 JSON 的配置方式来定义的。比如收藏表的权限规则:
{
"read": "auth.uid == resource.data.userId",
"write": "auth.uid == resource.data.userId"
}
这个规则的意思是:
- 读权限:只有当登录用户的 uid 等于文档中的 userId 时,才能读
- 写权限:只有当登录用户的 uid 等于文档中的 userId 时,才能写
这样,每个用户只能访问自己的数据,非常安全。
💡 安全第一:权限规则一定要在设计阶段就考虑好。不要等到上线了才发现有安全漏洞。
步骤3:实时同步——数据变化自动推送
3.1 什么是实时同步?
实时同步是云数据库最强大的特性之一。
传统方式:端侧轮询(每隔一段时间去查一次数据库)
- 缺点:不实时(有延迟)、浪费流量(没变化也在查)、耗电(频繁唤醒)
实时同步方式:数据变化时,云端主动推送给端侧
- 优点:实时性好、省流量(只有变化才推)、省电(不用轮询)
传统轮询:
端侧 ──查询──► 云端
端侧 ──查询──► 云端 (没变化,白查了)
端侧 ──查询──► 云端 (没变化,白查了)
端侧 ──查询──► 云端 ← 数据变了
...
实时同步:
端侧 ──订阅──► 云端
(保持长连接)
端侧 ◄──推送── 云端 (数据变了才推)
(保持长连接)
端侧 ◄──推送── 云端 (数据又变了)
3.2 实时同步的原理
实时同步的底层原理大致是这样的:
- 端侧和云端建立一条长连接(WebSocket 或类似协议)
- 端侧向云端订阅某个查询(比如"我的收藏列表")
- 云端把初始数据推给端侧
- 之后,只要数据有变化(新增、修改、删除),云端就把变化推给端侧
- 端侧收到变化后,自动更新本地数据
整个过程对开发者来说是透明的——你只需要监听数据变化,框架会自动同步。
3.3 「民族图鉴」中的实时同步场景
对于「民族图鉴」来说,实时同步的价值体现在:
场景1:多设备同步
- 手机上收藏了一个民族 → 平板上立刻就能看到
- 平板上删除了一条历史记录 → 手机上立刻消失
场景2:数据更新不用刷新页面
- 在详情页收藏了民族 → 返回收藏页,列表自动更新
- 不需要手动下拉刷新,也不需要在页面返回时重新加载
3.4 实时同步的代码示例
在鸿蒙7中,使用云数据库的实时同步大致是这样的:
// 监听收藏列表变化
this.favoriteListener = cloudDatabase
.collection('favorites')
.where('userId', '==', this.userId)
.orderBy('createdAt', 'desc')
.onSnapshot((snapshot) => {
// 数据变化时自动回调
this.favorites = snapshot.docs.map(doc => doc.data());
console.log('收藏列表已更新,共', this.favorites.length, '条');
}, (error) => {
console.error('监听失败:', error);
});
// 页面销毁时取消监听
aboutToDisappear() {
if (this.favoriteListener) {
this.favoriteListener();
}
}
就这么简单——你不需要自己管理同步逻辑,只需要监听变化,UI 自动更新。
步骤4:云存储——文件上云与 CDN 加速
4.1 为什么需要云存储?
「民族图鉴」里有大量的图片和音频文件:
- 56 个民族的封面图
- 民族详情页的介绍音频
- 未来可能还会有视频
这些文件如果都打包在 App 里,会导致:
- 包体积很大:用户下载慢,占存储空间
- 更新困难:想加新图片、换音频,必须发版
- 分发效率低:所有用户都从应用商店下载,没有 CDN 加速
用云存储的话,这些问题都解决了:
- 文件存在云端,App 体积小
- 想更新内容,直接换云存储上的文件就行,不用发版
- CDN 加速,全国各地用户访问都很快
4.2 云存储的核心概念
| 概念 | 说明 |
|---|---|
| 存储空间(Bucket) | 文件存放的容器,类似文件夹 |
| 对象(Object) | 具体的文件(图片、音频、视频等) |
| 访问路径 | 文件的 URL,可以直接访问 |
| 访问控制 | 文件的读写权限(公开读/私有读/自定义) |
| CDN 加速 | 内容分发网络,加速文件访问 |
4.3 「民族图鉴」的文件存储方案
对于「民族图鉴」,我们的文件存储策略是:
目录结构:
ethnic-chronicles-bucket/
├── images/
│ ├── covers/ # 民族封面图
│ │ ├── 01_han.jpg
│ │ ├── 02_zhuang.jpg
│ │ └── ...
│ └── details/ # 详情页大图
│ ├── 01_han_detail.jpg
│ └── ...
├── audio/
│ ├── introduction/ # 民族介绍音频
│ │ ├── 01_han.mp3
│ │ └── ...
│ └── music/ # 民族音乐
│ └── ...
└── video/ # 视频(未来扩展)
└── ...
访问权限:
- 图片、音频都是公开可读(因为是公开内容,不需要登录就能看)
- 写权限只有管理员有(不能让用户随便改)
4.4 文件上传与下载
文件上传的代码大致是这样的:
// 上传文件到云存储
async uploadFile(localPath: string, cloudPath: string): Promise<string> {
try {
const result = await cloudStorage.uploadFile({
localPath: localPath, // 本地文件路径
cloudPath: cloudPath, // 云端存储路径
onProgress: (progress) => {
console.log('上传进度:', progress.percent + '%');
}
});
return result.fileUrl; // 返回文件的访问URL
} catch (e) {
console.error('上传失败:', e);
throw e;
}
}
文件下载就更简单了——直接用 URL 加载就行:
// 加载云存储上的图片
Image('https://cdn.example.com/images/covers/01_han.jpg')
.width(100)
.height(100)
💡 图片加载优化:结合我们之前学过的图片懒加载、缓存策略,云存储 + CDN + 本地缓存 = 又快又省。
步骤5:云函数——Serverless 架构与业务逻辑
5.1 什么是云函数?
云函数是一种Serverless 计算服务。你只需要写函数代码,上传到云端,它就能运行。
你不需要:
- 买服务器
- 搭环境
- 运维
- 考虑扩缩容
你只需要:
- 写函数逻辑
- 配置触发方式(HTTP 请求、定时触发、数据库变更触发等)
- 按调用次数和执行时间付费
5.2 云函数的优势
| 优势 | 说明 |
|---|---|
| 免运维 | 不用管服务器,专注写业务逻辑 |
| 自动扩缩容 | 请求多了自动扩容,请求少了自动缩容 |
| 按需付费 | 不运行不花钱,用多少付多少 |
| 事件驱动 | 可以被各种事件触发(HTTP、定时器、数据库变更) |
| 独立部署 | 每个函数独立,互不影响 |
5.3 「民族图鉴」的云函数应用场景
哪些功能适合放到云函数里?
适合的:
- ✅ AI 问答(需要调用大模型 API,端侧跑不动)
- ✅ 内容推荐(根据用户行为推荐民族,需要算法)
- ✅ 数据统计(每日活跃用户、收藏排行等)
- ✅ 敏感操作(比如删除账号,需要服务端验证)
- ✅ 第三方 API 调用(比如发送短信、邮件)
不适合的:
- ❌ 简单的 CRUD(云数据库直接操作就行)
- ❌ 需要长连接的(云函数有执行时间限制,一般最多几分钟)
- ❌ 计算量特别大的(成本太高,不如用专门的计算服务)
5.4 云函数的触发方式
| 触发方式 | 说明 | 适用场景 |
|---|---|---|
| HTTP 触发 | 通过 HTTP 请求调用 | API 接口、前端调用 |
| 定时触发 | 按时间周期自动运行 | 数据统计、定时任务 |
| 数据库触发 | 数据库变化时触发 | 数据变更后做后续处理 |
| 存储触发 | 文件上传/删除时触发 | 图片上传后生成缩略图 |
对于「民族图鉴」的 AI 问答功能,我们用 HTTP 触发:
// 云函数:AI 问答
export default async function handler(event: any, context: any) {
const { question, userId } = event;
// 1. 调用大模型 API
const aiResponse = await callAIModel(question);
// 2. 记录用户提问日志(存到云数据库)
await saveChatLog(userId, question, aiResponse);
// 3. 返回答案
return {
answer: aiResponse,
timestamp: Date.now()
};
}
端侧调用云函数:
// 端侧调用 AI 问答云函数
async askAI(question: string): Promise<string> {
try {
const result = await cloudFunction.callFunction({
name: 'ai_chat', // 云函数名称
data: { // 传给云函数的参数
question: question,
userId: this.userId
}
});
return result.result.answer;
} catch (e) {
console.error('调用云函数失败:', e);
throw e;
}
}
就这么简单——你不需要自己搭后端服务器,写个函数就能用。
5.5 云函数冷启动问题
云函数有一个著名的问题:冷启动。
什么是冷启动?
- 云函数不是一直运行的,它是按需启动的
- 第一次调用的时候,需要先启动函数实例 → 这个过程叫冷启动
- 冷启动需要时间(几百毫秒到几秒不等)
- 调用完之后,实例会保留一段时间(几分钟),这段时间内再调用就是热启动,很快
冷启动对用户体验的影响:
- 第一次调用云函数的时候,用户可能会感觉到延迟
- 特别是 AI 问答这种对响应速度要求高的场景,冷启动会让用户觉得"卡"
怎么缓解冷启动?
| 方案 | 说明 | 效果 |
|---|---|---|
| 保持实例预热 | 定期 ping 一下,让实例不释放 | 好,但费钱 |
| 减少函数体积 | 代码精简,依赖少,启动快 | 好,推荐 |
| 使用预留实例 | 付费保留一定数量的常驻实例 | 最好,但贵 |
| 端侧预加载 | 提前触发一次,让用户用的时候是热的 | 适合特定场景 |
对于「民族图鉴」,我们的策略是:
- 精简云函数代码,减少依赖
- 对于 AI 问答这种高频使用的功能,可以考虑预留实例
- 端侧进入 AI 聊天页时,提前发一个空请求预热一下
步骤6:端云协同——本地缓存、离线可用、冲突解决
6.1 为什么需要端云协同?
纯云端的应用有一个大问题:没网就用不了。
但用户的网络环境是复杂的:
- 地铁上信号差
- 地下室没网
- 出国漫游流量贵
所以一个好的应用,必须离线也能用。这就需要端云协同——端侧有本地缓存,云端有持久存储,两者自动同步。
6.2 本地缓存策略
云数据库 SDK 一般都内置了本地缓存。它的工作方式是:
读数据:
先读本地缓存 → 立即返回(快)
同时读云端 → 有新数据就更新本地和UI
写数据:
先写本地缓存 → 立即返回(用户感觉很快)
后台异步同步到云端 → 同步成功就好,失败了重试
这样,用户的操作永远是"秒级响应",因为先操作本地。同步在后台默默进行。
6.3 离线可用
有了本地缓存,离线时应用也能正常工作:
- 浏览收藏列表 → 读本地缓存
- 新增收藏 → 写本地缓存,等联网了再同步
- 删除收藏 → 删本地缓存,等联网了再同步
等用户重新联网,SDK 会自动把离线期间的变更同步到云端。
💡 用户感知:好的端云一体化应用,用户应该感觉不到"同步"的存在。一切都在后台默默进行,用户只觉得"这个 App 又快又稳,没网也能用"。
6.4 冲突解决
端云同步有一个经典问题:冲突。
什么情况会有冲突?
- 用户在手机上修改了某条数据(没网,存在本地)
- 同时用户在平板上也修改了同一条数据(并且同步到了云端)
- 手机联网后,两边的数据不一样 → 冲突了
怎么解决冲突?常见的策略有:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 最后写入为准(LWW) | 谁最后改的,以谁为准 | 大多数场景 |
| 云端为准 | 云端数据覆盖端侧 | 云端是权威数据源 |
| 端侧为准 | 端侧数据覆盖云端 | 端侧是主要操作入口 |
| 手动合并 | 让用户自己选怎么合并 | 复杂的文档编辑 |
对于「民族图鉴」的收藏和历史记录:
- 收藏:最后操作为准(最后收藏/取消收藏的操作生效)
- 历史记录:两端合并(两边的浏览记录都保留,去重即可)
大多数云数据库 SDK 默认使用"最后写入为准"策略,对于大多数场景都够用了。
步骤7:实战——将收藏和历史记录迁移到云数据库
讲了这么多理论,现在让我们动手实战——把「民族图鉴」的收藏和历史记录从本地 Preferences 迁移到云数据库。
7.1 总体思路
原方案:StorageService + Preferences(纯本地)
新方案:CloudService + 云数据库(端云协同)
迁移策略:
1. 新数据直接存云端
2. 旧数据一次性迁移到云端
3. 保留本地缓存作为兜底
7.2 封装 CloudService
首先,我们封装一个 CloudService,统一管理云数据库操作:
// services/CloudService.ets
import { FavoriteRecord, ViewHistoryRecord } from '../models/CloudModels';
export class CloudService {
private static instance: CloudService;
private isInitialized: boolean = false;
private userId: string = '';
private constructor() {}
public static getInstance(): CloudService {
if (!CloudService.instance) {
CloudService.instance = new CloudService();
}
return CloudService.instance;
}
/**
* 初始化云服务
*/
public async init(): Promise<void> {
if (this.isInitialized) return;
try {
// 初始化云数据库、云存储等
// cloudDatabase.init(...);
// cloudStorage.init(...);
this.isInitialized = true;
console.log('[CloudService] 初始化成功');
} catch (e) {
console.error('[CloudService] 初始化失败:', JSON.stringify(e));
throw e;
}
}
/**
* 设置当前用户ID
*/
public setUserId(userId: string): void {
this.userId = userId;
}
// ========== 收藏相关 ==========
/**
* 获取收藏列表
*/
public async getFavorites(): Promise<FavoriteRecord[]> {
if (!this.userId) return [];
try {
const result = await cloudDatabase
.collection('favorites')
.where('userId', '==', this.userId)
.orderBy('createdAt', 'desc')
.get();
return result.docs.map(doc => doc.data());
} catch (e) {
console.error('[CloudService] 获取收藏失败:', JSON.stringify(e));
return [];
}
}
/**
* 添加收藏
*/
public async addFavorite(ethnic: EthnicGroup): Promise<boolean> {
if (!this.userId) return false;
try {
const record: FavoriteRecord = {
userId: this.userId,
ethnicId: ethnic.id,
ethnicName: ethnic.name,
ethnicCover: ethnic.coverImage,
createdAt: Date.now()
};
await cloudDatabase.collection('favorites').add(record);
return true;
} catch (e) {
console.error('[CloudService] 添加收藏失败:', JSON.stringify(e));
return false;
}
}
/**
* 取消收藏
*/
public async removeFavorite(ethnicId: string): Promise<boolean> {
if (!this.userId) return false;
try {
await cloudDatabase
.collection('favorites')
.where('userId', '==', this.userId)
.where('ethnicId', '==', ethnicId)
.delete();
return true;
} catch (e) {
console.error('[CloudService] 取消收藏失败:', JSON.stringify(e));
return false;
}
}
/**
* 是否已收藏
*/
public async isFavorite(ethnicId: string): Promise<boolean> {
if (!this.userId) return false;
try {
const result = await cloudDatabase
.collection('favorites')
.where('userId', '==', this.userId)
.where('ethnicId', '==', ethnicId)
.limit(1)
.get();
return result.docs.length > 0;
} catch (e) {
console.error('[CloudService] 查询收藏失败:', JSON.stringify(e));
return false;
}
}
/**
* 监听收藏列表变化(实时同步)
*/
public onFavoritesChanged(callback: (list: FavoriteRecord[]) => void): () => void {
if (!this.userId) return () => {};
const unsubscribe = cloudDatabase
.collection('favorites')
.where('userId', '==', this.userId)
.orderBy('createdAt', 'desc')
.onSnapshot((snapshot) => {
const list = snapshot.docs.map(doc => doc.data());
callback(list);
});
return unsubscribe;
}
// ========== 历史记录相关 ==========
/**
* 获取历史记录列表
*/
public async getViewHistory(limit: number = 50): Promise<ViewHistoryRecord[]> {
if (!this.userId) return [];
try {
const result = await cloudDatabase
.collection('view_history')
.where('userId', '==', this.userId)
.orderBy('viewedAt', 'desc')
.limit(limit)
.get();
return result.docs.map(doc => doc.data());
} catch (e) {
console.error('[CloudService] 获取历史记录失败:', JSON.stringify(e));
return [];
}
}
/**
* 记录浏览历史
*/
public async addViewHistory(ethnic: EthnicGroup, duration: number = 0): Promise<void> {
if (!this.userId) return;
try {
// 先查有没有这条记录,有就更新时间,没有就新增
const existing = await cloudDatabase
.collection('view_history')
.where('userId', '==', this.userId)
.where('ethnicId', '==', ethnic.id)
.limit(1)
.get();
if (existing.docs.length > 0) {
// 更新时间
await existing.docs[0].ref.update({
viewedAt: Date.now(),
duration: duration
});
} else {
// 新增
const record: ViewHistoryRecord = {
userId: this.userId,
ethnicId: ethnic.id,
ethnicName: ethnic.name,
ethnicCover: ethnic.coverImage,
viewedAt: Date.now(),
duration: duration
};
await cloudDatabase.collection('view_history').add(record);
}
} catch (e) {
console.error('[CloudService] 记录历史失败:', JSON.stringify(e));
}
}
}
7.3 数据迁移——从本地到云端
老用户升级 App 时,本地已经有收藏和历史记录了。我们需要把这些数据迁移到云端。
// services/DataMigrationService.ets
export class DataMigrationService {
private static instance: DataMigrationService;
private migrationKey: string = 'migration_done_v1';
private constructor() {}
public static getInstance(): DataMigrationService {
if (!DataMigrationService.instance) {
DataMigrationService.instance = new DataMigrationService();
}
return DataMigrationService.instance;
}
/**
* 执行数据迁移(如果还没迁移过)
*/
public async migrateIfNeeded(): Promise<void> {
const storageService = StorageService.getInstance();
const cloudService = CloudService.getInstance();
// 检查是否已经迁移过
const migrated = await storageService.getBoolean(this.migrationKey, false);
if (migrated) {
console.log('[DataMigration] 已迁移过,跳过');
return;
}
console.log('[DataMigration] 开始数据迁移...');
try {
// 1. 迁移收藏数据
await this.migrateFavorites();
// 2. 迁移历史记录
await this.migrateViewHistory();
// 3. 标记已迁移
await storageService.saveBoolean(this.migrationKey, true);
console.log('[DataMigration] 数据迁移完成');
} catch (e) {
console.error('[DataMigration] 迁移失败:', JSON.stringify(e));
}
}
/**
* 迁移收藏数据
*/
private async migrateFavorites(): Promise<void> {
const storageService = StorageService.getInstance();
const cloudService = CloudService.getInstance();
const mockData = EthnicMockData.getInstance();
const favoriteIds = await storageService.getFavoriteEthnics();
console.log('[DataMigration] 待迁移收藏数:', favoriteIds.length);
for (const ethnicId of favoriteIds) {
const ethnic = mockData.getEthnicById(ethnicId);
if (ethnic) {
await cloudService.addFavorite(ethnic);
}
}
console.log('[DataMigration] 收藏迁移完成');
}
/**
* 迁移浏览历史
*/
private async migrateViewHistory(): Promise<void> {
const storageService = StorageService.getInstance();
const cloudService = CloudService.getInstance();
const mockData = EthnicMockData.getInstance();
const viewedIds = await storageService.getViewedEthnics();
console.log('[DataMigration] 待迁移历史记录数:', viewedIds.length);
// 注意:本地只存了ID,没有时间和时长,迁移时用当前时间兜底
// 更完善的方案应该本地也存完整信息
for (const ethnicId of viewedIds) {
const ethnic = mockData.getEthnicById(ethnicId);
if (ethnic) {
await cloudService.addViewHistory(ethnic, 0);
}
}
console.log('[DataMigration] 历史记录迁移完成');
}
}
7.4 改造收藏页
收藏页需要改造,从读本地改为读云端,并且支持实时同步:
// pages/CollectionPage.ets
@Entry
@Component
struct CollectionPage {
@State favorites: FavoriteRecord[] = [];
@State isLoading: boolean = true;
private unsubscribe: (() => void) | null = null;
aboutToAppear(): void {
this.loadFavorites();
this.subscribeToChanges();
}
aboutToDisappear(): void {
if (this.unsubscribe) {
this.unsubscribe();
}
}
/**
* 加载收藏列表
*/
private async loadFavorites(): Promise<void> {
this.isLoading = true;
const cloudService = CloudService.getInstance();
this.favorites = await cloudService.getFavorites();
this.isLoading = false;
}
/**
* 订阅实时变化
*/
private subscribeToChanges(): void {
const cloudService = CloudService.getInstance();
this.unsubscribe = cloudService.onFavoritesChanged((list) => {
this.favorites = list;
});
}
build() {
Column() {
if (this.isLoading) {
// 加载中
Text('加载中...')
} else if (this.favorites.length === 0) {
// 空状态
Text('还没有收藏哦')
} else {
// 收藏列表
List() {
LazyForEach(this.favorites, (item: FavoriteRecord) => {
ListItem() {
// 收藏项 UI...
}
}, (item: FavoriteRecord) => item._id || item.ethnicId)
}
}
}
}
}
7.5 保留本地兜底
虽然用了云端,但我们还是要保留本地存储作为兜底——万一云服务挂了、或者用户一直没网,至少还能用本地数据。
策略是:
- 优先读云端,实时同步
- 云端不可用时,降级到本地
- 两者定期同步
这就是端云一体化的精髓——云端为主,本地兜底,自动同步,无缝切换。
⚠️ 常见问题与解决方案
问题1:云函数冷启动太慢,用户体验差怎么办?
现象:
用户第一次使用 AI 问答功能时,要等好几秒才收到回复,感觉很卡。
原因分析:
云函数第一次调用时需要冷启动——创建实例、加载代码、初始化运行环境,这个过程需要时间。
解决方案:
| 方案 | 效果 | 成本 | 推荐度 |
|---|---|---|---|
| 精简函数代码 | 减少依赖,代码越少启动越快 | 无 | ⭐⭐⭐⭐⭐ |
| 预热策略 | 进入相关页面时提前触发一次空调用 | 低(少量调用费用) | ⭐⭐⭐⭐ |
| 保留实例 | 付费保留常驻实例,消除冷启动 | 高 | ⭐⭐⭐ |
| 端侧缓存 | 常见问题的答案缓存到端侧 | 低 | ⭐⭐⭐⭐ |
| 流式响应 | 边生成边返回,让用户感觉快 | 中 | ⭐⭐⭐⭐⭐ |
「民族图鉴」的最佳实践:
- 精简 AI 问答云函数的依赖
- 进入 AI 聊天页时,提前发一个空请求预热
- 常见问题(比如"泼水节是什么时候")缓存到端侧
- 使用流式响应,答案一个字一个字蹦出来,比等全部生成完再显示感觉快得多
💡 用户心理:用户对"正在生成"的接受度比"空白等待"高得多。给个加载动画、或者流式输出,体验会好很多。
问题2:网络不稳定,数据同步老是失败怎么办?
现象:
用户网络不好的时候,收藏操作半天没反应,或者提示失败。
原因分析:
网络不稳定是移动端的常态——地铁上、电梯里、地下室,网络说断就断。
解决方案:
1. 本地优先,后台同步
用户点击收藏 → 立即更新本地缓存和UI(用户觉得秒响应)
→ 后台默默同步到云端(失败了自动重试)
用户永远觉得"很流畅",同步在后台默默进行。
2. 指数退避重试
同步失败了不要立刻重试,等一会儿再试,间隔越来越长:
- 第1次失败 → 等 1 秒重试
- 第2次失败 → 等 2 秒重试
- 第3次失败 → 等 4 秒重试
- …
- 直到成功,或者达到最大重试次数
3. 网络恢复后自动同步
监听网络状态变化,网络恢复时自动触发一次同步。
4. 给用户适当的反馈
虽然是后台同步,但用户有权知道同步状态:
- 同步中 → 显示一个小小的同步图标
- 同步失败 → 不打扰用户,但可以在设置页看到
- 完全离线 → 提示"当前离线,数据将在联网后同步"
问题3:云服务会不会很贵?成本超支了怎么办?
现象:
担心用了云服务后,费用蹭蹭涨,最后交不起。
原因分析:
Serverless 是按需付费,用多少付多少。但如果用得不合理,确实可能超支。
成本控制建议:
1. 了解定价模型
- 云数据库:按读写次数 + 存储量收费
- 云存储:按存储量 + 下行流量收费
- 云函数:按调用次数 + 执行时间收费
2. 设置预算告警
- 大部分云平台都支持设置预算告警
- 比如:月消费达到 10 元时发邮件提醒,达到 50 元时自动停用
3. 优化读写次数
- 不要频繁读写,能批量就批量
- 合理使用本地缓存,减少云端请求
- 实时同步不要滥用,只有需要实时更新的页面才订阅
4. 优化存储和流量
- 图片用 WebP 格式,减小体积
- 开启 CDN 缓存
- 大文件用分片上传
- 合理设置缓存策略
5. 「民族图鉴」的成本估算
对于一个中小规模的应用(日活几千),大致费用:
- 云数据库:几十到几百元/月
- 云存储 + CDN:几十到几百元/月
- 云函数:几十元/月
- 总计:几百元/月级别
和自己搭服务器(至少几千元/月)比,还是便宜很多的。
💡 小技巧:大多数云平台都有免费额度,个人开发者的小项目,免费用额度基本就够了。等用户量起来了再考虑付费的事。
问题4:数据安全和隐私合规怎么保障?
现象:
用户数据存到云端,安全吗?会不会泄露?符合隐私法规吗?
原因分析:
数据安全和隐私合规是红线问题,绝对不能大意。
安全最佳实践:
1. 权限最小化原则
- 每个用户只能访问自己的数据
- 端侧只能调用必要的云函数
- 云函数的权限也要控制(不能随便删数据库)
2. 数据加密
- 传输加密:HTTPS/TLS(一般云平台默认就有)
- 存储加密:敏感数据加密存储(比如用户手机号、身份证号)
- 端侧加密:本地缓存也可以加密
3. 隐私合规
- 用户数据采集要有明确的目的,不能乱收集
- 要有隐私政策,告诉用户收集了什么、用来做什么
- 用户有权删除自己的数据(“被遗忘权”)
- 数据要存在合规的地域(比如国内用户的数据要存在国内)
4. 「民族图鉴」的数据安全策略
- 收藏、历史记录:仅用户本人可访问
- 不收集敏感个人信息(不需要手机号、身份证号)
- 用户可以随时删除账号和所有数据
- 数据存储在国内,符合法规要求
💡 安全无小事:不要等出了问题才想起安全。从设计阶段就要把安全考虑进去。
📝 本章小结
核心知识点
本文系统讲解了端云一体化的概念与实践,核心内容包括:
1. 端云一体化理念
- 不是"云取代端",而是"端云协同,1+1>2"
- 端侧负责展示、交互、本地缓存、离线可用
- 云端负责存储、计算、多端同步、内容分发
- 比传统前后端分离更紧密、更一体化
2. 鸿蒙云服务四大组件
- 云数据库:结构化数据存储,支持实时同步、离线可用
- 云存储:文件存储,支持 CDN 加速
- 云函数:Serverless 计算,免运维、自动扩缩容
- 云托管:容器化部署,适合复杂服务端
3. 云数据库核心能力
- Schema 设计:NoSQL 思维,适度冗余,以空间换时间
- 权限管理:灵活的规则配置,确保数据安全
- 实时同步:数据变化自动推送,多端一致
- 离线支持:本地缓存,没网也能用
4. 云存储核心能力
- 文件上传下载,CDN 加速
- 访问控制,安全可靠
- 适合图片、音视频等大文件
5. 云函数与 Serverless
- 免运维、自动扩缩容、按需付费
- 适合 AI 问答、数据统计、第三方调用等场景
- 注意冷启动问题,用预热、精简代码等方式缓解
6. 端云协同设计
- 本地优先,后台同步(用户永远觉得快)
- 离线可用,联网后自动同步
- 冲突解决:最后写入为准、手动合并等策略
最佳实践总结
✅ 先规划,后动手
做端云一体化之前先想清楚:
- 哪些数据要上云?
- 哪些逻辑放云端?
- 权限怎么控制?
- 离线怎么用?
不要上来就写代码,架构想清楚再动手。
✅ 本地优先,云端同步
用户操作先写本地,立即反馈
后台默默同步到云端
用户感知不到同步的存在
这才是好的端云一体化体验
✅ 安全第一,权限最小化
每个用户只能访问自己的数据
敏感数据加密存储
隐私合规要放在心上
安全是底线,不能有侥幸心理
✅ 成本可控,监控告警
设置预算告警,防止超支
优化读写次数和流量
从小规模开始,逐步放大
Serverless 虽好,也要精打细算
下一步预告
在下一篇文章中,我们将:
- 🎬 深入学习 LTPO 动态刷新率技术
- 📱 了解 LTPO 的工作原理与优势
- 🎨 掌握不同场景下的帧率控制策略
- ⚡ 学习动画与高刷的适配方法
- 🔋 理解性能与功耗的平衡艺术
- 🚀 将「民族图鉴」适配 LTPO,提升流畅度同时降低功耗
🔗 相关链接
- 项目源码: GitCode 仓库
- 鸿蒙云服务开发指南: 官方文档
- 云数据库开发指南: 官方文档
- 云存储开发指南: 官方文档
- 云函数开发指南: 官方文档
💡 提示:端云一体化是鸿蒙生态的重要发展方向。对于个人开发者和小团队来说,Serverless 架构的价值尤其明显——你不需要后端工程师、不需要运维,一个人就能搞定从端到云的全栈开发。「民族图鉴」虽然现在功能还不复杂,但通过端云一体化改造,我们为未来的功能扩展打下了坚实的基础。等你想加社区、加社交、加 AI 推荐的时候,就会发现——当初把数据迁到云端的决定是多么正确。
更多推荐



所有评论(0)