当前位置:首页>鸿蒙APP>鸿蒙APP从入门到上架实战手册

鸿蒙APP从入门到上架实战手册

  • 2026-08-17 04:27:28
鸿蒙APP从入门到上架实战手册

—— 以短视频APP「快映」为完整案例

版本:v1.0
适用:HarmonyOS NEXT (API 12+) / DevEco Studio 5.0+
案例APP:快映短视频(类抖音的极简版短视频应用)
定位:上岗级实操手册,看完能独立完成从开发到上架的全流程


第1章 鸿蒙开发环境搭建与IDE配置

1.1 鸿蒙开发体系全景图

在动手之前,先搞清楚鸿蒙开发的"全家福",避免概念混淆:

┌─────────────────────────────────────────────────────────┐│ HarmonyOS NEXT ││ ┌─────────────┐ ┌─────────────┐ ┌───────────────┐ ││ │ ArkUI 声明式 │ │ ArkTS 语言 │ │ ArkCompiler │ ││ │ UI 框架 │ │ (TypeScript│ │ 编译器 │ ││ │ │ │ 超集) │ │ │ ││ └──────┬──────┘ └──────┬──────┘ └───────┬───────┘ ││ │ │ │ ││ └────────────────┼─────────────────┘ ││ │ ││ ┌───────▼───────┐ ││ │ DevEco Studio │ ││ │ (IDE) │ ││ └───────┬───────┘ ││ │ ││ ┌───────▼───────┐ ││ │ hvigor 构建 │ ││ │ 工具 │ ││ └───────────────┘ │└─────────────────────────────────────────────────────────┘

核心概念速记:

  • ArkTS
    :鸿蒙主力开发语言,TypeScript的超集,增加了静态类型检查和装饰器
  • ArkUI
    :声明式UI框架,类似Flutter/SwiftUI的写法
  • DevEco Studio
    :官方IDE,基于IntelliJ平台
  • hvigor
    :鸿蒙构建工具链,替代Gradle

1.2 电脑配置要求

别上来就装,先看看你的电脑够不够用:

项目
最低配置
推荐配置
操作系统
Windows 10 64位 / macOS 12+
Windows 11 / macOS 14+
内存
8GB
16GB以上
(模拟器很吃内存)
硬盘
10GB可用空间
50GB SSD(SDK+模拟器+缓存)
CPU
i5 8代 / Ryzen 5
i7 12代 / M系列芯片

避坑提醒:Mac M系列芯片体验最好,Windows上模拟器经常出各种兼容问题。如果是Windows且配置一般,建议直接用真机调试。

1.3 DevEco Studio下载与安装

1.3.1 下载地址

官方下载页:https://developer.huawei.com/consumer/cn/deveco-studio/

选择 DevEco Studio 5.0+ 版本,支持HarmonyOS NEXT。

1.3.2 安装步骤(Windows版)

# 1. 双击安装包,一路Next# 2. 选择安装路径,注意:路径不要有中文和空格!# ✅ 正确:D:\DevEco\DevEco Studio# ❌ 错误:D:\开发工具\DevEco Studio# 3. 勾选 Create Desktop Shortcut# 4. 安装完成后启动

1.3.3 安装步骤(Mac版)

# 1. 拖拽到Applications文件夹# 2. 首次打开如果提示”无法打开”,去:# 系统设置 → 隐私与安全性 → 仍要打开# 3. 或者终端执行:xattr -d com.apple.quarantine /Applications/DevEco\ Studio.app

1.4 Node.js环境配置

DevEco Studio自带Node.js,但建议自己装一个,方便命令行操作:

# 推荐版本:Node.js 18.x LTS# 用nvm管理(Mac/Linux)curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bashnvm install 18nvm use 18# Windows用nvm-windows# 下载地址:https://github.com/coreybutler/nvm-windows/releases

验证安装:

node -v# 应该输出 v18.x.xnpm -v# 应该输出 9.x.x

1.5 SDK下载与配置

首次启动DevEco Studio会进入SDK配置向导:

  1. 选择SDK安装路径
    :同样不要有中文空格
  2. 勾选需要的SDK版本
    :
  • HarmonyOS NEXT SDK (API 12) — 必选
  • HarmonyOS 4.0 SDK (API 10) — 兼容旧设备可选
  1. 勾选工具链
    :
  • ArkTS SDK
  • Native SDK(C++开发用,短视频编解码会用到)
  • hvigor
  1. 点击Next,等待下载完成(约5-10GB,耐心等)

避坑提醒:SDK下载经常失败,多试几次。如果一直失败,可以去华为开发者论坛找离线SDK包手动导入。

1.6 华为账号登录与实名认证

开发鸿蒙应用必须有华为开发者账号:

  1. 访问:https://developer.huawei.com/
  2. 注册华为账号(手机号即可)
  3. 完成实名认证(个人开发者选个人认证)
  4. 在DevEco Studio中登录:
  • 右下角或顶部菜单 Tools → Device Manager
  • 点击登录按钮,扫码登录

重要:不实名认证也能开发调试,但无法上架应用市场,也无法使用部分云服务。建议一开始就搞定。

1.7 模拟器配置与启动

1.7.1 创建模拟器

  1. 打开 Tools → Device Manager
  2. 选择 Remote Emulator(远程模拟器)或 Local Emulator(本地模拟器)
  3. 点击 + Create Device
  4. 选择设备型号:推荐 Mate 60 Pro 或 Pura 70
  5. 选择系统镜像:HarmonyOS NEXT (API 12)
  6. 点击Next → Finish

1.7.2 启动模拟器

点击设备列表右侧的 ▶️ 按钮启动。

避坑提醒:

  • 本地模拟器需要开启VT-x(BIOS中设置)
  • Windows上Hyper-V和模拟器冲突,需要关闭Hyper-V
  • Mac M系列芯片原生支持,体验最好
  • 远程模拟器不需要本地资源,但需要联网,且有使用时长限制

1.8 真机调试配置(推荐)

强烈建议用真机开发,模拟器在视频播放、相机等功能上经常有问题。

1.8.1 开启开发者模式

  1. 手机设置 → 关于手机 → 连续点击"版本号"7次
  2. 返回设置 → 系统和更新 → 开发者选项
  3. 开启"USB调试"和"调试授权"

1.8.2 连接手机

  1. 用USB线连接电脑
  2. 手机上弹出"是否允许USB调试" → 允许
  3. DevEco Studio右上角设备列表中会出现你的手机型号

1.8.3 无线调试(HarmonyOS NEXT支持)

# 1. 先USB连接一次,确保调试授权# 2. 查看设备IP:设置 → 关于手机 → 状态信息# 3. 命令行连接hdc tconn 192.168.1.100:5555# 4. 拔掉USB线,无线调试生效

1.9 第一个Hello World项目

1.9.1 创建项目

  1. File → New → Create Project
  2. 选择 Application → Empty Ability
  3. 填写项目信息:
  • Project name: QuickVideo(快映短视频)
  • Bundle name: com.quickvideo.app(这个很重要,上架不能改)
  • Save location: 路径不要有中文
  • Compatible SDK: API 12
  • Language: ArkTS
  1. 点击Finish,等待项目初始化

1.9.2 项目目录结构

QuickVideo/├── AppScope/# 应用全局配置│ ├── resources/# 全局资源│ └── app.json5# 应用配置文件├── entry/# 主模块│ ├── src/main/│ │ ├── ets/# ArkTS源码│ │ │ ├── entryability/# 应用入口│ │ │ ├── pages/# 页面│ │ │ └── components/# 自定义组件│ │ ├── resources/# 资源文件│ │ └── module.json5# 模块配置│ └── build-profile.json5# 构建配置├── oh_modules/# 依赖包(类似node_modules)├── hvigor/# 构建工具配置├── oh-package.json5# 依赖管理(类似package.json)└── build-profile.json5# 项目构建配置

1.9.3 运行项目

  1. 右上角选择设备(模拟器或真机)
  2. 点击 ▶️ Run按钮
  3. 等待编译安装,手机上会出现APP图标
  4. 打开看到"Hello World"就成功了

1.10 常见环境问题排查

问题
原因
解决方案
SDK下载失败
网络问题
换网络/挂代理/找离线包
模拟器启动黑屏
VT-x未开/Hyper-V冲突
BIOS开VT-x / Windows关闭Hyper-V
hdc命令找不到
环境变量未配
DevEco Studio自带hdc,用Tools → Terminal
真机连不上
USB调试未开/驱动问题
开开发者模式/装华为手机助手
编译报oh-package错误
依赖没装好
删除oh_modules,重新Sync

面试题:鸿蒙开发和Android开发的主要区别是什么?

参考答案:

  1. 语言不同
    :鸿蒙用ArkTS(TS超集),Android用Kotlin/Java
  2. UI范式不同
    :鸿蒙是声明式UI(ArkUI),Android传统是命令式(Jetpack Compose也是声明式了)
  3. 系统架构不同
    :鸿蒙是分布式微内核,Android是Linux宏内核
  4. 跨设备能力
    :鸿蒙天然支持多设备流转,Android需要额外适配
  5. 构建工具不同
    :鸿蒙用hvigor,Android用Gradle

第2章 ArkTS语言核心与声明式UI范式

2.1 ArkTS是什么

ArkTS是鸿蒙的主力开发语言,基于TypeScript扩展而来:

TypeScript → ArkTS(增加了静态类型检查 + 装饰器 + 状态管理)

核心特点:

  • 完全兼容TypeScript语法
  • 强制静态类型(运行时更安全,性能更好)
  • 内置声明式UI装饰器
  • 支持状态管理(@State、@Prop、@Link等)

2.2 基础语法速览

2.2.1 变量与类型

// 基本类型let name: string = '快映短视频'let version: number = 1.0let isVip: boolean = false// 数组let videoList: string[] = ['视频1', '视频2']let userList: Array<User> = []// 对象接口interface Video { id: string title: string duration: number author: string coverUrl: string videoUrl: string likeCount: number isLiked: boolean}// 联合类型let status: 'loading' | 'success' | 'error' = 'loading'// 可选类型let description?: string

2.2.2 函数

// 普通函数function formatDuration(seconds: number): string { const min = Math.floor(seconds / 60) const sec = seconds % 60 return `${min}:${sec.toString().padStart(2, '0')}`}// 箭头函数const formatCount = (count: number): string => { if (count > 10000) { return (count / 10000).toFixed(1) + 'w' } return count.toString()}// 可选参数和默认值function fetchVideoList(page: number = 1, pageSize?: number): Video[] { // ... return []}

2.2.3 类与继承

class BaseViewModel { protected isLoading: boolean = false showLoading(): void { this.isLoading = true } hideLoading(): void { this.isLoading = false }}class VideoViewModel extends BaseViewModel { private videoList: Video[] = [] async loadMore(): Promise<void> { this.showLoading() // 加载更多逻辑 this.hideLoading() }}

2.3 声明式UI核心概念

ArkUI的核心思想:UI = f(State),状态驱动视图。

状态变化 → 框架自动重新渲染 → UI更新

2.3.1 基础组件结构

@Entry // 标记为页面入口@Component // 标记为组件struct VideoPage { // 状态变量 @State videoList: Video[] = [] @State currentIndex: number = 0 // 构建方法,必须叫build build() { // 根容器 Column() { // 子组件 Text('快映短视频') .fontSize(20) .fontWeight(FontWeight.Bold) // 列表 List() { ForEach(this.videoList, (item: Video) => { ListItem() { VideoItemCard(item) } }) } } .width('100%') .height('100%') .backgroundColor('#F5F5F5') }}

链式调用:每个组件后面的 .xxx() 是属性方法,用来设置样式。

2.4 常用布局容器

2.4.1 Column(垂直布局)

Column() { Text('第一行') Text('第二行') Text('第三行')}.width('100%').height(200).justifyContent(FlexAlign.Center) // 主轴对齐(垂直方向居中).alignItems(HorizontalAlign.Center) // 交叉轴对齐(水平方向居中).space(10) // 子元素间距

2.4.2 Row(水平布局)

Row() { Image($r('app.media.icon_like')) .width(24) .height(24) Text(this.likeCount) .fontSize(14) .fontColor('#666')}.space(4).alignItems(VerticalAlign.Center)

2.4.3 Stack(堆叠布局)

短视频封面+播放按钮就是典型的堆叠:

Stack({ alignContent: Alignment.Center }) { // 底层:视频封面 Image(this.video.coverUrl) .width('100%') .height('100%') .objectFit(ImageFit.Cover) // 中层:渐变遮罩 LinearGradient(...) // 顶层:播放按钮 Image($r('app.media.icon_play')) .width(60) .height(60) .opacity(0.8)}.width('100%').aspectRatio(9 / 16)

2.4.4 Flex布局

Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { // 标签列表 ForEach(this.tags, (tag: string) => { Text(tag) .fontSize(12) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor('#EEE') .borderRadius(12) .margin(4) })}.width('100%')

2.4.5 List(列表)

短视频Feed流的核心就是List:

List({ scroller: this.listScroller }) { ForEach(this.videoList, (item: Video, index: number) => { ListItem() { VideoItem({ video: item, index: index }) } .width('100%') .height('100%') // 每个视频占满一屏 })}.width('100%').height('100%').listDirection(Axis.Vertical) // 垂直滚动.scrollBar(BarState.Off) // 隐藏滚动条.cachedCount(3) // 缓存3个item,优化性能.onScrollIndex((first: number) => { // 滚动到第几个item的回调 this.currentIndex = first this.onVideoChange(first)})

2.5 状态管理装饰器详解

这是ArkUI最核心也最容易搞混的部分,用短视频场景一个个讲清楚。

2.5.1 @State(组件内部状态)

组件自己用的状态,变化后触发当前组件重新渲染。

@Componentstruct LikeButton { @State isLiked: boolean = false @State likeCount: number = 100 build() { Row() { Image(this.isLiked ? $r('app.media.icon_liked') : $r('app.media.icon_like')) .width(28) .height(28) .onClick(() => { this.isLiked = !this.isLiked this.likeCount += this.isLiked ? 1 : -1 }) Text(this.likeCount.toString()) .fontSize(12) .fontColor('#FFF') } .space(4) }}

2.5.2 @Prop(父传子,单向)

父组件传给子组件,子组件只能读不能改(或者改了不影响父组件)。

// 父组件@Componentstruct VideoItem { @State video: Video build() { VideoInfo({ videoData: this.video }) }}// 子组件@Componentstruct VideoInfo { @Prop videoData: Video // 单向,子组件修改不影响父组件 build() { Column() { Text(this.videoData.author) .fontSize(16) .fontColor('#FFF') Text(this.videoData.title) .fontSize(14) .fontColor('#FFF') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) }}

2.5.3 @Link(父子双向绑定)

子组件修改会同步到父组件,类似Vue的v-model。

// 父组件@Componentstruct VideoPlayer { @State isPlaying: boolean = false build() { Stack() { // 视频画面... PlayControl({ isPlaying: $isPlaying }) // 用$传引用 } }}// 子组件@Componentstruct PlayControl { @Link isPlaying: boolean // 双向绑定 build() { Image(this.isPlaying ? $r('app.media.icon_pause') : $r('app.media.icon_play')) .width(60) .height(60) .onClick(() => { this.isPlaying = !this.isPlaying // 修改会同步到父组件 }) }}

2.5.4 @Provide / @Consume(跨层级传递)

爷孙组件直接传数据,不用一层层@Prop往下传。

// 爷爷组件@Componentstruct HomePage { @Provide('currentTheme') theme: string = 'dark' build() { Column() { VideoFeed() // 中间层,不用管theme } }}// 孙子组件@Componentstruct VideoItem { @Consume('currentTheme') theme: string // 直接拿到爷爷的theme build() { Text('视频标题') .fontColor(this.theme === 'dark' ? '#FFF' : '#000') }}

2.5.5 @Observed / @ObjectLink(监听对象内部变化)

默认@State只监听对象引用变化,对象内部属性变化不触发更新。需要用@Observed装饰类。

// 模型类加@Observed@Observedclass VideoModel { id: string = '' title: string = '' likeCount: number = 0 isLiked: boolean = false constructor(id: string, title: string) { this.id = id this.title = title }}// 组件中用@ObjectLink@Componentstruct VideoCard { @ObjectLink video: VideoModel // 监听对象内部变化 build() { Column() { Text(this.video.title) Text(`点赞:${this.video.likeCount}`) Button('点赞') .onClick(() => { this.video.likeCount++ // 属性变化会触发UI更新 this.video.isLiked = true }) } }}

避坑提醒:这是新手最容易踩的坑!对象内部属性改了但UI没更新?99%是因为没加@Observed/@ObjectLink。

2.6 自定义组件封装

2.6.1 基础封装

// 自定义视频卡片组件@Componentexport struct VideoCard { // 入参 @Prop video: Video @Prop showAuthor: boolean = true // 事件回调(类似Vue的emit) onPlay?: () => void onLike?: () => void build() { Column() { // 封面 Stack({ alignContent: Alignment.Center }) { Image(this.video.coverUrl) .width('100%') .aspectRatio(3 / 4) .objectFit(ImageFit.Cover) .borderRadius(8) Image($r('app.media.icon_play_small')) .width(32) .height(32) } .onClick(() => { this.onPlay?.() }) // 标题 Text(this.video.title) .fontSize(14) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .margin({ top: 8 }) // 作者信息 if (this.showAuthor) { Row() { Image(this.video.authorAvatar) .width(20) .height(20) .borderRadius(10) Text(this.video.authorName) .fontSize(12) .fontColor('#999') .margin({ left: 4 }) } .margin({ top: 6 }) } } .width('100%') .padding(8) .backgroundColor('#FFF') .borderRadius(12) }}

2.6.2 使用自定义组件

// 导入import { VideoCard } from '../components/VideoCard'// 使用VideoCard({  video: item, showAuthor: true, onPlay: () => { this.playVideo(item) }, onLike: () => { this.toggleLike(item) }})

2.7 页面路由与导航

2.7.1 路由配置(module.json5)

{ ”module”: { ”abilities”: [ { ”name”: ”EntryAbility”, ”pages”: [ ”pages/Index”, ”pages/HomePage”, ”pages/VideoDetailPage”, ”pages/ProfilePage”, ”pages/LoginPage” ] } ] }}

2.7.2 页面跳转

import router from '@ohos.router'// 跳转到视频详情页router.pushUrl({ url: 'pages/VideoDetailPage', params: { videoId: this.video.id }})// 接收参数(目标页面)@Entry@Componentstruct VideoDetailPage { private videoId: string = '' aboutToAppear() { // 获取路由参数 const params = router.getParams() as Record<string, Object> this.videoId = params['videoId'] as string } build() { // ... }}// 返回上一页router.back()// 返回指定页面router.back({ url: 'pages/HomePage' })

2.7.3 底部Tab导航(短视频APP标配)

@Entry@Componentstruct MainPage { @State currentTab: number = 0 private tabs: string[] = ['首页', '发现', '发布', '消息', '我的'] build() { Column() { // 内容区域 Stack() { if (this.currentTab === 0) { HomeTab() } else if (this.currentTab === 1) { DiscoverTab() } else if (this.currentTab === 2) { PublishTab() } else if (this.currentTab === 3) { MessageTab() } else { ProfileTab() } } .layoutWeight(1) // 占满剩余空间 // 底部Tab栏 Row() { ForEach(this.tabs, (tab: string, index: number) => { Column() { Image(this.getTabIcon(index)) .width(24) .height(24) Text(tab) .fontSize(10) .fontColor(this.currentTab === index ? '#FF2D55' : '#999') } .layoutWeight(1) .justifyContent(FlexAlign.Center) .height(56) .onClick(() => { this.currentTab = index }) }) } .width('100%') .backgroundColor('#FFF') .border({ width: { top: 1 }, color: '#EEE' }) } .width('100%') .height('100%') } private getTabIcon(index: number): Resource { // 根据选中状态返回不同图标 const icons = [ $r('app.media.tab_home'), $r('app.media.tab_discover'), $r('app.media.tab_publish'), $r('app.media.tab_message'), $r('app.media.tab_profile') ] return icons[index] }}

2.8 生命周期钩子

@Entry@Componentstruct VideoPage { // 页面即将出现(类似onCreate) aboutToAppear() { console.log('页面即将显示') this.loadVideoList() } // 页面已经显示 onPageShow() { console.log('页面已显示') this.resumePlay() } // 页面隐藏 onPageHide() { console.log('页面已隐藏') this.pausePlay() } // 页面即将销毁 aboutToDisappear() { console.log('页面即将销毁') this.releasePlayer() } build() { // ... }}

2.9 资源管理与主题

2.9.1 资源目录结构

resources/├── base/│ ├── element/ // 字符串、颜色、数值等│ │ └── string.json│ ├── media/ // 图片资源│ │ ├── icon_like.png│ │ └── ...│ └── profile/ // 配置文件├── dark/ // 暗色模式资源│ └── element/│ └── color.json└── en_US/ // 英文资源 └── element/ └── string.json

2.9.2 字符串资源(string.json)

{ ”string”: [ { ”name”: ”app_name”, ”value”: ”快映短视频” }, { ”name”: ”home_tab”, ”value”: ”首页” }, { ”name”: ”like_count”, ”value”: ”%s人点赞” } ]}

使用:

Text($r('app.string.app_name'))// 带参数Text($r('app.string.like_count', '100'))

2.9.3 颜色资源(color.json)

{ ”color”: [ { ”name”: ”primary_color”, ”value”: ”#FF2D55” }, { ”name”: ”text_primary”, ”value”: ”#333333” }, { ”name”: ”text_secondary”, ”value”: ”#999999” }, { ”name”: ”bg_color”, ”value”: ”#F5F5F5” } ]}

2.10 本章避坑指南

  1. 对象属性变化UI不更新
     → 检查是否加了@Observed/@ObjectLink
  2. 列表滚动卡顿
     → 检查是否加了cachedCount,图片是否做了缓存
  3. 样式不生效
     → 检查链式调用顺序,有些属性会被后面的覆盖
  4. 路由跳转没反应
     → 检查module.json5里有没有注册页面
  5. 图片不显示
     → 检查资源路径,网络图片要加https

面试题:@State、@Prop、@Link三者的区别是什么?

参考答案:

  • @State:组件内部状态,自己管理自己用,变化触发当前组件重绘
  • @Prop:父传子,单向数据流,子组件修改不影响父组件
  • @Link:父子双向绑定,子组件修改会同步到父组件,用$符号传递

第3章 短视频APP项目架构设计与目录规范

3.1 为什么要做架构设计

新手写代码的特点:所有逻辑堆在页面里,一个文件几千行,改一个bug出三个bug。

好架构的标准:

  • 分层清晰,各司其职
  • 易扩展:加功能不用大改
  • 易维护:别人接手能看懂
  • 可测试:核心逻辑能单测

3.2 MVVM架构选型

鸿蒙官方推荐MVVM,我们也用这个。

┌─────────────────────────────────────────┐│ View(页面/组件) ││ 只负责UI展示和用户交互,不写业务逻辑 │└──────────────┬──────────────────────────┘ │ 绑定(@State / @Link) ▼┌─────────────────────────────────────────┐│ ViewModel(视图模型) ││ 业务逻辑处理、数据转换、状态管理 │└──────────────┬──────────────────────────┘ │ 调用 ▼┌─────────────────────────────────────────┐│ Model(数据层) ││ 网络请求、数据库、本地存储 │└─────────────────────────────────────────┘

3.3 完整目录结构

QuickVideo/├── AppScope/│ ├── resources/│ └── app.json5├── entry/│ └── src/main/│ ├── ets/│ │ ├── entryability/│ │ │ └── EntryAbility.ets# 应用入口│ │ ││ │ ├── pages/# 页面层(View)│ │ │ ├── home/# 首页模块│ │ │ │ ├── HomePage.ets│ │ │ │ └── HomeViewModel.ets│ │ │ ├── video/# 视频模块│ │ │ │ ├── VideoDetailPage.ets│ │ │ │ ├── VideoDetailViewModel.ets│ │ │ │ └── components/# 页面级组件│ │ │ │ ├── VideoPlayer.ets│ │ │ │ └── CommentList.ets│ │ │ ├── publish/# 发布模块│ │ │ │ ├── PublishPage.ets│ │ │ │ └── PublishViewModel.ets│ │ │ ├── profile/# 个人中心│ │ │ │ ├── ProfilePage.ets│ │ │ │ └── ProfileViewModel.ets│ │ │ └── login/# 登录模块│ │ │ ├── LoginPage.ets│ │ │ └── LoginViewModel.ets│ │ ││ │ ├── components/# 通用组件(跨页面复用)│ │ │ ├── common/# 基础组件│ │ │ │ ├── QVButton.ets│ │ │ │ ├── QVLoading.ets│ │ │ │ └── QVEmpty.ets│ │ │ ├── video/# 视频相关组件│ │ │ │ ├── VideoCard.ets│ │ │ │ ├── VideoFeed.ets│ │ │ │ └── LikeAnimation.ets│ │ │ └── user/# 用户相关组件│ │ │ ├── UserAvatar.ets│ │ │ └── FollowButton.ets│ │ ││ │ ├── model/# 模型层│ │ │ ├── api/# 网络请求│ │ │ │ ├── VideoApi.ets│ │ │ │ ├── UserApi.ets│ │ │ │ └── HttpManager.ets# 网络封装│ │ │ ├── repository/# 数据仓库│ │ │ │ ├── VideoRepository.ets│ │ │ │ └── UserRepository.ets│ │ │ └── bean/# 数据模型│ │ │ ├── VideoBean.ets│ │ │ ├── UserBean.ets│ │ │ └── CommentBean.ets│ │ ││ │ ├── utils/# 工具类│ │ │ ├── Logger.ets# 日志封装│ │ │ ├── TimeUtils.ets# 时间格式化│ │ │ ├── StringUtils.ets# 字符串工具│ │ │ ├── StorageUtils.ets# 本地存储│ │ │ └── ToastUtils.ets# Toast封装│ │ ││ │ ├── constants/# 常量│ │ │ ├── ApiConstants.ets# 接口地址│ │ │ ├── RouteConstants.ets# 路由地址│ │ │ └── AppConstants.ets# 应用常量│ │ ││ │ └── common/# 公共状态/主题│ │ ├── AppState.ets# 全局状态│ │ └── ThemeConfig.ets# 主题配置│ ││ └── resources/# 资源文件│├── oh_modules/├── hvigor/├── oh-package.json5└── build-profile.json5

3.4 网络层封装(HttpManager)

这是每个项目的基础,先把网络请求封装好。

// model/api/HttpManager.etsimport http from '@ohos.net.http'import { Logger } from '../../utils/Logger'import { ApiConstants } from '../../constants/ApiConstants'// 统一响应格式export interface ApiResponse<T> { code: number message: string data: T}export class HttpManager { private static instance: HttpManager private httpRequest: http.HttpRequest private constructor() { this.httpRequest = http.createHttp() } static getInstance(): HttpManager { if (!HttpManager.instance) { HttpManager.instance = new HttpManager() } return HttpManager.instance } // GET请求 async get<T>(url: string, params?: Record<string, string | number>): Promise<ApiResponse<T>> { // 拼接参数 let fullUrl = ApiConstants.BASE_URL + url if (params) { const queryString = Object.entries(params) .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`) .join('&') fullUrl += '?' + queryString } Logger.info('GET请求:', fullUrl) try { const response = await this.httpRequest.request(fullUrl, { method: http.RequestMethod.GET, header: this.getHeaders(), connectTimeout: 10000, readTimeout: 15000 }) return this.handleResponse<T>(response) } catch (error) { Logger.error('GET请求失败:', JSON.stringify(error)) throw error } } // POST请求 async post<T>(url: string, data?: Record<string, Object>): Promise<ApiResponse<T>> { const fullUrl = ApiConstants.BASE_URL + url Logger.info('POST请求:', fullUrl, JSON.stringify(data)) try { const response = await this.httpRequest.request(fullUrl, { method: http.RequestMethod.POST, header: { ...this.getHeaders(), 'Content-Type': 'application/json' }, extraData: JSON.stringify(data), connectTimeout: 10000, readTimeout: 15000 }) return this.handleResponse<T>(response) } catch (error) { Logger.error('POST请求失败:', JSON.stringify(error)) throw error } } // 处理响应 private handleResponse<T>(response: http.HttpResponse): ApiResponse<T> { if (response.responseCode === 200) { const result = JSON.parse(response.result as string) as ApiResponse<T> if (result.code === 0) { return result } else { Logger.error('业务错误:', result.message) throw new Error(result.message) } } else { Logger.error('HTTP错误:', response.responseCode) throw new Error(`网络错误: ${response.responseCode}`) } } // 请求头 private getHeaders(): Record<string, string> { const token = AppState.getInstance().token return { 'Authorization': token ? `Bearer ${token}` : '', 'User-Agent': 'QuickVideo/1.0', 'deviceType': 'harmonyos' } }}

3.5 数据仓库层(Repository)

ViewModel不直接调用Api,而是通过Repository,方便后续加缓存、换数据源。

// model/repository/VideoRepository.etsimport { HttpManager } from '../api/HttpManager'import { VideoBean } from '../bean/VideoBean'import { VideoApi } from '../api/VideoApi'export class VideoRepository { private static instance: VideoRepository static getInstance(): VideoRepository { if (!VideoRepository.instance) { VideoRepository.instance = new VideoRepository() } return VideoRepository.instance } // 获取视频列表 async getVideoList(page: number, pageSize: number): Promise<VideoBean[]> { const response = await HttpManager.getInstance().get<VideoBean[]>( VideoApi.VIDEO_LIST, { page: page, pageSize: pageSize } ) return response.data } // 获取视频详情 async getVideoDetail(videoId: string): Promise<VideoBean> { const response = await HttpManager.getInstance().get<VideoBean>( VideoApi.VIDEO_DETAIL + videoId ) return response.data } // 点赞/取消点赞 async toggleLike(videoId: string, isLike: boolean): Promise<void> { await HttpManager.getInstance().post( isLike ? VideoApi.LIKE : VideoApi.UNLIKE, { videoId: videoId } ) } // 发布评论 async publishComment(videoId: string, content: string): Promise<void> { await HttpManager.getInstance().post( VideoApi.PUBLISH_COMMENT, { videoId: videoId, content: content } ) }}

3.6 ViewModel基类封装

// common/BaseViewModel.etsimport { Logger } from '../utils/Logger'export abstract class BaseViewModel { isLoading: boolean = false errorMessage: string = '' protected showLoading(): void { this.isLoading = true this.errorMessage = '' } protected hideLoading(): void { this.isLoading = false } protected handleError(error: Error): void { this.isLoading = false this.errorMessage = error.message Logger.error(error.message) } // 页面加载时调用,子类重写 onPageLoad(): void {} // 页面销毁时调用,子类重写 onPageDestroy(): void {}}

3.7 全局状态管理(AppState)

用户信息、token这种全局共享的数据,用单例管理。

// common/AppState.etsimport { UserBean } from '../model/bean/UserBean'import { StorageUtils } from '../utils/StorageUtils'export class AppState { private static instance: AppState // 用户信息 userInfo: UserBean | null = null // 登录token token: string = '' // 是否已登录 isLoggedIn: boolean = false private constructor() { // 启动时从本地读取 this.loadFromStorage() } static getInstance(): AppState { if (!AppState.instance) { AppState.instance = new AppState() } return AppState.instance } // 登录成功后保存 setLoginInfo(user: UserBean, token: string): void { this.userInfo = user this.token = token this.isLoggedIn = true // 持久化存储 StorageUtils.set('user_info', JSON.stringify(user)) StorageUtils.set('token', token) } // 退出登录 logout(): void { this.userInfo = null this.token = '' this.isLoggedIn = false StorageUtils.remove('user_info') StorageUtils.remove('token') } // 从本地加载 private loadFromStorage(): void { const token = StorageUtils.get('token') if (token) { this.token = token this.isLoggedIn = true const userStr = StorageUtils.get('user_info') if (userStr) { this.userInfo = JSON.parse(userStr) as UserBean } } }}

3.8 工具类封装

3.8.1 日志工具

// utils/Logger.etsimport hilog from '@ohos.hilog'const TAG = 'QuickVideo'const DOMAIN = 0x0001export class Logger { static debug(...args: string[]): void { hilog.debug(DOMAIN, TAG, args.join(' ')) } static info(...args: string[]): void { hilog.info(DOMAIN, TAG, args.join(' ')) } static warn(...args: string[]): void { hilog.warn(DOMAIN, TAG, args.join(' ')) } static error(...args: string[]): void { hilog.error(DOMAIN, TAG, args.join(' ')) }}

3.8.2 时间格式化

// utils/TimeUtils.etsexport class TimeUtils { // 格式化视频时长:秒 → 分:秒 static formatDuration(seconds: number): string { const min = Math.floor(seconds / 60) const sec = Math.floor(seconds % 60) return `${min}:${sec.toString().padStart(2, '0')}` } // 格式化发布时间:时间戳 → 友好显示 static formatPublishTime(timestamp: number): string { const now = Date.now() const diff = now - timestamp const minute = 60 * 1000 const hour = 60 * minute const day = 24 * hour if (diff < minute) { return '刚刚' } else if (diff < hour) { return Math.floor(diff / minute) + '分钟前' } else if (diff < day) { return Math.floor(diff / hour) + '小时前' } else if (diff < 7 * day) { return Math.floor(diff / day) + '天前' } else { const date = new Date(timestamp) return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}` } } // 格式化数字:10000 → 1.0w static formatNumber(num: number): string { if (num >= 100000000) { return (num / 100000000).toFixed(1) + '亿' } else if (num >= 10000) { return (num / 10000).toFixed(1) + 'w' } return num.toString() }}

3.8.3 本地存储

// utils/StorageUtils.etsimport preferences from '@ohos.data.preferences'import { common } from '@kit.AbilityKit'export class StorageUtils { private static preferences: preferences.Preferences | null = null private static async getPreferences(): Promise<preferences.Preferences> { if (!this.preferences) { const context = getContext() as common.UIAbilityContext this.preferences = await preferences.getPreferences(context, 'quick_video_prefs') } return this.preferences } static async set(key: string, value: string): Promise<void> { const prefs = await this.getPreferences() await prefs.put(key, value) await prefs.flush() } static async get(key: string, defaultValue: string = ''): Promise<string> { const prefs = await this.getPreferences() return (await prefs.get(key, defaultValue)) as string } static async remove(key: string): Promise<void> { const prefs = await this.getPreferences() await prefs.delete(key) await prefs.flush() } static async clear(): Promise<void> { const prefs = await this.getPreferences() await prefs.clear() await prefs.flush() }}

3.9 常量定义

// constants/ApiConstants.etsexport class ApiConstants { // 基础地址(开发环境) static readonly BASE_URL = 'https://api.quickvideo.com/v1' // 视频相关 static readonly VIDEO_LIST = '/video/list' static readonly VIDEO_DETAIL = '/video/detail/' static readonly LIKE = '/video/like' static readonly UNLIKE = '/video/unlike' static readonly PUBLISH_COMMENT = '/video/comment'}// constants/RouteConstants.etsexport class RouteConstants { static readonly HOME = 'pages/home/HomePage' static readonly VIDEO_DETAIL = 'pages/video/VideoDetailPage' static readonly LOGIN = 'pages/login/LoginPage' static readonly PROFILE = 'pages/profile/ProfilePage' static readonly PUBLISH = 'pages/publish/PublishPage'}// constants/AppConstants.etsexport class AppConstants { static readonly PAGE_SIZE = 10 static readonly MAX_VIDEO_DURATION = 60 // 最长60秒 static readonly MAX_VIDEO_SIZE = 100 * 1024 * 1024 // 100MB}

3.10 本章避坑指南

  1. 目录不要太深
    :最多3-4层,太深了找文件累
  2. 组件复用原则
    :两个页面以上用到的组件才抽出来,不要过度设计
  3. ViewModel不要直接操作UI
    :ViewModel只处理数据,UI更新靠状态驱动
  4. 网络请求一定要封装
    :不然后面加token、加loading、加错误处理会疯掉
  5. 常量统一管理
    :接口地址、路由地址别散落在各个文件里

面试题:为什么要用MVVM架构?MVC和MVVM的区别是什么?

参考答案:

  • MVC中Controller既处理逻辑又操作View,容易臃肿,耦合度高
  • MVVM通过数据绑定解耦,ViewModel不直接引用View,更易测试
  • 声明式UI天然适合MVVM,状态驱动视图更新
  • 分层清晰后,业务逻辑可以复用,换UI不影响逻辑

第4章 短视频首页Feed流实现(核心模块)

4.1 Feed流交互设计

抖音式的全屏上下滑动是短视频APP的标配交互:

  • 每个视频占满一屏
  • 上下滑动切换视频
  • 当前视频自动播放,离开自动暂停
  • 左滑进入作者主页
  • 右侧固定操作栏(点赞、评论、分享、头像)

4.2 数据模型定义

// model/bean/VideoBean.ets@Observedexport class VideoBean { id: string = '' title: string = '' description: string = '' videoUrl: string = '' coverUrl: string = '' duration: number = 0 // 秒 width: number = 0 height: number = 0 authorId: string = '' authorName: string = '' authorAvatar: string = '' isFollowed: boolean = false likeCount: number = 0 commentCount: number = 0 shareCount: number = 0 viewCount: number = 0 isLiked: boolean = false isCollected: boolean = false publishTime: number = 0 // 时间戳 constructor() {}}

4.3 HomeViewModel实现

// pages/home/HomeViewModel.etsimport { BaseViewModel } from '../../common/BaseViewModel'import { VideoRepository } from '../../model/repository/VideoRepository'import { VideoBean } from '../../model/bean/VideoBean'import { AppConstants } from '../../constants/AppConstants'import { Logger } from '../../utils/Logger'@Observedexport class HomeViewModel extends BaseViewModel { videoList: VideoBean[] = [] currentIndex: number = 0 hasMore: boolean = true private page: number = 1 // 加载第一页 async loadFirstPage(): Promise<void> { this.page = 1 this.hasMore = true try { this.showLoading() const list = await VideoRepository.getInstance().getVideoList( this.page, AppConstants.PAGE_SIZE ) this.videoList = list this.hasMore = list.length >= AppConstants.PAGE_SIZE this.hideLoading() } catch (error) { this.handleError(error as Error) } } // 加载更多 async loadMore(): Promise<void> { if (!this.hasMore || this.isLoading) return this.page++ try { const list = await VideoRepository.getInstance().getVideoList( this.page, AppConstants.PAGE_SIZE ) this.videoList = this.videoList.concat(list) this.hasMore = list.length >= AppConstants.PAGE_SIZE } catch (error) { this.page-- // 加载失败回退 Logger.error('加载更多失败:', (error as Error).message) } } // 点赞切换 async toggleLike(index: number): Promise<void> { const video = this.videoList[index] if (!video) return // 先更新UI(乐观更新) video.isLiked = !video.isLiked video.likeCount += video.isLiked ? 1 : -1 try { await VideoRepository.getInstance().toggleLike(video.id, video.isLiked) } catch (error) { // 失败回滚 video.isLiked = !video.isLiked video.likeCount += video.isLiked ? 1 : -1 Logger.error('点赞失败:', (error as Error).message) } } // 关注切换 async toggleFollow(index: number): Promise<void> { const video = this.videoList[index] if (!video) return video.isFollowed = !video.isFollowed // TODO: 调用关注接口 }}

4.4 首页Feed流主页面

// pages/home/HomePage.etsimport { HomeViewModel } from './HomeViewModel'import { VideoFeed } from './components/VideoFeed'import { QVLoading } from '../../components/common/QVLoading'import { QVEmpty } from '../../components/common/QVEmpty'import { Logger } from '../../utils/Logger'@Entry@Componentstruct HomePage { private viewModel: HomeViewModel = new HomeViewModel() aboutToAppear() { this.viewModel.loadFirstPage() } build() { Stack() { if (this.viewModel.isLoading && this.viewModel.videoList.length === 0) { // 首次加载 QVLoading() } else if (this.viewModel.errorMessage && this.viewModel.videoList.length === 0) { // 加载失败 QVEmpty({ message: this.viewModel.errorMessage, onRetry: () => { this.viewModel.loadFirstPage() } }) } else { // 视频Feed流 VideoFeed({ videoList: this.viewModel.videoList, currentIndex: this.viewModel.currentIndex, hasMore: this.viewModel.hasMore, onIndexChange: (index: number) => { this.viewModel.currentIndex = index Logger.info('切换到第', index.toString(), '个视频') // 快到底部时加载更多 if (index >= this.viewModel.videoList.length - 3) { this.viewModel.loadMore() } }, onLike: (index: number) => { this.viewModel.toggleLike(index) }, onFollow: (index: number) => { this.viewModel.toggleFollow(index) } }) } // 顶部导航栏 this.TopNavBar() } .width('100%') .height('100%') .backgroundColor('#000') } @Builder TopNavBar() { Row() { Text('关注') .fontSize(16) .fontColor('#FFF') .opacity(0.7) Text('|') .fontSize(14) .fontColor('#FFF') .opacity(0.3) .margin({ left: 12, right: 12 }) Text('推荐') .fontSize(18) .fontColor('#FFF') .fontWeight(FontWeight.Bold) Blank() Image($r('app.media.icon_search')) .width(24) .height(24) .fillColor('#FFF') } .width('100%') .height(56) .padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Center) .backgroundColor('rgba(0,0,0,0.3)') .position({ x: 0, y: 0 }) }}

4.5 VideoFeed组件(核心滑动组件)

// pages/home/components/VideoFeed.etsimport { VideoBean } from '../../../model/bean/VideoBean'import { VideoItem } from './VideoItem'import { Scroller } from '@kit.ArkUI'@Componentexport struct VideoFeed { @Prop videoList: VideoBean[] @Prop currentIndex: number @Prop hasMore: boolean onIndexChange?: (index: number) => void onLike?: (index: number) => void onFollow?: (index: number) => void private listScroller: Scroller = new Scroller() build() { List({ scroller: this.listScroller }) { ForEach(this.videoList, (item: VideoBean, index: number) => { ListItem() { VideoItem({ video: item, index: index, isPlaying: index === this.currentIndex, onLike: () => { this.onLike?.(index) }, onFollow: () => { this.onFollow?.(index) } }) } .width('100%') .height('100%') }, (item: VideoBean) => item.id) // 加载更多提示 if (this.hasMore) { ListItem() { Column() { Text('加载中...') .fontSize(14) .fontColor('#999') } .width('100%') .height(60) .justifyContent(FlexAlign.Center) } } else if (this.videoList.length > 0) { ListItem() { Column() { Text('— 没有更多了 —') .fontSize(14) .fontColor('#666') } .width('100%') .height(60) .justifyContent(FlexAlign.Center) } } } .width('100%') .height('100%') .listDirection(Axis.Vertical) .scrollBar(BarState.Off) .cachedCount(3) .onScrollIndex((first: number) => { this.onIndexChange?.(first) }) }}

4.6 VideoItem单条视频组件

// pages/home/components/VideoItem.etsimport { VideoBean } from '../../../model/bean/VideoBean'import { VideoPlayer } from '../../../components/video/VideoPlayer'import { VideoSideBar } from './VideoSideBar'import { VideoInfo } from './VideoInfo'@Componentexport struct VideoItem { @ObjectLink video: VideoBean @Prop index: number @Prop isPlaying: boolean onLike?: () => void onFollow?: () => void build() { Stack() { // 视频播放器 VideoPlayer({ videoUrl: this.video.videoUrl, coverUrl: this.video.coverUrl, isPlaying: this.isPlaying }) // 底部渐变遮罩 LinearGradient({ direction: GradientDirection.BottomTop, colors: [ [0x00000000, 0.0], [0x80000000, 0.5], [0xCC000000, 1.0] ] }) .width('100%') .height(200) .position({ x: 0, y: '100%' }) .translate({ y: -200 }) // 左侧视频信息 VideoInfo({ video: this.video }) .position({ x: 16, y: '100%' }) .translate({ y: -100 }) // 右侧操作栏 VideoSideBar({ video: this.video, onLike: () => this.onLike?.(), onFollow: () => this.onFollow?.() }) .position({ x: '100%', y: '70%' }) .translate({ x: -16, y: -50 }) } .width('100%') .height('100%') }}

4.7 右侧操作栏组件

// pages/home/components/VideoSideBar.etsimport { VideoBean } from '../../../model/bean/VideoBean'import { TimeUtils } from '../../../utils/TimeUtils'import { LikeAnimation } from '../../../components/video/LikeAnimation'@Componentexport struct VideoSideBar { @ObjectLink video: VideoBean onLike?: () => void onComment?: () => void onShare?: () => void onFollow?: () => void onAvatarClick?: () => void @State showLikeAnim: boolean = false build() { Column() { // 头像+关注按钮 Stack({ alignContent: Alignment.BottomCenter }) { Image(this.video.authorAvatar) .width(48) .height(48) .borderRadius(24) .border({ width: 2, color: '#FFF' }) .onClick(() => this.onAvatarClick?.()) // 关注按钮(未关注时显示) if (!this.video.isFollowed) { Image($r('app.media.icon_add')) .width(20) .height(20) .backgroundColor('#FF2D55') .borderRadius(10) .position({ y: 48 }) .onClick(() => this.onFollow?.()) } } .width(48) .height(58) // 点赞 Column() { Image(this.video.isLiked ? $r('app.media.icon_liked') : $r('app.media.icon_like')) .width(36) .height(36) .fillColor(this.video.isLiked ? '#FF2D55' : '#FFF') .onClick(() => { this.onLike?.() // 点赞动画 if (!this.video.isLiked) { this.showLikeAnim = true setTimeout(() => { this.showLikeAnim = false }, 500) } }) Text(TimeUtils.formatNumber(this.video.likeCount)) .fontSize(12) .fontColor('#FFF') .margin({ top: 4 }) } .margin({ top: 24 }) // 评论 Column() { Image($r('app.media.icon_comment')) .width(36) .height(36) .fillColor('#FFF') .onClick(() => this.onComment?.()) Text(TimeUtils.formatNumber(this.video.commentCount)) .fontSize(12) .fontColor('#FFF') .margin({ top: 4 }) } .margin({ top: 20 }) // 分享 Column() { Image($r('app.media.icon_share')) .width(36) .height(36) .fillColor('#FFF') .onClick(() => this.onShare?.()) Text(TimeUtils.formatNumber(this.video.shareCount)) .fontSize(12) .fontColor('#FFF') .margin({ top: 4 }) } .margin({ top: 20 }) // 收藏 Column() { Image(this.video.isCollected ? $r('app.media.icon_collected') : $r('app.media.icon_collect')) .width(36) .height(36) .fillColor(this.video.isCollected ? '#FFD700' : '#FFF') .onClick(() => { this.video.isCollected = !this.video.isCollected }) Text('收藏') .fontSize(12) .fontColor('#FFF') .margin({ top: 4 }) } .margin({ top: 20 }) } .alignItems(HorizontalAlign.Center) }}

4.8 视频信息组件

// pages/home/components/VideoInfo.etsimport { VideoBean } from '../../../model/bean/VideoBean'@Componentexport struct VideoInfo { @Prop video: VideoBean build() { Column() { // 作者名 Text('@' + this.video.authorName) .fontSize(16) .fontColor('#FFF') .fontWeight(FontWeight.Bold) // 视频标题 Text(this.video.title) .fontSize(14) .fontColor('#FFF') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .margin({ top: 8 }) .width('80%') // 音乐信息 Row() { Image($r('app.media.icon_music')) .width(14) .height(14) .fillColor('#FFF') Text('原声 - ' + this.video.authorName) .fontSize(12) .fontColor('#FFF') .margin({ left: 4 }) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .margin({ top: 12 }) } .alignItems(HorizontalAlign.Start) }}

4.9 点赞动画组件

// components/video/LikeAnimation.ets@Componentexport struct LikeAnimation { @State show: boolean = false build() { if (this.show) { Image($r('app.media.icon_heart_big')) .width(120) .height(120) .fillColor('#FF2D55') .opacity(0) .scale({ x: 0.5, y: 0.5 }) .animation({ duration: 500, curve: Curve.EaseOut, onFinish: () => { this.show = false } }) } } // 外部调用触发动画 play(): void { this.show = true }}

4.10 本章避坑指南

  1. List的cachedCount很重要
    :视频列表一定要设缓存,不然滑动会卡
  2. ForEach的key要唯一
    :用video.id当key,不要用index,不然复用会出问题
  3. 视频播放器要复用
    :不要每个ListItem都创建一个播放器,后面第5章会讲优化
  4. 乐观更新要回滚
    :点赞先更新UI,接口失败要还原状态
  5. 预加载策略
    :快滑到底部时提前加载下一页,用户感觉不到加载

面试题:短视频Feed流性能优化有哪些方案?

参考答案:

  1. 列表复用:使用List组件的cachedCount缓存机制
  2. 播放器复用:全局只有一个播放器实例,切换时只换数据源
  3. 预加载:当前播放时预加载下一个视频的关键帧
  4. 图片缓存:封面图使用内存+磁盘二级缓存
  5. 懒加载:评论区、分享面板等进入时才加载
  6. 降级策略:弱网下降低视频清晰度
  7. 离屏销毁:滑出屏幕的视频释放资源

第5章 视频播放器内核与横竖屏切换

5.1 鸿蒙视频播放能力概览

鸿蒙提供了两套视频播放方案:

方案
适用场景
优点
缺点
Video组件
简单播放场景
上手快,声明式写法
自定义程度低
AVPlayer
复杂播放场景
高度可控,功能全
上手稍复杂

短视频APP需要精细控制播放、预加载、无缝切换,所以我们用AVPlayer。

5.2 Video组件快速上手

先从简单的Video组件开始,快速实现播放功能:

// components/video/SimpleVideoPlayer.etsimport { Logger } from '../../utils/Logger'@Componentexport struct SimpleVideoPlayer { @Prop videoUrl: string @Prop coverUrl: string @Prop isPlaying: boolean = false private videoController: VideoController = new VideoController() build() { Stack({ alignContent: Alignment.Center }) { Video({ src: this.videoUrl, poster: this.coverUrl, controller: this.videoController }) .width('100%') .height('100%') .objectFit(ImageFit.Cover) .autoPlay(false) .controls(false) // 隐藏原生控制栏 .loop(true) .muted(false) .onStart(() => { Logger.info('视频开始播放') }) .onFinish(() => { Logger.info('视频播放结束') }) .onError(() => { Logger.error('视频播放出错') }) // 自定义播放/暂停按钮(点击视频区域) if (!this.isPlaying) { Image($r('app.media.icon_play_big')) .width(80) .height(80) .opacity(0.8) } } .width('100%') .height('100%') .onClick(() => { if (this.isPlaying) { this.videoController.pause() } else { this.videoController.start() } }) } // 外部调用:开始播放 start(): void { this.videoController.start() } // 外部调用:暂停 pause(): void { this.videoController.pause() } // 外部调用:停止 stop(): void { this.videoController.stop() }}

5.3 AVPlayer高级播放器封装

短视频Feed流需要无缝切换、预加载、全局单例,Video组件不够用,上AVPlayer。

5.3.1 播放器单例封装

// utils/PlayerManager.etsimport { avplayer } from '@kit.AVKit'import { Logger } from './Logger'export type PlayerState = 'idle' | 'preparing' | 'prepared' | 'playing' | 'paused' | 'stopped' | 'error'export interface PlayerListener { onPlayStateChange?: (state: PlayerState) => void onProgress?: (currentTime: number, duration: number) => void onError?: (error: string) => void}export class PlayerManager { private static instance: PlayerManager private player: avplayer.AVPlayer | null = null private currentUrl: string = '' private state: PlayerState = 'idle' private listeners: Set<PlayerListener> = new Set() // 进度定时器 private progressTimer: number | null = null private constructor() { this.initPlayer() } static getInstance(): PlayerManager { if (!PlayerManager.instance) { PlayerManager.instance = new PlayerManager() } return PlayerManager.instance } // 初始化播放器 private initPlayer(): void { try { this.player = avplayer.createAVPlayer() // 状态变化监听 this.player.on('stateChange', (state: avplayer.AVPlayerState) => { this.handleStateChange(state) }) // 错误监听 this.player.on('error', (error: Error) => { Logger.error('播放器错误:', error.message) this.state = 'error' this.notifyError(error.message) }) // 播放完成监听 this.player.on('endOfStream', () => { Logger.info('播放结束') // 循环播放 this.player?.reset() if (this.currentUrl) { this.player?.url = this.currentUrl this.player?.prepare() } }) Logger.info('播放器初始化成功') } catch (error) { Logger.error('播放器初始化失败:', JSON.stringify(error)) } } // 状态转换处理 private handleStateChange(state: avplayer.AVPlayerState): void { Logger.info('播放器状态变化:', state) switch (state) { case 'idle': this.state = 'idle' break case 'preparing': this.state = 'preparing' break case 'prepared': this.state = 'prepared' break case 'playing': this.state = 'playing' this.startProgressTimer() break case 'paused': this.state = 'paused' this.stopProgressTimer() break case 'stopped': this.state = 'stopped' this.stopProgressTimer() break } this.notifyStateChange(this.state) } // 播放指定视频 play(url: string): void { if (!this.player) return // 同一个视频,暂停状态下直接播放 if (url === this.currentUrl && this.state === 'paused') { this.player.resume() return } // 新视频,重置后播放 this.currentUrl = url this.player.reset() this.player.url = url this.player.prepare() // prepared状态后自动播放 const checkPrepared = () => { if (this.state === 'prepared') { this.player?.play() } else if (this.state !== 'error') { setTimeout(checkPrepared, 50) } } checkPrepared() } // 暂停 pause(): void { if (this.player && this.state === 'playing') { this.player.pause() } } // 继续播放 resume(): void { if (this.player && this.state === 'paused') { this.player.resume() } } // 停止 stop(): void { if (this.player) { this.player.stop() this.currentUrl = '' } } // 释放资源 release(): void { this.stopProgressTimer() if (this.player) { this.player.release() this.player = null } } // 跳转进度 seek(timeMs: number): void { if (this.player) { this.player.seek(timeMs) } } // 设置音量(0-1) setVolume(volume: number): void { if (this.player) { this.player.volume = volume } } // 设置是否循环 setLooping(looping: boolean): void { if (this.player) { this.player.loop = looping } } // 获取当前状态 getState(): PlayerState { return this.state } // 获取当前播放URL getCurrentUrl(): string { return this.currentUrl } // 添加监听器 addListener(listener: PlayerListener): void { this.listeners.add(listener) } // 移除监听器 removeListener(listener: PlayerListener): void { this.listeners.delete(listener) } // 通知状态变化 private notifyStateChange(state: PlayerState): void { this.listeners.forEach(listener => { listener.onPlayStateChange?.(state) }) } // 通知进度 private notifyProgress(currentTime: number, duration: number): void { this.listeners.forEach(listener => { listener.onProgress?.(currentTime, duration) }) } // 通知错误 private notifyError(error: string): void { this.listeners.forEach(listener => { listener.onError?.(error) }) } // 启动进度定时器 private startProgressTimer(): void { this.stopProgressTimer() this.progressTimer = setInterval(() => { if (this.player && this.state === 'playing') { const currentTime = this.player.currentTime const duration = this.player.duration this.notifyProgress(currentTime, duration) } }, 500) as unknown as number } // 停止进度定时器 private stopProgressTimer(): void { if (this.progressTimer !== null) { clearInterval(this.progressTimer) this.progressTimer = null } }}

5.4 视频渲染组件(XComponent)

AVPlayer需要配合XComponent来渲染画面:

// components/video/AVVideoPlayer.etsimport { PlayerManager, PlayerListener } from '../../utils/PlayerManager'import { Logger } from '../../utils/Logger'import { xcomponent } from '@kit.ArkUI'@Componentexport struct AVVideoPlayer { @Prop videoUrl: string @Prop coverUrl: string @Prop isPlaying: boolean = false @State showCover: boolean = true private xComponentId: string = 'video_surface_' + Math.random().toString(36).substr(2, 9) private playerManager: PlayerManager = PlayerManager.getInstance() // 播放器监听器 private playerListener: PlayerListener = { onPlayStateChange: (state: string) => { if (state === 'playing') { this.showCover = false } } } aboutToAppear() { this.playerManager.addListener(this.playerListener) } aboutToDisappear() { this.playerManager.removeListener(this.playerListener) } build() { Stack({ alignContent: Alignment.Center }) { // XComponent 渲染视频画面 XComponent({ id: this.xComponentId, type: 'surface', libraryname: '', controller: new XComponentController() }) .width('100%') .height('100%') .onLoad(() => { // XComponent加载完成后,设置播放器的渲染surface this.setupSurface() }) // 封面图(首帧前显示) if (this.showCover) { Image(this.coverUrl) .width('100%') .height('100%') .objectFit(ImageFit.Cover) } } .width('100%') .height('100%') } // 设置渲染Surface private setupSurface(): void { // 获取surfaceId并设置给播放器 // 实际开发中需要通过NAPI获取native window // 这里简化处理,实际项目需要配合native层 Logger.info('XComponent loaded, id:', this.xComponentId) } // 监听isPlaying变化 @Watch('isPlaying') onPlayingChange() { if (this.isPlaying) { this.playerManager.play(this.videoUrl) } else { this.playerManager.pause() } }}

避坑提醒:AVPlayer的视频渲染需要通过XComponent的surface来实现,涉及Native层开发。如果你的项目不需要那么高的自定义程度,直接用Video组件更省心。

5.5 Feed流无缝切换优化方案

短视频Feed流的核心体验:滑动切换时视频秒开,没有黑屏。

5.5.1 方案一:封面图兜底(最简单)

滑动时 → 显示下一个视频的封面图 → 播放器准备好后 → 隐藏封面

优点:实现简单,体验也不错
缺点:封面和视频第一帧可能有差异

5.5.2 方案二:双播放器预加载(体验最好)

当前播放器播放视频A → 同时预加载视频B的前几秒 → 滑动到B时 → 直接从预加载位置开始播放 → 同时释放A,预加载C

实现思路:

// utils/DualPlayerManager.ets// 双播放器管理器,实现无缝切换export class DualPlayerManager { private activePlayer: number = 0 // 0或1 private players: avplayer.AVPlayer[] = [] // 切换到下一个视频 switchTo(url: string): void { const nextPlayer = 1 - this.activePlayer // 下一个播放器预加载 this.players[nextPlayer].reset() this.players[nextPlayer].url = url this.players[nextPlayer].prepare() // 准备好后切换 // ... }}

5.5.3 方案三:关键帧预加载(折中方案)

提前下载下一个视频的前几帧,滑动时直接显示。

5.6 横竖屏切换实现

5.6.1 页面配置支持横竖屏

在module.json5中配置:

{ ”module”: { ”abilities”: [ { ”name”: ”EntryAbility”, ”orientation”: ”unspecified”, // 支持横竖屏 ”pages”: [...] } ] }}

5.6.2 监听屏幕方向变化

// utils/OrientationManager.etsimport { display } from '@kit.ArkUI'export type Orientation = 'portrait' | 'landscape'export class OrientationManager { private static instance: OrientationManager private currentOrientation: Orientation = 'portrait' private listeners: Set<(orientation: Orientation) => void> = new Set() private constructor() { this.initListener() } static getInstance(): OrientationManager { if (!OrientationManager.instance) { OrientationManager.instance = new OrientationManager() } return OrientationManager.instance } private initListener(): void { display.getDefaultDisplay().then(displayInfo => { this.currentOrientation = displayInfo.width > displayInfo.height ? 'landscape' : 'portrait' }) // 监听变化 display.on('displayChange', (displayId: number) => { display.getAllDisplay().then(displays => { const info = displays.find(d => d.id === displayId) if (info) { const newOrientation = info.width > info.height ? 'landscape' : 'portrait' if (newOrientation !== this.currentOrientation) { this.currentOrientation = newOrientation this.notifyListeners(newOrientation) } } }) }) } getOrientation(): Orientation { return this.currentOrientation } addListener(listener: (orientation: Orientation) => void): void { this.listeners.add(listener) } removeListener(listener: (orientation: Orientation) => void): void { this.listeners.delete(listener) } private notifyListeners(orientation: Orientation): void { this.listeners.forEach(fn => fn(orientation)) }}

5.6.3 视频详情页横竖屏适配

// pages/video/VideoDetailPage.etsimport { OrientationManager, Orientation } from '../../utils/OrientationManager'@Entry@Componentstruct VideoDetailPage { @State orientation: Orientation = 'portrait' private orientationManager: OrientationManager = OrientationManager.getInstance() aboutToAppear() { this.orientation = this.orientationManager.getOrientation() this.orientationManager.addListener(this.onOrientationChange.bind(this)) } aboutToDisappear() { this.orientationManager.removeListener(this.onOrientationChange.bind(this)) } private onOrientationChange(orientation: Orientation): void { this.orientation = orientation } build() { Column() { // 视频播放区域 Stack() { // 视频播放器... // 横屏时显示更多控制按钮 if (this.orientation === 'landscape') { this.LandscapeControls() } } .width('100%') .aspectRatio(this.orientation === 'portrait' ? 9 / 16 : 16 / 9) // 竖屏时显示下方信息 if (this.orientation === 'portrait') { this.VideoInfoPanel() .layoutWeight(1) } } .width('100%') .height('100%') .backgroundColor('#000') } @Builder LandscapeControls() { Row() { // 倍速按钮 Text('1.0x') .fontSize(14) .fontColor('#FFF') .padding(8) .backgroundColor('rgba(0,0,0,0.5)') .borderRadius(4) Blank() // 全屏按钮 Image($r('app.media.icon_fullscreen')) .width(24) .height(24) .fillColor('#FFF') } .width('100%') .padding(16) .position({ x: 0, y: 0 }) } @Builder VideoInfoPanel() { Scroll() { Column() { // 视频标题、作者、评论等... } } .width('100%') .backgroundColor('#FFF') }}

5.7 倍速播放实现

// components/video/PlaybackSpeedControl.ets@Componentexport struct PlaybackSpeedControl { @State currentSpeed: number = 1.0 @State showPanel: boolean = false private speeds: number[] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] onSpeedChange?: (speed: number) => void build() { Stack() { // 触发按钮 Text(this.currentSpeed + 'x') .fontSize(14) .fontColor('#FFF') .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .backgroundColor('rgba(0,0,0,0.6)') .borderRadius(16) .onClick(() => { this.showPanel = !this.showPanel }) // 倍速选择面板 if (this.showPanel) { Column() { ForEach(this.speeds, (speed: number) => { Text(speed + 'x') .fontSize(14) .fontColor(this.currentSpeed === speed ? '#FF2D55' : '#FFF') .width('100%') .height(36) .textAlign(TextAlign.Center) .onClick(() => { this.currentSpeed = speed this.showPanel = false this.onSpeedChange?.(speed) // 设置播放器倍速 // playerManager.setSpeed(speed) }) }) } .width(80) .backgroundColor('rgba(0,0,0,0.8)') .borderRadius(8) .position({ x: 0, y: -200 }) } } }}

5.8 手势控制(快进/快退/音量/亮度)

// components/video/VideoGestureControl.etsimport { Logger } from '../../utils/Logger'@Componentexport struct VideoGestureControl { @Prop totalDuration: number @State currentTime: number = 0 @State showProgress: boolean = false @State seekTime: number = 0 // 手势起始位置 private startX: number = 0 private startY: number = 0 private startTime: number = 0 private isHorizontalGesture: boolean = false onSeek?: (time: number) => void build() { Stack() { // 手势检测区域 Column() .width('100%') .height('100%') .gesture( PanGesture({ direction: PanDirection.All, distance: 20 }) .onActionStart((event: GestureEvent) => { this.startX = event.offsetX this.startY = event.offsetY this.startTime = this.currentTime }) .onActionUpdate((event: GestureEvent) => { const dx = event.offsetX - this.startX const dy = event.offsetY - this.startY // 判断是水平还是垂直手势 if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 30) { this.isHorizontalGesture = true // 水平滑动:快进快退 this.handleHorizontalGesture(dx) } else if (!this.isHorizontalGesture && Math.abs(dy) > 30) { // 垂直滑动:左侧亮度,右侧音量 this.handleVerticalGesture(dx, dy) } }) .onActionEnd(() => { if (this.isHorizontalGesture) { // 确认跳转 this.onSeek?.(this.seekTime) this.currentTime = this.seekTime } this.showProgress = false this.isHorizontalGesture = false }) ) // 进度提示 if (this.showProgress) { Column() { Text(this.formatTime(this.seekTime) + ' / ' + this.formatTime(this.totalDuration)) .fontSize(16) .fontColor('#FFF') .fontWeight(FontWeight.Bold) // 进度条 Progress({ value: this.seekTime, total: this.totalDuration }) .width(200) .height(4) .color('#FF2D55') .margin({ top: 8 }) } .padding(20) .backgroundColor('rgba(0,0,0,0.7)') .borderRadius(12) } } .width('100%') .height('100%') } private handleHorizontalGesture(dx: number): void { // 每100像素对应10秒 const secondsPerPixel = 10 / 100 const seekDelta = dx * secondsPerPixel this.seekTime = Math.max(0, Math.min(this.totalDuration, this.startTime + seekDelta)) this.showProgress = true } private handleVerticalGesture(dx: number, dy: number): void { // 判断是左侧还是右侧 // 左侧:亮度 右侧:音量 // 实际实现需要获取屏幕宽度 Logger.info('垂直手势:', dx.toString(), dy.toString()) } private formatTime(seconds: number): string { const min = Math.floor(seconds / 60) const sec = Math.floor(seconds % 60) return `${min}:${sec.toString().padStart(2, '0')}` }}

5.9 后台播放与音频焦点

5.9.1 配置后台播放权限

module.json5中添加:

{ ”module”: { ”abilities”: [ { ”name”: ”EntryAbility”, ”backgroundModes”: [ ”audioPlayback” // 后台音频播放 ] } ] }}

5.9.2 音频焦点管理

// utils/AudioFocusManager.etsimport { audio } from '@kit.AudioKit'import { Logger } from './Logger'export class AudioFocusManager { private static instance: AudioFocusManager private audioRendererInfo: audio.AudioRendererInfo | null = null private constructor() {} static getInstance(): AudioFocusManager { if (!AudioFocusManager.instance) { AudioFocusManager.instance = new AudioFocusManager() } return AudioFocusManager.instance } // 请求音频焦点 async requestFocus(): Promise<boolean> { try { const focusInfo: audio.AudioFocusInfo = { focusType: audio.AudioFocusType.AUDIO_FOCUS_TYPE_FOCUS, audioStreamInfo: { usage: audio.StreamUsage.STREAM_USAGE_MEDIA, rendererFlags: 0 } } const result = await audio.getAudioManager().requestAudioFocus(focusInfo) Logger.info('请求音频焦点结果:', result.toString()) return result === audio.AudioFocusRequestResult.AUDIO_FOCUS_REQUEST_SUCCESS } catch (error) { Logger.error('请求音频焦点失败:', JSON.stringify(error)) return false } } // 放弃音频焦点 async abandonFocus(): Promise<void> { try { await audio.getAudioManager().abandonAudioFocus() Logger.info('放弃音频焦点') } catch (error) { Logger.error('放弃音频焦点失败:', JSON.stringify(error)) } }}

5.10 本章避坑指南

  1. Video组件和AVPlayer选哪个?
     → 简单播放用Video,需要精细控制用AVPlayer
  2. AVPlayer渲染画面黑屏
     → 检查XComponent的surface是否正确设置
  3. 切换视频有黑屏
     → 用封面图兜底,或者双播放器预加载
  4. 后台播放无声
     → 检查module.json5是否配置了audioPlayback的backgroundMode
  5. 视频没有声音
     → 检查是否请求了音频焦点,或者设备是否静音
  6. 横竖屏切换布局错乱
     → 用orientation状态控制布局,不要依赖系统自动旋转

面试题:短视频APP如何实现无缝切换播放?

参考答案:

  1. 封面图兜底
    :滑动时先显示封面,播放器准备好后隐藏,实现最简单
  2. 双播放器方案
    :两个播放器实例,一个播放一个预加载,切换时无缝衔接
  3. 关键帧预加载
    :提前下载下一个视频的前几帧数据
  4. 播放器复用
    :全局单例播放器,切换时只换数据源,减少初始化开销
  5. CDN优化
    :使用支持Range请求的CDN,支持边下边播

第6章 用户登录与账号体系(华为账号+手机号)

6.1 登录方案选型

短视频APP常见的登录方式:

登录方式
优点
缺点
优先级
华为账号一键登录
体验最好,鸿蒙生态原生
只有华为手机能用
高
手机号验证码登录
通用,用户接受度高
需要短信费用
高
微信/QQ登录
用户基数大
需要申请第三方SDK
中
密码登录
传统方式
体验差,容易忘
低

我们实现华为账号 + 手机号验证码两种方式。

6.2 华为账号一键登录集成

6.2.1 前置准备

  1. 在AppGallery Connect创建应用
  2. 开通"账号服务"
  3. 配置应用签名(后面第10章讲)
  4. 下载agconnect-services.json放到项目里

6.2.2 安装依赖

oh-package.json5中添加:

{ ”dependencies”: { ”@hw-agconnect/auth-ohos”: ”^1.0.0” }}

然后执行Sync Project。

6.2.3 登录封装

// utils/HuaweiAuthManager.etsimport { Logger } from './Logger'import { UserBean } from '../model/bean/UserBean'// 实际项目中需要引入AGConnect SDK// 这里演示核心逻辑export class HuaweiAuthManager { private static instance: HuaweiAuthManager static getInstance(): HuaweiAuthManager { if (!HuaweiAuthManager.instance) { HuaweiAuthManager.instance = new HuaweiAuthManager() } return HuaweiAuthManager.instance } // 华为账号一键登录 async login(): Promise<{ user: UserBean, token: string }> { try { Logger.info('开始华为账号登录') // 1. 调用AGConnect SDK获取华为账号信息 // const authProvider = new HuaweiIdAuthProvider() // const signInResult = await AGCAuth.getInstance().signIn(authProvider) // 2. 获取华为账号的token和用户信息 // const huaweiUser = signInResult.getUser() // const huaweiToken = huaweiUser.getToken() // 3. 将华为token传给后端,换取我们自己的用户token // const result = await UserRepository.getInstance().loginWithHuawei(huaweiToken) // 模拟返回(实际开发替换成真逻辑) const mockUser: UserBean = { id: 'hw_' + Date.now(), nickname: '华为用户', avatar: 'https://example.com/avatar.png', phone: '', isVip: false, followCount: 0, followerCount: 0, likeCount: 0 } Logger.info('华为账号登录成功') return { user: mockUser, token: 'mock_huawei_token_' + Date.now() } } catch (error) { Logger.error('华为账号登录失败:', JSON.stringify(error)) throw error } } // 检查是否已登录华为账号 async isLoggedIn(): Promise<boolean> { try { // const user = await AGCAuth.getInstance().getCurrentUser() // return user !== null return false } catch (error) { return false } } // 登出 async logout(): Promise<void> { try { // await AGCAuth.getInstance().signOut() Logger.info('华为账号登出成功') } catch (error) { Logger.error('华为账号登出失败:', JSON.stringify(error)) } }}

6.3 手机号验证码登录

6.3.1 登录页面UI

// pages/login/LoginPage.etsimport { LoginViewModel } from './LoginViewModel'import { QVButton } from '../../components/common/QVButton'import { ToastUtils } from '../../utils/ToastUtils'import { RouteConstants } from '../../constants/RouteConstants'import router from '@ohos.router'@Entry@Componentstruct LoginPage { private viewModel: LoginViewModel = new LoginViewModel() @State phone: string = '' @State code: string = '' @State countdown: number = 0 @State isAgreed: boolean = false private timer: number | null = null build() { Column() { // 顶部Logo Column() { Image($r('app.media.app_logo')) .width(80) .height(80) .borderRadius(20) Text('快映短视频') .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 16 }) } .margin({ top: 80 }) // 手机号输入 Column() { Text('手机号登录') .fontSize(20) .fontWeight(FontWeight.Bold) .width('100%') .margin({ bottom: 24 }) // 手机号输入框 Row() { Text('+86') .fontSize(16) .fontColor('#333') .margin({ right: 12 }) TextInput({ placeholder: '请输入手机号' }) .layoutWeight(1) .fontSize(16) .maxLength(11) .type(InputType.Number) .onChange((value: string) => { this.phone = value.replace(/\D/g, '') }) } .width('100%') .height(50) .border({ width: { bottom: 1 }, color: '#EEE' }) // 验证码输入框 Row() { TextInput({ placeholder: '请输入验证码' }) .layoutWeight(1) .fontSize(16) .maxLength(6) .type(InputType.Number) .onChange((value: string) => { this.code = value.replace(/\D/g, '') }) // 获取验证码按钮 Text(this.countdown > 0 ? `${this.countdown}s后重发` : '获取验证码') .fontSize(14) .fontColor(this.countdown > 0 || this.phone.length !== 11 ? '#999' : '#FF2D55') .onClick(() => { if (this.countdown > 0 || this.phone.length !== 11) return this.sendCode() }) } .width('100%') .height(50) .margin({ top: 16 }) .border({ width: { bottom: 1 }, color: '#EEE' }) // 登录按钮 QVButton({ text: '登录', enabled: this.phone.length === 11 && this.code.length === 6 && this.isAgreed, loading: this.viewModel.isLoading, onClick: () => { this.doLogin() } }) .margin({ top: 32 }) // 协议勾选 Row() { Checkbox({ name: 'agree' }) .select(this.isAgreed) .selectedColor('#FF2D55') .onChange((value: boolean) => { this.isAgreed = value }) .size(16) Text('我已阅读并同意') .fontSize(12) .fontColor('#999') .margin({ left: 4 }) Text('《用户协议》') .fontSize(12) .fontColor('#FF2D55') Text('和') .fontSize(12) .fontColor('#999') Text('《隐私政策》') .fontSize(12) .fontColor('#FF2D55') } .margin({ top: 16 }) } .width('80%') .margin({ top: 60 }) Blank() // 其他登录方式 Column() { Row() { Divider() .layoutWeight(1) .color('#EEE') Text('其他登录方式') .fontSize(12) .fontColor('#999') .margin({ left: 12, right: 12 }) Divider() .layoutWeight(1) .color('#EEE') } .width('80%') // 华为账号登录 Row() { Column() { Image($r('app.media.icon_huawei')) .width(44) .height(44) .borderRadius(22) Text('华为账号') .fontSize(12) .fontColor('#666') .margin({ top: 6 }) } .onClick(() => { this.loginWithHuawei() }) } .margin({ top: 24 }) } .margin({ bottom: 60 }) } .width('100%') .height('100%') .backgroundColor('#FFF') } // 发送验证码 private sendCode(): void { if (this.phone.length !== 11) { ToastUtils.show('请输入正确的手机号') return } this.viewModel.sendCode(this.phone).then(() => { ToastUtils.show('验证码已发送') this.startCountdown() }).catch((error: Error) => { ToastUtils.show(error.message) }) } // 倒计时 private startCountdown(): void { this.countdown = 60 this.timer = setInterval(() => { this.countdown-- if (this.countdown <= 0) { if (this.timer) { clearInterval(this.timer) this.timer = null } } }, 1000) as unknown as number } // 手机号登录 private doLogin(): void { if (!this.isAgreed) { ToastUtils.show('请先同意用户协议') return } this.viewModel.loginWithPhone(this.phone, this.code).then(() => { ToastUtils.show('登录成功') // 登录成功后返回 router.back() }).catch((error: Error) => { ToastUtils.show(error.message) }) } // 华为账号登录 private loginWithHuawei(): void { this.viewModel.loginWithHuawei().then(() => { ToastUtils.show('登录成功') router.back() }).catch((error: Error) => { ToastUtils.show(error.message) }) } aboutToDisappear() { if (this.timer) { clearInterval(this.timer) this.timer = null } }}

6.4 LoginViewModel实现

// pages/login/LoginViewModel.etsimport { BaseViewModel } from '../../common/BaseViewModel'import { UserRepository } from '../../model/repository/UserRepository'import { AppState } from '../../common/AppState'import { HuaweiAuthManager } from '../../utils/HuaweiAuthManager'import { Logger } from '../../utils/Logger'export class LoginViewModel extends BaseViewModel { // 发送验证码 async sendCode(phone: string): Promise<void> { try { this.showLoading() await UserRepository.getInstance().sendCode(phone) this.hideLoading() } catch (error) { this.handleError(error as Error) throw error } } // 手机号验证码登录 async loginWithPhone(phone: string, code: string): Promise<void> { try { this.showLoading() const result = await UserRepository.getInstance().loginWithPhone(phone, code) // 保存登录信息 AppState.getInstance().setLoginInfo(result.user, result.token) this.hideLoading() Logger.info('手机号登录成功') } catch (error) { this.handleError(error as Error) throw error } } // 华为账号登录 async loginWithHuawei(): Promise<void> { try { this.showLoading() // 1. 华为账号授权 const huaweiResult = await HuaweiAuthManager.getInstance().login() // 2. 用华为token换我们自己的token(实际项目中调用后端接口) const result = await UserRepository.getInstance().loginWithThirdParty( 'huawei', huaweiResult.token ) // 3. 保存登录信息 AppState.getInstance().setLoginInfo(result.user, result.token) this.hideLoading() Logger.info('华为账号登录成功') } catch (error) { this.handleError(error as Error) throw error } } // 退出登录 async logout(): Promise<void> { try { // 调用后端登出接口 await UserRepository.getInstance().logout() // 清除本地状态 AppState.getInstance().logout() // 华为账号也登出 await HuaweiAuthManager.getInstance().logout() Logger.info('退出登录成功') } catch (error) { Logger.error('退出登录失败:', (error as Error).message) // 即使接口失败也清除本地状态 AppState.getInstance().logout() } }}

6.5 UserRepository数据层

// model/repository/UserRepository.etsimport { HttpManager, ApiResponse } from '../api/HttpManager'import { UserBean } from '../bean/UserBean'import { UserApi } from '../api/UserApi'export interface LoginResult { user: UserBean token: string}export class UserRepository { private static instance: UserRepository static getInstance(): UserRepository { if (!UserRepository.instance) { UserRepository.instance = new UserRepository() } return UserRepository.instance } // 发送验证码 async sendCode(phone: string): Promise<void> { await HttpManager.getInstance().post(UserApi.SEND_CODE, { phone: phone }) } // 手机号登录 async loginWithPhone(phone: string, code: string): Promise<LoginResult> { const response = await HttpManager.getInstance().post<LoginResult>( UserApi.LOGIN_PHONE, { phone: phone, code: code } ) return response.data } // 第三方登录 async loginWithThirdParty(platform: string, thirdToken: string): Promise<LoginResult> { const response = await HttpManager.getInstance().post<LoginResult>( UserApi.LOGIN_THIRD_PARTY, { platform: platform, token: thirdToken } ) return response.data } // 登出 async logout(): Promise<void> { await HttpManager.getInstance().post(UserApi.LOGOUT) } // 获取用户信息 async getUserInfo(userId: string): Promise<UserBean> { const response = await HttpManager.getInstance().get<UserBean>( UserApi.USER_INFO + userId ) return response.data } // 更新用户信息 async updateUserInfo(data: Record<string, Object>): Promise<UserBean> { const response = await HttpManager.getInstance().post<UserBean>( UserApi.UPDATE_USER_INFO, data ) return response.data } // 上传头像 async uploadAvatar(filePath: string): Promise<string> { // 实际项目中需要实现文件上传 // 这里返回模拟URL return 'https://example.com/avatar_' + Date.now() + '.png' }}

6.6 登录状态拦截(路由守卫)

有些页面需要登录才能访问,实现一个简单的路由守卫:

// utils/RouteGuard.etsimport { AppState } from '../common/AppState'import { RouteConstants } from '../constants/RouteConstants'import router from '@ohos.router'export class RouteGuard { // 需要登录的页面 private static authRequiredPages: string[] = [ RouteConstants.PUBLISH, 'pages/message/MessagePage', 'pages/setting/SettingPage' ] // 检查是否需要登录 static needAuth(url: string): boolean { return this.authRequiredPages.some(page => url.includes(page)) } // 导航(带登录检查) static navigateTo(url: string, params?: Record<string, Object>): void { if (this.needAuth(url) && !AppState.getInstance().isLoggedIn) { // 需要登录,先跳登录页 router.pushUrl({ url: RouteConstants.LOGIN, params: { redirectUrl: url, redirectParams: params } }) } else { // 不需要登录或已登录,直接跳转 router.pushUrl({ url: url, params: params }) } } // 检查登录,返回Promise static requireLogin(): Promise<boolean> { if (AppState.getInstance().isLoggedIn) { return Promise.resolve(true) } // 跳登录页 router.pushUrl({ url: RouteConstants.LOGIN }) return Promise.resolve(false) }}

6.7 Token刷新机制

Token过期了怎么办?不能让用户重新登录,要无感刷新。

// model/api/TokenInterceptor.etsimport { HttpManager } from './HttpManager'import { AppState } from '../../common/AppState'import { Logger } from '../../utils/Logger'// 这个类扩展HttpManager,添加token刷新拦截器export class TokenInterceptor { private static isRefreshing: boolean = false private static refreshPromise: Promise<string> | null = null // 处理401错误(token过期) static async handleTokenExpired(): Promise<boolean> { if (this.isRefreshing) { // 已经在刷新了,等结果 if (this.refreshPromise) { await this.refreshPromise return true } return false } this.isRefreshing = true this.refreshPromise = this.refreshToken().then((newToken: string) => { // 刷新成功,更新token AppState.getInstance().token = newToken this.isRefreshing = false this.refreshPromise = null return newToken }).catch(() => { // 刷新失败,跳登录页 this.isRefreshing = false this.refreshPromise = null AppState.getInstance().logout() // router.pushUrl({ url: RouteConstants.LOGIN }) throw new Error('登录已过期,请重新登录') }) try { await this.refreshPromise return true } catch { return false } } // 调用刷新token接口 private static async refreshToken(): Promise<string> { Logger.info('开始刷新token') // 实际项目中调用刷新接口 // const response = await HttpManager.getInstance().post('/auth/refresh', { // refreshToken: AppState.getInstance().refreshToken // }) // 模拟刷新成功 return 'new_token_' + Date.now() }}

6.8 用户信息模型

// model/bean/UserBean.ets@Observedexport class UserBean { id: string = '' nickname: string = '' avatar: string = '' phone: string = '' bio: string = '' gender: number = 0 // 0未知 1男 2女 birthday: string = '' location: string = '' // 统计数据 followCount: number = 0 followerCount: number = 0 likeCount: number = 0 workCount: number = 0 favoriteCount: number = 0 // 会员信息 isVip: boolean = false vipExpireTime: number = 0 // 认证信息 isVerified: boolean = false verifiedType: string = '' constructor() {}}

6.9 个人中心页面

// pages/profile/ProfilePage.etsimport { ProfileViewModel } from './ProfileViewModel'import { UserAvatar } from '../../components/user/UserAvatar'import { AppState } from '../../common/AppState'import { RouteConstants } from '../../constants/RouteConstants'import { RouteGuard } from '../../utils/RouteGuard'@Entry@Componentstruct ProfilePage { private viewModel: ProfileViewModel = new ProfileViewModel() private appState: AppState = AppState.getInstance() @State currentTab: number = 0 // 0作品 1喜欢 2收藏 build() { Column() { // 顶部用户信息 this.UserHeader() // 统计数据 this.StatsRow() // 编辑资料按钮 Button('编辑资料') .width('90%') .height(36) .fontSize(14) .fontColor('#333') .backgroundColor('#F5F5F5') .borderRadius(18) .margin({ top: 16 }) .onClick(() => { RouteGuard.navigateTo('pages/profile/EditProfilePage') }) // Tab切换 Row() { ForEach(['作品', '喜欢', '收藏'], (tab: string, index: number) => { Column() { Text(tab) .fontSize(14) .fontColor(this.currentTab === index ? '#333' : '#999') .fontWeight(this.currentTab === index ? FontWeight.Bold : FontWeight.Normal) if (this.currentTab === index) { Divider() .width(20) .color('#FF2D55') .strokeWidth(2) .margin({ top: 4 }) } } .layoutWeight(1) .height(44) .justifyContent(FlexAlign.Center) .onClick(() => { this.currentTab = index }) }) } .width('100%') .backgroundColor('#FFF') .margin({ top: 16 }) // 内容网格 this.VideoGrid() .layoutWeight(1) } .width('100%') .height('100%') .backgroundColor('#F5F5F5') } @Builder UserHeader() { Row() { UserAvatar({ avatar: this.appState.userInfo?.avatar || '', size: 64 }) Column() { Text(this.appState.userInfo?.nickname || '未登录') .fontSize(18) .fontWeight(FontWeight.Bold) if (this.appState.userInfo?.bio) { Text(this.appState.userInfo.bio) .fontSize(12) .fontColor('#999') .margin({ top: 4 }) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } } .margin({ left: 16 }) .alignItems(HorizontalAlign.Start) Blank() Image($r('app.media.icon_setting')) .width(24) .height(24) .onClick(() => { RouteGuard.navigateTo('pages/setting/SettingPage') }) } .width('100%') .padding(16) .backgroundColor('#FFF') } @Builder StatsRow() { Row() { ForEach([ { label: '关注', value: this.appState.userInfo?.followCount || 0 }, { label: '粉丝', value: this.appState.userInfo?.followerCount || 0 }, { label: '获赞', value: this.appState.userInfo?.likeCount || 0 } ], (item: { label: string, value: number }) => { Column() { Text(item.value.toString()) .fontSize(18) .fontWeight(FontWeight.Bold) Text(item.label) .fontSize(12) .fontColor('#999') .margin({ top: 2 }) } .layoutWeight(1) }) } .width('100%') .padding({ top: 16, bottom: 16 }) .backgroundColor('#FFF') } @Builder VideoGrid() { Grid() { ForEach(this.viewModel.videoList, (item: any, index: number) => { GridItem() { Stack({ alignContent: Alignment.BottomStart }) { Image(item.coverUrl) .width('100%') .height('100%') .objectFit(ImageFit.Cover) Row() { Image($r('app.media.icon_play_small')) .width(12) .height(12) .fillColor('#FFF') Text(item.playCount) .fontSize(10) .fontColor('#FFF') .margin({ left: 2 }) } .padding(6) } .width('100%') .aspectRatio(3 / 4) } }) } .columnsTemplate('1fr 1fr 1fr') .columnsGap(2) .rowsGap(2) .width('100%') }}

6.10 本章避坑指南

  1. 华为账号登录必须配签名
    :没有正确的签名,SDK会报错
  2. 验证码倒计时要清理
    :页面销毁时记得清定时器,不然内存泄漏
  3. Token要安全存储
    :不要存在明文里,用加密存储(后面第9章讲)
  4. 登录状态同步
    :多页面共享登录状态,用AppState单例+@Observed
  5. 第三方登录要绑定手机号
    :不然用户换手机就找不回账号了
  6. 隐私协议必须勾选
    :上架审核要求,必须有协议且用户主动勾选

面试题:如何实现无感Token刷新?

参考答案:

  1. 后端返回access_token(短时效)和refresh_token(长时效)
  2. 网络请求返回401时,拦截并触发刷新流程
  3. 多个请求同时401时,只刷新一次,其他请求等待结果(防止并发刷新)
  4. 刷新成功后,用新token重试之前失败的请求
  5. 刷新失败时,清除登录状态,跳转到登录页

第7章 视频拍摄与上传功能实现

7.1 拍摄功能技术选型

鸿蒙上实现视频拍摄有几种方案:

方案
难度
自定义程度
推荐度
系统相机Picker
简单
低
⭐⭐⭐ 快速上手
Camera组件
中等
中
⭐⭐⭐⭐ 推荐
Camera Kit + 自定义
困难
高
⭐⭐ 特殊需求用

我们用Camera组件实现,平衡自定义和开发效率。

7.2 权限申请

拍摄需要相机和麦克风权限,先配置:

7.2.1 module.json5配置权限

{ ”module”: { ”requestPermissions”: [ { ”name”: ”ohos.permission.CAMERA”, ”reason”: ”$string:camera_permission_reason” }, { ”name”: ”ohos.permission.MICROPHONE”, ”reason”: ”$string:mic_permission_reason” }, { ”name”: ”ohos.permission.READ_IMAGEVIDEO”, ”reason”: ”$string:read_media_reason” }, { ”name”: ”ohos.permission.WRITE_IMAGEVIDEO”, ”reason”: ”$string:write_media_reason” } ] }}

7.2.2 运行时权限申请

// utils/PermissionManager.etsimport { abilityAccessCtrl, Permissions } from '@kit.AbilityKit'import { Logger } from './Logger'import { common } from '@kit.AbilityKit'export class PermissionManager { private static instance: PermissionManager static getInstance(): PermissionManager { if (!PermissionManager.instance) { PermissionManager.instance = new PermissionManager() } return PermissionManager.instance } // 检查权限 async checkPermission(permission: string): Promise<boolean> { try { const context = getContext() as common.UIAbilityContext const atManager = abilityAccessCtrl.createAtManager() const result = await atManager.checkAccessToken( context.applicationInfo.accessTokenId, permission ) return result === 0 // 0表示已授权 } catch (error) { Logger.error('检查权限失败:', JSON.stringify(error)) return false } } // 请求权限 async requestPermission(permission: string): Promise<boolean> { const hasPermission = await this.checkPermission(permission) if (hasPermission) return true try { const context = getContext() as common.UIAbilityContext const atManager = abilityAccessCtrl.createAtManager() const permissions: Permissions[] = [permission as Permissions] const result = await atManager.requestPermissionsFromUser(context, permissions) return result.authResults[0] === 0 } catch (error) { Logger.error('请求权限失败:', JSON.stringify(error)) return false } } // 批量请求权限 async requestPermissions(permissions: string[]): Promise<boolean> { const results = await Promise.all( permissions.map(p => this.requestPermission(p)) ) return results.every(r => r) } // 相机+麦克风权限 async requestCameraAndMic(): Promise<boolean> { return this.requestPermissions([ 'ohos.permission.CAMERA', 'ohos.permission.MICROPHONE' ]) } // 存储权限 async requestStoragePermission(): Promise<boolean> { return this.requestPermissions([ 'ohos.permission.READ_IMAGEVIDEO', 'ohos.permission.WRITE_IMAGEVIDEO' ]) }}

7.3 相机预览页面

// pages/publish/CameraPage.etsimport { PermissionManager } from '../../utils/PermissionManager'import { ToastUtils } from '../../utils/ToastUtils'import { Logger } from '../../utils/Logger'import { CameraPosition } from '@kit.CameraKit'@Entry@Componentstruct CameraPage { @State isReady: boolean = false @State isRecording: boolean = false @State recordDuration: number = 0 // 秒 @State cameraPosition: CameraPosition = CameraPosition.CAMERA_POSITION_BACK @State flashMode: string = 'off' // off on auto private cameraController: CameraController = new CameraController() private recordTimer: number | null = null aboutToAppear() { this.checkPermissions() } private async checkPermissions(): Promise<void> { const granted = await PermissionManager.getInstance().requestCameraAndMic() if (granted) { this.isReady = true } else { ToastUtils.show('需要相机和麦克风权限才能拍摄') } } build() { Stack() { if (this.isReady) { // 相机预览 Camera({ controller: this.cameraController, devicePosition: this.cameraPosition }) .width('100%') .height('100%') .flash(this.flashMode) .onReady(() => { Logger.info('相机准备就绪') }) .onError((error: Error) => { Logger.error('相机错误:', error.message) }) // 顶部工具栏 this.TopToolbar() // 底部拍摄控制 this.BottomControls() // 录制时长 if (this.isRecording) { this.RecordTimer() } } else { // 权限未授予 Column() { Text('需要相机和麦克风权限') .fontSize(16) .fontColor('#FFF') Button('去授权') .margin({ top: 16 }) .onClick(() => { this.checkPermissions() }) } .width('100%') .height('100%') .backgroundColor('#000') .justifyContent(FlexAlign.Center) } } .width('100%') .height('100%') .backgroundColor('#000') } @Builder TopToolbar() { Row() { // 返回 Image($r('app.media.icon_back')) .width(28) .height(28) .fillColor('#FFF') .onClick(() => { router.back() }) Blank() // 闪光灯切换 Image(this.flashMode === 'on' ? $r('app.media.icon_flash_on') : $r('app.media.icon_flash_off')) .width(28) .height(28) .fillColor('#FFF') .onClick(() => { this.toggleFlash() }) // 切换前后摄像头 Image($r('app.media.icon_switch_camera')) .width(28) .height(28) .fillColor('#FFF') .margin({ left: 20 }) .onClick(() => { this.switchCamera() }) } .width('100%') .height(56) .padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Center) .backgroundColor('rgba(0,0,0,0.3)') .position({ x: 0, y: 0 }) } @Builder BottomControls() { Row() { // 左侧:从相册选择 Column() { Image($r('app.media.icon_album')) .width(32) .height(32) .fillColor('#FFF') Text('相册') .fontSize(10) .fontColor('#FFF') .margin({ top: 4 }) } .layoutWeight(1) .onClick(() => { this.pickFromAlbum() }) // 中间:录制按钮 Stack({ alignContent: Alignment.Center }) { // 外圈 Circle() .width(76) .height(76) .strokeWidth(4) .strokeColor('#FFF') .fillColor(Color.Transparent) // 内圈(录制时变方形) if (this.isRecording) { Rect() .width(28) .height(28) .fillColor('#FF2D55') .borderRadius(4) } else { Circle() .width(64) .height(64) .fillColor('#FF2D55') } } .layoutWeight(1) .onClick(() => { if (this.isRecording) { this.stopRecord() } else { this.startRecord() } }) // 右侧:滤镜/特效 Column() { Image($r('app.media.icon_filter')) .width(32) .height(32) .fillColor('#FFF') Text('滤镜') .fontSize(10) .fontColor('#FFF') .margin({ top: 4 }) } .layoutWeight(1) .onClick(() => { ToastUtils.show('滤镜功能开发中') }) } .width('100%') .padding({ bottom: 60, left: 20, right: 20 }) .position({ x: 0, y: '100%' }) .translate({ y: -120 }) } @Builder RecordTimer() { Row() { Circle() .width(8) .height(8) .fillColor('#FF2D55') Text(this.formatDuration(this.recordDuration)) .fontSize(14) .fontColor('#FFF') .margin({ left: 6 }) } .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .backgroundColor('rgba(0,0,0,0.6)') .borderRadius(16) .position({ x: 16, y: 72 }) } // 开始录制 private startRecord(): void { try { // 生成输出路径 const outputPath = getContext().filesDir + '/video_' + Date.now() + '.mp4' this.cameraController.startRecord({ Rotation: 0, fileUri: outputPath }) this.isRecording = true this.recordDuration = 0 // 开始计时 this.recordTimer = setInterval(() => { this.recordDuration++ // 最长60秒自动停止 if (this.recordDuration >= 60) { this.stopRecord() } }, 1000) as unknown as number Logger.info('开始录制:', outputPath) } catch (error) { Logger.error('开始录制失败:', JSON.stringify(error)) ToastUtils.show('录制失败') } } // 停止录制 private stopRecord(): void { try { this.cameraController.stopRecord() this.isRecording = false if (this.recordTimer) { clearInterval(this.recordTimer) this.recordTimer = null } Logger.info('停止录制,时长:', this.recordDuration.toString(), '秒') // 跳转到编辑页 if (this.recordDuration >= 3) { // 至少3秒才有效 router.pushUrl({ url: 'pages/publish/VideoEditPage', params: { videoPath: getContext().filesDir + '/video_' + Date.now() + '.mp4', duration: this.recordDuration } }) } else { ToastUtils.show('视频太短了,至少3秒') } } catch (error) { Logger.error('停止录制失败:', JSON.stringify(error)) } } // 切换前后摄像头 private switchCamera(): void { this.cameraPosition = this.cameraPosition === CameraPosition.CAMERA_POSITION_BACK  ? CameraPosition.CAMERA_POSITION_FRONT  : CameraPosition.CAMERA_POSITION_BACK } // 切换闪光灯 private toggleFlash(): void { const modes = ['off', 'on', 'auto'] const currentIndex = modes.indexOf(this.flashMode) this.flashMode = modes[(currentIndex + 1) % modes.length] } // 从相册选择 private pickFromAlbum(): void { // 调用系统相册选择器 // 实际项目中使用pickVideo接口 ToastUtils.show('相册选择功能') } private formatDuration(seconds: number): string { const min = Math.floor(seconds / 60) const sec = seconds % 60 return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}` } aboutToDisappear() { if (this.recordTimer) { clearInterval(this.recordTimer) this.recordTimer = null } }}

7.4 视频编辑页面

// pages/publish/VideoEditPage.etsimport { PublishViewModel } from './PublishViewModel'import { QVButton } from '../../components/common/QVButton'import { ToastUtils } from '../../utils/ToastUtils'import router from '@ohos.router'@Entry@Componentstruct VideoEditPage { private viewModel: PublishViewModel = new PublishViewModel() @State videoPath: string = '' @State title: string = '' @State description: string = '' @State isPrivate: boolean = false @State isPublishing: boolean = false aboutToAppear() { const params = router.getParams() as Record<string, Object> this.videoPath = params['videoPath'] as string } build() { Column() { // 顶部导航 Row() { Image($r('app.media.icon_back')) .width(24) .height(24) .fillColor('#333') .onClick(() => { router.back() }) Text('发布视频') .fontSize(18) .fontWeight(FontWeight.Bold) .layoutWeight(1) .textAlign(TextAlign.Center) Text('') .width(24) } .width('100%') .height(56) .padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Center) .backgroundColor('#FFF') Scroll() { Column() { // 视频预览 Stack({ alignContent: Alignment.Center }) { // 视频缩略图(实际用第一帧) Rect() .width('100%') .aspectRatio(9 / 16) .fillColor('#000') .borderRadius(8) Image($r('app.media.icon_play')) .width(48) .height(48) .fillColor('#FFF') .opacity(0.8) } .width(120) .margin({ top: 16 }) // 标题输入 Column() { Text('标题') .fontSize(14) .fontColor('#333') .fontWeight(FontWeight.Medium) TextInput({ placeholder: '添加标题,让更多人看到你的作品', text: this.title }) .width('100%') .height(44) .fontSize(14) .maxLength(50) .margin({ top: 8 }) .onChange((value: string) => { this.title = value }) } .width('100%') .alignItems(HorizontalAlign.Start) .margin({ top: 24 }) // 描述输入 Column() { Text('描述') .fontSize(14) .fontColor('#333') .fontWeight(FontWeight.Medium) TextArea({ placeholder: '添加描述,分享你的故事...', text: this.description }) .width('100%') .height(120) .fontSize(14) .maxLength(500) .margin({ top: 8 }) .backgroundColor('#F5F5F5') .borderRadius(8) .padding(12) .onChange((value: string) => { this.description = value }) } .width('100%') .alignItems(HorizontalAlign.Start) .margin({ top: 20 }) // 话题标签 Column() { Text('话题') .fontSize(14) .fontColor('#333') .fontWeight(FontWeight.Medium) Flex({ wrap: FlexWrap.Wrap }) { ForEach(['# 日常', '# 搞笑', '# 美食', '# 旅行'], (tag: string) => { Text(tag) .fontSize(12) .fontColor('#FF2D55') .padding({ left: 10, right: 10, top: 6, bottom: 6 }) .backgroundColor('#FFF0F3') .borderRadius(14) .margin({ right: 8, bottom: 8 }) .onClick(() => { this.description += ' ' + tag }) }) } .width('100%') .margin({ top: 8 }) } .width('100%') .alignItems(HorizontalAlign.Start) .margin({ top: 20 }) // 谁可以看 Row() { Text('谁可以看') .fontSize(14) .fontColor('#333') Blank() Text(this.isPrivate ? '私密' : '公开') .fontSize(14) .fontColor('#666') Toggle({ type: ToggleType.Switch, isOn: !this.isPrivate }) .selectedColor('#FF2D55') .margin({ left: 8 }) .onChange((isOn: boolean) => { this.isPrivate = !isOn }) } .width('100%') .height(52) .margin({ top: 20 }) .backgroundColor('#F5F5F5') .padding({ left: 16, right: 16 }) .borderRadius(8) .alignItems(VerticalAlign.Center) } .width('100%') .padding(16) } .layoutWeight(1) // 底部发布按钮 QVButton({ text: '发布', enabled: this.title.length > 0 && !this.isPublishing, loading: this.isPublishing, onClick: () => { this.publishVideo() } }) .margin({ left: 16, right: 16, bottom: 32 }) } .width('100%') .height('100%') .backgroundColor('#FFF') } // 发布视频 private async publishVideo(): Promise<void> { if (!this.title.trim()) { ToastUtils.show('请输入标题') return } this.isPublishing = true try { await this.viewModel.publishVideo({ videoPath: this.videoPath, title: this.title, description: this.description, isPrivate: this.isPrivate }) ToastUtils.show('发布成功') // 发布成功后返回首页 router.back() router.back() } catch (error) { ToastUtils.show((error as Error).message) } finally { this.isPublishing = false } }}

7.5 PublishViewModel实现

// pages/publish/PublishViewModel.etsimport { BaseViewModel } from '../../common/BaseViewModel'import { VideoRepository } from '../../model/repository/VideoRepository'import { Logger } from '../../utils/Logger'export interface PublishData { videoPath: string title: string description: string isPrivate: boolean tags?: string[]}export class PublishViewModel extends BaseViewModel { // 发布视频 async publishVideo(data: PublishData): Promise<void> { try { this.showLoading() // 1. 先上传视频文件 const videoUrl = await this.uploadVideo(data.videoPath) // 2. 上传封面(截取第一帧) const coverUrl = await this.uploadCover(data.videoPath) // 3. 调用发布接口 await VideoRepository.getInstance().publishVideo({ videoUrl: videoUrl, coverUrl: coverUrl, title: data.title, description: data.description, isPrivate: data.isPrivate, tags: data.tags || [] }) this.hideLoading() Logger.info('视频发布成功') } catch (error) { this.handleError(error as Error) throw error } } // 上传视频文件 private async uploadVideo(filePath: string): Promise<string> { Logger.info('开始上传视频:', filePath) // 实际项目中实现分片上传 // 这里返回模拟URL return 'https://cdn.quickvideo.com/videos/' + Date.now() + '.mp4' } // 上传封面 private async uploadCover(videoPath: string): Promise<string> { Logger.info('开始上传封面:', videoPath) // 截取视频第一帧作为封面,然后上传 // 这里返回模拟URL return 'https://cdn.quickvideo.com/covers/' + Date.now() + '.jpg' }}

7.6 文件上传封装

// utils/UploadManager.etsimport { Logger } from './Logger'export interface UploadTask { file: string progress: number status: 'pending' | 'uploading' | 'success' | 'failed'}export class UploadManager { private static instance: UploadManager static getInstance(): UploadManager { if (!UploadManager.instance) { UploadManager.instance = new UploadManager() } return UploadManager.instance } // 上传文件(支持进度回调) async uploadFile( filePath: string, onProgress?: (progress: number) => void ): Promise<string> { return new Promise((resolve, reject) => { Logger.info('开始上传:', filePath) // 实际项目中: // 1. 获取上传签名 // 2. 分片上传(大文件) // 3. 合并分片 // 4. 返回文件URL // 模拟上传进度 let progress = 0 const timer = setInterval(() => { progress += Math.random() * 10 if (progress >= 100) { progress = 100 clearInterval(timer) onProgress?.(100) const fileUrl = 'https://cdn.quickvideo.com/uploads/' + Date.now() + '.mp4' Logger.info('上传完成:', fileUrl) resolve(fileUrl) } else { onProgress?.(Math.floor(progress)) } }, 200) }) } // 分片上传(大文件用) async uploadFileInChunks( filePath: string, chunkSize: number = 2 * 1024 * 1024, // 2MB一片 onProgress?: (progress: number) => void ): Promise<string> { Logger.info('分片上传,文件:', filePath, '分片大小:', chunkSize.toString()) // 1. 计算文件大小和分片数量 // 2. 逐个上传分片 // 3. 最后通知后端合并 // 4. 返回URL return this.uploadFile(filePath, onProgress) } // 取消上传 cancelUpload(taskId: string): void { // 实现取消逻辑 Logger.info('取消上传:', taskId) }}

7.7 相册选择视频

// utils/AlbumPicker.etsimport { picker } from '@kit.CoreFileKit'import { Logger } from './Logger'export interface PickedVideo { uri: string name: string size: number duration: number}export class AlbumPicker { // 从相册选择视频 static async pickVideo(): Promise<PickedVideo | null> { try { const photoSelectOptions = new picker.PhotoSelectOptions() photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.VIDEO_TYPE photoSelectOptions.maxSelectNumber = 1 const photoPicker = new picker.PhotoViewPicker() const result = await photoPicker.select(photoSelectOptions) if (result.photoUris && result.photoUris.length > 0) { Logger.info('选择视频成功:', result.photoUris[0]) return { uri: result.photoUris[0], name: result.photoFileNames?.[0] || '', size: 0, // 需要额外获取 duration: 0 // 需要额外获取 } } return null } catch (error) { Logger.error('选择视频失败:', JSON.stringify(error)) return null } } // 选择多个视频 static async pickVideos(maxCount: number = 9): Promise<PickedVideo[]> { try { const photoSelectOptions = new picker.PhotoSelectOptions() photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.VIDEO_TYPE photoSelectOptions.maxSelectNumber = maxCount const photoPicker = new picker.PhotoViewPicker() const result = await photoPicker.select(photoSelectOptions) return (result.photoUris || []).map((uri: string, index: number) => ({ uri: uri, name: result.photoFileNames?.[index] || '', size: 0, duration: 0 })) } catch (error) { Logger.error('选择视频失败:', JSON.stringify(error)) return [] } }}

7.8 视频压缩(可选优化)

上传前压缩视频,减少用户流量和等待时间:

// utils/VideoCompressor.etsimport { Logger } from './Logger'export class VideoCompressor { // 压缩视频 static async compress( inputPath: string, quality: 'low' | 'medium' | 'high' = 'medium' ): Promise<string> { Logger.info('开始压缩视频:', inputPath, quality) // 实际项目中: // 1. 使用VideoProcessor或Native编解码库 // 2. 调整分辨率、码率、帧率 // 3. 输出压缩后的文件 const qualityConfig = { low: { width: 480, bitrate: 500000 }, // 标清 medium: { width: 720, bitrate: 1500000 }, // 高清 high: { width: 1080, bitrate: 3000000 } // 超清 } const config = qualityConfig[quality] Logger.info('压缩配置:', JSON.stringify(config)) // 模拟压缩,返回原路径 return inputPath } // 获取视频信息 static async getVideoInfo(videoPath: string): Promise<{ width: number height: number duration: number size: number bitrate: number }> { Logger.info('获取视频信息:', videoPath) // 实际项目中解析视频元数据 return { width: 1080, height: 1920, duration: 30, size: 50 * 1024 * 1024, bitrate: 3000000 } }}

7.9 发布进度展示

// components/publish/UploadProgressDialog.ets@Componentexport struct UploadProgressDialog { @State show: boolean = false @State progress: number = 0 @State statusText: string = '上传中...' onCancel?: () => void build() { if (this.show) { Stack({ alignContent: Alignment.Center }) { // 遮罩 Column() .width('100%') .height('100%') .backgroundColor('rgba(0,0,0,0.5)') // 弹窗 Column() { Text(this.statusText) .fontSize(16) .fontColor('#333') .fontWeight(FontWeight.Medium) // 进度条 Progress({ value: this.progress, total: 100 }) .width(240) .height(8) .color('#FF2D55') .margin({ top: 16 }) Text(this.progress + '%') .fontSize(14) .fontColor('#999') .margin({ top: 8 }) Button('取消') .width(100) .height(36) .fontSize(14) .backgroundColor('#F5F5F5') .fontColor('#666') .margin({ top: 20 }) .onClick(() => { this.onCancel?.() this.show = false }) } .width(280) .padding(24) .backgroundColor('#FFF') .borderRadius(12) } .width('100%') .height('100%') } } // 显示 showDialog(): void { this.show = true this.progress = 0 this.statusText = '上传中...' } // 更新进度 updateProgress(progress: number): void { this.progress = progress } // 隐藏 hideDialog(): void { this.show = false }}

7.10 本章避坑指南

  1. 相机权限必须动态申请
    :不能只在配置文件里写,运行时还要弹框
  2. 录制视频要有时长限制
    :短视频一般15-60秒,太长用户传不动
  3. 上传要支持断点续传
    :视频文件大,网络不稳定容易失败
  4. 发布前要压缩视频
    :不然用户流量和存储都扛不住
  5. 相机页面要处理生命周期
    :页面隐藏时要释放相机资源
  6. 横竖屏拍摄要支持
    :用户可能横屏拍,预览和上传都要适配

面试题:大文件上传如何优化?

参考答案:

  1. 分片上传
    :把大文件切成小分片,逐个上传,失败了只重传失败的片
  2. 断点续传
    :记录已上传的分片,下次接着传,不用从头来
  3. 并发上传
    :同时传多个分片,提高速度(注意控制并发数)
  4. 秒传
    :上传前计算MD5,如果服务器已有相同文件直接返回成功
  5. 压缩优化
    :上传前压缩视频/图片,减少文件体积
  6. CDN加速
    :上传到离用户最近的CDN节点

第8章 网络请求与数据缓存策略

8.1 网络层架构设计

一个好的网络层应该具备:

  • 统一的请求封装
  • 统一的错误处理
  • Token自动刷新
  • 请求取消
  • 缓存支持
  • 日志打印

8.2 完善的HttpManager封装

第3章写了基础版,这里升级成完整版:

// model/api/HttpManager.etsimport http from '@ohos.net.http'import { Logger } from '../../utils/Logger'import { ApiConstants } from '../../constants/ApiConstants'import { AppState } from '../../common/AppState'import { TokenInterceptor } from './TokenInterceptor'export interface ApiResponse<T> { code: number message: string data: T}export interface RequestOptions { showLoading?: boolean showError?: boolean retryCount?: number timeout?: number}export class HttpManager { private static instance: HttpManager private httpRequest: http.HttpRequest private pendingRequests: Map<string, http.HttpRequest> = new Map() private constructor() { this.httpRequest = http.createHttp() } static getInstance(): HttpManager { if (!HttpManager.instance) { HttpManager.instance = new HttpManager() } return HttpManager.instance } // GET请求 async get<T>(url: string, params?: Record<string, string | number>, options?: RequestOptions): Promise<T> { const fullUrl = this.buildUrl(url, params) return this.request<T>(fullUrl, { method: http.RequestMethod.GET, ...options }) } // POST请求(JSON) async post<T>(url: string, data?: Record<string, Object>, options?: RequestOptions): Promise<T> { return this.request<T>(ApiConstants.BASE_URL + url, { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/json' }, extraData: JSON.stringify(data), ...options }) } // POST请求(FormData) async postForm<T>(url: string, data?: Record<string, string>, options?: RequestOptions): Promise<T> { const formData = Object.entries(data || {}) .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) .join('&') return this.request<T>(ApiConstants.BASE_URL + url, { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/x-www-form-urlencoded' }, extraData: formData, ...options }) } // 核心请求方法 private async request<T>(url: string, config: http.HttpRequestOptions & RequestOptions): Promise<T> { const requestId = `${config.method}_${url}_${Date.now()}` Logger.info('请求开始:', requestId, url) const finalConfig: http.HttpRequestOptions = { ...config, header: { ...this.getDefaultHeaders(), ...config.header }, connectTimeout: config.timeout || 10000, readTimeout: config.timeout || 15000 } try { const response = await this.httpRequest.request(url, finalConfig) return this.handleResponse<T>(response, url, config) } catch (error) { Logger.error('请求失败:', requestId, (error as Error).message) // 重试逻辑 const retryCount = config.retryCount || 0 if (retryCount > 0) { Logger.info('重试剩余次数:', retryCount.toString()) return this.request<T>(url, { ...config, retryCount: retryCount - 1 }) } if (config.showError !== false) { // ToastUtils.show('网络请求失败') } throw error } } // 处理响应 private async handleResponse<T>(response: http.HttpResponse, url: string, config: http.HttpRequestOptions & RequestOptions): Promise<T> { // HTTP层成功 if (response.responseCode === 200) { const result = JSON.parse(response.result as string) as ApiResponse<T> // 业务层成功 if (result.code === 0) { Logger.info('请求成功:', url) return result.data } // Token过期,尝试刷新 if (result.code === 401) { Logger.warn('Token过期,尝试刷新') const refreshed = await TokenInterceptor.handleTokenExpired() if (refreshed) { // 刷新成功,重试请求 return this.request<T>(url, config) } } // 其他业务错误 Logger.error('业务错误:', url, result.code, result.message) throw new Error(result.message || '请求失败') } // HTTP层错误 Logger.error('HTTP错误:', url, response.responseCode) throw new Error(`网络错误: ${response.responseCode}`) } // 构建URL(带参数) private buildUrl(url: string, params?: Record<string, string | number>): string { let fullUrl = ApiConstants.BASE_URL + url if (params && Object.keys(params).length > 0) { const queryString = Object.entries(params) .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`) .join('&') fullUrl += '?' + queryString } return fullUrl } // 默认请求头 private getDefaultHeaders(): Record<string, string> { const token = AppState.getInstance().token return { 'Authorization': token ? `Bearer ${token}` : '', 'User-Agent': 'QuickVideo/1.0 (HarmonyOS)', 'Accept-Language': 'zh-CN', 'X-Platform': 'harmonyos', 'X-Version': '1.0.0' } } // 取消请求 cancelRequest(requestId: string): void { const request = this.pendingRequests.get(requestId) if (request) { request.destroy() this.pendingRequests.delete(requestId) Logger.info('取消请求:', requestId) } } // 取消所有请求 cancelAllRequests(): void { this.pendingRequests.forEach((request) => { request.destroy() }) this.pendingRequests.clear() Logger.info('取消所有请求') }}

8.3 缓存策略设计

短视频APP需要缓存的场景:

  • 视频列表数据(下拉刷新时更新)
  • 用户信息(频繁访问)
  • 视频封面图(图片缓存)
  • 评论列表(分页缓存)

8.3.1 缓存工具类

// utils/CacheManager.etsimport { Logger } from './Logger'import preferences from '@ohos.data.preferences'import { common } from '@kit.AbilityKit'interface CacheItem<T> { data: T timestamp: number expireTime: number // 过期时间(毫秒),0表示永不过期}export class CacheManager { private static instance: CacheManager private static PREFS_NAME = 'app_cache' private memoryCache: Map<string, CacheItem<any>> = new Map() private constructor() {} static getInstance(): CacheManager { if (!CacheManager.instance) { CacheManager.instance = new CacheManager() } return CacheManager.instance } // 内存缓存:存 setMemory<T>(key: string, data: T, expireSeconds: number = 0): void { const item: CacheItem<T> = { data: data, timestamp: Date.now(), expireTime: expireSeconds > 0 ? expireSeconds * 1000 : 0 } this.memoryCache.set(key, item) Logger.debug('内存缓存存入:', key) } // 内存缓存:取 getMemory<T>(key: string): T | null { const item = this.memoryCache.get(key) if (!item) return null // 检查是否过期 if (item.expireTime > 0 && Date.now() - item.timestamp > item.expireTime) { this.memoryCache.delete(key) Logger.debug('内存缓存已过期:', key) return null } Logger.debug('内存缓存命中:', key) return item.data as T } // 磁盘缓存:存 async setDisk<T>(key: string, data: T, expireSeconds: number = 0): Promise<void> { try { const prefs = await this.getPreferences() const item: CacheItem<T> = { data: data, timestamp: Date.now(), expireTime: expireSeconds > 0 ? expireSeconds * 1000 : 0 } await prefs.put(key, JSON.stringify(item)) await prefs.flush() Logger.debug('磁盘缓存存入:', key) } catch (error) { Logger.error('磁盘缓存存入失败:', key, (error as Error).message) } } // 磁盘缓存:取 async getDisk<T>(key: string): Promise<T | null> { try { const prefs = await this.getPreferences() const value = await prefs.get(key, '') as string if (!value) return null const item = JSON.parse(value) as CacheItem<T> // 检查是否过期 if (item.expireTime > 0 && Date.now() - item.timestamp > item.expireTime) { await prefs.delete(key) await prefs.flush() Logger.debug('磁盘缓存已过期:', key) return null } Logger.debug('磁盘缓存命中:', key) return item.data } catch (error) { Logger.error('磁盘缓存读取失败:', key, (error as Error).message) return null } } // 二级缓存:先查内存,再查磁盘 async get<T>(key: string): Promise<T | null> { // 先查内存 const memoryData = this.getMemory<T>(key) if (memoryData !== null) { return memoryData } // 再查磁盘 const diskData = await this.getDisk<T>(key) if (diskData !== null) { // 查到了,同步到内存 this.setMemory(key, diskData) return diskData } return null } // 二级缓存:存(同时存内存和磁盘) async set<T>(key: string, data: T, expireSeconds: number = 0): Promise<void> { this.setMemory(key, data, expireSeconds) await this.setDisk(key, data, expireSeconds) } // 删除缓存 async remove(key: string): Promise<void> { this.memoryCache.delete(key) const prefs = await this.getPreferences() await prefs.delete(key) await prefs.flush() } // 清空所有缓存 async clear(): Promise<void> { this.memoryCache.clear() const prefs = await this.getPreferences() await prefs.clear() await prefs.flush() Logger.info('清空所有缓存') } // 计算缓存大小 async getCacheSize(): Promise<number> { // 简化实现,实际需要计算文件大小 return this.memoryCache.size } private async getPreferences(): Promise<preferences.Preferences> { const context = getContext() as common.UIAbilityContext return preferences.getPreferences(context, CacheManager.PREFS_NAME) }}

8.4 带缓存的Repository

// model/repository/CachedVideoRepository.etsimport { VideoRepository } from './VideoRepository'import { CacheManager } from '../../utils/CacheManager'import { VideoBean } from '../bean/VideoBean'import { Logger } from '../../utils/Logger'// 缓存key前缀const CACHE_KEY_VIDEO_LIST = 'video_list_'const CACHE_KEY_VIDEO_DETAIL = 'video_detail_'const CACHE_EXPIRE_SHORT = 5 * 60 // 5分钟const CACHE_EXPIRE_MEDIUM = 30 * 60 // 30分钟export class CachedVideoRepository extends VideoRepository { private static instance: CachedVideoRepository private cacheManager: CacheManager = CacheManager.getInstance() static getInstance(): CachedVideoRepository { if (!CachedVideoRepository.instance) { CachedVideoRepository.instance = new CachedVideoRepository() } return CachedVideoRepository.instance } // 获取视频列表(带缓存) async getVideoListWithCache( page: number, pageSize: number, forceRefresh: boolean = false ): Promise<VideoBean[]> { const cacheKey = `${CACHE_KEY_VIDEO_LIST}${page}_${pageSize}` // 不强制刷新时,先查缓存 if (!forceRefresh) { const cached = await this.cacheManager.get<VideoBean[]>(cacheKey) if (cached && cached.length > 0) { Logger.info('视频列表缓存命中:', cacheKey) return cached } } // 缓存没有或强制刷新,请求网络 const list = await this.getVideoList(page, pageSize) // 存入缓存 if (list.length > 0) { await this.cacheManager.set(cacheKey, list, CACHE_EXPIRE_SHORT) } return list } // 获取视频详情(带缓存) async getVideoDetailWithCache(videoId: string, forceRefresh: boolean = false): Promise<VideoBean> { const cacheKey = CACHE_KEY_VIDEO_DETAIL + videoId if (!forceRefresh) { const cached = await this.cacheManager.get<VideoBean>(cacheKey) if (cached) { Logger.info('视频详情缓存命中:', cacheKey) return cached } } const detail = await this.getVideoDetail(videoId) await this.cacheManager.set(cacheKey, detail, CACHE_EXPIRE_MEDIUM) return detail } // 清除视频列表缓存(下拉刷新时调用) async clearVideoListCache(): Promise<void> { // 简单处理:清除所有列表缓存 // 实际项目中可以用更精细的key管理 Logger.info('清除视频列表缓存') }}

8.5 图片缓存策略

图片是短视频APP最占流量的部分,一定要做好缓存。

8.5.1 图片缓存工具

// utils/ImageCacheManager.etsimport { Logger } from './Logger'export class ImageCacheManager { private static instance: ImageCacheManager // 内存缓存大小限制(10MB) private static readonly MAX_MEMORY_CACHE_SIZE = 10 * 1024 * 1024 private memoryCache: Map<string, ArrayBuffer> = new Map() private memoryCacheSize: number = 0 private constructor() {} static getInstance(): ImageCacheManager { if (!ImageCacheManager.instance) { ImageCacheManager.instance = new ImageCacheManager() } return ImageCacheManager.instance } // 从缓存获取图片 async getImage(url: string): Promise<ArrayBuffer | null> { // 1. 先查内存 const memoryData = this.memoryCache.get(url) if (memoryData) { Logger.debug('图片内存缓存命中:', url) return memoryData } // 2. 再查磁盘 const diskData = await this.getFromDisk(url) if (diskData) { Logger.debug('图片磁盘缓存命中:', url) // 同步到内存 this.addToMemory(url, diskData) return diskData } return null } // 缓存图片 async cacheImage(url: string, data: ArrayBuffer): Promise<void> { // 存内存 this.addToMemory(url, data) // 存磁盘 await this.saveToDisk(url, data) } // 添加到内存缓存 private addToMemory(url: string, data: ArrayBuffer): void { // 如果超过大小限制,清理旧的 if (this.memoryCacheSize + data.byteLength > ImageCacheManager.MAX_MEMORY_CACHE_SIZE) { this.trimMemoryCache() } this.memoryCache.set(url, data) this.memoryCacheSize += data.byteLength } // 清理内存缓存(LRU简化版:清理一半) private trimMemoryCache(): void { const keys = Array.from(this.memoryCache.keys()) const removeCount = Math.floor(keys.length / 2) for (let i = 0; i < removeCount; i++) { const key = keys[i] const data = this.memoryCache.get(key) if (data) { this.memoryCacheSize -= data.byteLength } this.memoryCache.delete(key) } Logger.info('清理内存缓存,剩余:', this.memoryCache.size.toString()) } // 存到磁盘 private async saveToDisk(url: string, data: ArrayBuffer): Promise<void> { // 实际项目中保存到缓存目录 // 文件名用url的md5 Logger.debug('图片存磁盘:', url) } // 从磁盘读取 private async getFromDisk(url: string): Promise<ArrayBuffer | null> { // 实际项目中从缓存目录读取 return null } // 清空所有缓存 async clearAll(): Promise<void> { this.memoryCache.clear() this.memoryCacheSize = 0 // 清空磁盘缓存... Logger.info('清空图片缓存') } // 获取缓存大小 async getCacheSize(): Promise<number> { // 内存 + 磁盘 return this.memoryCacheSize }}

8.6 离线缓存策略

没有网络的时候也能看视频,体验更好。

// utils/OfflineManager.etsimport { Logger } from './Logger'import { CacheManager } from './CacheManager'export class OfflineManager { private static instance: OfflineManager static getInstance(): OfflineManager { if (!OfflineManager.instance) { OfflineManager.instance = new OfflineManager() } return OfflineManager.instance } // 检查是否有网络 async isNetworkAvailable(): Promise<boolean> { // 实际项目中使用@ohos.net.connection return true } // 预加载首页数据(启动时调用) async preloadHomeData(): Promise<void> { Logger.info('预加载首页数据') try { // 预加载第一页视频列表 // const list = await VideoRepository.getInstance().getVideoList(1, 10) // CacheManager.getInstance().set('home_video_list', list, 30 * 60) // 预加载用户信息 // ... } catch (error) { Logger.error('预加载失败:', (error as Error).message) } } // 离线模式下获取数据 async getOfflineData<T>(cacheKey: string): Promise<T | null> { const isOnline = await this.isNetworkAvailable() if (isOnline) { return null // 有网就不用离线数据 } Logger.info('离线模式,从缓存获取:', cacheKey) return CacheManager.getInstance().get<T>(cacheKey) } // 保存离线视频(下载到本地) async saveVideoOffline(videoId: string, videoUrl: string): Promise<void> { Logger.info('离线下载视频:', videoId) // 1. 下载视频文件到本地 // 2. 保存视频信息到数据库 // 3. 更新离线视频列表 } // 获取离线视频列表 async getOfflineVideos(): Promise<any[]> { // 从本地数据库查询 return [] } // 删除离线视频 async deleteOfflineVideo(videoId: string): Promise<void> { // 删除本地文件和数据库记录 }}

8.7 请求防抖与节流

搜索、输入框等场景需要防抖,避免频繁请求。

// utils/DebounceManager.etsexport class DebounceManager { private timers: Map<string, number> = new Map() // 防抖:延迟执行,期间再次调用则重置计时 debounce(key: string, fn: () => void, delay: number = 300): void { // 清除之前的定时器 const existingTimer = this.timers.get(key) if (existingTimer) { clearTimeout(existingTimer) } // 设置新的定时器 const timer = setTimeout(() => { fn() this.timers.delete(key) }, delay) as unknown as number this.timers.set(key, timer) } // 节流:固定时间内只执行一次 throttle(key: string, fn: () => void, interval: number = 300): void { const lastTime = this.timers.get(key) || 0 const now = Date.now() if (now - lastTime >= interval) { fn() this.timers.set(key, now) } } // 取消 cancel(key: string): void { const timer = this.timers.get(key) if (timer) { clearTimeout(timer) this.timers.delete(key) } } // 取消所有 cancelAll(): void { this.timers.forEach(timer => { clearTimeout(timer) }) this.timers.clear() }}

8.8 搜索功能(带防抖)

// pages/search/SearchViewModel.etsimport { BaseViewModel } from '../../common/BaseViewModel'import { VideoRepository } from '../../model/repository/VideoRepository'import { VideoBean } from '../../model/bean/VideoBean'import { DebounceManager } from '../../utils/DebounceManager'import { Logger } from '../../utils/Logger'export class SearchViewModel extends BaseViewModel { searchResults: VideoBean[] = [] hotKeywords: string[] = ['搞笑', '美食', '旅行', '音乐', '舞蹈'] historyKeywords: string[] = [] private debounceManager: DebounceManager = new DebounceManager() // 搜索(防抖) search(keyword: string): void { if (!keyword.trim()) { this.searchResults = [] return } this.debounceManager.debounce('search', async () => { await this.doSearch(keyword) }, 500) } // 实际搜索 private async doSearch(keyword: string): Promise<void> { try { this.showLoading() const results = await VideoRepository.getInstance().searchVideos(keyword) this.searchResults = results // 保存搜索历史 this.saveHistory(keyword) this.hideLoading() Logger.info('搜索完成,结果数:', results.length.toString()) } catch (error) { this.handleError(error as Error) } } // 保存搜索历史 private saveHistory(keyword: string): void { // 去重 this.historyKeywords = this.historyKeywords.filter(k => k !== keyword) // 加到最前面 this.historyKeywords.unshift(keyword) // 最多保留10条 if (this.historyKeywords.length > 10) { this.historyKeywords = this.historyKeywords.slice(0, 10) } // 持久化 // StorageUtils.set('search_history', JSON.stringify(this.historyKeywords)) } // 清除搜索历史 clearHistory(): void { this.historyKeywords = [] // StorageUtils.remove('search_history') } // 加载热门搜索 async loadHotKeywords(): Promise<void> { // 从接口获取热门关键词 // 这里用模拟数据 this.hotKeywords = ['搞笑视频', '美食教程', '旅行vlog', '音乐翻唱', '舞蹈教学'] }}

8.9 WebSocket实时通信

评论、消息通知等需要实时更新的场景用WebSocket。

// utils/WebSocketManager.etsimport { Logger } from './Logger'export type WSMessageType = 'comment' | 'like' | 'follow' | 'notification'export interface WSMessage { type: WSMessageType data: any timestamp: number}export class WebSocketManager { private static instance: WebSocketManager private ws: WebSocket | null = null private reconnectTimer: number | null = null private heartbeatTimer: number | null = null private url: string = '' private isConnected: boolean = false private listeners: Map<string, Set<(data: any) => void>> = new Map() // 重连配置 private reconnectAttempts: number = 0 private maxReconnectAttempts: number = 5 private reconnectDelay: number = 3000 private constructor() {} static getInstance(): WebSocketManager { if (!WebSocketManager.instance) { WebSocketManager.instance = new WebSocketManager() } return WebSocketManager.instance } // 连接 connect(url: string): void { if (this.isConnected) { Logger.warn('WebSocket已连接') return } this.url = url Logger.info('WebSocket连接:', url) try { this.ws = new WebSocket(url) this.ws.onopen = () => { Logger.info('WebSocket连接成功') this.isConnected = true this.reconnectAttempts = 0 this.startHeartbeat() } this.ws.onmessage = (event: MessageEvent) => { this.handleMessage(event.data) } this.ws.onerror = (error: Event) => { Logger.error('WebSocket错误:', JSON.stringify(error)) } this.ws.onclose = () => { Logger.info('WebSocket连接关闭') this.isConnected = false this.stopHeartbeat() this.tryReconnect() } } catch (error) { Logger.error('WebSocket创建失败:', JSON.stringify(error)) this.tryReconnect() } } // 发送消息 send(type: string, data: any): void { if (!this.isConnected || !this.ws) { Logger.warn('WebSocket未连接,消息发送失败') return } const message = { type: type, data: data, timestamp: Date.now() } this.ws.send(JSON.stringify(message)) Logger.debug('WebSocket发送:', type) } // 监听消息 on(type: string, callback: (data: any) => void): void { if (!this.listeners.has(type)) { this.listeners.set(type, new Set()) } this.listeners.get(type)?.add(callback) } // 取消监听 off(type: string, callback: (data: any) => void): void { this.listeners.get(type)?.delete(callback) } // 处理消息 private handleMessage(data: string): void { try { const message = JSON.parse(data) as WSMessage Logger.debug('WebSocket收到:', message.type) // 分发给对应监听器 const listeners = this.listeners.get(message.type) if (listeners) { listeners.forEach(callback => { callback(message.data) }) } // 也分发给all监听器 const allListeners = this.listeners.get('*') if (allListeners) { allListeners.forEach(callback => { callback(message) }) } } catch (error) { Logger.error('WebSocket消息解析失败:', (error as Error).message) } } // 心跳检测 private startHeartbeat(): void { this.stopHeartbeat() this.heartbeatTimer = setInterval(() => { if (this.isConnected) { this.send('ping', {}) } }, 30000) as unknown as number // 30秒一次 } private stopHeartbeat(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer) this.heartbeatTimer = null } } // 重连 private tryReconnect(): void { if (this.reconnectAttempts >= this.maxReconnectAttempts) { Logger.error('WebSocket重连次数超限') return } this.reconnectAttempts++ Logger.info(`WebSocket重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`) if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) } this.reconnectTimer = setTimeout(() => { this.connect(this.url) }, this.reconnectDelay * this.reconnectAttempts) as unknown as number // 递增延迟 } // 断开连接 disconnect(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } this.stopHeartbeat() if (this.ws) { this.ws.close() this.ws = null } this.isConnected = false Logger.info('WebSocket断开连接') } // 是否已连接 getIsConnected(): boolean { return this.isConnected }}

8.10 本章避坑指南

  1. 缓存一定要有过期时间
    :不然用户一直看到旧数据
  2. 内存缓存要限制大小
    :不然图片缓存会把内存撑爆
  3. Token刷新要防并发
    :多个请求同时401时,只刷新一次
  4. 搜索一定要防抖
    :不然用户输入一个字就请求一次,太浪费
  5. WebSocket要做重连
    :网络不稳定时自动重连,还要有心跳
  6. 离线缓存要加密
    :用户数据存在本地,要做好安全保护

面试题:图片缓存的LRU算法怎么实现?

参考答案:
LRU(Least Recently Used)最近最少使用算法,核心思路:

  1. 用Map存储数据,保持插入顺序
  2. 每次访问数据时,把它移到最后(表示最近使用)
  3. 当缓存满了,删除最前面的(最久没使用的)
  4. 鸿蒙中可以用Map的有序特性来实现,或者用双向链表+哈希表

优化点:

  • 按大小淘汰,不按数量(图片大小差异大)
  • 可以分多级缓存:内存 → 磁盘 → 网络
  • 预加载策略:根据用户行为预测可能需要的图片


第9章 性能优化与包体积瘦身

9.1 性能优化全景图

性能优化不是玄学,是有方法论的。我们从四个维度入手:

┌─────────────────────────────────────────────────────┐│ 性能优化四大维度 │├──────────┬──────────┬──────────┬───────────────────┤│ 启动速度 │ 流畅度 │ 内存占用 │ 包体积 ││ - 白屏时间│ - 帧率 │ - OOM │ - 安装包大小 ││ - 首屏时间│ - 滑动 │ - 泄漏 │ - 资源压缩 ││ - 预热 │ - 响应 │ - 峰值 │ - 按需加载 │└──────────┴──────────┴──────────┴───────────────────┘

9.2 启动性能优化

9.2.1 启动阶段拆解

APP启动分为三个阶段:

  1. Application创建
    :进程初始化、Application.onCreate
  2. Ability创建
    :主Ability初始化
  3. 首屏渲染
    :首页UI渲染完成

9.2.2 优化手段

// entryability/EntryAbility.etsimport { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'import { Logger } from '../utils/Logger'export default class EntryAbility extends UIAbility { // 冷启动时调用,不要放耗时操作! onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { Logger.info('Ability onCreate') // ❌ 不要在这里做耗时初始化 // 比如:初始化所有SDK、加载所有配置 // ✅ 只做最核心的初始化 this.initCore() // ✅ 延迟初始化非核心模块 setTimeout(() => { this.initNonCore() }, 1000) } // 核心初始化(必须在启动时完成) private initCore(): void { // 1. 日志初始化 // 2. 全局状态初始化 // 3. 路由注册 } // 非核心初始化(延后执行) private initNonCore(): void { // 1. 统计SDK初始化 // 2. 推送SDK初始化 // 3. 分享SDK初始化 // 4. 预加载首页数据 } onDestroy(): void { Logger.info('Ability onDestroy') }}

9.2.3 首页数据预加载

// 启动时就开始加载首页数据,等UI渲染好数据也到了export default class EntryAbility extends UIAbility { onWindowStageCreate(windowStage: window.WindowStage): void { // 开始预加载首页数据 this.preloadHomeData() // 加载UI windowStage.loadContent('pages/home/HomePage', (err, data) => { // ... }) } private preloadHomeData(): void { // 异步加载,不阻塞UI import('../model/repository/VideoRepository').then(({ VideoRepository }) => { VideoRepository.getInstance().getVideoList(1, 10).catch(err => { Logger.error('预加载失败:', err.message) }) }) }}

9.3 列表滑动性能优化

短视频Feed流是性能重灾区,必须优化。

9.3.1 List组件优化

List({ scroller: this.listScroller }) { ForEach(this.videoList, (item: VideoBean, index: number) => { ListItem() { VideoItem({ video: item, index: index }) } .width('100%') .height('100%') }, (item: VideoBean) => item.id) // ✅ 用唯一ID当key,不要用index}.width('100%').height('100%').cachedCount(3) // ✅ 缓存3个item,滑动更流畅.listDirection(Axis.Vertical).scrollBar(BarState.Off).chainAnimation(false) // ✅ 关闭链式动画,提升性能

9.3.2 组件懒加载

// 评论区、分享面板等不常用的组件,用的时候才创建@BuilderCommentPanel() { if (this.showCommentPanel) { // 评论列表组件 CommentList({ videoId: this.videoId }) }}// 更高级:用LazyForEach// 适合数据量大的列表

9.3.3 图片优化

// 1. 图片尺寸要和显示尺寸匹配,不要加载大图显示小区域Image(this.video.coverUrl) .width(200) .height(300) .objectFit(ImageFit.Cover) .autoResize(true) // ✅ 自动缩放// 2. 用WebP格式,比PNG/JPG小30%// 3. 列表中的图片用缩略图,详情页再用原图// 4. 离屏图片不加载(List会自动处理,但自定义的要注意)

9.4 内存优化

9.4.1 常见内存泄漏场景

泄漏场景
原因
解决方案
定时器未清理
setTimeout/setInterval没清
aboutToDisappear中clear
事件监听未移除
on/ addEventListener没移除
对应生命周期中移除
大图片未释放
大图占内存,不显示了还在
离屏时释放图片资源
单例持有Context
单例持有页面Context
用ApplicationContext
闭包引用
回调闭包持有外部引用
及时解除引用

9.4.2 正确清理资源

@Componentstruct VideoPlayer { private timer: number | null = null aboutToAppear() { // 启动定时器 this.timer = setInterval(() => { // 更新进度 }, 1000) as unknown as number } aboutToDisappear() { // ✅ 页面销毁时清理定时器 if (this.timer) { clearInterval(this.timer) this.timer = null } // ✅ 释放播放器 PlayerManager.getInstance().release() // ✅ 取消网络请求 HttpManager.getInstance().cancelAllRequests() }}

9.4.3 图片内存优化

// 大图片列表,滑动时释放离屏图片// 可以用cachedCount配合,缓存之外的自动释放// 手动管理图片缓存import { ImageCacheManager } from '../utils/ImageCacheManager'// 进入页面时预加载aboutToAppear() { // 预加载当前可见的图片 this.videoList.slice(0, 3).forEach(video => { // ImageCacheManager.getInstance().preload(video.coverUrl) })}// 离开页面时清理aboutToDisappear() { // 清理非核心缓存 // ImageCacheManager.getInstance().trimMemory()}

9.5 渲染性能优化

9.5.1 减少不必要的重绘

// ❌ 不好的写法:每次状态变化整个组件都重绘@Componentstruct BadExample { @State count: number = 0 @State text: string = 'hello' build() { Column() { Text(this.text) // text没变也会跟着重绘 Text(this.count.toString()) Button('+1').onClick(() => this.count++) } }}// ✅ 好的写法:拆分成小组件,只重绘需要的部分@Componentstruct GoodExample { @State count: number = 0 @State text: string = 'hello' build() { Column() { StaticText({ text: this.text }) // text不变就不重绘 Counter({ count: $count }) // 只有count变了才重绘 } }}@Componentstruct StaticText { @Prop text: string build() { Text(this.text) }}@Componentstruct Counter { @Link count: number build() { Column() { Text(this.count.toString()) Button('+1').onClick(() => this.count++) } }}

9.5.2 避免在build中做计算

// ❌ 不好的写法:每次build都计算@Componentstruct BadExample { @State list: number[] = [1, 2, 3, 4, 5] build() { Column() { // 每次重绘都过滤一次 ForEach(this.list.filter(n => n > 2), item => { Text(item.toString()) }) } }}// ✅ 好的写法:计算结果缓存起来@Componentstruct GoodExample { @State list: number[] = [1, 2, 3, 4, 5] @State filteredList: number[] = [] aboutToAppear() { this.filteredList = this.list.filter(n => n > 2) } build() { Column() { ForEach(this.filteredList, item => { Text(item.toString()) }) } }}

9.6 包体积瘦身

包体积直接影响用户下载意愿,越小越好。

9.6.1 资源优化

  1. 图片压缩
  • 用TinyPNG等工具压缩PNG
  • 小图标用SVG或iconfont
  • 列表图用WebP格式
  1. 只保留必要的分辨率
  • 不是所有密度的资源都要放
  • 优先放xxhdpi的,其他密度系统会自动缩放
  1. 删除无用资源
  • 定期清理不用的图片、字符串
  • 用Lint工具检测无用资源

9.6.2 代码优化

// 1. 按需加载(动态import)// 不常用的页面和功能,用的时候再加载// ❌ 顶部静态导入// import { BigFeature } from '../bigfeature/BigFeature'// ✅ 动态导入async openBigFeature() { const { BigFeature } = await import('../bigfeature/BigFeature') // 使用BigFeature}

9.6.3 混淆与压缩

build-profile.json5中配置:

{ ”apiType”: ”stageMode”, ”buildOption”: { ”arkOptions”: { ”obfuscation”: { ”enable”: true, // 开启混淆 ”rules”: { ”removeUnusedCode”: true, // 移除无用代码 ”removeUnusedImport”: true // 移除无用import } } } }}

9.6.4 按需分发(App Bundle)

华为应用市场支持App Bundle,用户只下载自己需要的部分:

  • 不同分辨率的资源
  • 不同语言的字符串
  • 不同架构的so库

9.7 网络性能优化

9.7.1 请求优化

// 1. 合并请求:一个页面的多个接口合并成一个// 减少HTTP握手和TCP连接开销// 2. 接口分页:不要一次返回所有数据// 每页10-20条,滚动加载更多// 3. 数据压缩:接口返回gzip压缩的数据// 服务端开启,客户端自动解压

9.7.2 图片CDN优化

// 1. 用CDN加速,就近访问// 2. 图片服务支持实时裁剪、格式转换// 3. 根据网络状况选择不同清晰度// 根据网络选择图片质量getImageUrl(baseUrl: string, quality: 'low' | 'medium' | 'high'): string { const qualityMap = { low: '?w=200&q=60', medium: '?w=400&q=75', high: '?w=800&q=90' } return baseUrl + qualityMap[quality]}

9.8 性能监控与分析

优化不能瞎猜,要有数据支撑。

9.8.1 性能埋点

// utils/PerformanceMonitor.etsimport { Logger } from './Logger'export class PerformanceMonitor { private static marks: Map<string, number> = new Map() // 开始计时 static startMark(name: string): void { this.marks.set(name, Date.now()) } // 结束计时并上报 static endMark(name: string): void { const startTime = this.marks.get(name) if (startTime) { const duration = Date.now() - startTime Logger.info(`[性能] ${name}: ${duration}ms`) // 上报到统计平台 // Analytics.logEvent('performance', { name, duration }) this.marks.delete(name) } } // 监控页面启动时间 static trackPageLoad(pageName: string): void { this.startMark(`page_${pageName}_load`) } static trackPageLoaded(pageName: string): void { this.endMark(`page_${pageName}_load`) }}// 使用aboutToAppear() { PerformanceMonitor.trackPageLoad('HomePage')}onPageShow() { PerformanceMonitor.trackPageLoaded('HomePage')}

9.8.2 DevEco Studio性能分析工具

DevEco Studio自带性能分析工具:

  1. Profiler
    :CPU、内存、网络分析
  2. ArkUI Inspector
    :UI层级分析
  3. Frame Analyzer
    :帧率分析

使用方法:

  1. 连接真机
  2. 点击底部的Profiler标签
  3. 选择要分析的进程
  4. 开始录制,操作APP
  5. 停止录制,查看分析结果

9.9 弱网与降级策略

用户网络环境复杂,要做好适配。

// utils/NetworkQuality.etsimport { connection } from '@kit.NetworkKit'import { Logger } from './Logger'export type NetworkType = 'none' | '2g' | '3g' | '4g' | '5g' | 'wifi'export class NetworkQuality { private static instance: NetworkQuality private currentType: NetworkType = 'wifi' private constructor() { this.init() } static getInstance(): NetworkQuality { if (!NetworkQuality.instance) { NetworkQuality.instance = new NetworkQuality() } return NetworkQuality.instance } private init(): void { // 监听网络变化 // connection.getNetWorkType(...).then(type => { ... }) } // 获取当前网络类型 getNetworkType(): NetworkType { return this.currentType } // 是否是慢速网络 isSlowNetwork(): boolean { return this.currentType === '2g' || this.currentType === '3g' } // 获取视频清晰度 getVideoQuality(): 'sd' | 'hd' | 'fhd' { switch (this.currentType) { case 'wifi': case '5g': return 'fhd' case '4g': return 'hd' default: return 'sd' } } // 是否自动播放视频 shouldAutoPlay(): boolean { return this.currentType === 'wifi' || this.currentType === '5g' }}

9.10 本章避坑指南

  1. 不要过早优化
    :先实现功能,再根据数据优化,不要瞎优化
  2. 性能优化要有数据支撑
    :用Profiler看瓶颈在哪,不要凭感觉
  3. 列表优化重点在复用
    :List的cachedCount和ForEach的key很重要
  4. 图片是性能大户
    :压缩、缓存、懒加载,三板斧搞定
  5. 内存泄漏要早发现
    :定期用Profiler看内存曲线,有问题及时修
  6. 包体积要持续关注
    :每次发版前检查,不要不知不觉就变大了

面试题:鸿蒙APP性能优化有哪些手段?

参考答案:

启动优化:

  1. 减少Application.onCreate中的耗时操作,非核心模块延迟初始化
  2. 首页数据预加载,UI和数据并行
  3. 首屏简化,复杂功能延后加载

列表流畅度优化:

  1. List组件设置cachedCount,利用复用机制
  2. ForEach使用唯一ID作为key,不要用index
  3. 图片压缩,使用合适的尺寸和格式
  4. 减少组件嵌套层级,扁平化布局
  5. 复杂计算移出build方法,提前计算好

内存优化:

  1. 及时清理定时器、事件监听
  2. 图片缓存限制大小,LRU淘汰
  3. 避免单例持有页面Context
  4. 大对象用完及时释放

包体积优化:

  1. 资源压缩,图片用WebP
  2. 代码混淆,移除无用代码
  3. 按需加载,动态import
  4. 删除无用资源和依赖

第10章 应用签名与打包构建

10.1 签名基础知识

10.1.1 为什么要签名

  • 身份标识
    :证明APP是你开发的,防止被篡改
  • 应用升级
    :只有相同签名的APP才能覆盖安装
  • 权限验证
    :部分系统功能需要签名验证
  • 上架要求
    :应用市场必须要签名包

10.1.2 签名类型

类型
用途
说明
调试签名
开发调试
DevEco Studio自动生成,有效期短
正式签名
发布上架
自己生成,要妥善保管

10.2 生成签名证书

10.2.1 方式一:DevEco Studio可视化生成

  1. 打开 Build → Generate Key and CSR
  2. 选择存储路径
  3. 填写证书信息:
  • 密钥库密码
  • 密钥别名
  • 密钥密码
  • 姓名、组织、城市等
  1. 点击OK,生成两个文件:
  • .p12
    :密钥库文件(最重要,丢了就没法更新APP了)
  • .csr
    :证书请求文件(用于申请正式证书)

10.2.2 方式二:命令行生成

# 用keytool生成(JDK自带)keytool -genkeypair \ -alias quickvideo \ -keyalg RSA \ -keysize 2048 \ -validity 36500 \ -keystore quickvideo.p12 \ -storetype PKCS12# 参数说明:# -alias: 密钥别名# -keyalg: 算法,用RSA# -keysize: 密钥长度,2048位# -validity: 有效期(天),36500=100年# -keystore: 输出的密钥库文件

重要提醒:密钥库文件和密码一定要备份好!丢了的话:

  • APP无法更新(签名不一致不能覆盖安装)
  • 只能改包名重新上架,用户数据全没了
  • 建议多处备份:本地、云盘、U盘各存一份

10.3 申请华为发布证书

上架华为应用市场需要华为颁发的正式证书。

10.3.1 申请流程

  1. 登录AppGallery Connect:https://developer.huawei.com/consumer/cn/service/josp/agc/index.html
  2. 进入「用户与访问」→「证书管理」
  3. 点击「新增证书」
  4. 上传之前生成的.csr文件
  5. 填写证书名称
  6. 提交申请,几分钟后就能下载证书(.cer文件)

10.3.2 配置Profile文件

证书有了,还需要Profile文件(配置文件):

  1. 进入「我的项目」→ 选择你的项目
  2. 进入「HarmonyOS应用」→「HAP Provision Profile」
  3. 点击「新增」
  4. 填写信息:
  • 类型:选择「发布」
  • 证书:选择刚才申请的证书
  • 设备:选择「所有设备」
  1. 提交后下载Profile文件(.p7b格式)

10.4 配置签名信息

证书和Profile都有了,配置到项目里。

10.4.1 项目结构配置

在项目根目录创建 signing 文件夹,放入:

  • quickvideo.p12
     — 密钥库
  • quickvideo.cer
     — 发布证书
  • quickvideo.p7b
     — Profile文件

10.4.2 build-profile.json5配置

{ ”apiType”: ”stageMode”, ”buildOption”: { ”signingConfig”: { ”default”: { ”certpath”: ”signing/quickvideo.cer”, ”profile”: ”signing/quickvideo.p7b”, ”keystorePath”: ”signing/quickvideo.p12”, ”keystoreAlias”: ”quickvideo”, ”keystorePassword”: ”你的密码”, ”keyPassword”: ”你的密钥密码” } } }, ”targets”: [ { ”name”: ”default”, ”runtimeOS”: ”HarmonyOS” } ]}

安全提醒:不要把密码明文写在配置文件里!

更安全的做法:

  1. 用环境变量存储密码
  2. 或者在打包时手动输入
  3. 签名文件不要提交到Git

10.4.3 多环境签名配置

{ ”buildOption”: { ”signingConfigs”: { ”debug”: { ”certpath”: ”signing/debug.cer”, ”profile”: ”signing/debug.p7b”, ”keystorePath”: ”signing/debug.p12”, ”keystoreAlias”: ”debug”, ”keystorePassword”: ”debug123”, ”keyPassword”: ”debug123” }, ”release”: { ”certpath”: ”signing/release.cer”, ”profile”: ”signing/release.p7b”, ”keystorePath”: ”signing/release.p12”, ”keystoreAlias”: ”release”, ”keystorePassword”: ”${KEYSTORE_PASSWORD}”, ”keyPassword”: ”${KEY_PASSWORD}” } } }, ”products”: [ { ”name”: ”development”, ”signingConfig”: ”debug” }, { ”name”: ”production”, ”signingConfig”: ”release” } ]}

10.5 打包构建

10.5.1 可视化打包

  1. 点击 Build → Build Hap(s)/APP(s) → Build APP(s)
  2. 选择构建类型:release
  3. 选择目标设备:phone
  4. 点击Build,等待打包完成
  5. 打包好的APP在:entry/build/default/outputs/default/
  6. 文件名一般是:entry-default-signed.app

10.5.2 命令行打包

# 进入项目目录cd /path/to/QuickVideo# 执行打包命令./hvigorw assembleApp --mode release# 或者用hvigor命令hvigor assembleApp -p product=production

10.5.3 打包产物说明

文件
说明
.hap
Harmony Ability Package,单个模块的包
.app
APP包,包含多个hap,上架用这个

上架应用市场传.app包,用户安装时系统会自动拆包。

10.6 多渠道打包

不同渠道可能需要不同的配置,比如渠道号、统计key等。

10.6.1 多渠道配置

// build-profile.json5{ ”products”: [ { ”name”: ”huawei”, ”buildOption”: { ”arkOptions”: { ”define”: { ”CHANNEL”: ”huawei”, ”CHANNEL_NAME”: ”华为应用市场” } } } }, { ”name”: ”official”, ”buildOption”: { ”arkOptions”: { ”define”: { ”CHANNEL”: ”official”, ”CHANNEL_NAME”: ”官网” } } } } ]}

10.6.2 代码中使用渠道信息

// constants/ChannelConstants.etsexport class ChannelConstants { static readonly CHANNEL = '${CHANNEL}' static readonly CHANNEL_NAME = '${CHANNEL_NAME}'}// 使用import { ChannelConstants } from '../constants/ChannelConstants'Logger.info('当前渠道:', ChannelConstants.CHANNEL_NAME)// 上报渠道信息// Analytics.setChannel(ChannelConstants.CHANNEL)

10.7 版本号管理

10.7.1 版本号规则

鸿蒙APP的版本号由两部分组成:

  • versionName
    :版本名称,给用户看的,如 1.0.0
  • versionCode
    :版本号,给系统看的,整数,每次升级必须+1

10.7.2 配置版本号

在 AppScope/app.json5 中配置:

{ ”app”: { ”bundleName”: ”com.quickvideo.app”, ”vendor”: ”quickvideo”, ”versionCode”: 1, ”versionName”: ”1.0.0”, ”icon”: ”$media:app_icon”, ”label”: ”$string:app_name” }}

10.7.3 版本号命名规范

推荐用语义化版本(Semantic Versioning):

主版本号.次版本号.修订号 │ │ │ │ │ └── 修复bug,兼容更新 │ └────────── 新增功能,兼容更新 └─────────────────── 不兼容的重大更新

示例:

  • 1.0.0 → 1.0.1:修复了几个bug
  • 1.0.0 → 1.1.0:新增了评论功能
  • 1.0.0 → 2.0.0:架构大改,不兼容旧版本

10.8 打包前检查清单

发布前一定要检查这些项:

  • 签名正确
    :用的是正式签名,不是调试签名
  • 版本号正确
    :versionCode比上一版大
  • 包名正确
    :和应用市场上的一致
  • 调试代码已移除
    :console.log、测试按钮等
  • 接口地址是正式环境
    :不是测试环境
  • Log已关闭
    :release包不要打太多log
  • 资源已压缩
    :图片、视频都压缩过
  • 权限合理
    :不要申请不需要的权限
  • 隐私协议
    :有且正确链接
  • 测试通过
    :核心功能都测过了

10.9 安装包验证

打包好后,验证一下包是否正常。

10.9.1 安装测试

# 用hdc安装到真机hdc install entry-default-signed.app# 或者安装haphdc install entry-default-signed.hap

安装后检查:

  • 能否正常安装
  • 图标和名称是否正确
  • 能否正常启动
  • 核心功能是否正常

10.9.2 查看包信息

# 查看APP包信息hdc shell dumpsys package com.quickvideo.app# 或者用aapt工具(Android的,鸿蒙类似)

10.10 本章避坑指南

  1. 密钥库一定要备份
    :丢了APP就废了,血泪教训
  2. 密码不要写在代码里
    :用环境变量或打包时输入
  3. versionCode必须递增
    :每次升级都要+1,不然装不上
  4. debug和release签名要分开
    :不要用debug签名上架
  5. 打包前清理缓存
    :有时候旧缓存会导致打包出问题
  6. 签名文件不要提交Git
    :加到.gitignore里,防止泄露

面试题:鸿蒙APP的签名机制是什么?

参考答案:

  1. 鸿蒙采用数字签名机制,使用RSA算法对APP进行签名
  2. 签名的作用:身份认证、防篡改、保证升级一致性
  3. 签名需要两个文件:密钥库(.p12)和证书(.cer)
  4. 还需要Profile文件(.p7b),包含证书和权限配置
  5. 调试阶段用自动生成的调试签名,发布用正式签名
  6. 上架华为应用市场需要申请华为颁发的发布证书

第11章 华为应用市场上架全流程

11.1 上架前准备

11.1.1 账号准备

  1. 注册华为开发者账号
  • 网址:https://developer.huawei.com/
  • 个人开发者:身份证实名认证,1-3个工作日审核
  • 企业开发者:营业执照认证,需要对公账户验证
  1. 缴纳开发者年费
  • 个人开发者:免费
  • 企业开发者:免费(以前收费,现在免费了)

注意:不实名认证也能开发,但不能上架应用市场。

11.1.2 材料准备

上架需要准备这些材料:

材料
说明
要求
APP安装包
.app格式
正式签名
应用图标
PNG格式
512x512px,圆角
应用截图
PNG/JPG
至少3张,手机截图
应用名称
中文
不超过8个字符
应用简介
中文
简要介绍
详细描述
中文
详细功能介绍
隐私协议
网页链接
必须有,且可访问
服务协议
网页链接
部分类别需要
版权证明
如有
软件著作权等

11.2 创建应用

11.2.1 进入AppGallery Connect

网址:https://developer.huawei.com/consumer/cn/service/josp/agc/index.html

11.2.2 创建项目

  1. 点击「我的项目」→「添加项目」
  2. 填写项目名称:如「快映短视频」
  3. 选择项目分类:工具/影音/社交等
  4. 点击「确定」

11.2.3 创建应用

  1. 进入项目后,点击「添加应用」
  2. 选择平台:HarmonyOS
  3. 选择设备:手机
  4. 填写应用信息:
  • 应用包名:com.quickvideo.app(和代码里的一致!)
  • 应用名称:快映短视频
  • 应用分类:娱乐 → 短视频
  • 简介:一句话介绍
  1. 点击「确定」

重要:包名一旦创建就不能改了,一定要确认好。

11.3 配置应用信息

创建好应用后,需要完善各种信息。

11.3.1 基本信息

进入「应用信息」→「基本信息」:

  1. 应用图标
  • 上传512x512px的PNG图标
  • 建议用圆角,直角的话系统会自动加圆角
  1. 应用名称
  • 中文名称:不超过8个字符
  • 英文名称:如果有海外版
  1. 应用简介
  • 一句话介绍,显示在列表页
  • 要吸引人,突出核心功能
  1. 详细描述
  • 详细介绍应用功能
  • 建议分点列出,配截图说明
  • 可以加更新日志
  1. 应用分类
  • 一级分类、二级分类
  • 选对分类很重要,影响推荐

11.3.2 截图与视频

  1. 手机截图
  • 至少3张,最多8张
  • 建议尺寸:1080x2340(主流手机分辨率)
  • 要展示核心功能和界面
  • 可以加文字说明
  1. 宣传图(可选)
  • 横幅图,用于推荐位
  • 尺寸:1080x576
  1. 宣传视频(可选)
  • 介绍视频,展示应用功能
  • 不超过30秒
  • 能提高转化率

11.3.3 隐私与协议

  1. 隐私协议
  • 必须有!没有的话审核不通过
  • 要是可访问的网页链接
  • 内容要符合《个人信息保护法》
  • 建议包含:收集哪些信息、怎么用、怎么保护
  1. 服务协议
  • 用户协议,部分类别必须
  • 包含用户权利义务、免责声明等
  1. 权限说明
  • 解释每个权限的用途
  • 比如:相机权限用于拍摄视频

11.4 上传安装包

11.4.1 进入版本管理

「应用信息」→「版本信息」→「创建版本」

11.4.2 填写版本信息

  1. 版本号
    :和包里的versionName一致,如1.0.0
  2. 更新日志
    :这个版本更新了什么
  3. 发布范围
    :
  • 全量发布:所有用户都能搜到
  • 灰度发布:先给一部分用户用,没问题再全量
  • 定向发布:只给指定用户

11.4.3 上传软件包

  1. 点击「软件包」→「上传」
  2. 选择打包好的.app文件
  3. 等待上传和解析
  4. 解析成功后会显示包信息:
  • 包名
  • 版本号
  • 大小
  • 权限列表

11.4.4 权限说明

上传后系统会列出APP申请的所有权限,需要逐一说明用途:

权限
用途说明
相机
用于拍摄视频
麦克风
用于录制视频声音
存储
用于保存视频和缓存
网络
用于加载视频和上传内容

注意:不要申请不需要的权限!权限越多审核越严,用户也越警惕。

11.5 内容审核与合规

11.5.1 内容分级

根据应用内容选择分级:

  • 3+
  • 7+
  • 12+
  • 16+
  • 18+

短视频APP一般选12+或16+,取决于内容。

11.5.2 内容安全

短视频类应用审核比较严,需要注意:

  1. 内容审核机制
  • 要有用户举报功能
  • 要有内容审核机制(机器+人工)
  • 违规内容要及时处理
  1. 用户协议
  • 明确禁止发布的内容类型
  • 违规处理措施
  1. 未成年人保护
  • 青少年模式
  • 防沉迷机制
  • 内容过滤

11.5.3 隐私合规

隐私是现在审核的重点:

  1. 隐私协议
  • 必须在首次启动时弹窗展示
  • 用户同意后才能收集信息
  • 要清晰说明收集了什么、用来做什么
  1. 权限申请
  • 不要一启动就申请所有权限
  • 用到的时候再申请,并说明用途
  • 用户拒绝后要有降级方案
  1. 数据安全
  • 个人信息加密存储
  • 不收集无关信息
  • 提供注销账号功能

11.6 提交审核

所有信息都填好后,就可以提交审核了。

11.6.1 提交前检查清单

  • 应用信息填写完整
  • 图标、截图都上传了
  • 隐私协议链接可访问
  • 安装包已上传且正确
  • 权限都说明了用途
  • 版本号正确
  • 更新日志填写了
  • 分类选择正确
  • 内容分级合适

11.6.2 提交审核

  1. 点击「提交审核」
  2. 确认信息无误
  3. 选择审核加急(如有需要,付费的)
  4. 提交成功,等待审核

11.6.3 审核时间

  • 普通审核
    :1-3个工作日
  • 加急审核
    :几个小时(需要申请,有次数限制)
  • 首次上架
    :一般慢一些,可能3-5天

11.6.4 审核被拒怎么办

被拒很正常,不要慌:

  1. 查看拒绝原因
    :审核员会写清楚哪里有问题
  2. 针对性修改
    :按照要求改
  3. 重新提交
    :改完再提交
  4. 申诉
    :如果觉得被冤枉,可以申诉

常见被拒原因:

  • 隐私协议不符合要求
  • 权限申请过多或说明不清
  • 内容违规(色情、暴力等)
  • 功能不完善(有bug、打不开)
  • 应用图标/截图不符合规范
  • 抄袭或侵权

11.7 发布与更新

11.7.1 审核通过后发布

审核通过后:

  • 如果选的是「自动发布」:审核通过后自动上线
  • 如果选的是「手动发布」:需要手动点击发布

发布后一般几分钟内就能在应用市场搜到了。

11.7.2 版本更新

更新版本和首次上架流程差不多:

  1. 修改代码,增加功能、修复bug
  2. 版本号+1(versionCode和versionName都改)
  3. 重新打包
  4. 创建新版本
  5. 上传新包
  6. 填写更新日志
  7. 提交审核

11.7.3 灰度发布

大版本更新建议先用灰度:

  1. 选择「灰度发布」
  2. 设置灰度比例:5%、10%、20%...
  3. 观察几天,看有没有崩溃、差评
  4. 没问题就逐步扩大比例
  5. 最后全量发布

好处:

  • 有问题影响范围小
  • 可以及时止损
  • 降低风险

11.8 应用商店运营

上架只是开始,运营才是重点。

11.8.1 ASO优化(应用商店优化)

让用户更容易搜到你的APP:

  1. 关键词优化
  • 标题里包含核心关键词
  • 描述里多次出现关键词
  • 研究用户搜什么词
  1. 图标和截图
  • 图标要有辨识度
  • 截图要吸引人
  • 突出核心功能
  1. 评分和评论
  • 引导用户好评
  • 及时回复差评
  • 评分高推荐多

11.8.2 数据分析

华为应用市场有数据分析后台:

  • 下载量
  • 活跃用户
  • 留存率
  • 崩溃率
  • 收入数据

定期看数据,发现问题及时优化。

11.8.3 活动运营

应用市场经常有各种活动:

  • 新品推荐
  • 专题活动
  • 节日活动
  • 开发者活动

多参加活动,能获得免费流量。

11.9 常见问题FAQ

Q: 个人开发者能上架短视频APP吗?
A: 可以,但审核会比较严,需要有内容审核机制和用户举报功能。

Q: 上架需要软著吗?
A: 个人开发者不是必须的,但有软著更容易通过审核。企业开发者建议有。

Q: 审核要多久?
A: 一般1-3个工作日,首次可能慢一些。

Q: 被拒了怎么办?
A: 看拒绝原因,改了重新提交,一般都能过。

Q: 能赚钱吗?
A: 可以,华为应用市场支持:

  • 付费下载
  • 应用内购买
  • 广告变现
  • 会员订阅

11.10 本章避坑指南

  1. 包名一定要确认好
    :创建后不能改,上架前想清楚
  2. 隐私协议是重中之重
    :现在审核最看重这个,一定要规范
  3. 权限越少越好
    :只申请必须的,多了审核严用户也怕
  4. 首次上架预留充足时间
    :可能被拒几次,提前一两周准备
  5. 图标截图要专业
    :影响下载转化率,找设计师好好做
  6. 版本号记得递增
    :每次更新versionCode都要+1,不然传不上去

面试题:上架华为应用市场的流程是什么?

参考答案:

  1. 准备阶段
    :注册开发者账号、实名认证、准备签名证书
  2. 创建应用
    :在AppGallery Connect创建项目和应用,填写包名
  3. 完善信息
    :上传图标、截图、填写描述、隐私协议等
  4. 打包签名
    :用正式签名打release包
  5. 上传版本
    :创建版本,上传安装包,填写更新日志
  6. 提交审核
    :确认信息无误后提交审核
  7. 审核发布
    :等待审核,通过后发布上线
  8. 运营迭代
    :数据分析、版本更新、活动运营

第12章 避坑指南与面试题汇总

12.1 开发环境常见坑

12.1.1 SDK下载失败

症状:DevEco Studio下载SDK一直失败或超时

解决方案:

  1. 换网络:手机热点试试,有时候公司网络有限制
  2. 挂代理:配置HTTP代理
  3. 离线安装:去论坛找离线SDK包,手动导入
  4. 换时间段:晚上或者早上人少的时候快

12.1.2 模拟器启动不了

症状:点击启动模拟器,半天没反应或者黑屏

解决方案:

  1. Windows检查VT-x是否开启(BIOS里设置)
  2. Windows关闭Hyper-V(和模拟器冲突)
  3. Mac检查内存是否够(至少8G,推荐16G)
  4. 用远程模拟器(不用本地资源)
  5. 直接用真机(推荐,体验最好)

12.1.3 真机连不上

症状:插了USB线,DevEco Studio里不显示设备

解决方案:

  1. 手机端:开启开发者模式、开启USB调试
  2. 手机端:弹出授权对话框时点"允许"
  3. 电脑端:装华为手机助手(Windows)
  4. 换根USB线(有些线只能充电不能传数据)
  5. 换个USB口(台式机插后面的口)

12.1.4 编译报各种奇怪错误

症状:编译失败,错误信息看不懂

解决方案:

  1. 先试试 Build → Clean Project 然后重新编译
  2. 删除 oh_modules 文件夹,重新Sync
  3. 检查oh-package.json5里的依赖版本是否正确
  4. 检查SDK版本是否和项目要求匹配
  5. 重启DevEco Studio(万能方案)

12.2 ArkUI开发常见坑

12.2.1 对象属性变了UI不更新

症状:改了对象的某个属性,但界面没变

原因:@State只监听引用变化,不监听内部属性

解决方案:

// ❌ 这样不行@State user: User = { name: '张三', age: 20 }// 改属性不触发更新this.user.age = 21 // UI不更新// ✅ 方案一:用@Observed + @ObjectLink@Observedclass User { name: string = '' age: number = 0}@ObjectLink user: User// ✅ 方案二:整个对象替换this.user = { ...this.user, age: 21 }

12.2.2 ForEach列表不更新

症状:数组内容变了,但列表没变

原因:ForEach的key用了index,或者数组引用没变

解决方案:

// ❌ 用index当key,数据变了可能不更新ForEach(this.list, (item, index) => { Text(item.name)}, (item, index) => index)// ✅ 用唯一ID当keyForEach(this.list, (item) => { Text(item.name)}, (item) => item.id)// ✅ 更新数组时创建新数组this.list = [...this.list, newItem] // 不是push,是创建新数组

12.2.3 样式不生效

症状:加了样式但没效果

常见原因:

  1. 链式调用顺序
    :有些属性会被后面的覆盖
  2. 组件不支持该属性
    :不是所有组件都支持所有样式
  3. 父组件限制了大小
    :子组件设置的宽高被父组件限制了
  4. 优先级问题
    :系统样式优先级更高

排查方法:用ArkUI Inspector看实际样式。

12.2.4 页面跳转没反应

症状:调用router.pushUrl没反应

解决方案:

  1. 检查module.json5里有没有注册这个页面
  2. 检查url路径是否正确(相对路径,从pages开始)
  3. 检查是不是跳转到了当前页面(自己跳自己没反应)
  4. 用try-catch看看有没有报错

12.3 网络请求常见坑

12.3.1 HTTP请求失败

症状:网络请求报错,连不上

常见原因:

  1. 没有网络权限
    :module.json5里加ohos.permission.INTERNET
  2. HTTP明文被禁
    :Android 9+和鸿蒙默认禁止HTTP,用HTTPS
  3. 域名证书问题
    :自签名证书或者证书过期
  4. 跨域问题
    :Web场景下才有,原生APP一般没有

12.3.2 请求头设置不生效

症状:加了header但服务端收不到

解决方案:

  1. 检查header的key大小写(有些服务端大小写敏感)
  2. 检查Content-Type是否正确(POST JSON要设application/json)
  3. 用抓包工具看看实际发出去的请求是什么

12.3.3 图片加载不出来

症状:Image组件显示空白

常见原因:

  1. 网络图片地址是HTTP的(被禁了)
  2. 图片地址有中文或特殊字符(要encode)
  3. 图片太大(超过纹理限制)
  4. 路径不对(本地图片检查资源名,网络的检查url)

12.4 视频播放常见坑

12.4.1 视频只有声音没有画面

原因:渲染层没设置好

解决方案:

  1. Video组件检查是否设置了正确的宽高
  2. AVPlayer检查XComponent的surface是否正确绑定
  3. 检查视频格式是否支持(推荐H.264编码的MP4)

12.4.2 视频播放卡顿

原因:

  1. 视频码率太高,手机解码不了
  2. 网络太慢,边下边播卡
  3. 同时播放太多视频(Feed流每个都播)

解决方案:

  1. 降低视频码率和分辨率
  2. 做预加载和缓冲
  3. Feed流只播放当前可见的那个

12.4.3 切换视频有黑屏

原因:播放器初始化需要时间

解决方案:

  1. 用封面图兜底,播放器准备好再隐藏
  2. 双播放器预加载(一个播放一个准备)
  3. 提前预加载下一个视频的关键帧

12.5 性能常见坑

12.5.1 列表滑动卡顿

优化清单:

  • List设置了cachedCount(至少3)
  • ForEach用了唯一ID当key
  • 图片做了压缩和缓存
  • 每个item不要嵌套太深
  • 不要在build里做复杂计算
  • 大列表用LazyForEach

12.5.2 内存泄漏

检查清单:

  • 定时器都清理了吗(setTimeout/setInterval)
  • 事件监听都移除了吗
  • 有没有单例持有页面Context
  • 大图片不用了有没有释放
  • 闭包有没有循环引用

检测工具:DevEco Studio Profiler看内存曲线,一直涨不回落就有泄漏。

12.5.3 启动太慢

优化方向:

  1. Application.onCreate里不要做耗时操作
  2. 非核心SDK延迟初始化
  3. 首页数据预加载
  4. 首屏UI简化
  5. 用Skeleton屏替代白屏

12.6 上架常见坑

12.6.1 审核被拒Top5原因

  1. 隐私问题
    :隐私协议不规范、过度收集信息
  2. 权限问题
    :申请了不需要的权限、没说明用途
  3. 内容问题
    :有违规内容、版权问题
  4. 功能问题
    :有bug、功能不完善、闪退
  5. 资料问题
    :图标截图不规范、描述不完整

12.6.2 包上传失败

常见原因:

  1. 签名不对(用了debug签名)
  2. 包名和应用市场上的不一致
  3. 版本号比之前的低(必须递增)
  4. 包太大(有大小限制,一般几百MB)

12.6.3 搜不到应用

原因:

  1. 刚发布,还没收录(等几小时)
  2. 应用下架了(违规或者自己下架的)
  3. 关键词不对(ASO优化)
  4. 设备不兼容(只支持某些设备)

12.7 调试技巧

12.7.1 日志调试

// 用hilog,不要用console.logimport hilog from '@ohos.hilog'hilog.info(0x0001, 'TAG', '这是info日志')hilog.error(0x0001, 'TAG', '这是error日志: %{public}s', errorMsg)// 注意:%{public}s 表示这个参数是公开的,会显示出来// 不加的话默认是private,会显示<private>

12.7.2 断点调试

DevEco Studio支持断点调试:

  1. 在代码行号左边点击,打上断点
  2. 用Debug模式运行APP
  3. 运行到断点处会停下来
  4. 可以看变量值、调用栈
  5. 单步执行,一步步看

12.7.3 真机调试日志查看

# 用hdc查看日志hdc shell hilog | grep ”TAG”# 或者用DevEco Studio的Log工具# 底部面板 → HiLog

12.7.4 抓包调试

网络问题用抓包工具:

  • Charles
  • Fiddler
  • 或DevEco Studio自带的网络分析

手机设置代理到电脑,就能看到所有请求了。

12.8 鸿蒙开发面试题精选

基础题

Q1:ArkTS和TypeScript是什么关系?
A:ArkTS是TypeScript的超集,在TS基础上增加了静态类型检查、装饰器、状态管理等特性,更适合鸿蒙UI开发。

Q2:@State、@Prop、@Link、@Provide/@Consume的区别?
A:

  • @State:组件内部状态,自己用
  • @Prop:父传子,单向
  • @Link:父子双向绑定
  • @Provide/@Consume:跨层级传递,不用一层层传

Q3:@Observed和@ObjectLink是干嘛的?
A:@Observed装饰类,@ObjectLink装饰变量,用来监听对象内部属性的变化。默认@State只监听引用变化,对象内部属性变了不触发更新,加了这两个才行。

进阶题

Q4:鸿蒙的UI渲染原理是什么?
A:鸿蒙用声明式UI,状态驱动视图。状态变化时,框架会diff比较,只更新变化的部分,不是整个重绘。底层用ArkUI引擎渲染,性能不错。

Q5:如何做列表性能优化?
A:

  1. 用List组件,设置cachedCount
  2. ForEach用唯一ID当key
  3. 图片压缩和缓存
  4. 减少组件嵌套
  5. 复杂计算移出build
  6. 大列表用LazyForEach

Q6:MVVM架构在鸿蒙中怎么实现?
A:

  • Model:数据层,网络请求、数据库
  • ViewModel:业务逻辑,状态管理
  • View:页面和组件,只负责UI展示
  • 用@State/@Link做数据绑定,状态驱动UI更新

实战题

Q7:短视频Feed流怎么实现无缝切换?
A:

  1. 封面图兜底,滑动时先显示封面
  2. 播放器单例复用,切换只换数据源
  3. 预加载下一个视频的前几秒
  4. 双播放器方案,一个播放一个预加载
  5. CDN优化,支持Range请求边下边播

Q8:鸿蒙的跨设备能力怎么理解?
A:鸿蒙是分布式操作系统,支持:

  • 应用流转:手机上看视频,流转到平板继续看
  • 分布式数据:多设备数据同步
  • 分布式任务调度:不同设备跑不同模块
  • 硬件共享:手机用平板的摄像头、音箱

开放题

Q9:你觉得鸿蒙开发和Android开发最大的区别是什么?
A:(开放性问题,说自己的理解就行)

  1. 语言不同:ArkTS vs Kotlin/Java
  2. UI范式不同:声明式 vs 传统命令式(Jetpack Compose也是声明式了)
  3. 系统架构不同:分布式微内核 vs Linux宏内核
  4. 跨设备能力:鸿蒙天然支持多设备协同
  5. 生态不同:Android生态成熟,鸿蒙还在发展中

Q10:为什么选择做鸿蒙开发?前景怎么样?
A:(开放性问题,结合自己的情况说)

  1. 国产操作系统,政策支持,发展快
  2. 华为大力投入,生态越来越好
  3. 现在开发者还不多,竞争小,机会多
  4. 多设备协同是未来趋势,鸿蒙走在前面
  5. 从Android转过来成本不高,很多概念是通的

12.9 学习资源推荐

官方资源

  • 鸿蒙开发者官网
    :https://developer.huawei.com/consumer/cn/
  • ArkUI开发指南
    :官方文档,最权威
  • Codelabs
    :手把手教程,跟着做就行
  • 开发者论坛
    :遇到问题可以搜,也可以问

社区资源

  • 鸿蒙开发者社区
    :https://developer.huawei.com/consumer/cn/forum/
  • Gitee鸿蒙专区
    :很多开源项目
  • B站
    :搜"鸿蒙开发",有很多教程视频
  • 知乎
    :有一些鸿蒙开发的专栏

书籍推荐

  • 《鸿蒙应用开发实战》
  • 《ArkUI实战》
  • 《HarmonyOS手机应用开发》

12.10 写在最后

恭喜你看到这里!这本手册从环境搭建到上架全流程,用短视频APP作为贯穿案例,覆盖了鸿蒙开发的核心知识点。

给新手的建议:

  1. 动手最重要
    :不要光看,跟着敲代码,边做边学
  2. 从小功能开始
    :先实现简单的,再做复杂的
  3. 多查官方文档
    :官方文档是最权威的
  4. 遇到问题别慌
    :99%的问题别人都遇到过,搜一搜
  5. 持续学习
    :鸿蒙更新很快,保持学习的心态

学习路径建议:

  1. 第1-2章:环境搭建 + 基础语法,能写简单页面
  2. 第3章:架构设计,养成好习惯
  3. 第4-8章:核心功能,跟着做一个完整的APP
  4. 第9章:性能优化,让APP更流畅
  5. 第10-11章:打包上架,把APP发布出去
  6. 第12章:避坑和面试,查漏补缺

希望这本手册能帮你入门鸿蒙开发,做出自己的APP。有问题随时可以问我,加油!


手册版本:v1.0
最后更新:2026年7月
适用版本:HarmonyOS NEXT (API 12+) / DevEco Studio 5.0+

本手册以短视频APP为案例,涵盖鸿蒙APP开发全流程。内容基于官方文档和实战经验整理,如有错误欢迎指正。

最新文章

随机文章