在这里插入图片描述

📖 引言

想象一下这个场景:

你用「民族图鉴」收藏了十几个感兴趣的民族,历史记录里存着几十条浏览记录。某天你换了一部新手机,重新安装 App 后,发现收藏和历史记录全都没了——因为它们都存在本地。

这就是纯本地应用的痛点:数据不跨设备、换手机数据丢失、无法多端同步、复杂逻辑端侧跑不动

怎么解决?答案是端云一体化

你可能会问:

  • 什么是端云一体化?它和传统的前后端分离有什么区别?
  • 鸿蒙云服务都提供了哪些能力?云数据库、云存储、云函数分别是什么?
  • 「民族图鉴」这种应用,哪些功能适合放到云上?
  • 云数据库怎么设计 Schema?权限怎么管理?实时同步是什么原理?
  • 云函数是 Serverless 吗?冷启动问题怎么解决?
  • 数据安全和隐私合规怎么保障?会不会很花钱?

这些问题非常关键。端云一体化不是简单地"把数据放到云上",而是一套完整的架构理念——端侧负责展示和交互,云端负责存储和计算,两者协同工作,给用户带来无缝体验。

本文将带你深入理解端云一体化的核心概念,系统学习鸿蒙云服务的四大能力(云数据库、云存储、云函数、云托管),并以「民族图鉴」项目为例,完成收藏和历史记录从本地到云端的迁移实战。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 深入理解端云一体化的核心理念与架构模式
  • ✅ 掌握鸿蒙云服务四大组件:云数据库、云存储、云函数、云托管
  • ✅ 学会云数据库 Schema 设计、权限管理、实时同步的使用
  • ✅ 掌握云存储的文件上传下载、访问控制、CDN 加速
  • ✅ 理解云函数 Serverless 架构的原理与适用场景
  • ✅ 学会端云协同设计:本地缓存、离线可用、冲突解决
  • ✅ 了解云服务的成本控制与数据安全最佳实践
  • ✅ 能够独立完成「民族图鉴」收藏和历史记录的云端迁移
  • ✅ 解决端云一体化开发中的常见问题

💡 需求分析

为什么需要端云一体化?

在讲技术之前,我们先搞清楚一个问题:好好的本地应用,为什么要搞端云一体化?

纯本地应用的痛点
痛点 说明 影响
数据不跨设备 手机上收藏的,平板上看不到 用户体验差
换设备数据丢 换手机、重装 App,数据全没了 用户流失
计算能力有限 复杂 AI 模型、大数据分析跑不动 功能受限
存储容量有限 大量图片音视频存不下 内容少
无法多人协作 没有账号体系,不能社交 缺粘性
内容更新困难 每次更新内容都要发版 迭代慢

对于「民族图鉴」这样的应用来说,前三个痛点尤为突出:

  1. 收藏和历史记录只存在本地,换手机就没了
  2. 56个民族的图片和音频占了不少包体积,而且想加新内容必须发版
  3. 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 实时同步的原理

实时同步的底层原理大致是这样的:

  1. 端侧和云端建立一条长连接(WebSocket 或类似协议)
  2. 端侧向云端订阅某个查询(比如"我的收藏列表")
  3. 云端把初始数据推给端侧
  4. 之后,只要数据有变化(新增、修改、删除),云端就把变化推给端侧
  5. 端侧收到变化后,自动更新本地数据

整个过程对开发者来说是透明的——你只需要监听数据变化,框架会自动同步。

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 里,会导致:

  1. 包体积很大:用户下载慢,占存储空间
  2. 更新困难:想加新图片、换音频,必须发版
  3. 分发效率低:所有用户都从应用商店下载,没有 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 一下,让实例不释放 好,但费钱
减少函数体积 代码精简,依赖少,启动快 好,推荐
使用预留实例 付费保留一定数量的常驻实例 最好,但贵
端侧预加载 提前触发一次,让用户用的时候是热的 适合特定场景

对于「民族图鉴」,我们的策略是:

  1. 精简云函数代码,减少依赖
  2. 对于 AI 问答这种高频使用的功能,可以考虑预留实例
  3. 端侧进入 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. 优先读云端,实时同步
  2. 云端不可用时,降级到本地
  3. 两者定期同步

这就是端云一体化的精髓——云端为主,本地兜底,自动同步,无缝切换


⚠️ 常见问题与解决方案

问题1:云函数冷启动太慢,用户体验差怎么办?

现象
用户第一次使用 AI 问答功能时,要等好几秒才收到回复,感觉很卡。

原因分析
云函数第一次调用时需要冷启动——创建实例、加载代码、初始化运行环境,这个过程需要时间。

解决方案

方案 效果 成本 推荐度
精简函数代码 减少依赖,代码越少启动越快 ⭐⭐⭐⭐⭐
预热策略 进入相关页面时提前触发一次空调用 低(少量调用费用) ⭐⭐⭐⭐
保留实例 付费保留常驻实例,消除冷启动 ⭐⭐⭐
端侧缓存 常见问题的答案缓存到端侧 ⭐⭐⭐⭐
流式响应 边生成边返回,让用户感觉快 ⭐⭐⭐⭐⭐

「民族图鉴」的最佳实践

  1. 精简 AI 问答云函数的依赖
  2. 进入 AI 聊天页时,提前发一个空请求预热
  3. 常见问题(比如"泼水节是什么时候")缓存到端侧
  4. 使用流式响应,答案一个字一个字蹦出来,比等全部生成完再显示感觉快得多

💡 用户心理:用户对"正在生成"的接受度比"空白等待"高得多。给个加载动画、或者流式输出,体验会好很多。


问题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,提升流畅度同时降低功耗

🔗 相关链接


💡 提示:端云一体化是鸿蒙生态的重要发展方向。对于个人开发者和小团队来说,Serverless 架构的价值尤其明显——你不需要后端工程师、不需要运维,一个人就能搞定从端到云的全栈开发。「民族图鉴」虽然现在功能还不复杂,但通过端云一体化改造,我们为未来的功能扩展打下了坚实的基础。等你想加社区、加社交、加 AI 推荐的时候,就会发现——当初把数据迁到云端的决定是多么正确。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐