HarmonyOS鸿蒙Next中涂鸦画图云开发实践
HarmonyOS鸿蒙Next中涂鸦画图云开发实践
项目介绍
涂鸦画图是一款操作简单,功能丰富的绘画APP,Android版的涂鸦画图在华为应用商店的下载量已突破380万,是一款广受欢迎的绘画APP。目前正在将Android版的涂鸦画图鸿蒙化,在鸿蒙化过程中,涂鸦画图HarmonyOS NEXT版本充分利用了认证服务,云对象,云数据库,云存储等云开发能力。本文对涂鸦画图HarmonyOS NEXT版本使用到云开发能力的部分场景进行介绍。
开发环境
- DevEco Studio 5.0.5 Release
- 华为Mate60 pro 5.0.1
参考文档
手机号验证码登录
手机号验证码登录使用了认证服务,云对象和云数据库的能力。
端侧主要实现用户手机号验证码登录页面,验证码获取以及认证服务登录接口调用。
手机号验证码登录页面ArkUI布局代码:
build() {
Stack(){
RelativeContainer() {
Column() {
Image($r("app.media.creative_draw_logo"))
.width(120)
.height(120)
TextInput({
placeholder: "请输入手机号码"
})
.placeholderColor(0x4c3d3d3d)
.maxLength(12)
.maxLines(1)
.backgroundColor(Color.White)
.type(InputType.PhoneNumber)
.borderRadius(10)
.width("90%")
.height(48)
.margin({
top: 40
})
.onChange((phoneNumber) => {
this.currPhoneNumber = phoneNumber;
})
Row() {
TextInput({
placeholder: "请输入验证码"
})
.placeholderColor(0x4c3d3d3d)
.maxLength(6)
.maxLines(1)
.backgroundColor(Color.White)
.type(InputType.Number)
.borderRadius(10)
.width("60%")
.height(48)
.onChange((verifyCode) => {
this.currVerifyCode = verifyCode;
})
Blank()
Button(this.getSendCodeButtonText(), {type: ButtonType.Normal})
.fontSize(12)
.width("30%")
.height(48)
.fontColor("#9B57DF")
.borderRadius(10)
.borderWidth(1)
.borderColor("#9B57DF")
.backgroundColor(Color.Transparent)
.enabled(this.isSendCodeButtonEnable())
.onClick((event) => {
this.getVerifyCode()
})
}.width("90%")
.margin({
top: 10
})
Button("立即登录", {type: ButtonType.Normal})
.fontSize(16)
.width("90%")
.height(48)
.fontColor(Color.White)
.borderRadius(10)
.backgroundColor("#9B57DF")
.onClick((event) => {
this.phoneLogin()
})
.margin(
{
top: 30
}
)
}
.id('phone_login_content')
.alignRules({
center: { anchor: ConstData.RELATIVE_CONTAINER, align: VerticalAlign.Center },
middle: { anchor: ConstData.RELATIVE_CONTAINER, align: HorizontalAlign.Center }
})
.width("100%")
.alignItems(HorizontalAlign.Center)
.margin({top: -100})
}
.height('100%')
.width('100%')
Image($r("app.media.ic_huawei_login"))
.width(48)
.height(48)
.margin({
bottom: 40
})
.onClick((event: ClickEvent) => {
router.replaceUrl({
url: "pages/HuaweiOneKeyLogin"
})
})
}.width("100%")
.height("100%")
.align(Alignment.Bottom)
.backgroundColor($r('app.color.primary_bg_color'))
}
点击获取验证码按钮开始调用认证服务获取验证码:
public requestPhoneVerifyCode(phone: string, successCallback: ((result: VerifyCodeResult)=>void) | null = null,
failedCallback?: ((error: object)=>void) | null) {
auth.requestVerifyCode({
action: VerifyCodeAction.REGISTER_LOGIN,
lang: 'zh_CN',
sendInterval: 60,
verifyCodeType: {
phoneNumber: phone,
countryCode: '86',
kind: "phone"
}
}).then((verifyCodeResult) => {
console.log("验证码发送成功:", verifyCodeResult.getShortestInterval(), "---", verifyCodeResult.getValidityPeriod())
if (successCallback) {
successCallback(verifyCodeResult);
}
//验证码申请成功
}).catch((error: object) => {
//验证码申请失败
console.log("验证码发送失败:", error);
if (failedCallback) {
failedCallback(error);
}
});
}
点击立即登录调用认证服务的手机号验证码登录接口,认证服务登录成功之后,调用云对象的phoneLogin接口。
this.agcAuth?.login("86", this.currPhoneNumber, this.currVerifyCode)
.then(async (agUser)=> {
//调用云对象用户服务的手机号登录接口
const serverLoginResponse = await userFunction.phoneLogin({phone: this.currPhoneNumber})
let resultUserInfo = ""
if (serverLoginResponse.data) {
resultUserInfo = (serverLoginResponse.data as Record<string, string>).userInfo as string
}
console.log("手机验证码登录服务端返回:", resultUserInfo)
PreferenceUtils.setValue(PreferenceKey.LOGIN_USER_INFO, resultUserInfo!)
PreferenceUtils.setValue(PreferenceKey.LAST_LOGIN_TIME, new Date().getTime())
this.loadingDialogController.close()
router.replaceUrl({
url: "pages/MyWorkLibrary"
})
}).catch(async (error: object)=> {
console.log("手机号登录失败:", error)
PreferenceUtils.setValue(PreferenceKey.LOGIN_USER_INFO, "")
promptAction.showToast({message: "登录失败"})
this.loadingDialogController.close()
})
云侧使用云对象存储用户信息至云数据库及从云数据查询用户信息并返回端侧。
云数据库User表字段说明如下:
- uid:用户id,自增长类型
- userType:用户类型,用于区分华为账号一键登录类型和手机号登录类型
- phone:手机号
- openId:华为账号对应的openId
- unionId:华为账号对应的unionId
- registerTime:用户注册时间
- vipOverTime:vip过期时间
云同步功能
为了使用户的作品能够在多个设备上同步,涂鸦画图Harmony NEXT版开发了作品云同步功能,云同步功能充分利用了云存储能力。云同步功能需要结合关系型数据库SQLite和云存储能力共同实现。
用户的作品是通过SQLite数据库的WorkInfo表存储的,字段说明如下:
- uuid:作品的唯一标识
- uid:用户id
- name:作品名称
- localPath:本地路径
- cloudPath:云端路径
- canvasWidth:画布宽度
- canvasHeight:画布高度
- md5:作品文件的md5
- createTime:作品创建时间
- isDelete:删除标记
- isUpload:是否上传到云端
- extraInfo:扩展字段
const createWorkLibraryTableSQL = "CREATE TABLE IF NOT EXISTS \"WorkInfo\" (\n"
"\t\"uuid\"\tTEXT,\n"
"\t\"uid\"\tTEXT NOT NULL,\n"
"\t\"name\"\tTEXT,\n"
"\t\"localPath\"\tTEXT,\n"
"\t\"cloudPath\"\tTEXT,\n"
"\t\"canvasWidth\"\tINTEGER,\n"
"\t\"canvasHeight\"\tINTEGER,\n"
"\t\"md5\"\tTEXT,\n"
"\t\"createTime\"\tINTEGER,\n"
"\t\"isDelete\"\tINTEGER,\n"
"\t\"isUpload\"\tINTEGER,\n"
"\t\"extraInfo\"\tTEXT,\n"
"\tPRIMARY KEY(\"uuid\")\n"
");"
封装云存储的上传,下载,删除接口。
云存储下载功能实现:
public async downloadFile(localPath: string, cloudPath: string): Promise<boolean> {
await this.initCloudCommon()
console.log("本地路径:", localPath)
console.log("云存储路径:", cloudPath)
if (fileIo.accessSync(localPath)) {
return true
}
const downloadKey = localPath + cloudPath
if (this.downloadMap.has(downloadKey)) {
return this.downloadMap.get(downloadKey)!
}
const downloadPromise = new Promise<boolean>(async (resolve, reject)=>{
const cacheDirPath = GlobalData.appContext.cacheDir + "/" + ConstData.CLOUD_STORAGE_DOWNLOAD_CACHE_DIR_PATH
if (!fileIo.accessSync(cacheDirPath)) {
fileIo.mkdirSync(cacheDirPath)
}
const agcAuth = new AGCAuth()
const agcUser = await agcAuth.getCurrentUser()
if (!agcUser) {
await agcAuth.signWithAnonymous()
}
const lastPointIndex = localPath.lastIndexOf(".")
const fileFormat = localPath.substring(lastPointIndex)
const cacheFileName = Md5Utils.getStrMd5(localPath) + fileFormat
const saveCacheRelativePath = ConstData.CLOUD_STORAGE_DOWNLOAD_CACHE_DIR_PATH + "/" + cacheFileName
const saveCachePath = cacheDirPath + "/" + cacheFileName
if (fileIo.accessSync(saveCachePath)) {
fileIo.unlinkSync(saveCachePath)
}
this.storageBucket!.downloadFile(GlobalData.appContext, {
localPath: saveCacheRelativePath,
cloudPath: cloudPath
}).then((task: request.agent.Task)=>{
task.on('progress', (progress) =>{
console.log("云存储下载进度为:", progress.processed)
});
task.on('completed', (progress) =>{
console.log("云存储下载完成")
fileIo.copyFileSync(saveCachePath, localPath)
fileIo.unlinkSync(saveCachePath)
this.downloadMap.delete(downloadKey)
resolve(true)
});
task.on('failed', (progress) =>{
this.downloadMap.delete(downloadKey)
resolve(false)
});
task.on('response', (response) =>{
});
task.start((err: BusinessError) =>{
if (err) {
console.error("云存储开始下载失败:")
this.downloadMap.delete(downloadKey)
resolve(false)
} else {
console.log("云存储开始下载")
}
});
}).catch((err: BusinessError)=>{
console.error("云存储下载文件失败:", JSON.stringify(err))
this.downloadMap.delete(downloadKey)
resolve(false)
})
})
this.downloadMap.set(downloadKey, downloadPromise)
return downloadPromise
}
使用云存储上传用户作品时需要设置自定义metadata,将作品唯一标识uuid,作品名称name等数据同时上传,这个是为了从云存储下载用户作品时,能够同时将作品信息同步到本地SQLite数据库的WorkInfo表中。
public async uploadFile(userWorkInfo: UserWorkInfo): Promise<boolean> {
const localPath = userWorkInfo.localPath
await this.initCloudCommon()
const lastPointIndex = localPath.lastIndexOf(".")
const fileFormat = localPath.substring(lastPointIndex)
const cacheFileName = Md5Utils.getStrMd5(localPath) + fileFormat
const realCacheDirPath = GlobalData.appContext.cacheDir + "/" + ConstData.CLOUD_STORAGE_UPLOAD_CACHE_DIR_PATH
if (!fileIo.accessSync(realCacheDirPath)) {
fileIo.mkdirSync(realCacheDirPath)
}
const relativeCachePath = ConstData.CLOUD_STORAGE_UPLOAD_CACHE_DIR_PATH + "/" + cacheFileName
const realCachePath = GlobalData.appContext.cacheDir + "/" + relativeCachePath
if (fileIo.accessSync(realCachePath)) {
fileIo.unlinkSync(realCachePath)
}
fileIo.copyFileSync(localPath, realCachePath)
if (!fileIo.accessSync(realCachePath)) {
console.error("拷贝文件失败")
return false
}
const itemWorkMetaData: Record<string, string> = {}
itemWorkMetaData["uuid"] = userWorkInfo.uuid
itemWorkMetaData["uid"] = userWorkInfo.uid
itemWorkMetaData["name"] = userWorkInfo.name
itemWorkMetaData["localFileName"] = userWorkInfo.localPath.substring(userWorkInfo.localPath.lastIndexOf("/") + 1)
itemWorkMetaData["canvasWidth"] = "" + userWorkInfo.canvasWidth
itemWorkMetaData["canvasHeight"] = "" + userWorkInfo.canvasHeight
itemWorkMetaData["md5"] = userWorkInfo.md5
itemWorkMetaData["createTime"] = "" + userWorkInfo.createTime
itemWorkMetaData["extraInfo"] = userWorkInfo.extraInfo
const localFileName = localPath.substring(localPath.lastIndexOf("/") + 1)
const cloudPath = ConstData.CLOUD_USER_WORK + "/" + userWorkInfo.uid + "/" + localFileName
return new Promise((resolve, reject)=>{
this.storageBucket!.uploadFile(GlobalData.appContext, {
metadata: {customMetadata: itemWorkMetaData},
localPath: relativeCachePath, // context.cacheDir目录下的文件
cloudPath: cloudPath // 云侧路径,支持传入“文件目录/文件名”(如“screenshot/demo.jpg”),或仅传入文件名。
}).then((task: request.agent.Task)=>{
task.on('progress', (progress)=>{
console.log("云存储上传进度回调:", JSON.stringify(progress))
});
task.on('completed', (progress)=>{
console.log("云存储上传成功:", JSON.stringify(progress))
resolve(true)
});
task.on('failed', (progress)=>{
console.log("云存储上传失败:", JSON.stringify(progress))
resolve(false)
});
task.on('response', (response)=>{
console.log("云存储上传收到回调:", JSON.stringify(response))
});
// start task
task.start((err: BusinessError)=>{
if (err) {
console.error("启动云存储上传失败")
resolve(false)
} else {
console.log("启动云存储上传成功")
}
});
}).catch((err: BusinessError)=>{
console.error("云存储上传文件失败:", JSON.stringify(err))
resolve(false)
});
})
}
将云侧作品同步至SQLite数据库的WorkInfo表中
public async syncUserWorks(onFinish?: ()=>void) {
try {
const parentSaveWorkDir = AppUtils.getSaveWorkDirectory()
if (!fileIo.accessSync(parentSaveWorkDir)) {
fileIo.mkdirSync(parentSaveWorkDir)
}
const userSaveWorkDir = parentSaveWorkDir + "/" + AppUtils.getUid()
if (!fileIo.accessSync(userSaveWorkDir)) {
fileIo.mkdirSync(userSaveWorkDir)
}
const storageResult = await this.storageBucket!.list(ConstData.CLOUD_USER_WORK + "/" + AppUtils.getUid() + "/")
const cloudPaths = storageResult.files
const resultUserWorkInfos: UserWorkInfo[] = []
for (let i = 0; i < cloudPaths.length; i++) {
const itemMetaData = await this.storageBucket!.getMetadata(cloudPaths[i])
const itemCustomMetaData = itemMetaData.customMetadata as Record<string, string>
console.log("自定义meta data为:", JSON.stringify(itemCustomMetaData))
const itemWorkInfo = new UserWorkInfo()
itemWorkInfo.uuid = itemCustomMetaData["uuid"]
itemWorkInfo.uid = itemCustomMetaData["uid"]
if (itemCustomMetaData["name"]) {
itemWorkInfo.name = itemCustomMetaData["name"]
}
itemWorkInfo.localFileName = itemCustomMetaData["localfilename"]
itemWorkInfo.localPath = userSaveWorkDir + "/" + itemWorkInfo.localFileName
itemWorkInfo.cloudPath = cloudPaths[i]
itemWorkInfo.canvasWidth = parseInt(itemCustomMetaData["canvaswidth"])
itemWorkInfo.canvasHeight = parseInt(itemCustomMetaData["canvasheight"])
itemWorkInfo.createTime = parseInt(itemCustomMetaData["createtime"])
itemWorkInfo.md5 = itemCustomMetaData["md5"]
itemWorkInfo.isUpload = true
if (itemCustomMetaData["extrainfo"]) {
itemWorkInfo.extraInfo = itemCustomMetaData["extrainfo"]
}
itemWorkInfo.isUpload = true
if (fileIo.accessSync(itemWorkInfo.localPath)) {
//存在,校验文件md5
const currWorkMd5 = FileUtils.getFileMd5(itemWorkInfo.localPath)
if (currWorkMd5 == itemWorkInfo.md5) {
resultUserWorkInfos.push(itemWorkInfo)
} else {
fileIo.unlinkSync(itemWorkInfo.localPath)
const isDownloadSuccess = await this.downloadFile(itemWorkInfo.localPath, itemWorkInfo.cloudPath)
if (isDownloadSuccess && FileUtils.getFileMd5(itemWorkInfo.localPath) == itemWorkInfo.md5) {
resultUserWorkInfos.push(itemWorkInfo)
}
}
} else {
//不存在,则下载
const isDownloadSuccess = await this.downloadFile(itemWorkInfo.localPath, itemWorkInfo.cloudPath)
if (isDownloadSuccess && FileUtils.getFileMd5(itemWorkInfo.localPath) == itemWorkInfo.md5) {
resultUserWorkInfos.push(itemWorkInfo)
}
}
}
if (resultUserWorkInfos.length > 0) {
await DBUtils.insertUserWorkInfos(resultUserWorkInfos)
}
//清除本地需要被删除的文件
const uploadedUserWorkInfos = await DBUtils.getUploadedUserWorkInfos()
const currDeviceCloudPaths = new Set<string>()
for (let i = 0; i < uploadedUserWorkInfos.length; i++) {
currDeviceCloudPaths.add(uploadedUserWorkInfos[i].cloudPath)
}
for (let i = 0; i < cloudPaths.length; i++) {
currDeviceCloudPaths.delete(cloudPaths[i])
}
const leftDeviceCloudPaths: string[] = []
currDeviceCloudPaths.forEach((value, index, arr)=>{
leftDeviceCloudPaths.push(value)
})
await DBUtils.deleteUserWorkInfosByCloudPaths(leftDeviceCloudPaths)
if (onFinish) {
onFinish()
}
} catch (e) {
//no handle
const syncError = e as BusinessError
console.error("同步作品出现异常:" + syncError.name + "------" + syncError.message + "------" + JSON.stringify(e))
}
}
总结说明
涂鸦画图HarmonyOS NEXT版本除了在手机号验证码登录和云同步功能使用了云开发能力之外,在华为账号一键登录,事件统计,用户反馈,贴图和渐变背景图获取等场景下也使用了云对象,云数据库,云存储等云开发能力。从涂鸦画图HarmonyOS NEXT版开发实践来看,云开发具备易部署,免运维等特性,使应用能够聚焦于自身业务逻辑,极大的提高开发效率。
更多关于HarmonyOS鸿蒙Next中涂鸦画图云开发实践的实战教程也可以访问 https://www.itying.com/category-93-b0.html
HarmonyOS Next中涂鸦画图云开发实践主要涉及以下技术点:
- 使用ArkUI的Canvas组件实现绘图功能
- 通过云开发Kit调用华为云存储服务保存画作数据
- 使用分布式数据管理实现多设备同步
- 云函数处理图像识别等后端逻辑
关键API包括:
开发流程:
- 创建Canvas绘制界面
- 集成云数据库存储笔画坐标
- 部署云函数处理业务逻辑
- 测试分布式同步功能
更多关于HarmonyOS鸿蒙Next中涂鸦画图云开发实践的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html
从涂鸦画图HarmonyOS NEXT版本的开发实践来看,云开发能力在绘画类应用中展现了显著优势:
- 认证服务集成方面,通过AGC Auth实现了手机号验证码登录的完整流程,包括:
- 端侧调用requestVerifyCode获取验证码
- 使用login接口完成验证码校验
- 结合云对象处理用户数据持久化
- 云同步功能设计亮点:
- 采用SQLite本地存储+云存储的双层架构
- 文件上传时通过customMetadata保存作品元数据
- 实现了增量同步和冲突解决机制(通过MD5校验)
- 封装了完整的文件传输管理(包含进度回调)
- 云数据库应用:
- 合理设计了User表结构(uid/userType/phone等字段)
- 通过云对象封装数据访问逻辑
- 实现了用户信息的云端持久化
这种架构设计既保证了本地操作的响应速度,又通过云服务实现了多端数据同步,特别是对绘画作品这类富媒体内容的管理很有参考价值。云开发确实显著降低了后端复杂度,让开发者能更专注于核心业务逻辑的实现。