第31次:示例项目下载功能
本文讲解
DemoDownloadPage.ets和DemoProjectData.ets。这是项目里很有"作品集"气质的一部分:它把 11 个示例项目做成了可浏览、可筛选、可下载、可分享的内容中心。
这个页面到底在做什么
很多人第一次看到"成品下载"以为只是一个文件列表,但当前页面其实做了完整的展示与操作闭环:
- 项目分类筛选
- 项目预览图展示
- 权限标签提示
- 下载 ZIP 包到本地
- 分享项目压缩包
- 视频教程弹层
#mermaid-svg-PSOhEfFxw4U17j6Q{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PSOhEfFxw4U17j6Q .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PSOhEfFxw4U17j6Q .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PSOhEfFxw4U17j6Q .error-icon{fill:#552222;}#mermaid-svg-PSOhEfFxw4U17j6Q .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PSOhEfFxw4U17j6Q .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PSOhEfFxw4U17j6Q .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PSOhEfFxw4U17j6Q .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PSOhEfFxw4U17j6Q .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PSOhEfFxw4U17j6Q .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PSOhEfFxw4U17j6Q .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PSOhEfFxw4U17j6Q .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PSOhEfFxw4U17j6Q .marker.cross{stroke:#333333;}#mermaid-svg-PSOhEfFxw4U17j6Q svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PSOhEfFxw4U17j6Q p{margin:0;}#mermaid-svg-PSOhEfFxw4U17j6Q .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-PSOhEfFxw4U17j6Q .cluster-label text{fill:#333;}#mermaid-svg-PSOhEfFxw4U17j6Q .cluster-label span{color:#333;}#mermaid-svg-PSOhEfFxw4U17j6Q .cluster-label span p{background-color:transparent;}#mermaid-svg-PSOhEfFxw4U17j6Q .label text,#mermaid-svg-PSOhEfFxw4U17j6Q span{fill:#333;color:#333;}#mermaid-svg-PSOhEfFxw4U17j6Q .node rect,#mermaid-svg-PSOhEfFxw4U17j6Q .node circle,#mermaid-svg-PSOhEfFxw4U17j6Q .node ellipse,#mermaid-svg-PSOhEfFxw4U17j6Q .node polygon,#mermaid-svg-PSOhEfFxw4U17j6Q .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-PSOhEfFxw4U17j6Q .rough-node .label text,#mermaid-svg-PSOhEfFxw4U17j6Q .node .label text,#mermaid-svg-PSOhEfFxw4U17j6Q .image-shape .label,#mermaid-svg-PSOhEfFxw4U17j6Q .icon-shape .label{text-anchor:middle;}#mermaid-svg-PSOhEfFxw4U17j6Q .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-PSOhEfFxw4U17j6Q .rough-node .label,#mermaid-svg-PSOhEfFxw4U17j6Q .node .label,#mermaid-svg-PSOhEfFxw4U17j6Q .image-shape .label,#mermaid-svg-PSOhEfFxw4U17j6Q .icon-shape .label{text-align:center;}#mermaid-svg-PSOhEfFxw4U17j6Q .node.clickable{cursor:pointer;}#mermaid-svg-PSOhEfFxw4U17j6Q .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-PSOhEfFxw4U17j6Q .arrowheadPath{fill:#333333;}#mermaid-svg-PSOhEfFxw4U17j6Q .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-PSOhEfFxw4U17j6Q .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-PSOhEfFxw4U17j6Q .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PSOhEfFxw4U17j6Q .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-PSOhEfFxw4U17j6Q .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PSOhEfFxw4U17j6Q .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-PSOhEfFxw4U17j6Q .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-PSOhEfFxw4U17j6Q .cluster text{fill:#333;}#mermaid-svg-PSOhEfFxw4U17j6Q .cluster span{color:#333;}#mermaid-svg-PSOhEfFxw4U17j6Q div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-PSOhEfFxw4U17j6Q .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-PSOhEfFxw4U17j6Q rect.text{fill:none;stroke-width:0;}#mermaid-svg-PSOhEfFxw4U17j6Q .icon-shape,#mermaid-svg-PSOhEfFxw4U17j6Q .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PSOhEfFxw4U17j6Q .icon-shape p,#mermaid-svg-PSOhEfFxw4U17j6Q .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-PSOhEfFxw4U17j6Q .icon-shape .label rect,#mermaid-svg-PSOhEfFxw4U17j6Q .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PSOhEfFxw4U17j6Q .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-PSOhEfFxw4U17j6Q .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-PSOhEfFxw4U17j6Q :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} DemoDownloadPage
DemoProjectData 提供项目数据
分类筛选
项目卡片列表
downloadProject
shareProject
TutorialSheet 视频教程
一、为什么下载页一定要先把项目数据结构化
DemoProjectData.ets 中每个示例项目都带有完整信息:
idnamedescriptioniconfolderNamezipNameimageNamepermissionscategory
这说明下载页不是"从文件夹临时扫一遍就展示",而是有明确的数据层。这样做的好处很多:
- UI 展示字段稳定
- 分类筛选更简单
- 权限提示可以直接从数据里读取
- 未来增加新项目只需要补数据项
这就是典型的"页面不猜资源,数据层提前组织好资源信息"。
二、图文结合看一下页面里真实用到的示例图
当前下载页会直接读取 rawfile 中的预览图。你可以先看几个真实素材:



这些图片的存在,让下载页不再只是冷冰冰的文件列表,而是真正能让用户"看到项目长什么样"。这就是图文结合带来的价值。
三、分类筛选为什么做成横向标签条
示例项目一共有多个分类,例如:
- 社交
- 媒体
- 金融
- 健康
- 生活
- 效率
- 购物
- 教育
- 出行
- 设计
这类分类数量不少,但每个分类标签都比较短,所以最适合用横向滚动标签条来承载。这样既不占太多垂直空间,也方便用户快速切换。
这和源码学习、开源项目模块的分类条设计是一脉相承的,说明整个项目在"分类导航"这一模式上保持了统一。
四、项目卡片为什么必须包含预览图和权限标签
当前 ProjectItem(project) 卡片里,除了名称和描述,还展示了:
- 预览图
- 权限标签
- 下载按钮
- 分享按钮
预览图
帮助用户快速判断项目风格和适用方向。
权限标签
告诉用户这个项目可能会涉及哪些系统能力,比如:
- 相机
- 麦克风
- 定位
这一步尤其重要,因为它能帮助用户在下载前建立心理预期。比如看到"相机"权限,就知道这可能是一个拍照类项目;看到"定位"权限,就知道它可能和地图、运动或生活服务有关。
五、下载功能到底做了哪些步骤
downloadProject(project) 的核心流程非常清楚:
- 通过
getContext(this)获取UIAbilityContext - 从
resourceManager读取 rawfile 中的 ZIP 内容 - 计算本地
filesDir下载路径 - 使用
fileIo写入本地文件 - 成功后弹出提示框展示保存路径
fileIo resourceManager DemoDownloadPage 用户 fileIo resourceManager DemoDownloadPage 用户 #mermaid-svg-qscYunaeq68OkdaD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qscYunaeq68OkdaD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qscYunaeq68OkdaD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qscYunaeq68OkdaD .error-icon{fill:#552222;}#mermaid-svg-qscYunaeq68OkdaD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qscYunaeq68OkdaD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qscYunaeq68OkdaD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qscYunaeq68OkdaD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qscYunaeq68OkdaD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qscYunaeq68OkdaD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qscYunaeq68OkdaD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qscYunaeq68OkdaD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qscYunaeq68OkdaD .marker.cross{stroke:#333333;}#mermaid-svg-qscYunaeq68OkdaD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qscYunaeq68OkdaD p{margin:0;}#mermaid-svg-qscYunaeq68OkdaD .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-qscYunaeq68OkdaD text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-qscYunaeq68OkdaD .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-qscYunaeq68OkdaD .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-qscYunaeq68OkdaD .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-qscYunaeq68OkdaD .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-qscYunaeq68OkdaD #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-qscYunaeq68OkdaD .sequenceNumber{fill:white;}#mermaid-svg-qscYunaeq68OkdaD #sequencenumber{fill:#333;}#mermaid-svg-qscYunaeq68OkdaD #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-qscYunaeq68OkdaD .messageText{fill:#333;stroke:none;}#mermaid-svg-qscYunaeq68OkdaD .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-qscYunaeq68OkdaD .labelText,#mermaid-svg-qscYunaeq68OkdaD .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-qscYunaeq68OkdaD .loopText,#mermaid-svg-qscYunaeq68OkdaD .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-qscYunaeq68OkdaD .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-qscYunaeq68OkdaD .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-qscYunaeq68OkdaD .noteText,#mermaid-svg-qscYunaeq68OkdaD .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-qscYunaeq68OkdaD .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-qscYunaeq68OkdaD .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-qscYunaeq68OkdaD .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-qscYunaeq68OkdaD .actorPopupMenu{position:absolute;}#mermaid-svg-qscYunaeq68OkdaD .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-qscYunaeq68OkdaD .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-qscYunaeq68OkdaD .actor-man circle,#mermaid-svg-qscYunaeq68OkdaD line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-qscYunaeq68OkdaD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击下载getRawFileContent(zipName)openSync / writeSync / closeSyncAlertDialog 提示下载成功
这是一条非常典型的鸿蒙本地资源导出链路。教程里一定要按这个真实流程讲,不然读者只会记住"有个下载按钮",却不知道背后做了什么。
项目里真正执行下载的代码如下:
ts
const context = getContext(this) as common.UIAbilityContext;
const resourceManager = context.resourceManager;
const zipData = await resourceManager.getRawFileContent(project.zipName);
const filesDir = context.filesDir;
const downloadPath = `${filesDir}/${project.zipName}`;
const file = fileIo.openSync(downloadPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY);
fileIo.writeSync(file.fd, zipData.buffer);
fileIo.closeSync(file);
六、分享功能为什么要先保存本地 ZIP
shareProject(project) 并不是直接把 rawfile 资源拿去分享,而是先把 ZIP 从 rawfile 拷到本地,再通过 ShareService 分享本地文件路径。
这一步说明一个很重要的工程实践:
- 应用内资源不一定能直接拿去给外部系统分享
- 更稳妥的做法是先转存到可访问的本地路径
如果分享失败,页面还会降级为文字提示方案。这种"主方案 + 兜底方案"的设计非常值得学习。
七、视频教程弹层为什么也是这页的一部分
下载页底部还有一个"使用教程"入口,点击后会通过 bindSheet 弹出 TutorialSheet(),里面用 Video 组件播放 使用教程.mp4。
这说明作者不希望用户只把项目下载下来,还希望用户知道怎么使用这些 DEMO。对于学习型资源中心来说,这是非常合理的补充:
- 下载解决"拿到资源"
- 视频解决"知道怎么用"
这就是很典型的图文 + 视频结合式说明页面。
视频教程弹层用到的真实代码如下:
ts
Video({
src: $rawfile('使用教程.mp4'),
controller: this.videoController
})
.width('100%')
.height(220)
.controls(true)
.autoPlay(false)
.onStart(() => {
this.isPlaying = true;
})
八、实操步骤
- 打开下载页,切换几个分类标签。
- 对比不同卡片的预览图、描述和权限标签。
- 点击下载,确认是否能成功保存 ZIP。
- 点击分享,观察正常路径和异常兜底提示。
- 打开视频教程弹层,测试播放、暂停、重播。
只要这几个动作都自己走过一遍,你对这一页的实现思路就会很清楚。
九、本篇常见坑
1. 只有文件名,没有视觉预览
用户很难判断项目是否值得下载。
2. 下载前不提示项目所需权限
会降低用户对项目内容的预期清晰度。
3. 分享时直接拿 rawfile 资源去发
更稳定的方式是先导出到本地。
4. 只有下载,没有教程
这样资源拿到了,但上手成本仍然很高。
九、本篇常见坑
1. 只有文件名,没有视觉预览
用户很难判断项目是否值得下载。
2. 下载前不提示项目所需权限
会降低用户对项目内容的预期清晰度。
3. 分享时直接拿 rawfile 资源去发
更稳定的方式是先导出到本地。
4. 只有下载,没有教程
这样资源拿到了,但上手成本仍然很高。
本篇小结
示例项目下载功能的价值,不只是"导出几个 ZIP 文件",而是把项目资源做成了一个有预览、有分类、有说明、有下载、有分享、有教程的完整内容中心。
这一页最值得学习的有两点:
- 资源型页面如何做得有吸引力
- 鸿蒙本地资源如何转换成可下载、可分享的文件
课后练习
- 观察
DemoProjectData,思考如果要增加"推荐指数"字段,最适合在下载页的什么位置展示。 - 想一想为什么视频教程适合放在下载页,而不是首页。
- 设计一个"已下载"状态标记方案,说明你会把它存在哪里。