HBuilderX

HBuilderX

极客开发工具
uni-app

uni-app

开发一次,多端覆盖
uniCloud

uniCloud

云开发平台
HTML5+

HTML5+

增强HTML5的功能体验
MUI

MUI

上万Star的前端框架

【鸿蒙征文】星光不负,码向未来:uni-app + uniCloud 赋能社区管理系统的高效适配与生态融合实践

鸿蒙征文

前言

公司前段时间接了一个社区管理的项目,看到本次 鸿蒙征文 活动,正好做一次总结。

我们基于 uni-app 跨端框架和 uniCloud Serverless服务,开发“CommunityHub 社区管理系统”。该系统旨在连接社区物业与居民。

我会在本文中重点阐述了如何利用 uni-app 实现应用在鸿蒙 OS 上的快速适配、多设备响应式布局、已经App和鸿蒙元服务共享代码。

一、技术选型与项目概述

1.1 社区数字化面临的挑战

接到需求后,我们分析,主要有两个挑战:

  • 多端适配: 社区服务需要覆盖居民的手机、平板乃至管理员的PC,且在当下中国,鸿蒙的兼容是必须考虑的平台,维护多套代码成本极高。
  • 后端运维: 社区系统,日常请求量并不高,但需满足特殊情况下的高并发处理,需要稳定且易运维的后端支持。

1.2 uni-app + uniCloud:理想的解决方案

我们选择了 uni-app + uniCloud 这一黄金组合作为 CommunityHub 的技术底座,侧重于开发效率和后端弹性:

  • uni-app:高效跨端开发,一次编码,多端发行,开发效率高。基于 Web 技术的快速适配和高效迭代能力,能够迅速发布应用,并确保基础体验。

  • uniCloud:Serverless 后端,免运维、弹性伸缩,提供数据安全保障。极简 API 调用,为前端提供了统一、稳定的数据接口。

二、鸿蒙 OS 适配与生态融合策略

要使 CommunityHub 在鸿蒙生态中获得成功,我们采取了“高效适配先行,生态融合跟进”的策略。

2.1 策略一:统一的响应式布局,适配多设备形态

作为社区管理系统,CommunityHub 需兼顾居民的手机端和管理员的 PC/平板大屏端体验,充分利用了 uni-app 强大的响应式能力。

设计理念: 利用 uni-app 的条件编译和 CSS 媒体查询功能,配合简洁的组件化设计,实现 UI 的自适应重构。

  • 手机小屏(<768px): 采用底部 TabBar 导航,信息流以列表卡片形式展示,突出点击和触控体验。
  • PC/平板大屏(>1200px): 通过leftWindow切换为侧边栏导航和分栏布局。例如“报修管理”页面,在大屏上可以同时显示报修列表、详情和派单操作区,极大提升了管理员的效率。

这种“一套代码,两种形态”的响应式开发策略,最大限度地复用了代码,为未来应用在鸿蒙多终端上的部署打下了坚实基础。

2.2 策略二:面向鸿蒙 NEXT 生态的融合与升级路径

1. 鸿蒙元服务的支持

优势: 得益于 uni-app 对鸿蒙生态的深度支持,标准 uni-app 项目可以直接发行到鸿蒙元服务。

这极大地简化了开发流程,实现了主 App 与元服务代码基础的统一。

  • 解决方案: 我们采用 “uni-app 快速编译 + uniCloud 数据驱动” 的DCloud全家桶策略。
  • 元服务开发: 在 uni-app 项目中,使用条件编译或单独页面,针对性地开发轻量级的“快捷报修提交”功能模块。
  • 快速部署与统一代码: 将该模块通过 uni-app 编译部署为鸿蒙元服务,确保其 UI 和逻辑与主 App 保持高度一致,并享受 uni-app 的快速迭代优势。
  • 效果: 实现了“一次编码,多端复用”在 App 和元服务上的实践,确保了“即用即走”的用户体验,同时将复杂数据和业务逻辑留在了 uniCloud 统一后端,极大提升了社区服务的便捷性和开发效率。

2. 紧急公告卡片(服务卡片)设想:

对于“停水通知”等紧急公告,我们规划利用鸿蒙的服务卡片能力,通过原生卡片实时拉取 uniCloud 的最新公告数据,直接在用户桌面上显示公告摘要,确保紧急消息的触达率。

参考资料:鸿蒙元服务专题

三、关键功能实现与 uniCloud 赋能

3.1 高效报修与权限控制

核心逻辑:

  • 权限与通知: 通过 uni-id 的角色体系(居民/物业/维修人员)校验身份,并将任务自动派发给对应角色的物业管理员。
  • 进度跟踪: 利用 uni-push,居民端能实时获取报修单状态(已派单、处理中、已完成)的变更,保证了用户体验的实时性。

3.2 邻里交流与资源共享的数据安全

“邻里圈”和“资源共享”模块利用 uniCloud 强大的安全能力:

我们使用 uniCloud 的 云存储 存储用户上传的动态图片和视频,利用 权限规则 确保居民之间数据隔离。

对于“私信联系”功能,基于 uni-id 实现了安全的用户身份认证和通信机制,保护用户隐私。

3.3 技术特点

本系统全部基于uni-app内置组件和uni-ui扩展组件实现,尽可能使用图标代替图片,减少资源包大小,提升小程序的冷启动时间。

如下是对tabbar的一个配置,我们使用了uniicons.ttf,没有使用任何额外图片。

"tabBar": {  
        "color": "#7A7E83",  
        "selectedColor": "#007AFF",  
        "borderStyle": "black",  
        "backgroundColor": "#FFFFFF",  
        "iconfontSrc": "uni_modules/uni-icons/components/uni-icons/uniicons.ttf",  
        "list": [{  
            "pagePath": "pages/home/home",  
            "iconfont": {  
                "text": "\ue662",  
                "selectedText": "\ue663",  
                "fontSize": "22px",  
                "color": "#7A7E83",  
                "selectedColor": "#007AFF"  
            },  
            "text": "首页"  
        }, {  
            "pagePath": "pages/community/post-list",  
            "iconfont": {  
                "text": "\ue682",  
                "selectedText": "\ue682",  
                "fontSize": "22px",  
                "color": "#7A7E83",  
                "selectedColor": "#007AFF"  
            },  
            "text": "邻里圈"  
        }, {  
            "pagePath": "pages/message/message-list",  
            "iconfont": {  
                "text": "\ue6a6",  
                "selectedText": "\ue6c1",  
                "fontSize": "22px",  
                "color": "#7A7E83",  
                "selectedColor": "#007AFF"  
            },  
            "text": "消息"  
        }, {  
            "pagePath": "pages/ucenter/ucenter",  
            "iconfont": {  
                "text": "\ue699",  
                "selectedText": "\ue69d",  
                "fontSize": "22px",  
                "color": "#7A7E83",  
                "selectedColor": "#007AFF"  
            },  
            "text": "我的"  
        }]  
    }

四、总结与展望

感谢DCloud提供的uni-app + uniCloud纯前端方案,我们公司2个前端工程师就把整个项目上线了,系统开发周期大幅缩短,并保证了多端体验的统一性。

展望未来: 我们正积极关注 uni-app x 对鸿蒙 NEXT 的支持进度,并计划将 CommunityHub 逐步迁移至 uni-app x,彻底实现原生渲染,以达到最终的性能目标。

同时,我们也在打磨该系统的标准化,希望发布到 DCloud插件市场,供更多社区使用,或者开源出来。

继续阅读 »

前言

公司前段时间接了一个社区管理的项目,看到本次 鸿蒙征文 活动,正好做一次总结。

我们基于 uni-app 跨端框架和 uniCloud Serverless服务,开发“CommunityHub 社区管理系统”。该系统旨在连接社区物业与居民。

我会在本文中重点阐述了如何利用 uni-app 实现应用在鸿蒙 OS 上的快速适配、多设备响应式布局、已经App和鸿蒙元服务共享代码。

一、技术选型与项目概述

1.1 社区数字化面临的挑战

接到需求后,我们分析,主要有两个挑战:

  • 多端适配: 社区服务需要覆盖居民的手机、平板乃至管理员的PC,且在当下中国,鸿蒙的兼容是必须考虑的平台,维护多套代码成本极高。
  • 后端运维: 社区系统,日常请求量并不高,但需满足特殊情况下的高并发处理,需要稳定且易运维的后端支持。

1.2 uni-app + uniCloud:理想的解决方案

我们选择了 uni-app + uniCloud 这一黄金组合作为 CommunityHub 的技术底座,侧重于开发效率和后端弹性:

  • uni-app:高效跨端开发,一次编码,多端发行,开发效率高。基于 Web 技术的快速适配和高效迭代能力,能够迅速发布应用,并确保基础体验。

  • uniCloud:Serverless 后端,免运维、弹性伸缩,提供数据安全保障。极简 API 调用,为前端提供了统一、稳定的数据接口。

二、鸿蒙 OS 适配与生态融合策略

要使 CommunityHub 在鸿蒙生态中获得成功,我们采取了“高效适配先行,生态融合跟进”的策略。

2.1 策略一:统一的响应式布局,适配多设备形态

作为社区管理系统,CommunityHub 需兼顾居民的手机端和管理员的 PC/平板大屏端体验,充分利用了 uni-app 强大的响应式能力。

设计理念: 利用 uni-app 的条件编译和 CSS 媒体查询功能,配合简洁的组件化设计,实现 UI 的自适应重构。

  • 手机小屏(<768px): 采用底部 TabBar 导航,信息流以列表卡片形式展示,突出点击和触控体验。
  • PC/平板大屏(>1200px): 通过leftWindow切换为侧边栏导航和分栏布局。例如“报修管理”页面,在大屏上可以同时显示报修列表、详情和派单操作区,极大提升了管理员的效率。

这种“一套代码,两种形态”的响应式开发策略,最大限度地复用了代码,为未来应用在鸿蒙多终端上的部署打下了坚实基础。

2.2 策略二:面向鸿蒙 NEXT 生态的融合与升级路径

1. 鸿蒙元服务的支持

优势: 得益于 uni-app 对鸿蒙生态的深度支持,标准 uni-app 项目可以直接发行到鸿蒙元服务。

这极大地简化了开发流程,实现了主 App 与元服务代码基础的统一。

  • 解决方案: 我们采用 “uni-app 快速编译 + uniCloud 数据驱动” 的DCloud全家桶策略。
  • 元服务开发: 在 uni-app 项目中,使用条件编译或单独页面,针对性地开发轻量级的“快捷报修提交”功能模块。
  • 快速部署与统一代码: 将该模块通过 uni-app 编译部署为鸿蒙元服务,确保其 UI 和逻辑与主 App 保持高度一致,并享受 uni-app 的快速迭代优势。
  • 效果: 实现了“一次编码,多端复用”在 App 和元服务上的实践,确保了“即用即走”的用户体验,同时将复杂数据和业务逻辑留在了 uniCloud 统一后端,极大提升了社区服务的便捷性和开发效率。

2. 紧急公告卡片(服务卡片)设想:

对于“停水通知”等紧急公告,我们规划利用鸿蒙的服务卡片能力,通过原生卡片实时拉取 uniCloud 的最新公告数据,直接在用户桌面上显示公告摘要,确保紧急消息的触达率。

参考资料:鸿蒙元服务专题

三、关键功能实现与 uniCloud 赋能

3.1 高效报修与权限控制

核心逻辑:

  • 权限与通知: 通过 uni-id 的角色体系(居民/物业/维修人员)校验身份,并将任务自动派发给对应角色的物业管理员。
  • 进度跟踪: 利用 uni-push,居民端能实时获取报修单状态(已派单、处理中、已完成)的变更,保证了用户体验的实时性。

3.2 邻里交流与资源共享的数据安全

“邻里圈”和“资源共享”模块利用 uniCloud 强大的安全能力:

我们使用 uniCloud 的 云存储 存储用户上传的动态图片和视频,利用 权限规则 确保居民之间数据隔离。

对于“私信联系”功能,基于 uni-id 实现了安全的用户身份认证和通信机制,保护用户隐私。

3.3 技术特点

本系统全部基于uni-app内置组件和uni-ui扩展组件实现,尽可能使用图标代替图片,减少资源包大小,提升小程序的冷启动时间。

如下是对tabbar的一个配置,我们使用了uniicons.ttf,没有使用任何额外图片。

"tabBar": {  
        "color": "#7A7E83",  
        "selectedColor": "#007AFF",  
        "borderStyle": "black",  
        "backgroundColor": "#FFFFFF",  
        "iconfontSrc": "uni_modules/uni-icons/components/uni-icons/uniicons.ttf",  
        "list": [{  
            "pagePath": "pages/home/home",  
            "iconfont": {  
                "text": "\ue662",  
                "selectedText": "\ue663",  
                "fontSize": "22px",  
                "color": "#7A7E83",  
                "selectedColor": "#007AFF"  
            },  
            "text": "首页"  
        }, {  
            "pagePath": "pages/community/post-list",  
            "iconfont": {  
                "text": "\ue682",  
                "selectedText": "\ue682",  
                "fontSize": "22px",  
                "color": "#7A7E83",  
                "selectedColor": "#007AFF"  
            },  
            "text": "邻里圈"  
        }, {  
            "pagePath": "pages/message/message-list",  
            "iconfont": {  
                "text": "\ue6a6",  
                "selectedText": "\ue6c1",  
                "fontSize": "22px",  
                "color": "#7A7E83",  
                "selectedColor": "#007AFF"  
            },  
            "text": "消息"  
        }, {  
            "pagePath": "pages/ucenter/ucenter",  
            "iconfont": {  
                "text": "\ue699",  
                "selectedText": "\ue69d",  
                "fontSize": "22px",  
                "color": "#7A7E83",  
                "selectedColor": "#007AFF"  
            },  
            "text": "我的"  
        }]  
    }

四、总结与展望

感谢DCloud提供的uni-app + uniCloud纯前端方案,我们公司2个前端工程师就把整个项目上线了,系统开发周期大幅缩短,并保证了多端体验的统一性。

展望未来: 我们正积极关注 uni-app x 对鸿蒙 NEXT 的支持进度,并计划将 CommunityHub 逐步迁移至 uni-app x,彻底实现原生渲染,以达到最终的性能目标。

同时,我们也在打磨该系统的标准化,希望发布到 DCloud插件市场,供更多社区使用,或者开源出来。

收起阅读 »

ffmpeg 16KB问题

16KB

最近google市场要求16KB内存对齐的问题,市场上大多数的ffmpeg包都不支持;
我找到了一个,请在android studio中的build.gradle中增加
implementation 'com.moizhassan.ffmpeg:ffmpeg-kit-16kb:6.0.0'
通过google脚本测试,该包已修复16KB问题;

附件是我用ffmpeg写好的音视频处理页面;

继续阅读 »

最近google市场要求16KB内存对齐的问题,市场上大多数的ffmpeg包都不支持;
我找到了一个,请在android studio中的build.gradle中增加
implementation 'com.moizhassan.ffmpeg:ffmpeg-kit-16kb:6.0.0'
通过google脚本测试,该包已修复16KB问题;

附件是我用ffmpeg写好的音视频处理页面;

收起阅读 »

HBuilder 上架 iOS 应用全流程指南:从云打包到开心上架(Appuploader)上传的跨平台发布实践

iOS

'''随着 uni-app 与 HBuilderX 的普及,越来越多的前端开发者开始进入移动应用开发领域。
借助 HBuilder 的云打包服务,开发者可以在不使用 Xcode 的情况下,快速生成 iOS 的 .ipa 包。

但问题随之而来:许多团队没有 Mac 电脑,也无法使用 Xcode 或 Transporter 完成 App Store 上传。
开心上架(Appuploader)能在 Windows / Linux / macOS 系统中直接上传 IPA,并支持证书创建、描述文件管理和多语言信息批量提交。

本文将演示:从 HBuilder 打包到 iOS 应用上架的全流程,并介绍如何通过 Appuploader 实现免 Mac 跨平台上架。


一、为什么选择 HBuilder 打包 iOS 应用?

HBuilder 是 DCloud 推出的跨平台开发工具,支持 HTML5、Vue、uni-app 等多框架项目,
通过 云打包服务 自动生成 Android APK 与 iOS IPA 包。

优势总结:

特点 说明
无需本地 Xcode 环境 由云端完成编译与签名
跨平台开发 前端技术栈(Vue + JS)快速上手
支持插件扩展 可集成本地 SDK 与原生模块
App Store 上架兼容 云打包输出的 IPA 可直接提交审核

对前端开发者而言,HBuilder 是通往原生应用开发与上架的理想桥梁。


二、HBuilder 云打包生成 IPA 文件

步骤 1:配置应用信息

在 HBuilderX 中打开项目,点击顶部菜单:

发行 → 云打包 → iOS 应用

填写以下信息:

  • 应用名称、Bundle ID(需与 Apple Developer 保持一致);
  • 图标、启动图;
  • 版本号、应用描述。

步骤 2:选择证书模式

HBuilder 支持两种方式:
使用自己的苹果证书(需上传 .p12 与描述文件);
使用 DCloud 提供的公用证书(仅用于测试,不建议用于正式上架)。

步骤 3:打包完成后下载 .ipa 文件

系统会生成一个可安装或上架的 iOS 安装包。

示例文件路径:

./unpackage/release/ios/APP_NAME.ipa

三、准备 App Store 上传所需条件

要将 IPA 上架到 App Store,需要以下三项内容:

项目 说明
Apple 开发者账号 年费 99 美元(个人或企业)
App 专用密码 上传时使用,保护主账号安全
应用元数据 名称、简介、截图、隐私政策等

若没有 Mac,可完全依靠开心上架(Appuploader)进行后续操作。


四、开心上架(Appuploader)简介与核心功能

新版 开心上架(Appuploader) 是一款跨平台的 iOS 应用上架工具,
可替代 Application Loader、Transporter 等官方工具,支持 GUI 与命令行双模式。
首页

核心特性:

功能 说明
跨平台支持 兼容 Windows、Linux、macOS
上传 IPA 直接将 IPA 文件提交 App Store Connect
证书生成与管理 支持开发、发布、推送证书一键生成
多语言与截图上传 批量上传多语言描述与截图
命令行模式 适合自动化部署与持续集成

五、使用开心上架上传 HBuilder 生成的 IPA 文件

图形界面方式(推荐给新手):

打开 开心上架;
登录 Apple 开发者账号;
点击「上传 IPA」;
选择打包生成的 .ipa 文件;
等待上传完成后,即可在 App Store Connect 中看到应用信息。
ipa上传

命令行方式(适合开发者):

appuploader_cli -u dev@icloud.com -p xxx-xxx-xxx-xxx -c 2 -f ./unpackage/release/ios/myapp.ipa

参数说明:

参数 含义
-u Apple 开发者账号
-p App 专用密码
-c 上传通道(1=旧通道,2=新通道)
-f 要上传的 IPA 文件路径

执行结果:

  • 自动建立连接;
  • 上传并验证包体信息;
  • 输出上传日志与状态报告。

六、App Store Connect 审核流程简述

IPA 上传成功后,需在 App Store Connect 填写以下内容:

项目 说明
应用名称 上架显示名称
隐私政策 必填链接
截图 支持多设备尺寸上传
关键词与描述 提高搜索曝光
审核提交 点击“提交审核”按钮

审核时间:

  • 一般应用: 1–3 天;
  • 含支付、推送等功能: 3–7 天。

七、跨平台上架实践案例

某 uni-app 团队在 Windows 环境中使用以下流程完成 iOS 上架:

使用 HBuilder 云打包生成 .ipa
在 Appuploader 中创建 iOS 发布证书;
执行上传命令:

appuploader_cli -u ios@team.com -p xxxx-xxxx-xxxx -c 2 -f ./unpackage/release/ios/teamapp.ipa

登录 App Store Connect 填写资料并提交审核。

全流程无需 Mac,整个过程耗时不足两小时。


八、常见问题与解决方案

问题 原因 解决方案
上传报错 “Invalid Credentials” 密码错误 使用 App 专用密码
IPA 无法识别 打包方式不正确 使用正式证书重新打包
上传超时 网络不稳定 切换上传通道 -c 1/2
审核被拒 应用隐私不合规 补充隐私说明与权限用途
证书过期 签名证书已失效 在 Appuploader 中重新生成

九、结合 Fastlane 与 Appuploader 实现自动上架

对于团队项目,可进一步将 Appuploader 集成到 Fastlane 或 Jenkins CI 流程中,实现自动化上架。

示例命令:

fastlane gym --scheme "MyApp"  
appuploader_cli -u dev@icloud.com -p xxx-xxx-xxx-xxx -c 2 -f ./build/MyApp.ipa

优势:

  • 自动构建 + 上传;
  • 支持多版本号与自动日志记录;
  • 适用于 Windows 与 Linux CI 环境。

通过 HBuilder + 开心上架(Appuploader) 的组合,前端开发者与跨平台团队无需 Mac 电脑,也能高效完成 iOS 应用上架流程。

HBuilder 负责高效云打包,Appuploader 负责证书管理与上传发布,两者协同,构建出真正的 “跨系统、全自动化上架方案”。

从写代码到上架 App Store,你只需一台电脑,不必是 Mac。'''

继续阅读 »

'''随着 uni-app 与 HBuilderX 的普及,越来越多的前端开发者开始进入移动应用开发领域。
借助 HBuilder 的云打包服务,开发者可以在不使用 Xcode 的情况下,快速生成 iOS 的 .ipa 包。

但问题随之而来:许多团队没有 Mac 电脑,也无法使用 Xcode 或 Transporter 完成 App Store 上传。
开心上架(Appuploader)能在 Windows / Linux / macOS 系统中直接上传 IPA,并支持证书创建、描述文件管理和多语言信息批量提交。

本文将演示:从 HBuilder 打包到 iOS 应用上架的全流程,并介绍如何通过 Appuploader 实现免 Mac 跨平台上架。


一、为什么选择 HBuilder 打包 iOS 应用?

HBuilder 是 DCloud 推出的跨平台开发工具,支持 HTML5、Vue、uni-app 等多框架项目,
通过 云打包服务 自动生成 Android APK 与 iOS IPA 包。

优势总结:

特点 说明
无需本地 Xcode 环境 由云端完成编译与签名
跨平台开发 前端技术栈(Vue + JS)快速上手
支持插件扩展 可集成本地 SDK 与原生模块
App Store 上架兼容 云打包输出的 IPA 可直接提交审核

对前端开发者而言,HBuilder 是通往原生应用开发与上架的理想桥梁。


二、HBuilder 云打包生成 IPA 文件

步骤 1:配置应用信息

在 HBuilderX 中打开项目,点击顶部菜单:

发行 → 云打包 → iOS 应用

填写以下信息:

  • 应用名称、Bundle ID(需与 Apple Developer 保持一致);
  • 图标、启动图;
  • 版本号、应用描述。

步骤 2:选择证书模式

HBuilder 支持两种方式:
使用自己的苹果证书(需上传 .p12 与描述文件);
使用 DCloud 提供的公用证书(仅用于测试,不建议用于正式上架)。

步骤 3:打包完成后下载 .ipa 文件

系统会生成一个可安装或上架的 iOS 安装包。

示例文件路径:

./unpackage/release/ios/APP_NAME.ipa

三、准备 App Store 上传所需条件

要将 IPA 上架到 App Store,需要以下三项内容:

项目 说明
Apple 开发者账号 年费 99 美元(个人或企业)
App 专用密码 上传时使用,保护主账号安全
应用元数据 名称、简介、截图、隐私政策等

若没有 Mac,可完全依靠开心上架(Appuploader)进行后续操作。


四、开心上架(Appuploader)简介与核心功能

新版 开心上架(Appuploader) 是一款跨平台的 iOS 应用上架工具,
可替代 Application Loader、Transporter 等官方工具,支持 GUI 与命令行双模式。
首页

核心特性:

功能 说明
跨平台支持 兼容 Windows、Linux、macOS
上传 IPA 直接将 IPA 文件提交 App Store Connect
证书生成与管理 支持开发、发布、推送证书一键生成
多语言与截图上传 批量上传多语言描述与截图
命令行模式 适合自动化部署与持续集成

五、使用开心上架上传 HBuilder 生成的 IPA 文件

图形界面方式(推荐给新手):

打开 开心上架;
登录 Apple 开发者账号;
点击「上传 IPA」;
选择打包生成的 .ipa 文件;
等待上传完成后,即可在 App Store Connect 中看到应用信息。
ipa上传

命令行方式(适合开发者):

appuploader_cli -u dev@icloud.com -p xxx-xxx-xxx-xxx -c 2 -f ./unpackage/release/ios/myapp.ipa

参数说明:

参数 含义
-u Apple 开发者账号
-p App 专用密码
-c 上传通道(1=旧通道,2=新通道)
-f 要上传的 IPA 文件路径

执行结果:

  • 自动建立连接;
  • 上传并验证包体信息;
  • 输出上传日志与状态报告。

六、App Store Connect 审核流程简述

IPA 上传成功后,需在 App Store Connect 填写以下内容:

项目 说明
应用名称 上架显示名称
隐私政策 必填链接
截图 支持多设备尺寸上传
关键词与描述 提高搜索曝光
审核提交 点击“提交审核”按钮

审核时间:

  • 一般应用: 1–3 天;
  • 含支付、推送等功能: 3–7 天。

七、跨平台上架实践案例

某 uni-app 团队在 Windows 环境中使用以下流程完成 iOS 上架:

使用 HBuilder 云打包生成 .ipa
在 Appuploader 中创建 iOS 发布证书;
执行上传命令:

appuploader_cli -u ios@team.com -p xxxx-xxxx-xxxx -c 2 -f ./unpackage/release/ios/teamapp.ipa

登录 App Store Connect 填写资料并提交审核。

全流程无需 Mac,整个过程耗时不足两小时。


八、常见问题与解决方案

问题 原因 解决方案
上传报错 “Invalid Credentials” 密码错误 使用 App 专用密码
IPA 无法识别 打包方式不正确 使用正式证书重新打包
上传超时 网络不稳定 切换上传通道 -c 1/2
审核被拒 应用隐私不合规 补充隐私说明与权限用途
证书过期 签名证书已失效 在 Appuploader 中重新生成

九、结合 Fastlane 与 Appuploader 实现自动上架

对于团队项目,可进一步将 Appuploader 集成到 Fastlane 或 Jenkins CI 流程中,实现自动化上架。

示例命令:

fastlane gym --scheme "MyApp"  
appuploader_cli -u dev@icloud.com -p xxx-xxx-xxx-xxx -c 2 -f ./build/MyApp.ipa

优势:

  • 自动构建 + 上传;
  • 支持多版本号与自动日志记录;
  • 适用于 Windows 与 Linux CI 环境。

通过 HBuilder + 开心上架(Appuploader) 的组合,前端开发者与跨平台团队无需 Mac 电脑,也能高效完成 iOS 应用上架流程。

HBuilder 负责高效云打包,Appuploader 负责证书管理与上传发布,两者协同,构建出真正的 “跨系统、全自动化上架方案”。

从写代码到上架 App Store,你只需一台电脑,不必是 Mac。'''

收起阅读 »

关于使用Map组件截图技术方案

截图 地图 map

看网上好多技术方案都是使用集成原生插件,然后在原生插件里面生成截图,而且uniapp插件库里面的好几个这样的插件都需要收费。
如果大家对图片要求不高的话可以关注一些“天地图“的api,他们有根据坐标生成静态图片的接口,下面是链接地址
http://lbs.tianditu.gov.cn/staticapi/static.html
本人亲测可用

继续阅读 »

看网上好多技术方案都是使用集成原生插件,然后在原生插件里面生成截图,而且uniapp插件库里面的好几个这样的插件都需要收费。
如果大家对图片要求不高的话可以关注一些“天地图“的api,他们有根据坐标生成静态图片的接口,下面是链接地址
http://lbs.tianditu.gov.cn/staticapi/static.html
本人亲测可用

收起阅读 »

【报Bug】uni @touchmove 事件与 下拉刷新事件冲突

touchmove

【报Bug】uni @touchmove 事件与 下拉刷新事件冲突

这么多年的bug了为啥不解决呢, 很影响体验啊。

【报Bug】uni @touchmove 事件与 下拉刷新事件冲突

这么多年的bug了为啥不解决呢, 很影响体验啊。

UniApp开发鸿蒙应用实践:从底部小白条到代理提醒的完整解决方案

鸿蒙next 鸿蒙征文

前言

随着鸿蒙生态的快速发展,越来越多的开发者开始关注如何快速将现有应用适配到鸿蒙平台。UniApp作为一个成熟的跨平台开发框架,已经支持了鸿蒙应用的开发。然而在实际开发过程中,我们会遇到一些平台特有的问题和挑战。本文将基于一个服药提醒应用的开发实践,详细介绍如何解决鸿蒙平台的底部安全区适配问题,以及如何使用UTS插件实现代理提醒功能。

一、底部小白条问题的解决方案(SafeArea适配)

1.1 问题背景

在鸿蒙设备上,特别是全面屏设备,底部通常会有一个用于手势导航的"小白条"区域。这个区域被称为安全区域(Safe Area),如果应用界面没有正确处理这个区域,会导致底部内容被遮挡,影响用户体验。

1.2 SafeArea配置方案

UniApp为鸿蒙平台提供了完善的安全区域配置方案。我们需要在manifest.json文件中进行配置:

{  
  "app-harmony": {  
    "safearea": {  
      "background": "#ffffff",  
      "backgroundDark": "#2f0508",  
      "bottom": {  
        "offset": "none"  
      }  
    }  
  }  
}

配置说明:

  • background: 浅色模式下安全区域的背景色,这里设置为白色#ffffff,与应用主题保持一致
  • backgroundDark: 深色模式下安全区域的背景色,设置为深红色#2f0508,适配暗黑主题
  • bottom.offset: 底部区域占位策略,设置为"none"表示在没有TabBar时不需要占位

我们成功解决了底部小白条的适配问题,确保应用在各种鸿蒙设备上都能正常显示和交互。

二、代理提醒功能的UTS插件实现

2.1 代理提醒简介

代理提醒(Reminder Agent)是鸿蒙系统提供的一项重要能力,允许应用在后台设置定时提醒,即使应用被关闭也能准时触发。这对于服药提醒、日程管理等场景至关重要。

鸿蒙系统的代理提醒支持三种类型:

  • 闹钟提醒(ALARM): 基于时间的周期性提醒,适合每天固定时间的场景
  • 日历提醒(CALENDAR): 基于日期的事件提醒,适合特定日期的事件
  • 倒计时提醒(TIMER): 基于时长的一次性提醒,适合短期计时场景

2.2 为什么需要UTS插件

UniApp虽然支持鸿蒙平台,但并未内置代理提醒的API。要使用鸿蒙的原生能力,我们需要通过UTS(UniApp TypeScript)插件来调用鸿蒙的原生API。

UTS插件的优势:

  • 类型安全: 基于TypeScript,提供完整的类型检查
  • 性能优秀: 编译为原生代码,性能接近原生开发
  • 开发便捷: 语法接近TypeScript,学习成本低
  • 跨平台: 可以为不同平台编写不同的实现

2.3 UTS插件项目结构

我们创建了一个名为uni-reminder的UTS插件,项目结构如下:

uni_modules/  
└── uni-reminder/  
    ├── utssdk/  
    │   ├── app-harmony/        # 鸿蒙平台实现  
    │   │   └── index.uts       # 主要实现文件  
    │   └── interface.uts       # 接口定义文件  
    ├── package.json  
    └── readme.md

关键文件说明:

  • interface.uts: 定义插件的接口类型,包括所有方法的参数和回调类型
  • app-harmony/index.uts: 鸿蒙平台的具体实现,调用鸿蒙原生API

2.4 接口定义(interface.uts)

首先,我们需要定义清晰的接口类型。以闹钟提醒为例:

// 闹钟提醒选项  
export type PublishAlarmReminderOptions = {  
  hour: number                    // 闹钟小时数(0-23)  
  minute: number                  // 闹钟分钟数(0-59)  
  daysOfWeek?: Array<number> | null  // 重复日期,周日=0  
  title?: string | null           // 提醒标题  
  content?: string | null         // 提醒内容  
  success?: PublishAlarmReminderSuccessCallback | null  
  fail?: PublishAlarmReminderFailCallback | null  
  complete?: PublishAlarmReminderCompleteCallback | null  
}  

// 成功回调返回值  
export type PublishAlarmReminderSuccess = {  
  errMsg: string  
  reminderId: number  // 提醒ID,用于后续取消  
}  

// 定义uni对象上的方法  
export interface Uni {  
  publishAlarmReminder(options: PublishAlarmReminderOptions): void  
  publishCalendarReminder(options: PublishCalendarReminderOptions): void  
  publishTimerReminder(options: PublishTimerReminderOptions): void  
  cancelReminder(options: CancelReminderOptions): void  
  cancelAllReminders(options: CancelAllRemindersOptions): void  
  getValidReminders(options: GetValidRemindersOptions): void  
}

这种接口定义方式遵循了UniApp的API设计规范,使用success/fail/complete回调模式,保持了与UniApp其他API的一致性。

2.5 鸿蒙平台核心实现

2.5.1 导入鸿蒙原生API

import reminderAgentManager from '@ohos.reminderAgentManager'  
import { BusinessError } from '@kit.BasicServicesKit'
  • reminderAgentManager: 鸿蒙的代理提醒管理器
  • BusinessError: 鸿蒙的业务错误类型

2.5.2 实现闹钟提醒

export function publishAlarmReminder(options: PublishAlarmReminderOptions): void {  
  try {  
    // 构建闹钟提醒请求对象  
    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_ALARM,  
      hour: options.hour,  
      minute: options.minute,  
      title: options.title || '闹钟提醒',  
      content: options.content || '该起床了',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 设置重复日期  
    if (options.daysOfWeek && options.daysOfWeek.length > 0) {  
      requestObj['daysOfWeek'] = options.daysOfWeek  
    }  

    // 发布提醒  
    reminderAgentManager.publishReminder(  
      requestObj as unknown as reminderAgentManager.ReminderRequest  
    )  
      .then((reminderId: number) => {  
        options?.success?.({  
          errMsg: 'publishAlarmReminder:ok',  
          reminderId: reminderId  
        })  
        options?.complete?.({ errMsg: 'publishAlarmReminder:ok' })  
      })  
      .catch((err: BusinessError) => {  
        options?.fail?.({ errMsg: `publishAlarmReminder:fail ${err.message}` })  
        options?.complete?.({ errMsg: `publishAlarmReminder:fail ${err.message}` })  
      })  
  } catch (err) {  
    const error = err as BusinessError  
    options?.fail?.({ errMsg: `publishAlarmReminder:fail ${error.message}` })  
    options?.complete?.({ errMsg: `publishAlarmReminder:fail ${error.message}` })  
  }  
}

实现要点:

  1. reminderType: 指定提醒类型为闹钟
  2. wantAgent: 配置点击提醒后要启动的应用和页面
  3. actionButton: 添加操作按钮
  4. daysOfWeek: 可选的重复日期数组
  5. 错误处理: 使用try-catch和Promise.catch双重错误处理

2.5.3 实现日历提醒

export function publishCalendarReminder(options: PublishCalendarReminderOptions): void {  
  try {  
    const date = new Date(options.dateTime)  

    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,  
      dateTime: {  
        year: date.getFullYear(),  
        month: date.getMonth() + 1,  
        day: date.getDate(),  
        hour: date.getHours(),  
        minute: date.getMinutes()  
      },  
      title: options.title || '日历提醒',  
      content: options.content || '事件提醒',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 设置重复月份和日期  
    if (options.repeatMonths && options.repeatMonths.length > 0) {  
      requestObj['repeatMonths'] = options.repeatMonths  
    }  
    if (options.repeatDays && options.repeatDays.length > 0) {  
      requestObj['repeatDays'] = options.repeatDays  
    }  

    // 发布提醒(Promise处理逻辑同闹钟提醒)  
    // ...  
  } catch (err) {  
    // 错误处理  
  }  
}

2.5.4 实现倒计时提醒

export function publishTimerReminder(options: PublishTimerReminderOptions): void {  
  try {  
    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_TIMER,  
      triggerTimeInSeconds: options.triggerTimeInSeconds,  
      title: options.title || '倒计时提醒',  
      content: options.content || '倒计时已到',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 发布提醒  
    // ...  
  } catch (err) {  
    // 错误处理  
  }  
}

2.6 权限配置

使用代理提醒功能需要在manifest.json中声明权限:

{  
  "app-harmony": {  
    "permissions": [  
      "ohos.permission.PUBLISH_AGENT_REMINDER"  
    ]  
  }  
}

同时配合通知权限插件请求用户授权:

uni.requestNotification({  
  success: (res) => {  
    if (res.granted) {  
      console.log('通知权限已获取')  
    }  
  }  
})

2.7 在应用中使用插件

插件开发完成后,在应用中的使用非常简单:

export default {  
  data() {  
    return {  
      reminderIds: []  
    }  
  },  
  methods: {  
    // 设置每天早上8点的服药提醒  
    setMedicineReminder() {  
      uni.publishAlarmReminder({  
        hour: 8,  
        minute: 0,  
        daysOfWeek: [1, 2, 3, 4, 5, 6, 0],  
        title: '服药提醒',  
        content: '该吃药了,记得按时服药哦!',  
        success: (res) => {  
          console.log('提醒设置成功,ID:', res.reminderId)  
          this.reminderIds.push(res.reminderId)  
          uni.showToast({ title: '提醒设置成功' })  
        },  
        fail: (err) => {  
          console.error('提醒设置失败:', err.errMsg)  
        }  
      })  
    },  

    // 设置30分钟后的倒计时提醒  
    setTimerReminder() {  
      uni.publishTimerReminder({  
        triggerTimeInSeconds: 30 * 60,  
        title: '服药倒计时',  
        content: '30分钟已到,该服药了',  
        success: (res) => {  
          this.reminderIds.push(res.reminderId)  
        }  
      })  
    },  

    // 取消所有提醒  
    cancelAllReminders() {  
      uni.cancelAllReminders({  
        success: () => {  
          this.reminderIds = []  
          uni.showToast({ title: '已清空所有提醒' })  
        }  
      })  
    }  
  }  
}

希望本文的分享能够帮助到正在开发鸿蒙应用的开发者们,让我们一起为鸿蒙生态的繁荣贡献力量!

继续阅读 »

前言

随着鸿蒙生态的快速发展,越来越多的开发者开始关注如何快速将现有应用适配到鸿蒙平台。UniApp作为一个成熟的跨平台开发框架,已经支持了鸿蒙应用的开发。然而在实际开发过程中,我们会遇到一些平台特有的问题和挑战。本文将基于一个服药提醒应用的开发实践,详细介绍如何解决鸿蒙平台的底部安全区适配问题,以及如何使用UTS插件实现代理提醒功能。

一、底部小白条问题的解决方案(SafeArea适配)

1.1 问题背景

在鸿蒙设备上,特别是全面屏设备,底部通常会有一个用于手势导航的"小白条"区域。这个区域被称为安全区域(Safe Area),如果应用界面没有正确处理这个区域,会导致底部内容被遮挡,影响用户体验。

1.2 SafeArea配置方案

UniApp为鸿蒙平台提供了完善的安全区域配置方案。我们需要在manifest.json文件中进行配置:

{  
  "app-harmony": {  
    "safearea": {  
      "background": "#ffffff",  
      "backgroundDark": "#2f0508",  
      "bottom": {  
        "offset": "none"  
      }  
    }  
  }  
}

配置说明:

  • background: 浅色模式下安全区域的背景色,这里设置为白色#ffffff,与应用主题保持一致
  • backgroundDark: 深色模式下安全区域的背景色,设置为深红色#2f0508,适配暗黑主题
  • bottom.offset: 底部区域占位策略,设置为"none"表示在没有TabBar时不需要占位

我们成功解决了底部小白条的适配问题,确保应用在各种鸿蒙设备上都能正常显示和交互。

二、代理提醒功能的UTS插件实现

2.1 代理提醒简介

代理提醒(Reminder Agent)是鸿蒙系统提供的一项重要能力,允许应用在后台设置定时提醒,即使应用被关闭也能准时触发。这对于服药提醒、日程管理等场景至关重要。

鸿蒙系统的代理提醒支持三种类型:

  • 闹钟提醒(ALARM): 基于时间的周期性提醒,适合每天固定时间的场景
  • 日历提醒(CALENDAR): 基于日期的事件提醒,适合特定日期的事件
  • 倒计时提醒(TIMER): 基于时长的一次性提醒,适合短期计时场景

2.2 为什么需要UTS插件

UniApp虽然支持鸿蒙平台,但并未内置代理提醒的API。要使用鸿蒙的原生能力,我们需要通过UTS(UniApp TypeScript)插件来调用鸿蒙的原生API。

UTS插件的优势:

  • 类型安全: 基于TypeScript,提供完整的类型检查
  • 性能优秀: 编译为原生代码,性能接近原生开发
  • 开发便捷: 语法接近TypeScript,学习成本低
  • 跨平台: 可以为不同平台编写不同的实现

2.3 UTS插件项目结构

我们创建了一个名为uni-reminder的UTS插件,项目结构如下:

uni_modules/  
└── uni-reminder/  
    ├── utssdk/  
    │   ├── app-harmony/        # 鸿蒙平台实现  
    │   │   └── index.uts       # 主要实现文件  
    │   └── interface.uts       # 接口定义文件  
    ├── package.json  
    └── readme.md

关键文件说明:

  • interface.uts: 定义插件的接口类型,包括所有方法的参数和回调类型
  • app-harmony/index.uts: 鸿蒙平台的具体实现,调用鸿蒙原生API

2.4 接口定义(interface.uts)

首先,我们需要定义清晰的接口类型。以闹钟提醒为例:

// 闹钟提醒选项  
export type PublishAlarmReminderOptions = {  
  hour: number                    // 闹钟小时数(0-23)  
  minute: number                  // 闹钟分钟数(0-59)  
  daysOfWeek?: Array<number> | null  // 重复日期,周日=0  
  title?: string | null           // 提醒标题  
  content?: string | null         // 提醒内容  
  success?: PublishAlarmReminderSuccessCallback | null  
  fail?: PublishAlarmReminderFailCallback | null  
  complete?: PublishAlarmReminderCompleteCallback | null  
}  

// 成功回调返回值  
export type PublishAlarmReminderSuccess = {  
  errMsg: string  
  reminderId: number  // 提醒ID,用于后续取消  
}  

// 定义uni对象上的方法  
export interface Uni {  
  publishAlarmReminder(options: PublishAlarmReminderOptions): void  
  publishCalendarReminder(options: PublishCalendarReminderOptions): void  
  publishTimerReminder(options: PublishTimerReminderOptions): void  
  cancelReminder(options: CancelReminderOptions): void  
  cancelAllReminders(options: CancelAllRemindersOptions): void  
  getValidReminders(options: GetValidRemindersOptions): void  
}

这种接口定义方式遵循了UniApp的API设计规范,使用success/fail/complete回调模式,保持了与UniApp其他API的一致性。

2.5 鸿蒙平台核心实现

2.5.1 导入鸿蒙原生API

import reminderAgentManager from '@ohos.reminderAgentManager'  
import { BusinessError } from '@kit.BasicServicesKit'
  • reminderAgentManager: 鸿蒙的代理提醒管理器
  • BusinessError: 鸿蒙的业务错误类型

2.5.2 实现闹钟提醒

export function publishAlarmReminder(options: PublishAlarmReminderOptions): void {  
  try {  
    // 构建闹钟提醒请求对象  
    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_ALARM,  
      hour: options.hour,  
      minute: options.minute,  
      title: options.title || '闹钟提醒',  
      content: options.content || '该起床了',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 设置重复日期  
    if (options.daysOfWeek && options.daysOfWeek.length > 0) {  
      requestObj['daysOfWeek'] = options.daysOfWeek  
    }  

    // 发布提醒  
    reminderAgentManager.publishReminder(  
      requestObj as unknown as reminderAgentManager.ReminderRequest  
    )  
      .then((reminderId: number) => {  
        options?.success?.({  
          errMsg: 'publishAlarmReminder:ok',  
          reminderId: reminderId  
        })  
        options?.complete?.({ errMsg: 'publishAlarmReminder:ok' })  
      })  
      .catch((err: BusinessError) => {  
        options?.fail?.({ errMsg: `publishAlarmReminder:fail ${err.message}` })  
        options?.complete?.({ errMsg: `publishAlarmReminder:fail ${err.message}` })  
      })  
  } catch (err) {  
    const error = err as BusinessError  
    options?.fail?.({ errMsg: `publishAlarmReminder:fail ${error.message}` })  
    options?.complete?.({ errMsg: `publishAlarmReminder:fail ${error.message}` })  
  }  
}

实现要点:

  1. reminderType: 指定提醒类型为闹钟
  2. wantAgent: 配置点击提醒后要启动的应用和页面
  3. actionButton: 添加操作按钮
  4. daysOfWeek: 可选的重复日期数组
  5. 错误处理: 使用try-catch和Promise.catch双重错误处理

2.5.3 实现日历提醒

export function publishCalendarReminder(options: PublishCalendarReminderOptions): void {  
  try {  
    const date = new Date(options.dateTime)  

    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,  
      dateTime: {  
        year: date.getFullYear(),  
        month: date.getMonth() + 1,  
        day: date.getDate(),  
        hour: date.getHours(),  
        minute: date.getMinutes()  
      },  
      title: options.title || '日历提醒',  
      content: options.content || '事件提醒',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 设置重复月份和日期  
    if (options.repeatMonths && options.repeatMonths.length > 0) {  
      requestObj['repeatMonths'] = options.repeatMonths  
    }  
    if (options.repeatDays && options.repeatDays.length > 0) {  
      requestObj['repeatDays'] = options.repeatDays  
    }  

    // 发布提醒(Promise处理逻辑同闹钟提醒)  
    // ...  
  } catch (err) {  
    // 错误处理  
  }  
}

2.5.4 实现倒计时提醒

export function publishTimerReminder(options: PublishTimerReminderOptions): void {  
  try {  
    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_TIMER,  
      triggerTimeInSeconds: options.triggerTimeInSeconds,  
      title: options.title || '倒计时提醒',  
      content: options.content || '倒计时已到',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 发布提醒  
    // ...  
  } catch (err) {  
    // 错误处理  
  }  
}

2.6 权限配置

使用代理提醒功能需要在manifest.json中声明权限:

{  
  "app-harmony": {  
    "permissions": [  
      "ohos.permission.PUBLISH_AGENT_REMINDER"  
    ]  
  }  
}

同时配合通知权限插件请求用户授权:

uni.requestNotification({  
  success: (res) => {  
    if (res.granted) {  
      console.log('通知权限已获取')  
    }  
  }  
})

2.7 在应用中使用插件

插件开发完成后,在应用中的使用非常简单:

export default {  
  data() {  
    return {  
      reminderIds: []  
    }  
  },  
  methods: {  
    // 设置每天早上8点的服药提醒  
    setMedicineReminder() {  
      uni.publishAlarmReminder({  
        hour: 8,  
        minute: 0,  
        daysOfWeek: [1, 2, 3, 4, 5, 6, 0],  
        title: '服药提醒',  
        content: '该吃药了,记得按时服药哦!',  
        success: (res) => {  
          console.log('提醒设置成功,ID:', res.reminderId)  
          this.reminderIds.push(res.reminderId)  
          uni.showToast({ title: '提醒设置成功' })  
        },  
        fail: (err) => {  
          console.error('提醒设置失败:', err.errMsg)  
        }  
      })  
    },  

    // 设置30分钟后的倒计时提醒  
    setTimerReminder() {  
      uni.publishTimerReminder({  
        triggerTimeInSeconds: 30 * 60,  
        title: '服药倒计时',  
        content: '30分钟已到,该服药了',  
        success: (res) => {  
          this.reminderIds.push(res.reminderId)  
        }  
      })  
    },  

    // 取消所有提醒  
    cancelAllReminders() {  
      uni.cancelAllReminders({  
        success: () => {  
          this.reminderIds = []  
          uni.showToast({ title: '已清空所有提醒' })  
        }  
      })  
    }  
  }  
}

希望本文的分享能够帮助到正在开发鸿蒙应用的开发者们,让我们一起为鸿蒙生态的繁荣贡献力量!

收起阅读 »

解决uniapp打包h5刷新页面无法返回上一级页面的问题

h5环境直接拦截默认的返回方法,使用router自带的返回

拦截默认的pageHead的返回方法,判断整个页面的history

import Vue from 'vue'  
if (process.env.VUE_APP_PLATFORM === 'h5') {  
  // 替代默认的返回api  
  uni.navigateBack = function (params: any) {  
    let canBack = true  
    const pages = getCurrentPages()  
    let delta = params?.delta  
    if (typeof delta !== 'number') delta = 1  

    const router = getApp().$router  
    if (pages.length) {  
      const from = 'navigateBack'  
      function hasLifecycleHook(options: any, hook: string) {  
        return Array.isArray(options[hook]) && options[hook].length  
      }  
      const page = pages[pages.length - 1] as any  
      if (  
        hasLifecycleHook(page.$options, 'onBackPress') &&  
        page.__call_hook('onBackPress', {  
          from,  
        }) === true  
      ) {  
        canBack = false  
      }  
    }  
    if (canBack) {  
      if (delta > 1) {  
        router._$delta = delta  
      }  
      router.go(-delta, {  
        animationType: '',  
        animationDuration: '',  
      })  
    }  
  }  
  // 修复 自带的pageHead 刷新点击右上角回到首页  
  const Page = Vue.component('Page')  
  const PageHead = Page.component('PageHead')  
  // @ts-ignore  
  PageHead.methods._back = function () {  
    if (history.length === 1) {  
      return uni.reLaunch({  
        url: '/',  
      })  
    } else {  
      return uni.navigateBack({  
        // @ts-ignore  
        from: 'backbutton',  
      })  
    }  
  }  
}  
继续阅读 »

h5环境直接拦截默认的返回方法,使用router自带的返回

拦截默认的pageHead的返回方法,判断整个页面的history

import Vue from 'vue'  
if (process.env.VUE_APP_PLATFORM === 'h5') {  
  // 替代默认的返回api  
  uni.navigateBack = function (params: any) {  
    let canBack = true  
    const pages = getCurrentPages()  
    let delta = params?.delta  
    if (typeof delta !== 'number') delta = 1  

    const router = getApp().$router  
    if (pages.length) {  
      const from = 'navigateBack'  
      function hasLifecycleHook(options: any, hook: string) {  
        return Array.isArray(options[hook]) && options[hook].length  
      }  
      const page = pages[pages.length - 1] as any  
      if (  
        hasLifecycleHook(page.$options, 'onBackPress') &&  
        page.__call_hook('onBackPress', {  
          from,  
        }) === true  
      ) {  
        canBack = false  
      }  
    }  
    if (canBack) {  
      if (delta > 1) {  
        router._$delta = delta  
      }  
      router.go(-delta, {  
        animationType: '',  
        animationDuration: '',  
      })  
    }  
  }  
  // 修复 自带的pageHead 刷新点击右上角回到首页  
  const Page = Vue.component('Page')  
  const PageHead = Page.component('PageHead')  
  // @ts-ignore  
  PageHead.methods._back = function () {  
    if (history.length === 1) {  
      return uni.reLaunch({  
        url: '/',  
      })  
    } else {  
      return uni.navigateBack({  
        // @ts-ignore  
        from: 'backbutton',  
      })  
    }  
  }  
}  
收起阅读 »

分片上传、文件分片,使用Html5Plus API。测试对比MD5。

兄弟们,我在看掘金的时候找到分片上传失败的原因了,就很小的一个点:

html5Plus 的文档写的不太清楚出了,很多地方让人猜。就比如File.slice(start,end) ,在js中slice截取不包含end,但在Html5Plus File.slice包含end,导致合并文件后总是比原文件大一点

也就是使用Html5Plus的File.slice方法:每个分片end-1
已经在安卓模拟器上测试,并对比MD5值。

因为没有 readAsArrayBuffer 方法,只能通过 Base64 转 ArrayBuffer,需要注意去除dataUrl头部
关键代码

const readFileChunk = (file, start, end) => {  
    return new Promise((resolve, reject) => {  
        try {  
            const reader = new plus.io.FileReader();  
            reader.onload = () => {  
                try {  
                    // 排除base64头部  
                    const base64 = reader.result.substring(reader.result.indexOf(',')   1);  
                    resolve(uni.base64ToArrayBuffer(base64));  
                } catch (e) {  
                    reject(e)  
                }  
            };  
            reader.onerror = reject;  

            // 使用slice方法读取指定范围, 需要注意: end 包含  
            const blob = file.slice(start, end);  
            reader.readAsDataURL(blob); // HTML5 需要转换为base64读取  
        } catch (e) {  
            reject(e)  
        }  
    });  
};

完整的分片上传

    async uploadMultipart(filePath, chunkSize = 1024 * 1024 * 5) {  

        // 通过文件路径获取File对象(Html5Plus的File)  
        const file = await getFile(filePath);  
        const uploadId = await multipartUploadApi.startMultipart(file.name)  
        // console.log('uploadId', uploadId)  

        const partList = []  
        const totalSize = file.size;  
        for (let i = 0; i < totalSize / chunkSize; i  ) {  
            const start = i * chunkSize;  
            const end = Math.min((i   1) * chunkSize - 1, totalSize);  
            const chunkBuffer = await readFileChunk(file, start, end);  
            const partItem = await multipartUploadApi.uploadPart(uploadId, i   1, chunkBuffer)  
            partList.push(partItem)  
        }  
        const result = await multipartUploadApi.complete(uploadId, partList)  

        return result  
    },  

    getFile(filePath) {  
        return new Promise((resolve, reject) => {  
            plus.io.resolveLocalFileSystemURL(filePath, (entry) => {  
                if (!entry.isFile) {  
                    reject(new Error(`不是文件:${filePath}`))  
                    return  
                }  
                entry.file((file) => {  
                    resolve(file)  
                }, (err) => {  
                    // console.log('无法获取文件')  
                    reject(err)  
                })  
            }, (error) => {  
                // console.log('文件不存在', error)  
                reject(error)  
            })  
        })  
    },  
  readFileChunk (file, start, end) {  
    return new Promise((resolve, reject) => {  
        try {  
            const reader = new plus.io.FileReader();  
            reader.onload = () => {  
                try {  
                    // 排除base64头部  
                    const base64 = reader.result.substring(reader.result.indexOf(',')   1);  
                    resolve(uni.base64ToArrayBuffer(base64));  
                } catch (e) {  
                    reject(e)  
                }  
            };  
            reader.onerror = reject;  

            // 使用slice方法读取指定范围, 需要注意: end 包含  
            const blob = file.slice(start, end);  
            reader.readAsDataURL(blob); // HTML5 需要转换为base64读取  
        } catch (e) {  
            reject(e)  
        }  
    });  
};

参考的掘金文章:
解决Uniapp中文件切片问题
作者:Synmbrf
链接:https://juejin.cn/post/7493783786707058726
来源:稀土掘金

继续阅读 »

兄弟们,我在看掘金的时候找到分片上传失败的原因了,就很小的一个点:

html5Plus 的文档写的不太清楚出了,很多地方让人猜。就比如File.slice(start,end) ,在js中slice截取不包含end,但在Html5Plus File.slice包含end,导致合并文件后总是比原文件大一点

也就是使用Html5Plus的File.slice方法:每个分片end-1
已经在安卓模拟器上测试,并对比MD5值。

因为没有 readAsArrayBuffer 方法,只能通过 Base64 转 ArrayBuffer,需要注意去除dataUrl头部
关键代码

const readFileChunk = (file, start, end) => {  
    return new Promise((resolve, reject) => {  
        try {  
            const reader = new plus.io.FileReader();  
            reader.onload = () => {  
                try {  
                    // 排除base64头部  
                    const base64 = reader.result.substring(reader.result.indexOf(',')   1);  
                    resolve(uni.base64ToArrayBuffer(base64));  
                } catch (e) {  
                    reject(e)  
                }  
            };  
            reader.onerror = reject;  

            // 使用slice方法读取指定范围, 需要注意: end 包含  
            const blob = file.slice(start, end);  
            reader.readAsDataURL(blob); // HTML5 需要转换为base64读取  
        } catch (e) {  
            reject(e)  
        }  
    });  
};

完整的分片上传

    async uploadMultipart(filePath, chunkSize = 1024 * 1024 * 5) {  

        // 通过文件路径获取File对象(Html5Plus的File)  
        const file = await getFile(filePath);  
        const uploadId = await multipartUploadApi.startMultipart(file.name)  
        // console.log('uploadId', uploadId)  

        const partList = []  
        const totalSize = file.size;  
        for (let i = 0; i < totalSize / chunkSize; i  ) {  
            const start = i * chunkSize;  
            const end = Math.min((i   1) * chunkSize - 1, totalSize);  
            const chunkBuffer = await readFileChunk(file, start, end);  
            const partItem = await multipartUploadApi.uploadPart(uploadId, i   1, chunkBuffer)  
            partList.push(partItem)  
        }  
        const result = await multipartUploadApi.complete(uploadId, partList)  

        return result  
    },  

    getFile(filePath) {  
        return new Promise((resolve, reject) => {  
            plus.io.resolveLocalFileSystemURL(filePath, (entry) => {  
                if (!entry.isFile) {  
                    reject(new Error(`不是文件:${filePath}`))  
                    return  
                }  
                entry.file((file) => {  
                    resolve(file)  
                }, (err) => {  
                    // console.log('无法获取文件')  
                    reject(err)  
                })  
            }, (error) => {  
                // console.log('文件不存在', error)  
                reject(error)  
            })  
        })  
    },  
  readFileChunk (file, start, end) {  
    return new Promise((resolve, reject) => {  
        try {  
            const reader = new plus.io.FileReader();  
            reader.onload = () => {  
                try {  
                    // 排除base64头部  
                    const base64 = reader.result.substring(reader.result.indexOf(',')   1);  
                    resolve(uni.base64ToArrayBuffer(base64));  
                } catch (e) {  
                    reject(e)  
                }  
            };  
            reader.onerror = reject;  

            // 使用slice方法读取指定范围, 需要注意: end 包含  
            const blob = file.slice(start, end);  
            reader.readAsDataURL(blob); // HTML5 需要转换为base64读取  
        } catch (e) {  
            reject(e)  
        }  
    });  
};

参考的掘金文章:
解决Uniapp中文件切片问题
作者:Synmbrf
链接:https://juejin.cn/post/7493783786707058726
来源:稀土掘金

收起阅读 »

基于vue3.5+vite7.1+tauri2.8实战客户端聊天软件

vite vue.js vue3

vue3-tauri2-wechat:最新研发vite7.1+tauri2.8+vue3 setup+pinia3+elementPlus跨平台仿微信/QQ风格桌面聊天系统Exe模板。包含聊天、通讯录、收藏、朋友圈、短视频、我的等板块。

运用技术

  • 跨平台框架:tauri2.8
  • 前端技术框架:vite^7.1.10+vue^3.5.22+vue-router^4.6.3
  • 状态管理:pinia^3.0.3
  • 本地存储:pinia-plugin-persistedstate^4.5.0
  • 组件库:element-plus^2.11.5
  • 富文本编辑器:@vueup/vue-quill^1.2.0
  • 样式预处理:sass^1.93.2
  • 短视频滑动插件:swiper^12.0.2

项目框架目录

基于最新版跨平台框架tauri2+vite7创建项目模板,vue3 setup语法开发。

tauri2-vue3chat聊天系统已经更新到我的原创作品集。
Tauri2.0+Vite7+ElementPlus桌面聊天Exe程序

往期推荐

Electron38-Vue3OS客户端OS系统|vite7+electron38+arco桌面os后台管理
electron38-admin桌面端后台|Electron38+Vue3+ElementPlus管理系统
Electron38-Wechat电脑端聊天|vite7+electron38仿微信桌面端聊天系统
原创uniapp+vue3+deepseek+uv-ui跨端实战仿deepseek/豆包流式ai聊天对话助手。
vue3-webseek网页版AI问答|Vite6+DeepSeek+Arco流式ai聊天打字效果
最新版uni-app+vue3+uv-ui跨三端仿微信app聊天应用【h5+小程序+app端】
Flutter3-MacOS桌面OS系统|flutter3.32+window_manager客户端OS模板
最新研发flutter3.27+bitsdojo_window+getx客户端仿微信聊天Exe应用
最新版Flutter3.32+Dart3.8跨平台仿微信app聊天界面|朋友圈
最新版uniapp+vue3+uv-ui跨三端短视频+直播+聊天【H5+小程序+App端】
uniapp-vue3-os手机oa系统|uni-app+vue3跨三端os后台管理模板
Electron35-DeepSeek桌面端AI系统|vue3.5+electron+arco客户端ai模板
uniapp+vue3酒店预订|vite5+uniapp预约订房系统模板(h5+小程序+App端)
Tauri2.0+Vite5聊天室|vue3+tauri2+element-plus仿微信|tauri聊天应用
tauri2.0-admin桌面端后台系统|Tauri2+Vite5+ElementPlus管理后台EXE程序

继续阅读 »

vue3-tauri2-wechat:最新研发vite7.1+tauri2.8+vue3 setup+pinia3+elementPlus跨平台仿微信/QQ风格桌面聊天系统Exe模板。包含聊天、通讯录、收藏、朋友圈、短视频、我的等板块。

运用技术

  • 跨平台框架:tauri2.8
  • 前端技术框架:vite^7.1.10+vue^3.5.22+vue-router^4.6.3
  • 状态管理:pinia^3.0.3
  • 本地存储:pinia-plugin-persistedstate^4.5.0
  • 组件库:element-plus^2.11.5
  • 富文本编辑器:@vueup/vue-quill^1.2.0
  • 样式预处理:sass^1.93.2
  • 短视频滑动插件:swiper^12.0.2

项目框架目录

基于最新版跨平台框架tauri2+vite7创建项目模板,vue3 setup语法开发。

tauri2-vue3chat聊天系统已经更新到我的原创作品集。
Tauri2.0+Vite7+ElementPlus桌面聊天Exe程序

往期推荐

Electron38-Vue3OS客户端OS系统|vite7+electron38+arco桌面os后台管理
electron38-admin桌面端后台|Electron38+Vue3+ElementPlus管理系统
Electron38-Wechat电脑端聊天|vite7+electron38仿微信桌面端聊天系统
原创uniapp+vue3+deepseek+uv-ui跨端实战仿deepseek/豆包流式ai聊天对话助手。
vue3-webseek网页版AI问答|Vite6+DeepSeek+Arco流式ai聊天打字效果
最新版uni-app+vue3+uv-ui跨三端仿微信app聊天应用【h5+小程序+app端】
Flutter3-MacOS桌面OS系统|flutter3.32+window_manager客户端OS模板
最新研发flutter3.27+bitsdojo_window+getx客户端仿微信聊天Exe应用
最新版Flutter3.32+Dart3.8跨平台仿微信app聊天界面|朋友圈
最新版uniapp+vue3+uv-ui跨三端短视频+直播+聊天【H5+小程序+App端】
uniapp-vue3-os手机oa系统|uni-app+vue3跨三端os后台管理模板
Electron35-DeepSeek桌面端AI系统|vue3.5+electron+arco客户端ai模板
uniapp+vue3酒店预订|vite5+uniapp预约订房系统模板(h5+小程序+App端)
Tauri2.0+Vite5聊天室|vue3+tauri2+element-plus仿微信|tauri聊天应用
tauri2.0-admin桌面端后台系统|Tauri2+Vite5+ElementPlus管理后台EXE程序

收起阅读 »

使用 uni-app x 开发2048游戏适配鸿蒙6

鸿蒙征文

使用 uni-app x 开发2048游戏适配鸿蒙6

作者:坚果派小雨
发布时间:2025年10月
技术栈:uni-app x、UTS、HarmonyOS 6

📖 前言

2048是一款风靡全球的益智游戏,简单而富有策略性。本文将详细介绍如何使用 uni-app x 框架从零开始开发一款2048游戏,并实现深色模式适配、数据持久化等进阶功能。最终产品完美支持鸿蒙HarmonyOS 6、Android、iOS等多个平台。

为什么选择 uni-app x?

  • 🚀 原生性能:UTS 语言编译为原生代码,性能接近原生应用
  • 📱 一次开发,多端运行:支持鸿蒙、Android、iOS、Web等平台
  • 💪 类型安全:基于 TypeScript,享受完整的类型检查
  • 🎯 鸿蒙首选:官方支持鸿蒙6,是开发鸿蒙应用的最佳选择之一

🎯 项目目标

我们将实现以下功能:

  1. ✅ 完整的2048游戏逻辑
  2. ✅ 流畅的触摸手势控制
  3. ✅ 精美的动画效果
  4. ✅ 深色模式自动适配
  5. ✅ 最高分本地存储
  6. ✅ 多平台支持(重点支持鸿蒙6)

📐 架构设计

数据结构设计

游戏的核心是一个 4x4 的二维数组,用于存储每个格子的数值:

// 游戏网格数据  
grid: number[][] = [  
  [0, 0, 0, 0],  
  [0, 0, 0, 0],  
  [0, 0, 0, 0],  
  [0, 0, 0, 0]  
]

为了实现平滑的动画效果,我们需要一个独立的 Tile 数据结构:

type Tile = {  
  id: number          // 唯一标识  
  value: number       // 方块数值  
  row: number         // 行位置  
  col: number         // 列位置  
  isNew: boolean      // 是否是新生成的  
  isMerged: boolean   // 是否刚合并  
}

状态管理

使用 Vue 3 的响应式系统管理游戏状态:

data() {  
  return {  
    grid: [] as number[][],        // 游戏网格  
    tiles: [] as Tile[],           // 显示的方块  
    score: 0,                      // 当前分数  
    bestScore: 0,                  // 最高分  
    gameOver: false,               // 游戏结束  
    gameWon: false,                // 游戏胜利  
    keepPlaying: false,            // 继续游戏  
    tileIdCounter: 0,              // 方块ID计数器  
    isDarkMode: false              // 深色模式  
  }  
}

🔧 核心功能实现

1. 游戏初始化

游戏开始时需要初始化网格并随机生成两个方块:

initGame() {  
  // 初始化4x4网格  
  this.grid = []  
  for (let i = 0; i < 4; i++) {  
    this.grid.push([0, 0, 0, 0])  
  }  

  // 重置状态  
  this.tiles = []  
  this.score = 0  
  this.gameOver = false  
  this.gameWon = false  
  this.tileIdCounter = 0  

  // 添加两个初始方块  
  this.addRandomTile()  
  this.addRandomTile()  
}

2. 随机生成方块

90%概率生成2,10%概率生成4,这是经典2048的设定:

addRandomTile() {  
  // 找出所有空格子  
  const emptyCells = [] as {row: number, col: number}[]  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 0) {  
        emptyCells.push({row: i, col: j})  
      }  
    }  
  }  

  if (emptyCells.length > 0) {  
    // 随机选择一个空格子  
    const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)]  

    // 90%概率生成2,10%概率生成4  
    const value = Math.random() < 0.9 ? 2 : 4  
    this.grid[randomCell.row][randomCell.col] = value  

    // 创建新的方块对象  
    const tile: Tile = {  
      id: this.tileIdCounter++,  
      value: value,  
      row: randomCell.row,  
      col: randomCell.col,  
      isNew: true,  
      isMerged: false  
    }  
    this.tiles.push(tile)  

    // 200ms后移除新方块标记,触发动画  
    setTimeout(() => {  
      tile.isNew = false  
    }, 200)  
  }  
}

3. 触摸手势处理

实现流畅的滑动手势检测:

// 记录触摸起点  
touchStart(e: UniTouchEvent) {  
  this.touchStartX = e.touches[0].pageX  
  this.touchStartY = e.touches[0].pageY  
}  

// 计算滑动方向  
touchEnd(e: UniTouchEvent) {  
  this.touchEndX = e.changedTouches[0].pageX  
  this.touchEndY = e.changedTouches[0].pageY  
  this.handleSwipe()  
}  

// 判断滑动方向  
handleSwipe() {  
  const deltaX = this.touchEndX - this.touchStartX  
  const deltaY = this.touchEndY - this.touchStartY  
  const minSwipeDistance = 30  // 最小滑动距离  

  // 滑动距离太短,忽略  
  if (Math.abs(deltaX) < minSwipeDistance &&   
      Math.abs(deltaY) < minSwipeDistance) {  
    return  
  }  

  // 比较水平和垂直位移,判断主要方向  
  if (Math.abs(deltaX) > Math.abs(deltaY)) {  
    // 水平滑动  
    deltaX > 0 ? this.move('right') : this.move('left')  
  } else {  
    // 垂直滑动  
    deltaY > 0 ? this.move('down') : this.move('up')  
  }  
}

4. 移动和合并算法

这是游戏的核心逻辑。以向左移动为例:

moveLeft(): boolean {  
  let moved = false  

  for (let i = 0; i < 4; i++) {  
    // 记录每个位置是否已经合并过  
    let merged = [false, false, false, false]  

    // 从左到右遍历每一行  
    for (let j = 1; j < 4; j++) {  
      if (this.grid[i][j] !== 0) {  
        let col = j  

        // 尽可能向左移动  
        while (col > 0) {  
          // 左边是空格,移动过去  
          if (this.grid[i][col - 1] === 0) {  
            this.grid[i][col - 1] = this.grid[i][col]  
            this.grid[i][col] = 0  
            col--  
            moved = true  
          }   
          // 左边数字相同且未合并,合并  
          else if (this.grid[i][col - 1] === this.grid[i][col] &&   
                   !merged[col - 1]) {  
            this.grid[i][col - 1] *= 2  
            this.score += this.grid[i][col - 1]  
            this.grid[i][col] = 0  
            merged[col - 1] = true  
            moved = true  
            break  
          }   
          // 无法移动  
          else {  
            break  
          }  
        }  
      }  
    }  
  }  

  return moved  
}

算法关键点:

  • 使用 merged 数组防止一次移动中多次合并
  • 从移动方向的对侧开始遍历
  • 每个方块尽可能移动到最远位置

5. 游戏状态检测

胜利检测

hasWon(): boolean {  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 2048) {  
        return true  
      }  
    }  
  }  
  return false  
}

游戏结束检测

isGameOver(): boolean {  
  // 1. 检查是否有空格  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 0) {  
        return false  
      }  
    }  
  }  

  // 2. 检查是否可以合并  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      const current = this.grid[i][j]  
      // 检查右边  
      if (j < 3 && current === this.grid[i][j + 1]) {  
        return false  
      }  
      // 检查下边  
      if (i < 3 && current === this.grid[i + 1][j]) {  
        return false  
      }  
    }  
  }  

  return true  
}

🎨 界面设计与动画

1. 网格布局

使用 CSS 定位实现精确的网格布局:

.game-container {  
  position: relative;  
  width: 670rpx;  
  height: 670rpx;  
  background-color: #bbada0;  
  border-radius: 12rpx;  
  padding: 20rpx;  
}  

.grid-cell {  
  width: 142.5rpx;  
  height: 142.5rpx;  
  background-color: rgba(238, 228, 218, 0.35);  
  border-radius: 6rpx;  
}

计算公式:

单元格宽度 = (容器宽度 - 内边距*2 - 间距*3) / 4  
142.5rpx = (670 - 20*2 - 20*3) / 4

2. 方块定位

使用动态类名实现方块的精确定位:

<view   
  :class="[  
    'tile',   
    '' + tile.value,  
    'tile-' + tile.row + '-' + tile.col  
  ]"  
>  
  <text class="tile-inner">{{tile.value}}</text>  
</view>

CSS 定位规则:

.tile-position-0-0 { top: 0rpx; left: 0rpx; }  
.tile-position-0-1 { top: 0rpx; left: 162.5rpx; }  
/* ... 16个位置 ... */

3. 出现动画

新方块从小到大弹出:

.tile-new {  
  animation: appear 0.2s ease-in-out;  
}  

@keyframes appear {  
  0% {  
    opacity: 0;  
    transform: scale(0);  
  }  
  100% {  
    opacity: 1;  
    transform: scale(1);  
  }  
}

4. 合并动画

合并时放大再缩小:

.tile-merged {  
  animation: pop 0.2s ease-in-out;  
}  

@keyframes pop {  
  0% { transform: scale(1); }  
  50% { transform: scale(1.2); }  
  100% { transform: scale(1); }  
}

5. 渐变配色

不同数值的方块使用不同颜色:

.tile-2 { background-color: #eee4da; color: #776e65; }  
.tile-4 { background-color: #ede0c8; color: #776e65; }  
.tile-8 { background-color: #f2b179; color: #f9f6f2; }  
.tile-16 { background-color: #f59563; color: #f9f6f2; }  
/* ... */  
.tile-2048 { background-color: #edc22e; color: #f9f6f2; }

🌓 深色模式适配

1. 检测系统主题

detectTheme() {  
  const systemInfo = uni.getSystemInfoSync()  
  // @ts-ignore  
  this.isDarkMode = systemInfo.theme === 'dark' ||   
                    systemInfo.osTheme === 'dark'  
}  

onShow() {  
  // 每次显示时检测主题变化  
  this.detectTheme()  
}

2. 深色模式样式

使用级联选择器为深色模式定制样式:

/* 深色模式容器 */  
.dark-mode {  
  background-color: #1a1a1a;  
}  

/* 深色模式下的游戏网格 */  
.dark-mode .game-container {  
  background-color: #4a4340;  
}  

/* 深色模式下的方块 */  
.dark-mode .tile-2 {   
  background-color: #3a3a3a;  
  color: #c9c9c9;  
}  

.dark-mode .tile-4 {   
  background-color: #4a4a4a;  
  color: #d4d4d4;  
}

设计原则:

  • 降低对比度,减少眼睛疲劳
  • 保持色彩层次感
  • 确保文字清晰可读

💾 数据持久化

使用 uni-app 的本地存储 API 保存最高分:

// 保存最高分  
saveBestScore() {  
  uni.setStorageSync('bestScore', this.bestScore)  
}  

// 加载最高分  
loadBestScore() {  
  const saved = uni.getStorageSync('bestScore')  
  if (saved !== null && saved !== undefined && saved !== '') {  
    this.bestScore = parseInt(saved as string)  
  }  
}  

// 在分数更新时检查  
if (this.score > this.bestScore) {  
  this.bestScore = this.score  
  this.saveBestScore()  
}

📱 多平台适配

鸿蒙6特别优化

  1. 返回键处理(App.uvue):
onLastPageBackPress: function () {  
  if (firstBackTime == 0) {  
    uni.showToast({  
      title: '再按一次退出应用',  
      position: 'bottom',  
    })  
    firstBackTime = Date.now()  
    setTimeout(() => {  
      firstBackTime = 0  
    }, 2000)  
  } else if (Date.now() - firstBackTime < 2000) {  
    uni.exit()  
  }  
}
  1. rpx单位适配

    • rpx 是 uni-app 的响应式单位
    • 750rpx = 屏幕宽度
    • 自动适配不同屏幕尺寸
  2. 触摸事件优化

    • 使用原生触摸事件
    • 最小滑动距离30px
    • 防止误触

🐛 常见问题与解决方案

问题1:方块移动后位置不更新

原因:直接修改数组元素不会触发视图更新

解决方案:使用 updateTiles() 方法重建 tiles 数组

updateTiles() {  
  const newTiles = [] as Tile[]  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] !== 0) {  
        let existingTile = this.tiles.find(t =>   
          t.row === i && t.col === j && !t.isNew  
        )  
        if (existingTile) {  
          existingTile.row = i  
          existingTile.col = j  
          existingTile.value = this.grid[i][j]  
          newTiles.push(existingTile)  
        }  
      }  
    }  
  }  
  this.tiles = newTiles  
}

问题2:一次移动合并多次

原因:没有标记已合并的位置

解决方案:使用 merged 数组记录

let merged = [false, false, false, false]  
// 合并时检查  
if (!merged[col - 1]) {  
  // 执行合并  
  merged[col - 1] = true  
}

问题3:动画不流畅

原因:方块 ID 重复或变化

解决方案:使用全局计数器生成唯一 ID

tileIdCounter: 0  

// 创建新方块时  
tile.id = this.tileIdCounter++

📊 性能优化

1. 减少不必要的渲染

// 只在有移动时才更新  
if (moved) {  
  this.addRandomTile()  
  this.updateTiles()  
}

2. 使用 CSS 动画而非 JS

.tile {  
  transition-property: transform;  
  transition-duration: 0.1s;  
  transition-timing-function: ease-in-out;  
}

3. 合理使用 v-if 和 v-show

<!-- 游戏结束遮罩使用 v-if -->  
<view class="game-message" v-if="gameOver || gameWon">  
  <!-- ... -->  
</view>

🚀 打包发布

鸿蒙应用打包

  1. 配置 manifest.json
  2. 在 HBuilderX 中选择"发行" -> "原生App-云打包"
  3. 选择鸿蒙平台
  4. 配置签名证书
  5. 打包上传

注意事项

  • 准备应用图标(512x512px)
  • 准备启动页图片
  • 填写应用描述和权限说明
  • 测试多种屏幕尺寸

📈 后续优化方向

功能扩展

  1. 撤销功能:保存每一步的状态
  2. 自定义尺寸:支持 3x3、5x5 网格
  3. 主题切换:多种配色方案
  4. 音效反馈:移动、合并音效
  5. 震动反馈:使用 uni.vibrateShort()

社交功能

  1. 排行榜:云端存储最高分
  2. 分享功能:分享到社交平台
  3. 成就系统:解锁各种成就
  4. 每日挑战:特殊模式挑战

体验优化

  1. 引导动画:首次进入时的教程
  2. 手势提示:显示滑动方向
  3. 历史记录:查看历史最高分
  4. 统计数据:游戏次数、时长等

💡 总结

通过这个项目,我们学到了:

  1. uni-app x 的基本用法:页面结构、数据绑定、事件处理
  2. UTS 语言特性:类型定义、类型安全
  3. 游戏算法实现:移动、合并、状态检测
  4. CSS 动画技巧:关键帧动画、过渡效果
  5. 响应式设计:rpx 单位、多屏适配
  6. 鸿蒙应用开发:平台特性、适配要点

uni-app x 是一个强大的跨平台开发框架,特别适合开发鸿蒙应用。通过本项目的实践,希望能帮助你快速上手 uni-app x 开发,创造出更多优秀的应用!

📚 参考资源


作者:坚果派小雨
项目地址: GitCode
开源协议: MIT License

如果觉得本文对你有帮助,欢迎点赞、收藏、分享!有任何问题也欢迎在评论区讨论。


💡 提示:本文涉及的完整代码已开源,你可以直接下载运行,也可以在此基础上进行二次开发。期待看到你的创意!

继续阅读 »

使用 uni-app x 开发2048游戏适配鸿蒙6

作者:坚果派小雨
发布时间:2025年10月
技术栈:uni-app x、UTS、HarmonyOS 6

📖 前言

2048是一款风靡全球的益智游戏,简单而富有策略性。本文将详细介绍如何使用 uni-app x 框架从零开始开发一款2048游戏,并实现深色模式适配、数据持久化等进阶功能。最终产品完美支持鸿蒙HarmonyOS 6、Android、iOS等多个平台。

为什么选择 uni-app x?

  • 🚀 原生性能:UTS 语言编译为原生代码,性能接近原生应用
  • 📱 一次开发,多端运行:支持鸿蒙、Android、iOS、Web等平台
  • 💪 类型安全:基于 TypeScript,享受完整的类型检查
  • 🎯 鸿蒙首选:官方支持鸿蒙6,是开发鸿蒙应用的最佳选择之一

🎯 项目目标

我们将实现以下功能:

  1. ✅ 完整的2048游戏逻辑
  2. ✅ 流畅的触摸手势控制
  3. ✅ 精美的动画效果
  4. ✅ 深色模式自动适配
  5. ✅ 最高分本地存储
  6. ✅ 多平台支持(重点支持鸿蒙6)

📐 架构设计

数据结构设计

游戏的核心是一个 4x4 的二维数组,用于存储每个格子的数值:

// 游戏网格数据  
grid: number[][] = [  
  [0, 0, 0, 0],  
  [0, 0, 0, 0],  
  [0, 0, 0, 0],  
  [0, 0, 0, 0]  
]

为了实现平滑的动画效果,我们需要一个独立的 Tile 数据结构:

type Tile = {  
  id: number          // 唯一标识  
  value: number       // 方块数值  
  row: number         // 行位置  
  col: number         // 列位置  
  isNew: boolean      // 是否是新生成的  
  isMerged: boolean   // 是否刚合并  
}

状态管理

使用 Vue 3 的响应式系统管理游戏状态:

data() {  
  return {  
    grid: [] as number[][],        // 游戏网格  
    tiles: [] as Tile[],           // 显示的方块  
    score: 0,                      // 当前分数  
    bestScore: 0,                  // 最高分  
    gameOver: false,               // 游戏结束  
    gameWon: false,                // 游戏胜利  
    keepPlaying: false,            // 继续游戏  
    tileIdCounter: 0,              // 方块ID计数器  
    isDarkMode: false              // 深色模式  
  }  
}

🔧 核心功能实现

1. 游戏初始化

游戏开始时需要初始化网格并随机生成两个方块:

initGame() {  
  // 初始化4x4网格  
  this.grid = []  
  for (let i = 0; i < 4; i++) {  
    this.grid.push([0, 0, 0, 0])  
  }  

  // 重置状态  
  this.tiles = []  
  this.score = 0  
  this.gameOver = false  
  this.gameWon = false  
  this.tileIdCounter = 0  

  // 添加两个初始方块  
  this.addRandomTile()  
  this.addRandomTile()  
}

2. 随机生成方块

90%概率生成2,10%概率生成4,这是经典2048的设定:

addRandomTile() {  
  // 找出所有空格子  
  const emptyCells = [] as {row: number, col: number}[]  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 0) {  
        emptyCells.push({row: i, col: j})  
      }  
    }  
  }  

  if (emptyCells.length > 0) {  
    // 随机选择一个空格子  
    const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)]  

    // 90%概率生成2,10%概率生成4  
    const value = Math.random() < 0.9 ? 2 : 4  
    this.grid[randomCell.row][randomCell.col] = value  

    // 创建新的方块对象  
    const tile: Tile = {  
      id: this.tileIdCounter++,  
      value: value,  
      row: randomCell.row,  
      col: randomCell.col,  
      isNew: true,  
      isMerged: false  
    }  
    this.tiles.push(tile)  

    // 200ms后移除新方块标记,触发动画  
    setTimeout(() => {  
      tile.isNew = false  
    }, 200)  
  }  
}

3. 触摸手势处理

实现流畅的滑动手势检测:

// 记录触摸起点  
touchStart(e: UniTouchEvent) {  
  this.touchStartX = e.touches[0].pageX  
  this.touchStartY = e.touches[0].pageY  
}  

// 计算滑动方向  
touchEnd(e: UniTouchEvent) {  
  this.touchEndX = e.changedTouches[0].pageX  
  this.touchEndY = e.changedTouches[0].pageY  
  this.handleSwipe()  
}  

// 判断滑动方向  
handleSwipe() {  
  const deltaX = this.touchEndX - this.touchStartX  
  const deltaY = this.touchEndY - this.touchStartY  
  const minSwipeDistance = 30  // 最小滑动距离  

  // 滑动距离太短,忽略  
  if (Math.abs(deltaX) < minSwipeDistance &&   
      Math.abs(deltaY) < minSwipeDistance) {  
    return  
  }  

  // 比较水平和垂直位移,判断主要方向  
  if (Math.abs(deltaX) > Math.abs(deltaY)) {  
    // 水平滑动  
    deltaX > 0 ? this.move('right') : this.move('left')  
  } else {  
    // 垂直滑动  
    deltaY > 0 ? this.move('down') : this.move('up')  
  }  
}

4. 移动和合并算法

这是游戏的核心逻辑。以向左移动为例:

moveLeft(): boolean {  
  let moved = false  

  for (let i = 0; i < 4; i++) {  
    // 记录每个位置是否已经合并过  
    let merged = [false, false, false, false]  

    // 从左到右遍历每一行  
    for (let j = 1; j < 4; j++) {  
      if (this.grid[i][j] !== 0) {  
        let col = j  

        // 尽可能向左移动  
        while (col > 0) {  
          // 左边是空格,移动过去  
          if (this.grid[i][col - 1] === 0) {  
            this.grid[i][col - 1] = this.grid[i][col]  
            this.grid[i][col] = 0  
            col--  
            moved = true  
          }   
          // 左边数字相同且未合并,合并  
          else if (this.grid[i][col - 1] === this.grid[i][col] &&   
                   !merged[col - 1]) {  
            this.grid[i][col - 1] *= 2  
            this.score += this.grid[i][col - 1]  
            this.grid[i][col] = 0  
            merged[col - 1] = true  
            moved = true  
            break  
          }   
          // 无法移动  
          else {  
            break  
          }  
        }  
      }  
    }  
  }  

  return moved  
}

算法关键点:

  • 使用 merged 数组防止一次移动中多次合并
  • 从移动方向的对侧开始遍历
  • 每个方块尽可能移动到最远位置

5. 游戏状态检测

胜利检测

hasWon(): boolean {  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 2048) {  
        return true  
      }  
    }  
  }  
  return false  
}

游戏结束检测

isGameOver(): boolean {  
  // 1. 检查是否有空格  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 0) {  
        return false  
      }  
    }  
  }  

  // 2. 检查是否可以合并  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      const current = this.grid[i][j]  
      // 检查右边  
      if (j < 3 && current === this.grid[i][j + 1]) {  
        return false  
      }  
      // 检查下边  
      if (i < 3 && current === this.grid[i + 1][j]) {  
        return false  
      }  
    }  
  }  

  return true  
}

🎨 界面设计与动画

1. 网格布局

使用 CSS 定位实现精确的网格布局:

.game-container {  
  position: relative;  
  width: 670rpx;  
  height: 670rpx;  
  background-color: #bbada0;  
  border-radius: 12rpx;  
  padding: 20rpx;  
}  

.grid-cell {  
  width: 142.5rpx;  
  height: 142.5rpx;  
  background-color: rgba(238, 228, 218, 0.35);  
  border-radius: 6rpx;  
}

计算公式:

单元格宽度 = (容器宽度 - 内边距*2 - 间距*3) / 4  
142.5rpx = (670 - 20*2 - 20*3) / 4

2. 方块定位

使用动态类名实现方块的精确定位:

<view   
  :class="[  
    'tile',   
    '' + tile.value,  
    'tile-' + tile.row + '-' + tile.col  
  ]"  
>  
  <text class="tile-inner">{{tile.value}}</text>  
</view>

CSS 定位规则:

.tile-position-0-0 { top: 0rpx; left: 0rpx; }  
.tile-position-0-1 { top: 0rpx; left: 162.5rpx; }  
/* ... 16个位置 ... */

3. 出现动画

新方块从小到大弹出:

.tile-new {  
  animation: appear 0.2s ease-in-out;  
}  

@keyframes appear {  
  0% {  
    opacity: 0;  
    transform: scale(0);  
  }  
  100% {  
    opacity: 1;  
    transform: scale(1);  
  }  
}

4. 合并动画

合并时放大再缩小:

.tile-merged {  
  animation: pop 0.2s ease-in-out;  
}  

@keyframes pop {  
  0% { transform: scale(1); }  
  50% { transform: scale(1.2); }  
  100% { transform: scale(1); }  
}

5. 渐变配色

不同数值的方块使用不同颜色:

.tile-2 { background-color: #eee4da; color: #776e65; }  
.tile-4 { background-color: #ede0c8; color: #776e65; }  
.tile-8 { background-color: #f2b179; color: #f9f6f2; }  
.tile-16 { background-color: #f59563; color: #f9f6f2; }  
/* ... */  
.tile-2048 { background-color: #edc22e; color: #f9f6f2; }

🌓 深色模式适配

1. 检测系统主题

detectTheme() {  
  const systemInfo = uni.getSystemInfoSync()  
  // @ts-ignore  
  this.isDarkMode = systemInfo.theme === 'dark' ||   
                    systemInfo.osTheme === 'dark'  
}  

onShow() {  
  // 每次显示时检测主题变化  
  this.detectTheme()  
}

2. 深色模式样式

使用级联选择器为深色模式定制样式:

/* 深色模式容器 */  
.dark-mode {  
  background-color: #1a1a1a;  
}  

/* 深色模式下的游戏网格 */  
.dark-mode .game-container {  
  background-color: #4a4340;  
}  

/* 深色模式下的方块 */  
.dark-mode .tile-2 {   
  background-color: #3a3a3a;  
  color: #c9c9c9;  
}  

.dark-mode .tile-4 {   
  background-color: #4a4a4a;  
  color: #d4d4d4;  
}

设计原则:

  • 降低对比度,减少眼睛疲劳
  • 保持色彩层次感
  • 确保文字清晰可读

💾 数据持久化

使用 uni-app 的本地存储 API 保存最高分:

// 保存最高分  
saveBestScore() {  
  uni.setStorageSync('bestScore', this.bestScore)  
}  

// 加载最高分  
loadBestScore() {  
  const saved = uni.getStorageSync('bestScore')  
  if (saved !== null && saved !== undefined && saved !== '') {  
    this.bestScore = parseInt(saved as string)  
  }  
}  

// 在分数更新时检查  
if (this.score > this.bestScore) {  
  this.bestScore = this.score  
  this.saveBestScore()  
}

📱 多平台适配

鸿蒙6特别优化

  1. 返回键处理(App.uvue):
onLastPageBackPress: function () {  
  if (firstBackTime == 0) {  
    uni.showToast({  
      title: '再按一次退出应用',  
      position: 'bottom',  
    })  
    firstBackTime = Date.now()  
    setTimeout(() => {  
      firstBackTime = 0  
    }, 2000)  
  } else if (Date.now() - firstBackTime < 2000) {  
    uni.exit()  
  }  
}
  1. rpx单位适配

    • rpx 是 uni-app 的响应式单位
    • 750rpx = 屏幕宽度
    • 自动适配不同屏幕尺寸
  2. 触摸事件优化

    • 使用原生触摸事件
    • 最小滑动距离30px
    • 防止误触

🐛 常见问题与解决方案

问题1:方块移动后位置不更新

原因:直接修改数组元素不会触发视图更新

解决方案:使用 updateTiles() 方法重建 tiles 数组

updateTiles() {  
  const newTiles = [] as Tile[]  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] !== 0) {  
        let existingTile = this.tiles.find(t =>   
          t.row === i && t.col === j && !t.isNew  
        )  
        if (existingTile) {  
          existingTile.row = i  
          existingTile.col = j  
          existingTile.value = this.grid[i][j]  
          newTiles.push(existingTile)  
        }  
      }  
    }  
  }  
  this.tiles = newTiles  
}

问题2:一次移动合并多次

原因:没有标记已合并的位置

解决方案:使用 merged 数组记录

let merged = [false, false, false, false]  
// 合并时检查  
if (!merged[col - 1]) {  
  // 执行合并  
  merged[col - 1] = true  
}

问题3:动画不流畅

原因:方块 ID 重复或变化

解决方案:使用全局计数器生成唯一 ID

tileIdCounter: 0  

// 创建新方块时  
tile.id = this.tileIdCounter++

📊 性能优化

1. 减少不必要的渲染

// 只在有移动时才更新  
if (moved) {  
  this.addRandomTile()  
  this.updateTiles()  
}

2. 使用 CSS 动画而非 JS

.tile {  
  transition-property: transform;  
  transition-duration: 0.1s;  
  transition-timing-function: ease-in-out;  
}

3. 合理使用 v-if 和 v-show

<!-- 游戏结束遮罩使用 v-if -->  
<view class="game-message" v-if="gameOver || gameWon">  
  <!-- ... -->  
</view>

🚀 打包发布

鸿蒙应用打包

  1. 配置 manifest.json
  2. 在 HBuilderX 中选择"发行" -> "原生App-云打包"
  3. 选择鸿蒙平台
  4. 配置签名证书
  5. 打包上传

注意事项

  • 准备应用图标(512x512px)
  • 准备启动页图片
  • 填写应用描述和权限说明
  • 测试多种屏幕尺寸

📈 后续优化方向

功能扩展

  1. 撤销功能:保存每一步的状态
  2. 自定义尺寸:支持 3x3、5x5 网格
  3. 主题切换:多种配色方案
  4. 音效反馈:移动、合并音效
  5. 震动反馈:使用 uni.vibrateShort()

社交功能

  1. 排行榜:云端存储最高分
  2. 分享功能:分享到社交平台
  3. 成就系统:解锁各种成就
  4. 每日挑战:特殊模式挑战

体验优化

  1. 引导动画:首次进入时的教程
  2. 手势提示:显示滑动方向
  3. 历史记录:查看历史最高分
  4. 统计数据:游戏次数、时长等

💡 总结

通过这个项目,我们学到了:

  1. uni-app x 的基本用法:页面结构、数据绑定、事件处理
  2. UTS 语言特性:类型定义、类型安全
  3. 游戏算法实现:移动、合并、状态检测
  4. CSS 动画技巧:关键帧动画、过渡效果
  5. 响应式设计:rpx 单位、多屏适配
  6. 鸿蒙应用开发:平台特性、适配要点

uni-app x 是一个强大的跨平台开发框架,特别适合开发鸿蒙应用。通过本项目的实践,希望能帮助你快速上手 uni-app x 开发,创造出更多优秀的应用!

📚 参考资源


作者:坚果派小雨
项目地址: GitCode
开源协议: MIT License

如果觉得本文对你有帮助,欢迎点赞、收藏、分享!有任何问题也欢迎在评论区讨论。


💡 提示:本文涉及的完整代码已开源,你可以直接下载运行,也可以在此基础上进行二次开发。期待看到你的创意!

收起阅读 »

强烈建议在云空间控制台列表显示绑定的前端域名和后端域名或增加ssl管理页

强烈建议在云空间控制台列表增加绑定的前端网页空间和云函数/对象域名,因为目前没有api进行自动化的ssl证书更新覆盖功能,所以云空间多了,导致每次要更新域名的ssl都要找半天,不知道域名在哪个空间,绑定的是前端网页空间还是云函数/对象。

要么就单做一个ssl 频道页,把所有的ssl证书域名全部列出来,在一个界面集中展示管理

继续阅读 »

强烈建议在云空间控制台列表增加绑定的前端网页空间和云函数/对象域名,因为目前没有api进行自动化的ssl证书更新覆盖功能,所以云空间多了,导致每次要更新域名的ssl都要找半天,不知道域名在哪个空间,绑定的是前端网页空间还是云函数/对象。

要么就单做一个ssl 频道页,把所有的ssl证书域名全部列出来,在一个界面集中展示管理

收起阅读 »

uni-app 鸿蒙应用开发实战:优雅解决文件下载存储路径问题

uniapp 教程 鸿蒙next 鸿蒙征文

uni-app 鸿蒙应用开发实战:优雅解决文件下载存储路径问题

基于 uni-app + UTS 插件深度集成 HarmonyOS 文件选择器,让用户自主掌控下载文件的存储位置

一、背景:从痛点出发

在开发基于 uni-app 的鸿蒙应用时,我们遇到了一个典型的用户体验问题:

场景描述:我们的应用是一个视频处理工具,用户可以对视频进行格式转换、提取音频、压缩等操作。处理完成后,用户需要下载处理结果。

遇到的问题

  1. 使用 uni.saveFile API:文件会被自动保存到系统默认路径(通常是应用沙箱目录)
  2. 用户找不到文件:保存成功后,用户不知道文件存在哪里,无法在文件管理器中找到
  3. 分享困难:用户想要分享下载的文件时,需要先找到文件位置,操作繁琐

对比其他平台

  • iOS/Android:可以使用 uni.saveImageToPhotosAlbumuni.saveVideoToPhotosAlbum 保存到相册
  • HarmonyOS:同样支持保存到相册,但对于非视频/图片文件(如音频、文档等),缺少合适的用户可见路径

二、解决方案:HarmonyOS 文件选择器

2.1 技术选型

经过调研,我们发现 HarmonyOS 提供了 picker.DocumentViewPicker API,可以让用户:

  • 自主选择文件保存位置(如:Downloads、Documents 等)
  • 自定义文件名
  • 系统自动管理文件权限
  • 文件管理器中可见

对比方案

方案 优点 缺点 适用场景
uni.saveFile 跨平台,API 简单 文件保存在沙箱目录,用户不可见 应用内部使用的临时文件
uni.saveImageToPhotosAlbum 保存到相册,用户可见 仅支持图片 图片下载
uni.saveVideoToPhotosAlbum 保存到相册,用户可见 仅支持视频 视频下载
picker.DocumentViewPicker 用户自选路径,支持所有文件类型 需要使用 UTS 插件,仅鸿蒙平台 文档、音频等非媒体文件

2.2 技术架构

┌─────────────────────────────────────────────────┐  
│           uni-app Vue 页面层                     │  
│    (tasks.vue - 视频处理任务列表)                │  
└──────────────────┬──────────────────────────────┘  
                   │ 调用  
                   ↓  
┌─────────────────────────────────────────────────┐  
│         UTS 插件:al-downloadFile               │  
│    (跨平台文件下载,鸿蒙平台特殊处理)            │  
└──────────────────┬──────────────────────────────┘  
                   │ 鸿蒙平台  
                   ↓  
┌─────────────────────────────────────────────────┐  
│         HarmonyOS 原生 API                       │  
│  • picker.DocumentViewPicker (文件选择)         │  
│  • http.createHttp (网络下载)                   │  
│  • fs (文件系统操作)                             │  
└─────────────────────────────────────────────────┘

三、技术实现

3.1 创建 UTS 插件

UTS 是 uni-app 推出的全新插件语言,可以直接调用平台原生 API,无需编写原生代码。

项目结构

src/uni_modules/al-downloadFile/  
├── utssdk/  
│   ├── interface.uts              # 插件接口定义  
│   ├── app-harmony/  
│   │   └── index.uts              # 鸿蒙平台实现  
│   ├── app-android/  
│   │   └── index.uts              # Android 平台实现  
│   └── app-ios/  
│       └── index.uts              # iOS 平台实现  
├── package.json                    # 插件配置  
└── readme.md                       # 说明文档

3.2 定义插件接口

首先定义统一的插件接口,确保跨平台调用一致性:

// utssdk/interface.uts  
export type MyApiOptions = {  
  fullUrl: string,      // 下载文件的完整 URL  
  renameUrl: string,    // 保存的文件名(含后缀)  
  fail?: (res: string) => void,     // 失败回调  
  success?: (res: string) => void,  // 成功回调  
}  

export type MyApi = (options: MyApiOptions) => void

3.3 鸿蒙平台实现

核心代码在 utssdk/app-harmony/index.uts 中:

import { MyApiOptions, MyApi } from '../interface.uts';  
import { BusinessError } from '@kit.BasicServicesKit';  
import { picker } from '@kit.CoreFileKit';  
import fs from '@ohos.file.fs';  
import { http } from '@kit.NetworkKit';  

export const hmDownloadFile: MyApi = function (options: MyApiOptions) {  
  const context: Context = getContext();  

  try {  
    // 【第一步】先让用户选择保存位置  
    const documentSaveOptions = new picker.DocumentSaveOptions();  
    documentSaveOptions.newFileNames = [options.renameUrl];  

    // 根据文件后缀设置文件类型过滤器  
    const fileSuffix = options.renameUrl.substring(  
      options.renameUrl.lastIndexOf('.')  
    );  
    const fileTypeDescription = getFileTypeDescription(fileSuffix);  
    documentSaveOptions.fileSuffixChoices = [  
      `${fileTypeDescription}|${fileSuffix}`  
    ];  

    const documentViewPicker = new picker.DocumentViewPicker(context);  

    documentViewPicker.save(documentSaveOptions)  
      .then((documentSaveResult: Array<string>) => {  
        const uri = documentSaveResult[0];  
        console.info('用户选择保存位置成功, uri:', uri);  

        // 【第二步】开始下载文件  
        const httpRequest = http.createHttp();  

        const requestOptions: http.HttpRequestOptions = {  
          method: http.RequestMethod.GET,  
          expectDataType: http.HttpDataType.ARRAY_BUFFER,  
          usingCache: false,  
          connectTimeout: 60000,  
          readTimeout: 60000,  
          maxLimit: 100 * 1024 * 1024, // 最大 100MB  
        };  

        httpRequest.request(  
          options.fullUrl,  
          requestOptions,  
          (err: BusinessError, data: http.HttpResponse) => {  
            if (!err) {  
              try {  
                // 【第三步】写入文件到用户选择的路径  
                if (fs.accessSync(uri)) {  
                  fs.unlinkSync(uri); // 如果文件已存在,先删除  
                }  

                const file = fs.openSync(  
                  uri,  
                  fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE  
                );  

                if (data.result instanceof ArrayBuffer) {  
                  fs.writeSync(file.fd, data.result);  
                } else {  
                  fs.writeSync(file.fd, String(data.result));  
                }  

                fs.closeSync(file.fd);  

                console.info('文件保存成功:', uri);  
                options?.success?.('保存成功');  

              } catch (fileErr) {  
                console.error('文件写入失败:', JSON.stringify(fileErr));  
                options?.fail?.('文件保存失败,请重试');  
              }  
            } else {  
              console.error('文件下载失败:', err);  
              options?.fail?.(`下载失败: ${err.message}`);  
            }  

            httpRequest.destroy();  
          }  
        );  
      })  
      .catch((err: BusinessError) => {  
        if (err.code === 13900042) {  
          // 用户取消选择  
          console.info('用户取消保存');  
          options?.fail?.('用户取消保存');  
        } else {  
          console.error(`选择保存位置失败, code: ${err.code}, message: ${err.message}`);  
          options?.fail?.('选择保存位置失败');  
        }  
      });  

  } catch (err) {  
    console.error('下载异常:', JSON.stringify(err));  
    options?.fail?.('下载失败,请重试');  
  }  
}  

/**  
 * 根据文件后缀获取文件类型描述  
 */  
function getFileTypeDescription(extension: string): string {  
  switch (extension.toLowerCase()) {  
    case '.mp3':  
    case '.m4a':  
    case '.wav':  
    case '.flac':  
      return '音频文件';  
    case '.mp4':  
    case '.mov':  
    case '.avi':  
    case '.mkv':  
      return '视频文件';  
    case '.jpg':  
    case '.jpeg':  
    case '.png':  
    case '.gif':  
    case '.webp':  
      return '图片文件';  
    case '.pdf':  
      return 'PDF文档';  
    case '.doc':  
    case '.docx':  
      return 'Word文档';  
    case '.xls':  
    case '.xlsx':  
      return 'Excel文档';  
    case '.txt':  
      return '文本文档';  
    case '.zip':  
    case '.rar':  
    case '.7z':  
      return '压缩文件';  
    default:  
      return '文件';  
  }  
}

核心代码流程图

关键技术点解析

  1. picker.DocumentViewPicker

    • 弹出系统文件选择器,用户可以自主选择保存路径
    • 支持预设文件名 newFileNames
    • 支持文件类型过滤 fileSuffixChoices
    • 返回 URI 格式的文件路径
  2. http.createHttp

    • HarmonyOS 原生网络请求 API
    • 支持 ArrayBuffer 数据类型(适合二进制文件下载)
    • 可监听下载进度
  3. fs 文件系统操作

    • accessSync:检查文件是否存在
    • unlinkSync:删除文件
    • openSync:打开文件
    • writeSync:写入数据
    • closeSync:关闭文件

3.4 配置插件元信息

package.json 中配置插件的导出信息:

{  
  "id": "al-downloadFile",  
  "version": "1.0.0",  
  "uni_modules": {  
    "uni-ext-api": {  
      "uni": {  
        "openAppProduct": {  
          "name": "hmDownloadFile",  
          "app": {  
            "js": false,  
            "kotlin": false,  
            "swift": false,  
            "arkts": true  // 标记为鸿蒙 ArkTS 实现  
          }  
        }  
      }  
    }  
  }  
}

四、业务层调用

在 Vue 页面中,根据文件类型选择合适的保存方式:

<script setup lang="ts">  
// #ifdef APP-HARMONY  
import { hmDownloadFile } from '@/uni_modules/al-downloadFile'  
// #endif  
// IVideoTask 是我们系统内部定义的 type  
async function handleDownload(task: IVideoTask) {  
  if (!task.processed_file?.url) {  
    uni.showToast({ title: '下载地址不存在', icon: 'none' })  
    return  
  }  

  uni.showLoading({ title: '下载中...' })  

  const fileUrl = task.processed_file.url  
  const extension = fileUrl.split('.').pop().split('?')[0].toLowerCase()  

  const isVideo = isVideoFile(fileUrl)  
  const isImage = isImageFile(fileUrl)  

  if (isVideo || isImage) {  
    // 【方案 A】视频/图片保存到相册  
    uni.downloadFile({  
      url: fileUrl,  
      success: (res) => {  
        if (res.statusCode === 200) {  
          if (isVideo) {  
            uni.saveVideoToPhotosAlbum({  
              filePath: res.tempFilePath,  
              success: () => {  
                uni.hideLoading()  
                uni.showToast({ title: '视频已保存到相册', icon: 'success' })  
              }  
            })  
          } else {  
            uni.saveImageToPhotosAlbum({  
              filePath: res.tempFilePath,  
              success: () => {  
                uni.hideLoading()  
                uni.showToast({ title: '图片已保存到相册', icon: 'success' })  
              }  
            })  
          }  
        }  
      }  
    })  
  } else {  
    // 【方案 B】其他文件(音频、文档等)使用自定义下载器  
    // #ifdef APP-HARMONY  
    hmDownloadFile({  
      fullUrl: fileUrl,  
      renameUrl: `${task.id}-${task.type_label}.${extension}`,  
      success: (res) => {  
        uni.hideLoading()  
        uni.showToast({ title: '下载成功', icon: 'success' })  
      },  
      fail: (err) => {  
        console.error('下载失败:', err)  
        uni.hideLoading()  
        uni.showToast({ title: '下载失败', icon: 'error' })  
      }  
    })  
    // #endif  

    // #ifndef APP-HARMONY  
    // 其他平台使用默认方案  
    uni.saveFile({  
      tempFilePath: res.tempFilePath,  
      success: (saveRes) => {  
        uni.hideLoading()  
        uni.showToast({ title: '保存成功', icon: 'success' })  
      }  
    })  
    // #endif  
  }  
}  
</script>

业务流程图

分层设计优势

  • 视频/图片:保存到相册,符合用户习惯
  • 音频/文档:使用文件选择器,用户自主控制
  • 跨平台兼容:使用条件编译,其他平台回退到 uni.saveFile

五、实际效果

5.1 用户体验对比

优化前(使用 uni.saveFile

  1. 点击下载按钮
  2. 提示"保存成功"
  3. 用户:❓ 文件在哪里?

优化后(使用 picker.DocumentViewPicker

  1. 点击下载按钮
  2. 弹出文件选择器,默认路径为 Downloads
  3. 用户可以:
    • 修改文件名
    • 选择保存位置(Downloads、Documents、我的文件等)
    • 创建新文件夹
  4. 点击"保存"开始下载
  5. 下载完成后提示"保存成功"
  6. 用户在文件管理器中可以立即找到文件

用户操作流程对比图

5.2 实测数据

在我们的应用中(视频处理工具),集成该方案后:

  • 用户反馈问题减少 85%:"文件找不到"相关的客服咨询显著下降
  • 下载成功率提升 20%:用户不再因为找不到文件而重复下载
  • 分享率提升 35%:用户更容易分享处理后的文件

5.3 截图演示

文件选择器界面

文件选择器弹窗截图

  • 显示预设的文件名
  • 可选择保存路径(Downloads、Documents 等)
  • 支持创建新文件夹

文件管理器验证

文件管理器截图

  • 下载的文件清晰可见
  • 文件名正确
  • 可以直接分享或打开

六、开发心得与踩坑记录

6.1 技术难点

1. UTS 插件开发学习曲线

  • 问题:初次接触 UTS,不熟悉语法和 API 调用方式
  • 解决:参考官方 UNI UTS 文档

2. HarmonyOS API 文档查找

  • 问题:鸿蒙 API 文档庞大,不知道使用哪个 API
  • 解决
    • 在华为开发者官网搜索关键词"文件选择"、"文件保存"、"Picker"
    • API搜索地址:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/development-intro-api

3. ArrayBuffer 数据处理

  • 问题:下载大文件时,内存占用过高
  • 解决
    • 设置 maxLimit 限制最大下载大小,华为默认为 5MB,最大100MB
    • 使用流式写入(fs.writeSync 支持分块写入)

6.2 最佳实践

1. 错误码处理

.catch((err: BusinessError) => {  
  if (err.code === 13900042) {  
    // 用户主动取消,不视为错误,不弹 Toast  
    console.info('用户取消保存');  
  } else {  
    // 其他错误才提示用户  
    uni.showToast({ title: '操作失败', icon: 'error' });  
  }  
});

2. 文件名命名规范

// 推荐:任务ID + 操作类型 + 后缀 , 我们系统的命名,实际使用根据你们的命名规则来  
const filename = `${task.id}-${task.type_label}.${extension}`;  

// 不推荐:直接使用时间戳(不利于用户识别)  
const filename = `${Date.now()}.mp3`;

3. 文件类型过滤器

// 提供友好的文件类型描述,帮助用户理解  
documentSaveOptions.fileSuffixChoices = [`音频文件|.mp3`];  

// 而不是  
documentSaveOptions.fileSuffixChoices = [`.mp3`];

6.3 注意事项

  1. 条件编译:UTS 插件在 H5 和小程序中不可用,务必使用 #ifdef APP-HARMONY 包裹
  2. 文件大小限制http.createHttpmaxLimit 参数,默认5MB,建议设置合理值
  3. 网络超时:设置 connectTimeoutreadTimeout,避免长时间等待
  4. 用户取消处理:用户取消文件选择时(错误码 13900042),不要弹出错误提示

七、未来优化方向

7.1 多文件批量下载

支持选择多个文件同时下载,合并为 ZIP 压缩包。

7.2 云存储集成

提供"保存到云盘"选项,集成华为云空间 API。

八、总结

通过这次鸿蒙能力集成实践,我们深刻体会到:

  1. 用户体验至上:技术方案的选择,应始终以提升用户体验为核心目标
  2. 平台特性利用:充分利用 HarmonyOS 的原生能力,而不是简单地跨平台抹平差异
  3. UTS 插件强大:UTS 让我们无需掌握 ArkTS 也能调用鸿蒙原生 API,大大降低开发门槛
  4. 文档很重要:华为开发者文档质量很高,遇到问题多查阅官方文档

关键收获

  • 掌握了 UTS 插件的开发流程
  • 熟悉了 HarmonyOS 文件系统和网络 API
  • 理解了不同文件类型的最佳保存方案
  • 提升了跨平台应用的用户体验

附录

相关文档

继续阅读 »

uni-app 鸿蒙应用开发实战:优雅解决文件下载存储路径问题

基于 uni-app + UTS 插件深度集成 HarmonyOS 文件选择器,让用户自主掌控下载文件的存储位置

一、背景:从痛点出发

在开发基于 uni-app 的鸿蒙应用时,我们遇到了一个典型的用户体验问题:

场景描述:我们的应用是一个视频处理工具,用户可以对视频进行格式转换、提取音频、压缩等操作。处理完成后,用户需要下载处理结果。

遇到的问题

  1. 使用 uni.saveFile API:文件会被自动保存到系统默认路径(通常是应用沙箱目录)
  2. 用户找不到文件:保存成功后,用户不知道文件存在哪里,无法在文件管理器中找到
  3. 分享困难:用户想要分享下载的文件时,需要先找到文件位置,操作繁琐

对比其他平台

  • iOS/Android:可以使用 uni.saveImageToPhotosAlbumuni.saveVideoToPhotosAlbum 保存到相册
  • HarmonyOS:同样支持保存到相册,但对于非视频/图片文件(如音频、文档等),缺少合适的用户可见路径

二、解决方案:HarmonyOS 文件选择器

2.1 技术选型

经过调研,我们发现 HarmonyOS 提供了 picker.DocumentViewPicker API,可以让用户:

  • 自主选择文件保存位置(如:Downloads、Documents 等)
  • 自定义文件名
  • 系统自动管理文件权限
  • 文件管理器中可见

对比方案

方案 优点 缺点 适用场景
uni.saveFile 跨平台,API 简单 文件保存在沙箱目录,用户不可见 应用内部使用的临时文件
uni.saveImageToPhotosAlbum 保存到相册,用户可见 仅支持图片 图片下载
uni.saveVideoToPhotosAlbum 保存到相册,用户可见 仅支持视频 视频下载
picker.DocumentViewPicker 用户自选路径,支持所有文件类型 需要使用 UTS 插件,仅鸿蒙平台 文档、音频等非媒体文件

2.2 技术架构

┌─────────────────────────────────────────────────┐  
│           uni-app Vue 页面层                     │  
│    (tasks.vue - 视频处理任务列表)                │  
└──────────────────┬──────────────────────────────┘  
                   │ 调用  
                   ↓  
┌─────────────────────────────────────────────────┐  
│         UTS 插件:al-downloadFile               │  
│    (跨平台文件下载,鸿蒙平台特殊处理)            │  
└──────────────────┬──────────────────────────────┘  
                   │ 鸿蒙平台  
                   ↓  
┌─────────────────────────────────────────────────┐  
│         HarmonyOS 原生 API                       │  
│  • picker.DocumentViewPicker (文件选择)         │  
│  • http.createHttp (网络下载)                   │  
│  • fs (文件系统操作)                             │  
└─────────────────────────────────────────────────┘

三、技术实现

3.1 创建 UTS 插件

UTS 是 uni-app 推出的全新插件语言,可以直接调用平台原生 API,无需编写原生代码。

项目结构

src/uni_modules/al-downloadFile/  
├── utssdk/  
│   ├── interface.uts              # 插件接口定义  
│   ├── app-harmony/  
│   │   └── index.uts              # 鸿蒙平台实现  
│   ├── app-android/  
│   │   └── index.uts              # Android 平台实现  
│   └── app-ios/  
│       └── index.uts              # iOS 平台实现  
├── package.json                    # 插件配置  
└── readme.md                       # 说明文档

3.2 定义插件接口

首先定义统一的插件接口,确保跨平台调用一致性:

// utssdk/interface.uts  
export type MyApiOptions = {  
  fullUrl: string,      // 下载文件的完整 URL  
  renameUrl: string,    // 保存的文件名(含后缀)  
  fail?: (res: string) => void,     // 失败回调  
  success?: (res: string) => void,  // 成功回调  
}  

export type MyApi = (options: MyApiOptions) => void

3.3 鸿蒙平台实现

核心代码在 utssdk/app-harmony/index.uts 中:

import { MyApiOptions, MyApi } from '../interface.uts';  
import { BusinessError } from '@kit.BasicServicesKit';  
import { picker } from '@kit.CoreFileKit';  
import fs from '@ohos.file.fs';  
import { http } from '@kit.NetworkKit';  

export const hmDownloadFile: MyApi = function (options: MyApiOptions) {  
  const context: Context = getContext();  

  try {  
    // 【第一步】先让用户选择保存位置  
    const documentSaveOptions = new picker.DocumentSaveOptions();  
    documentSaveOptions.newFileNames = [options.renameUrl];  

    // 根据文件后缀设置文件类型过滤器  
    const fileSuffix = options.renameUrl.substring(  
      options.renameUrl.lastIndexOf('.')  
    );  
    const fileTypeDescription = getFileTypeDescription(fileSuffix);  
    documentSaveOptions.fileSuffixChoices = [  
      `${fileTypeDescription}|${fileSuffix}`  
    ];  

    const documentViewPicker = new picker.DocumentViewPicker(context);  

    documentViewPicker.save(documentSaveOptions)  
      .then((documentSaveResult: Array<string>) => {  
        const uri = documentSaveResult[0];  
        console.info('用户选择保存位置成功, uri:', uri);  

        // 【第二步】开始下载文件  
        const httpRequest = http.createHttp();  

        const requestOptions: http.HttpRequestOptions = {  
          method: http.RequestMethod.GET,  
          expectDataType: http.HttpDataType.ARRAY_BUFFER,  
          usingCache: false,  
          connectTimeout: 60000,  
          readTimeout: 60000,  
          maxLimit: 100 * 1024 * 1024, // 最大 100MB  
        };  

        httpRequest.request(  
          options.fullUrl,  
          requestOptions,  
          (err: BusinessError, data: http.HttpResponse) => {  
            if (!err) {  
              try {  
                // 【第三步】写入文件到用户选择的路径  
                if (fs.accessSync(uri)) {  
                  fs.unlinkSync(uri); // 如果文件已存在,先删除  
                }  

                const file = fs.openSync(  
                  uri,  
                  fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE  
                );  

                if (data.result instanceof ArrayBuffer) {  
                  fs.writeSync(file.fd, data.result);  
                } else {  
                  fs.writeSync(file.fd, String(data.result));  
                }  

                fs.closeSync(file.fd);  

                console.info('文件保存成功:', uri);  
                options?.success?.('保存成功');  

              } catch (fileErr) {  
                console.error('文件写入失败:', JSON.stringify(fileErr));  
                options?.fail?.('文件保存失败,请重试');  
              }  
            } else {  
              console.error('文件下载失败:', err);  
              options?.fail?.(`下载失败: ${err.message}`);  
            }  

            httpRequest.destroy();  
          }  
        );  
      })  
      .catch((err: BusinessError) => {  
        if (err.code === 13900042) {  
          // 用户取消选择  
          console.info('用户取消保存');  
          options?.fail?.('用户取消保存');  
        } else {  
          console.error(`选择保存位置失败, code: ${err.code}, message: ${err.message}`);  
          options?.fail?.('选择保存位置失败');  
        }  
      });  

  } catch (err) {  
    console.error('下载异常:', JSON.stringify(err));  
    options?.fail?.('下载失败,请重试');  
  }  
}  

/**  
 * 根据文件后缀获取文件类型描述  
 */  
function getFileTypeDescription(extension: string): string {  
  switch (extension.toLowerCase()) {  
    case '.mp3':  
    case '.m4a':  
    case '.wav':  
    case '.flac':  
      return '音频文件';  
    case '.mp4':  
    case '.mov':  
    case '.avi':  
    case '.mkv':  
      return '视频文件';  
    case '.jpg':  
    case '.jpeg':  
    case '.png':  
    case '.gif':  
    case '.webp':  
      return '图片文件';  
    case '.pdf':  
      return 'PDF文档';  
    case '.doc':  
    case '.docx':  
      return 'Word文档';  
    case '.xls':  
    case '.xlsx':  
      return 'Excel文档';  
    case '.txt':  
      return '文本文档';  
    case '.zip':  
    case '.rar':  
    case '.7z':  
      return '压缩文件';  
    default:  
      return '文件';  
  }  
}

核心代码流程图

关键技术点解析

  1. picker.DocumentViewPicker

    • 弹出系统文件选择器,用户可以自主选择保存路径
    • 支持预设文件名 newFileNames
    • 支持文件类型过滤 fileSuffixChoices
    • 返回 URI 格式的文件路径
  2. http.createHttp

    • HarmonyOS 原生网络请求 API
    • 支持 ArrayBuffer 数据类型(适合二进制文件下载)
    • 可监听下载进度
  3. fs 文件系统操作

    • accessSync:检查文件是否存在
    • unlinkSync:删除文件
    • openSync:打开文件
    • writeSync:写入数据
    • closeSync:关闭文件

3.4 配置插件元信息

package.json 中配置插件的导出信息:

{  
  "id": "al-downloadFile",  
  "version": "1.0.0",  
  "uni_modules": {  
    "uni-ext-api": {  
      "uni": {  
        "openAppProduct": {  
          "name": "hmDownloadFile",  
          "app": {  
            "js": false,  
            "kotlin": false,  
            "swift": false,  
            "arkts": true  // 标记为鸿蒙 ArkTS 实现  
          }  
        }  
      }  
    }  
  }  
}

四、业务层调用

在 Vue 页面中,根据文件类型选择合适的保存方式:

<script setup lang="ts">  
// #ifdef APP-HARMONY  
import { hmDownloadFile } from '@/uni_modules/al-downloadFile'  
// #endif  
// IVideoTask 是我们系统内部定义的 type  
async function handleDownload(task: IVideoTask) {  
  if (!task.processed_file?.url) {  
    uni.showToast({ title: '下载地址不存在', icon: 'none' })  
    return  
  }  

  uni.showLoading({ title: '下载中...' })  

  const fileUrl = task.processed_file.url  
  const extension = fileUrl.split('.').pop().split('?')[0].toLowerCase()  

  const isVideo = isVideoFile(fileUrl)  
  const isImage = isImageFile(fileUrl)  

  if (isVideo || isImage) {  
    // 【方案 A】视频/图片保存到相册  
    uni.downloadFile({  
      url: fileUrl,  
      success: (res) => {  
        if (res.statusCode === 200) {  
          if (isVideo) {  
            uni.saveVideoToPhotosAlbum({  
              filePath: res.tempFilePath,  
              success: () => {  
                uni.hideLoading()  
                uni.showToast({ title: '视频已保存到相册', icon: 'success' })  
              }  
            })  
          } else {  
            uni.saveImageToPhotosAlbum({  
              filePath: res.tempFilePath,  
              success: () => {  
                uni.hideLoading()  
                uni.showToast({ title: '图片已保存到相册', icon: 'success' })  
              }  
            })  
          }  
        }  
      }  
    })  
  } else {  
    // 【方案 B】其他文件(音频、文档等)使用自定义下载器  
    // #ifdef APP-HARMONY  
    hmDownloadFile({  
      fullUrl: fileUrl,  
      renameUrl: `${task.id}-${task.type_label}.${extension}`,  
      success: (res) => {  
        uni.hideLoading()  
        uni.showToast({ title: '下载成功', icon: 'success' })  
      },  
      fail: (err) => {  
        console.error('下载失败:', err)  
        uni.hideLoading()  
        uni.showToast({ title: '下载失败', icon: 'error' })  
      }  
    })  
    // #endif  

    // #ifndef APP-HARMONY  
    // 其他平台使用默认方案  
    uni.saveFile({  
      tempFilePath: res.tempFilePath,  
      success: (saveRes) => {  
        uni.hideLoading()  
        uni.showToast({ title: '保存成功', icon: 'success' })  
      }  
    })  
    // #endif  
  }  
}  
</script>

业务流程图

分层设计优势

  • 视频/图片:保存到相册,符合用户习惯
  • 音频/文档:使用文件选择器,用户自主控制
  • 跨平台兼容:使用条件编译,其他平台回退到 uni.saveFile

五、实际效果

5.1 用户体验对比

优化前(使用 uni.saveFile

  1. 点击下载按钮
  2. 提示"保存成功"
  3. 用户:❓ 文件在哪里?

优化后(使用 picker.DocumentViewPicker

  1. 点击下载按钮
  2. 弹出文件选择器,默认路径为 Downloads
  3. 用户可以:
    • 修改文件名
    • 选择保存位置(Downloads、Documents、我的文件等)
    • 创建新文件夹
  4. 点击"保存"开始下载
  5. 下载完成后提示"保存成功"
  6. 用户在文件管理器中可以立即找到文件

用户操作流程对比图

5.2 实测数据

在我们的应用中(视频处理工具),集成该方案后:

  • 用户反馈问题减少 85%:"文件找不到"相关的客服咨询显著下降
  • 下载成功率提升 20%:用户不再因为找不到文件而重复下载
  • 分享率提升 35%:用户更容易分享处理后的文件

5.3 截图演示

文件选择器界面

文件选择器弹窗截图

  • 显示预设的文件名
  • 可选择保存路径(Downloads、Documents 等)
  • 支持创建新文件夹

文件管理器验证

文件管理器截图

  • 下载的文件清晰可见
  • 文件名正确
  • 可以直接分享或打开

六、开发心得与踩坑记录

6.1 技术难点

1. UTS 插件开发学习曲线

  • 问题:初次接触 UTS,不熟悉语法和 API 调用方式
  • 解决:参考官方 UNI UTS 文档

2. HarmonyOS API 文档查找

  • 问题:鸿蒙 API 文档庞大,不知道使用哪个 API
  • 解决
    • 在华为开发者官网搜索关键词"文件选择"、"文件保存"、"Picker"
    • API搜索地址:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/development-intro-api

3. ArrayBuffer 数据处理

  • 问题:下载大文件时,内存占用过高
  • 解决
    • 设置 maxLimit 限制最大下载大小,华为默认为 5MB,最大100MB
    • 使用流式写入(fs.writeSync 支持分块写入)

6.2 最佳实践

1. 错误码处理

.catch((err: BusinessError) => {  
  if (err.code === 13900042) {  
    // 用户主动取消,不视为错误,不弹 Toast  
    console.info('用户取消保存');  
  } else {  
    // 其他错误才提示用户  
    uni.showToast({ title: '操作失败', icon: 'error' });  
  }  
});

2. 文件名命名规范

// 推荐:任务ID + 操作类型 + 后缀 , 我们系统的命名,实际使用根据你们的命名规则来  
const filename = `${task.id}-${task.type_label}.${extension}`;  

// 不推荐:直接使用时间戳(不利于用户识别)  
const filename = `${Date.now()}.mp3`;

3. 文件类型过滤器

// 提供友好的文件类型描述,帮助用户理解  
documentSaveOptions.fileSuffixChoices = [`音频文件|.mp3`];  

// 而不是  
documentSaveOptions.fileSuffixChoices = [`.mp3`];

6.3 注意事项

  1. 条件编译:UTS 插件在 H5 和小程序中不可用,务必使用 #ifdef APP-HARMONY 包裹
  2. 文件大小限制http.createHttpmaxLimit 参数,默认5MB,建议设置合理值
  3. 网络超时:设置 connectTimeoutreadTimeout,避免长时间等待
  4. 用户取消处理:用户取消文件选择时(错误码 13900042),不要弹出错误提示

七、未来优化方向

7.1 多文件批量下载

支持选择多个文件同时下载,合并为 ZIP 压缩包。

7.2 云存储集成

提供"保存到云盘"选项,集成华为云空间 API。

八、总结

通过这次鸿蒙能力集成实践,我们深刻体会到:

  1. 用户体验至上:技术方案的选择,应始终以提升用户体验为核心目标
  2. 平台特性利用:充分利用 HarmonyOS 的原生能力,而不是简单地跨平台抹平差异
  3. UTS 插件强大:UTS 让我们无需掌握 ArkTS 也能调用鸿蒙原生 API,大大降低开发门槛
  4. 文档很重要:华为开发者文档质量很高,遇到问题多查阅官方文档

关键收获

  • 掌握了 UTS 插件的开发流程
  • 熟悉了 HarmonyOS 文件系统和网络 API
  • 理解了不同文件类型的最佳保存方案
  • 提升了跨平台应用的用户体验

附录

相关文档

收起阅读 »