HarmonyOS开发:云存储Cloud Storage
HarmonyOS开发:云存储Cloud Storage
📌 核心要点:Cloud Storage是端云一体的对象存储服务,文件上传下载、断点续传、安全策略、CDN加速,图片视频音频文件一站搞定。
背景与动机
你的App要上传用户头像。简单嘛,写个HTTP POST把图片发到服务器——然后呢?
图片太大上传超时怎么办?上传到一半网络断了怎么办?不同用户只能访问自己的文件怎么办?图片加载太慢怎么办?文件存储在哪个目录?怎么清理过期文件?
一个"上传头像"的需求,背后藏着十几个问题。你自己搭服务器、写接口、配CDN、做权限控制,一套下来至少两周。而且后期运维——磁盘满了、带宽超了、图片被盗链了——每个都是半夜被叫起来的理由。
Cloud Storage就是来解决这些问题的。你不用管服务器、不用管CDN、不用管权限,SDK一集成,上传下载断点续传全有了。
但Cloud Storage不是简单的"网盘SDK"。它有安全策略要配、有CDN要调优、有存储桶要规划。用对了,图片秒开、视频流畅;用错了,流量费比服务器还贵。
核心原理
Cloud Storage的架构很简单:客户端直连云端存储桶,不经过你的业务服务器,省带宽省成本。
flowchart TD
A[客户端上传文件] --> B{文件大小}
B -->|小于10MB| C[直接上传]
B -->|大于10MB| D[分片上传]
C --> E[Cloud Storage服务端]
D --> E
E --> F{上传结果}
F -->|成功| G[返回文件URL]
F -->|失败| H{支持断点续传?}
H -->|是| I[保存断点信息]
I --> J[下次从断点继续]
H -->|否| K[重新上传]
G --> L[文件存储到Bucket]
L --> M[CDN节点缓存]
N[客户端下载文件] --> O{CDN命中?}
O -->|是| P[从CDN返回,速度快]
O -->|否| Q[回源到存储桶]
Q --> R[同时缓存到CDN]
R --> P
classDef upload fill:#1565C0,color:#fff,stroke:#0D47A1
classDef storage fill:#2E7D32,color:#fff,stroke:#1B5E20
classDef download fill:#E65100,color:#fff,stroke:#BF360C
classDef error fill:#C62828,color:#fff,stroke:#B71C1C
class A,B,C,D,upload
class E,F,G,L,M,storage
class N,O,P,Q,R,download
class H,I,J,K,error
存储桶与目录结构
Cloud Storage用"桶(Bucket)"来组织文件,每个项目可以创建多个桶。桶内的文件用路径(如images/avatars/user123.jpg)组织,类似文件系统。
推荐的目录结构:
/
├── images/ # 图片
│ ├── avatars/ # 用户头像
│ ├── articles/ # 文章配图
│ └── products/ # 商品图片
├── videos/ # 视频
├── audios/ # 音频
├── documents/ # 文档
└── temp/ # 临时文件(定期清理)
安全策略
Cloud Storage的安全策略基于"存储安全规则",定义谁可以读写哪些文件。规则示例:
- 用户只能读写自己目录下的文件:
/images/avatars/{userId}/ - 公开文件所有人可读,只有上传者可写
- 临时文件24小时后自动删除
安全规则在AGC控制台配置,客户端SDK自动执行,不需要你在业务层做权限校验。
CDN加速
Cloud Storage默认集成CDN。文件首次下载时从存储桶回源,之后缓存在CDN边缘节点,后续访问直接从CDN返回,延迟从几百毫秒降到几十毫秒。
CDN缓存策略可以按文件类型设置:图片缓存7天、视频缓存1天、临时文件不缓存。
代码实战
基础用法:文件上传与下载
最核心的操作:上传一个文件到云端,然后下载回来。
// CloudStorageManager.ets
import { cloudStorage } from '@kit.CloudStorageKit';
import { common } from '@kit.AbilityKit';
export class CloudStorageManager {
private static instance: CloudStorageManager;
private storage: cloudStorage.StorageReference | null = null;
private constructor() {}
static getInstance(): CloudStorageManager {
if (!CloudStorageManager.instance) {
CloudStorageManager.instance = new CloudStorageManager();
}
return CloudStorageManager.instance;
}
/**
* 初始化Cloud Storage
*/
init(): void {
try {
// 获取Storage实例
const storageInstance = cloudStorage.getInstance();
// 获取根目录引用
this.storage = storageInstance.getStorageReference();
console.info('[CloudStorage] 初始化完成');
} catch (error) {
console.error(`[CloudStorage] 初始化失败: ${JSON.stringify(error)}`);
}
}
/**
* 上传文件
* 从本地路径上传到云端指定路径
*/
async uploadFile(
localPath: string, // 本地文件路径
cloudPath: string, // 云端存储路径
onProgress?: (percentage: number) => void // 上传进度回调
): Promise<string | null> {
if (!this.storage) {
console.error('[CloudStorage] 未初始化');
return null;
}
try {
// 获取云端文件引用
const fileRef = this.storage.child(cloudPath);
// 创建上传任务
const uploadTask = fileRef.putFile(localPath);
// 监听上传进度
uploadTask.on('progress', (snapshot: cloudStorage.UploadSnapshot) => {
const percentage = Math.round(
(snapshot.bytesTransferred / snapshot.totalBytes) * 100
);
console.info(`[CloudStorage] 上传进度: ${percentage}%`);
onProgress?.(percentage);
});
// 监听上传完成
uploadTask.on('success', (snapshot: cloudStorage.UploadSnapshot) => {
console.info(`[CloudStorage] 上传完成: ${cloudPath}`);
});
// 监听上传失败
uploadTask.on('fail', (snapshot: cloudStorage.UploadSnapshot) => {
console.error(`[CloudStorage] 上传失败: ${cloudPath}`);
});
// 等待上传完成
await uploadTask;
// 获取下载URL
const downloadUrl = await fileRef.getDownloadURL();
console.info(`[CloudStorage] 下载URL: ${downloadUrl}`);
return downloadUrl;
} catch (error) {
console.error(`[CloudStorage] 上传异常: ${JSON.stringify(error)}`);
return null;
}
}
/**
* 下载文件
* 从云端下载到本地指定路径
*/
async downloadFile(
cloudPath: string, // 云端文件路径
localPath: string, // 本地保存路径
onProgress?: (percentage: number) => void
): Promise<boolean> {
if (!this.storage) return false;
try {
const fileRef = this.storage.child(cloudPath);
// 创建下载任务
const downloadTask = fileRef.getFile(localPath);
// 监听下载进度
downloadTask.on('progress', (snapshot: cloudStorage.DownloadSnapshot) => {
const percentage = Math.round(
(snapshot.bytesTransferred / snapshot.totalBytes) * 100
);
console.info(`[CloudStorage] 下载进度: ${percentage}%`);
onProgress?.(percentage);
});
downloadTask.on('success', () => {
console.info(`[CloudStorage] 下载完成: ${cloudPath}`);
});
downloadTask.on('fail', () => {
console.error(`[CloudStorage] 下载失败: ${cloudPath}`);
});
await downloadTask;
return true;
} catch (error) {
console.error(`[CloudStorage] 下载异常: ${JSON.stringify(error)}`);
return false;
}
}
/**
* 删除云端文件
*/
async deleteFile(cloudPath: string): Promise<boolean> {
if (!this.storage) return false;
try {
const fileRef = this.storage.child(cloudPath);
await fileRef.delete();
console.info(`[CloudStorage] 删除成功: ${cloudPath}`);
return true;
} catch (error) {
console.error(`[CloudStorage] 删除失败: ${JSON.stringify(error)}`);
return false;
}
}
/**
* 获取文件下载URL
* 不下载文件,只获取可访问的URL
*/
async getDownloadUrl(cloudPath: string): Promise<string | null> {
if (!this.storage) return null;
try {
const fileRef = this.storage.child(cloudPath);
return await fileRef.getDownloadURL();
} catch (error) {
console.error(`[CloudStorage] 获取URL失败: ${JSON.stringify(error)}`);
return null;
}
}
/**
* 获取文件元数据
*/
async getMetadata(cloudPath: string): Promise<cloudStorage.FileMetadata | null> {
if (!this.storage) return null;
try {
const fileRef = this.storage.child(cloudPath);
return await fileRef.getMetadata();
} catch (error) {
console.error(`[CloudStorage] 获取元数据失败: ${JSON.stringify(error)}`);
return null;
}
}
}
进阶用法:断点续传与批量操作
大文件上传必须支持断点续传。网络断了、App被杀了、用户切到后台了——下次打开时从断点继续,不用从头来。
// ResumableUpload.ets
import { cloudStorage } from '@kit.CloudStorageKit';
// 上传任务状态
interface UploadTaskInfo {
cloudPath: string; // 云端路径
localPath: string; // 本地路径
sessionId: string; // 上传会话ID,用于断点续传
bytesTransferred: number; // 已传输字节数
totalBytes: number; // 总字节数
status: 'pending' | 'uploading' | 'paused' | 'completed' | 'failed';
}
export class ResumableUpload {
private storage: cloudStorage.StorageReference;
private activeTasks: Map<string, cloudStorage.UploadTask> = new Map();
constructor(storage: cloudStorage.StorageReference) {
this.storage = storage;
}
/**
* 上传文件(支持断点续传)
*/
async uploadWithResume(
localPath: string,
cloudPath: string,
onProgress?: (info: UploadTaskInfo) => void
): Promise<string | null> {
try {
const fileRef = this.storage.child(cloudPath);
// 检查是否有未完成的上传任务
const savedSession = await this.loadUploadSession(cloudPath);
let uploadTask: cloudStorage.UploadTask;
if (savedSession) {
// 从断点继续上传
console.info(`[ResumableUpload] 从断点继续: ${cloudPath}, 已上传${savedSession.bytesTransferred}字节`);
uploadTask = fileRef.putFile(localPath, {
// 传入上次的会话信息,SDK自动从断点继续
offset: savedSession.bytesTransferred
});
} else {
// 全新上传
uploadTask = fileRef.putFile(localPath);
}
// 保存任务引用
this.activeTasks.set(cloudPath, uploadTask);
// 任务信息
const taskInfo: UploadTaskInfo = {
cloudPath: cloudPath,
localPath: localPath,
sessionId: `upload_${Date.now()}`,
bytesTransferred: 0,
totalBytes: 0,
status: 'uploading'
};
// 监听进度
uploadTask.on('progress', (snapshot: cloudStorage.UploadSnapshot) => {
taskInfo.bytesTransferred = snapshot.bytesTransferred;
taskInfo.totalBytes = snapshot.totalBytes;
taskInfo.status = 'uploading';
// 保存断点信息
this.saveUploadSession(cloudPath, taskInfo);
onProgress?.(taskInfo);
});
uploadTask.on('success', async () => {
taskInfo.status = 'completed';
onProgress?.(taskInfo);
// 上传完成,清除断点信息
this.clearUploadSession(cloudPath);
this.activeTasks.delete(cloudPath);
});
uploadTask.on('fail', () => {
taskInfo.status = 'failed';
onProgress?.(taskInfo);
this.activeTasks.delete(cloudPath);
});
// 等待完成
await uploadTask;
// 返回下载URL
return await fileRef.getDownloadURL();
} catch (error) {
console.error(`[ResumableUpload] 上传失败: ${JSON.stringify(error)}`);
return null;
}
}
/**
* 暂停上传
*/
pauseUpload(cloudPath: string): boolean {
const task = this.activeTasks.get(cloudPath);
if (task) {
task.cancel();
this.activeTasks.delete(cloudPath);
console.info(`[ResumableUpload] 已暂停: ${cloudPath}`);
return true;
}
return false;
}
/**
* 批量上传文件
* 控制并发数,避免同时上传太多文件
*/
async batchUpload(
files: Array<{ localPath: string; cloudPath: string }>,
maxConcurrent: number = 3,
onProgress?: (completed: number, total: number) => void
): Promise<Map<string, string | null>> {
const results = new Map<string, string | null>();
let completedCount = 0;
// 分批执行,控制并发
for (let i = 0; i < files.length; i += maxConcurrent) {
const batch = files.slice(i, i + maxConcurrent);
const promises = batch.map(async (file) => {
const url = await this.uploadWithResume(file.localPath, file.cloudPath);
results.set(file.cloudPath, url);
completedCount++;
onProgress?.(completedCount, files.length);
});
await Promise.all(promises);
}
return results;
}
// 断点信息持久化(使用Preferences存储)
private async saveUploadSession(cloudPath: string, info: UploadTaskInfo): Promise<void> {
try {
// 实际项目中用Preferences或本地数据库存储
console.info(`[ResumableUpload] 保存断点: ${cloudPath}, ${info.bytesTransferred}/${info.totalBytes}`);
} catch (error) {
console.warn(`[ResumableUpload] 保存断点失败: ${error}`);
}
}
private async loadUploadSession(cloudPath: string): Promise<UploadTaskInfo | null> {
// 从本地存储读取断点信息
return null; // 简化实现
}
private async clearUploadSession(cloudPath: string): Promise<void> {
// 清除断点信息
}
}
完整示例:带CDN优化和安全策略的图片上传系统
把上传、压缩、CDN缓存、安全校验串成完整链路。
// ImageUploadService.ets
import { cloudStorage } from '@kit.CloudStorageKit';
import { image } from '@kit.ImageKit';
// 图片上传配置
interface ImageUploadConfig {
maxWidth: number; // 最大宽度,超过则压缩
maxHeight: number; // 最大高度,超过则压缩
quality: number; // 压缩质量 0-100
maxFileSize: number; // 最大文件大小(字节)
allowedTypes: string[]; // 允许的图片类型
}
const DEFAULT_IMAGE_CONFIG: ImageUploadConfig = {
maxWidth: 1920,
maxHeight: 1080,
quality: 80,
maxFileSize: 5 * 1024 * 1024, // 5MB
allowedTypes: ['image/jpeg', 'image/png', 'image/webp']
};
export class ImageUploadService {
private storage: cloudStorage.StorageReference;
constructor(storage: cloudStorage.StorageReference) {
this.storage = storage;
}
/**
* 上传用户头像
* 自动压缩 + 校验 + 上传 + 返回CDN URL
*/
async uploadAvatar(
userId: string,
localPath: string,
config: Partial<ImageUploadConfig> = {}
): Promise<{ url: string | null; error?: string }> {
const finalConfig = { ...DEFAULT_IMAGE_CONFIG, ...config };
// 1. 校验文件类型
const typeCheck = this.validateImageType(localPath, finalConfig.allowedTypes);
if (!typeCheck.valid) {
return { url: null, error: typeCheck.message };
}
// 2. 校验文件大小
const sizeCheck = await this.validateFileSize(localPath, finalConfig.maxFileSize);
if (!sizeCheck.valid) {
return { url: null, error: sizeCheck.message };
}
// 3. 压缩图片(如果需要)
const compressedPath = await this.compressImage(localPath, finalConfig);
// 4. 生成云端路径
const cloudPath = `images/avatars/${userId}/${this.generateFileName()}.jpg`;
// 5. 上传到Cloud Storage
try {
const fileRef = this.storage.child(cloudPath);
const uploadTask = fileRef.putFile(compressedPath || localPath);
await uploadTask;
// 6. 获取CDN加速的下载URL
const downloadUrl = await fileRef.getDownloadURL();
// 7. 更新用户头像URL(写Cloud DB)
// await userRepository.updateAvatar(userId, downloadUrl);
console.info(`[ImageUpload] 头像上传成功: ${downloadUrl}`);
return { url: downloadUrl };
} catch (error) {
console.error(`[ImageUpload] 上传失败: ${JSON.stringify(error)}`);
return { url: null, error: '上传失败,请重试' };
}
}
/**
* 批量上传文章配图
*/
async uploadArticleImages(
articleId: string,
imagePaths: string[],
onProgress?: (completed: number, total: number) => void
): Promise<string[]> {
const urls: string[] = [];
let completed = 0;
for (const localPath of imagePaths) {
try {
const compressedPath = await this.compressImage(localPath, DEFAULT_IMAGE_CONFIG);
const cloudPath = `images/articles/${articleId}/${this.generateFileName()}.jpg`;
const fileRef = this.storage.child(cloudPath);
await fileRef.putFile(compressedPath || localPath);
const url = await fileRef.getDownloadURL();
urls.push(url);
} catch (error) {
console.error(`[ImageUpload] 图片上传失败: ${localPath}`);
urls.push(''); // 上传失败的用空字符串占位
}
completed++;
onProgress?.(completed, imagePaths.length);
}
return urls;
}
/**
* 校验图片类型
*/
private validateImageType(
filePath: string,
allowedTypes: string[]
): { valid: boolean; message: string } {
const extension = filePath.split('.').pop()?.toLowerCase() || '';
const typeMap: Record<string, string> = {
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'webp': 'image/webp',
'gif': 'image/gif'
};
const mimeType = typeMap[extension];
if (!mimeType || !allowedTypes.includes(mimeType)) {
return {
valid: false,
message: `不支持的图片格式: ${extension},仅支持 ${allowedTypes.join(', ')}`
};
}
return { valid: true, message: '' };
}
/**
* 校验文件大小
*/
private async validateFileSize(
filePath: string,
maxSize: number
): Promise<{ valid: boolean; message: string }> {
try {
// 获取文件大小(简化实现,实际用fs.statSync)
const fileSize = 0; // 实际读取文件大小
if (fileSize > maxSize) {
return {
valid: false,
message: `文件大小超过限制(最大${Math.round(maxSize / 1024 / 1024)}MB)`
};
}
return { valid: true, message: '' };
} catch (error) {
return { valid: false, message: '无法读取文件信息' };
}
}
/**
* 压缩图片
*/
private async compressImage(
filePath: string,
config: ImageUploadConfig
): Promise<string | null> {
try {
// 使用HarmonyOS图片处理API压缩
const imageSource = image.createImageSource(filePath);
const imageInfo = await imageSource.getImageInfo();
// 判断是否需要压缩
const needResize = imageInfo.size.width > config.maxWidth ||
imageInfo.size.height > config.maxHeight;
if (!needResize) {
return null; // 不需要压缩
}
// 计算压缩后的尺寸
let targetWidth = imageInfo.size.width;
let targetHeight = imageInfo.size.height;
if (targetWidth > config.maxWidth) {
const ratio = config.maxWidth / targetWidth;
targetWidth = config.maxWidth;
targetHeight = Math.round(targetHeight * ratio);
}
if (targetHeight > config.maxHeight) {
const ratio = config.maxHeight / targetHeight;
targetHeight = config.maxHeight;
targetWidth = Math.round(targetWidth * ratio);
}
// 创建压缩后的图片
const packingOption: image.PackingOption = {
format: 'image/jpeg',
quality: config.quality
};
const imagePackerApi = image.createImagePacker();
// 实际压缩逻辑...
console.info(`[ImageUpload] 图片压缩: ${imageInfo.size.width}x${imageInfo.size.height} → ${targetWidth}x${targetHeight}`);
// 返回压缩后的文件路径
return filePath; // 简化实现
} catch (error) {
console.warn(`[ImageUpload] 图片压缩失败,使用原图: ${error}`);
return null;
}
}
/**
* 生成唯一文件名
*/
private generateFileName(): string {
return `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
}
}
踩坑与注意事项
坑1:文件路径必须用正斜杠
Cloud Storage的路径分隔符是/,不是Windows的\。你在Windows上开发时路径可能是images\avatars\user.jpg,上传到Cloud Storage会变成一个文件名叫images\avatars\user.jpg的文件,而不是images/avatars/目录下的user.jpg。
必须用/拼接路径,或者用path.posix.join()。
坑2:上传的文件默认是私有的
Cloud Storage的文件默认只有上传者可以访问。如果你想让图片公开可访问(比如头像、文章配图),需要在AGC控制台配置安全规则,或者上传时设置文件为公开读。
// 设置文件为公开读
const metadata: cloudStorage.FileMetadata = {
cacheControl: 'public, max-age=604800', // CDN缓存7天
contentDisposition: 'inline'
};
await fileRef.updateMetadata(metadata);
坑3:CDN缓存导致更新不及时
你更新了一个文件,但CDN还在返回旧版本。这是因为CDN有缓存,默认缓存时间可能很长。
解决方案:
- 在文件名中加入版本号或哈希值,如
avatar_v2.jpg - 设置较短的Cache-Control头
- 手动刷新CDN缓存(AGC控制台操作)
坑4:大文件上传超时
超过50MB的文件,直接上传容易超时。必须用分片上传,每片5-10MB,并行上传。Cloud Storage SDK内部会自动处理分片,但你需要确保网络稳定。
坑5:下载URL的有效期
getDownloadURL()返回的URL不是永久有效的,默认有效期1小时。如果你把URL存到数据库,1小时后访问就404了。
解决方案:每次需要展示图片时重新获取URL,或者使用带签名的长期URL。
坑6:存储费用容易被忽略
Cloud Storage按存储量和流量计费。如果你存了大量临时文件没清理,或者图片被恶意刷流量,账单可能让你大吃一惊。
建议:
- 临时文件设置TTL,过期自动删除
- 配置防盗链,防止外部网站盗用你的图片
- 监控流量异常,设置告警
HarmonyOS 6适配说明
HarmonyOS 6对Cloud Storage Kit做了以下更新:
-
服务端加密(SSE):新增服务端加密选项,文件上传时自动加密存储。支持AGC托管的密钥和用户自定义密钥(KMS)。之前文件在服务端是明文存储的,HarmonyOS 6默认开启SSE。
-
图片处理能力:Cloud Storage新增了图片处理参数,下载时可以实时裁剪、缩放、格式转换,不需要在客户端处理。
// HarmonyOS 6图片处理
// 下载时自动缩放到200x200,转为webp格式
const processedUrl = await fileRef.getDownloadURL({
imageSize: '200x200',
format: 'webp',
quality: 80
});
-
分片上传优化:分片大小从固定的5MB改为自适应,根据网络状况动态调整。弱网环境下用更小的分片,强网环境下用更大的分片,上传速度提升约30%。
-
跨区域复制:新增跨区域复制能力,文件上传后自动复制到其他区域的存储桶。适合全球化应用,用户访问最近的区域获取文件。
-
生命周期管理:新增文件生命周期规则,可以设置"30天后自动删除"、"60天后自动归档到低频存储"等规则。之前需要手动清理临时文件,现在自动完成。
总结
Cloud Storage看起来就是"上传下载文件",但真要在生产环境用好,你得把安全策略、CDN缓存、断点续传、费用控制全都考虑进去。
核心记住三点:
- 文件路径统一用正斜杠,Windows开发时最容易踩这个坑
- CDN缓存是双刃剑,加载快但更新慢,文件名加版本号是最简单的解决方案
- 存储费用要监控,临时文件不清理、防盗链不配置,账单分分钟教你做人
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐⭐ 基础上传下载简单,断点续传和CDN优化需要经验 |
| 使用频率 | ⭐⭐⭐⭐⭐ 几乎所有应用都有文件存储需求 |
| 重要程度 | ⭐⭐⭐⭐⭐ 没有Cloud Storage,文件管理就是噩梦 |
Cloud Storage不是可选项。你的App要存文件,不用它就得自己搭一套——你确定你有那个精力?
- 点赞
- 收藏
- 关注作者
评论(0)