版本:v1.0
适用:HarmonyOS NEXT (API 12+) / DevEco Studio 5.0+
案例APP:快映短视频(类抖音的极简版短视频应用)
定位:上岗级实操手册,看完能独立完成从开发到上架的全流程
在动手之前,先搞清楚鸿蒙开发的"全家福",避免概念混淆:
┌─────────────────────────────────────────────────────────┐│ HarmonyOS NEXT ││ ┌─────────────┐ ┌─────────────┐ ┌───────────────┐ ││ │ ArkUI 声明式 │ │ ArkTS 语言 │ │ ArkCompiler │ ││ │ UI 框架 │ │ (TypeScript│ │ 编译器 │ ││ │ │ │ 超集) │ │ │ ││ └──────┬──────┘ └──────┬──────┘ └───────┬───────┘ ││ │ │ │ ││ └────────────────┼─────────────────┘ ││ │ ││ ┌───────▼───────┐ ││ │ DevEco Studio │ ││ │ (IDE) │ ││ └───────┬───────┘ ││ │ ││ ┌───────▼───────┐ ││ │ hvigor 构建 │ ││ │ 工具 │ ││ └───────────────┘ │└─────────────────────────────────────────────────────────┘
核心概念速记:
别上来就装,先看看你的电脑够不够用:
| 16GB以上 | ||
避坑提醒:Mac M系列芯片体验最好,Windows上模拟器经常出各种兼容问题。如果是Windows且配置一般,建议直接用真机调试。
官方下载页:https://developer.huawei.com/consumer/cn/deveco-studio/
选择 DevEco Studio 5.0+ 版本,支持HarmonyOS NEXT。
# 1. 双击安装包,一路Next# 2. 选择安装路径,注意:路径不要有中文和空格!# ✅ 正确:D:\DevEco\DevEco Studio# ❌ 错误:D:\开发工具\DevEco Studio# 3. 勾选 Create Desktop Shortcut# 4. 安装完成后启动
# 1. 拖拽到Applications文件夹# 2. 首次打开如果提示”无法打开”,去:# 系统设置 → 隐私与安全性 → 仍要打开# 3. 或者终端执行:xattr -d com.apple.quarantine /Applications/DevEco\ Studio.app
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
首次启动DevEco Studio会进入SDK配置向导:
避坑提醒:SDK下载经常失败,多试几次。如果一直失败,可以去华为开发者论坛找离线SDK包手动导入。
开发鸿蒙应用必须有华为开发者账号:
Tools → Device Manager重要:不实名认证也能开发调试,但无法上架应用市场,也无法使用部分云服务。建议一开始就搞定。
Tools → Device ManagerRemote Emulator(远程模拟器)或 Local Emulator(本地模拟器)+ Create DeviceMate 60 Pro 或 Pura 70点击设备列表右侧的 ▶️ 按钮启动。
避坑提醒:
本地模拟器需要开启VT-x(BIOS中设置) Windows上Hyper-V和模拟器冲突,需要关闭Hyper-V Mac M系列芯片原生支持,体验最好 远程模拟器不需要本地资源,但需要联网,且有使用时长限制
强烈建议用真机开发,模拟器在视频播放、相机等功能上经常有问题。
# 1. 先USB连接一次,确保调试授权# 2. 查看设备IP:设置 → 关于手机 → 状态信息# 3. 命令行连接hdc tconn 192.168.1.100:5555# 4. 拔掉USB线,无线调试生效
File → New → Create ProjectApplication → Empty AbilityQuickVideo/├── 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# 项目构建配置
面试题:鸿蒙开发和Android开发的主要区别是什么?
参考答案:
- 语言不同
:鸿蒙用ArkTS(TS超集),Android用Kotlin/Java - UI范式不同
:鸿蒙是声明式UI(ArkUI),Android传统是命令式(Jetpack Compose也是声明式了) - 系统架构不同
:鸿蒙是分布式微内核,Android是Linux宏内核 - 跨设备能力
:鸿蒙天然支持多设备流转,Android需要额外适配 - 构建工具不同
:鸿蒙用hvigor,Android用Gradle
ArkTS是鸿蒙的主力开发语言,基于TypeScript扩展而来:
TypeScript → ArkTS(增加了静态类型检查 + 装饰器 + 状态管理)核心特点:
// 基本类型let name: string = '快映短视频'let version: number = 1.0let isVip: boolean = false// 数组let videoList: string[] = ['视频1', '视频2']let userList: Array<User> = []// 对象接口interface Video {id: stringtitle: stringduration: numberauthor: stringcoverUrl: stringvideoUrl: stringlikeCount: numberisLiked: boolean}// 联合类型let status: 'loading' | 'success' | 'error' = 'loading'// 可选类型let description?: string
// 普通函数function formatDuration(seconds: number): string {const min = Math.floor(seconds / 60)const sec = seconds % 60return `${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 []}
class BaseViewModel {protected isLoading: boolean = falseshowLoading(): void {this.isLoading = true}hideLoading(): void {this.isLoading = false}}class VideoViewModel extends BaseViewModel {private videoList: Video[] = []async loadMore(): Promise<void> {this.showLoading()// 加载更多逻辑this.hideLoading()}}
ArkUI的核心思想:UI = f(State),状态驱动视图。
状态变化 → 框架自动重新渲染 → UI更新@Entry // 标记为页面入口@Component // 标记为组件struct VideoPage {// 状态变量@State videoList: Video[] = []@State currentIndex: number = 0// 构建方法,必须叫buildbuild() {// 根容器Column() {// 子组件Text('快映短视频').fontSize(20).fontWeight(FontWeight.Bold)// 列表List() {ForEach(this.videoList, (item: Video) => {ListItem() {VideoItemCard(item)}})}}.width('100%').height('100%').backgroundColor('#F5F5F5')}}
链式调用:每个组件后面的 .xxx() 是属性方法,用来设置样式。
Column() {Text('第一行')Text('第二行')Text('第三行')}.width('100%').height(200).justifyContent(FlexAlign.Center) // 主轴对齐(垂直方向居中).alignItems(HorizontalAlign.Center) // 交叉轴对齐(水平方向居中).space(10) // 子元素间距
Row() {Image($r('app.media.icon_like')).width(24).height(24)Text(this.likeCount).fontSize(14).fontColor('#666')}.space(4).alignItems(VerticalAlign.Center)
短视频封面+播放按钮就是典型的堆叠:
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)
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%')
短视频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 = firstthis.onVideoChange(first)})
这是ArkUI最核心也最容易搞混的部分,用短视频场景一个个讲清楚。
组件自己用的状态,变化后触发当前组件重新渲染。
@Componentstruct LikeButton {@State isLiked: boolean = false@State likeCount: number = 100build() {Row() {Image(this.isLiked ? $r('app.media.icon_liked') : $r('app.media.icon_like')).width(28).height(28).onClick(() => {this.isLiked = !this.isLikedthis.likeCount += this.isLiked ? 1 : -1})Text(this.likeCount.toString()).fontSize(12).fontColor('#FFF')}.space(4)}}
父组件传给子组件,子组件只能读不能改(或者改了不影响父组件)。
// 父组件@Componentstruct VideoItem {@State video: Videobuild() {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)}}
子组件修改会同步到父组件,类似Vue的v-model。
// 父组件@Componentstruct VideoPlayer {@State isPlaying: boolean = falsebuild() {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 // 修改会同步到父组件})}}
爷孙组件直接传数据,不用一层层@Prop往下传。
// 爷爷组件@Componentstruct HomePage {@Provide('currentTheme') theme: string = 'dark'build() {Column() {VideoFeed() // 中间层,不用管theme}}}// 孙子组件@Componentstruct VideoItem {@Consume('currentTheme') theme: string // 直接拿到爷爷的themebuild() {Text('视频标题').fontColor(this.theme === 'dark' ? '#FFF' : '#000')}}
默认@State只监听对象引用变化,对象内部属性变化不触发更新。需要用@Observed装饰类。
// 模型类加@Observed@Observedclass VideoModel {id: string = ''title: string = ''likeCount: number = 0isLiked: boolean = falseconstructor(id: string, title: string) {this.id = idthis.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。
// 自定义视频卡片组件@Componentexport struct VideoCard {// 入参@Prop video: Video@Prop showAuthor: boolean = true// 事件回调(类似Vue的emit)onPlay?: () => voidonLike?: () => voidbuild() {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)}}
// 导入import { VideoCard } from '../components/VideoCard'// 使用VideoCard({video: item,showAuthor: true,onPlay: () => {this.playVideo(item)},onLike: () => {this.toggleLike(item)}})
{”module”: {”abilities”: [{”name”: ”EntryAbility”,”pages”: [”pages/Index”,”pages/HomePage”,”pages/VideoDetailPage”,”pages/ProfilePage”,”pages/LoginPage”]}]}}
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' })
@Entry@Componentstruct MainPage {@State currentTab: number = 0private 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]}}
@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() {// ...}}
resources/├── base/│ ├── element/ // 字符串、颜色、数值等│ │ └── string.json│ ├── media/ // 图片资源│ │ ├── icon_like.png│ │ └── ...│ └── profile/ // 配置文件├── dark/ // 暗色模式资源│ └── element/│ └── color.json└── en_US/ // 英文资源└── element/└── 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'))
{”color”: [{ ”name”: ”primary_color”, ”value”: ”#FF2D55” },{ ”name”: ”text_primary”, ”value”: ”#333333” },{ ”name”: ”text_secondary”, ”value”: ”#999999” },{ ”name”: ”bg_color”, ”value”: ”#F5F5F5” }]}
面试题:@State、@Prop、@Link三者的区别是什么?
参考答案:
@State:组件内部状态,自己管理自己用,变化触发当前组件重绘 @Prop:父传子,单向数据流,子组件修改不影响父组件 @Link:父子双向绑定,子组件修改会同步到父组件,用$符号传递
新手写代码的特点:所有逻辑堆在页面里,一个文件几千行,改一个bug出三个bug。
好架构的标准:
鸿蒙官方推荐MVVM,我们也用这个。
┌─────────────────────────────────────────┐│ View(页面/组件) ││ 只负责UI展示和用户交互,不写业务逻辑 │└──────────────┬──────────────────────────┘│ 绑定(@State / @Link)▼┌─────────────────────────────────────────┐│ ViewModel(视图模型) ││ 业务逻辑处理、数据转换、状态管理 │└──────────────┬──────────────────────────┘│ 调用▼┌─────────────────────────────────────────┐│ Model(数据层) ││ 网络请求、数据库、本地存储 │└─────────────────────────────────────────┘
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
这是每个项目的基础,先把网络请求封装好。
// model/api/HttpManager.etsimport http from '@ohos.net.http'import { Logger } from '../../utils/Logger'import { ApiConstants } from '../../constants/ApiConstants'// 统一响应格式export interface ApiResponse<T> {code: numbermessage: stringdata: T}export class HttpManager {private static instance: HttpManagerprivate httpRequest: http.HttpRequestprivate 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 + urlif (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 + urlLogger.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().tokenreturn {'Authorization': token ? `Bearer ${token}` : '','User-Agent': 'QuickVideo/1.0','deviceType': 'harmonyos'}}}
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: VideoRepositorystatic 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 })}}
// common/BaseViewModel.etsimport { Logger } from '../utils/Logger'export abstract class BaseViewModel {isLoading: boolean = falseerrorMessage: string = ''protected showLoading(): void {this.isLoading = truethis.errorMessage = ''}protected hideLoading(): void {this.isLoading = false}protected handleError(error: Error): void {this.isLoading = falsethis.errorMessage = error.messageLogger.error(error.message)}// 页面加载时调用,子类重写onPageLoad(): void {}// 页面销毁时调用,子类重写onPageDestroy(): void {}}
用户信息、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// 登录tokentoken: string = ''// 是否已登录isLoggedIn: boolean = falseprivate constructor() {// 启动时从本地读取this.loadFromStorage()}static getInstance(): AppState {if (!AppState.instance) {AppState.instance = new AppState()}return AppState.instance}// 登录成功后保存setLoginInfo(user: UserBean, token: string): void {this.userInfo = userthis.token = tokenthis.isLoggedIn = true// 持久化存储StorageUtils.set('user_info', JSON.stringify(user))StorageUtils.set('token', token)}// 退出登录logout(): void {this.userInfo = nullthis.token = ''this.isLoggedIn = falseStorageUtils.remove('user_info')StorageUtils.remove('token')}// 从本地加载private loadFromStorage(): void {const token = StorageUtils.get('token')if (token) {this.token = tokenthis.isLoggedIn = trueconst userStr = StorageUtils.get('user_info')if (userStr) {this.userInfo = JSON.parse(userStr) as UserBean}}}}
// 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(' '))}}
// 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 - timestampconst minute = 60 * 1000const hour = 60 * minuteconst day = 24 * hourif (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.0wstatic 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()}}
// utils/StorageUtils.etsimport preferences from '@ohos.data.preferences'import { common } from '@kit.AbilityKit'export class StorageUtils {private static preferences: preferences.Preferences | null = nullprivate static async getPreferences(): Promise<preferences.Preferences> {if (!this.preferences) {const context = getContext() as common.UIAbilityContextthis.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()}}
// 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 = 10static readonly MAX_VIDEO_DURATION = 60 // 最长60秒static readonly MAX_VIDEO_SIZE = 100 * 1024 * 1024 // 100MB}
面试题:为什么要用MVVM架构?MVC和MVVM的区别是什么?
参考答案:
MVC中Controller既处理逻辑又操作View,容易臃肿,耦合度高 MVVM通过数据绑定解耦,ViewModel不直接引用View,更易测试 声明式UI天然适合MVVM,状态驱动视图更新 分层清晰后,业务逻辑可以复用,换UI不影响逻辑
抖音式的全屏上下滑动是短视频APP的标配交互:
// model/bean/VideoBean.ets@Observedexport class VideoBean {id: string = ''title: string = ''description: string = ''videoUrl: string = ''coverUrl: string = ''duration: number = 0 // 秒width: number = 0height: number = 0authorId: string = ''authorName: string = ''authorAvatar: string = ''isFollowed: boolean = falselikeCount: number = 0commentCount: number = 0shareCount: number = 0viewCount: number = 0isLiked: boolean = falseisCollected: boolean = falsepublishTime: number = 0 // 时间戳constructor() {}}
// 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 = 0hasMore: boolean = trueprivate page: number = 1// 加载第一页async loadFirstPage(): Promise<void> {this.page = 1this.hasMore = truetry {this.showLoading()const list = await VideoRepository.getInstance().getVideoList(this.page,AppConstants.PAGE_SIZE)this.videoList = listthis.hasMore = list.length >= AppConstants.PAGE_SIZEthis.hideLoading()} catch (error) {this.handleError(error as Error)}}// 加载更多async loadMore(): Promise<void> {if (!this.hasMore || this.isLoading) returnthis.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.isLikedvideo.likeCount += video.isLiked ? 1 : -1try {await VideoRepository.getInstance().toggleLike(video.id, video.isLiked)} catch (error) {// 失败回滚video.isLiked = !video.isLikedvideo.likeCount += video.isLiked ? 1 : -1Logger.error('点赞失败:', (error as Error).message)}}// 关注切换async toggleFollow(index: number): Promise<void> {const video = this.videoList[index]if (!video) returnvideo.isFollowed = !video.isFollowed// TODO: 调用关注接口}}
// 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 = indexLogger.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')}@BuilderTopNavBar() {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 })}}
// 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: booleanonIndexChange?: (index: number) => voidonLike?: (index: number) => voidonFollow?: (index: number) => voidprivate 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)})}}
// 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: booleanonLike?: () => voidonFollow?: () => voidbuild() {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%')}}
// 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: VideoBeanonLike?: () => voidonComment?: () => voidonShare?: () => voidonFollow?: () => voidonAvatarClick?: () => void@State showLikeAnim: boolean = falsebuild() {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 = truesetTimeout(() => {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)}}
// pages/home/components/VideoInfo.etsimport { VideoBean } from '../../../model/bean/VideoBean'@Componentexport struct VideoInfo {@Prop video: VideoBeanbuild() {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)}}
// components/video/LikeAnimation.ets@Componentexport struct LikeAnimation {@State show: boolean = falsebuild() {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}}
面试题:短视频Feed流性能优化有哪些方案?
参考答案:
列表复用:使用List组件的cachedCount缓存机制 播放器复用:全局只有一个播放器实例,切换时只换数据源 预加载:当前播放时预加载下一个视频的关键帧 图片缓存:封面图使用内存+磁盘二级缓存 懒加载:评论区、分享面板等进入时才加载 降级策略:弱网下降低视频清晰度 离屏销毁:滑出屏幕的视频释放资源
鸿蒙提供了两套视频播放方案:
| Video组件 | |||
| AVPlayer |
短视频APP需要精细控制播放、预加载、无缝切换,所以我们用AVPlayer。
先从简单的Video组件开始,快速实现播放功能:
// components/video/SimpleVideoPlayer.etsimport { Logger } from '../../utils/Logger'@Componentexport struct SimpleVideoPlayer {@Prop videoUrl: string@Prop coverUrl: string@Prop isPlaying: boolean = falseprivate 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()}}
短视频Feed流需要无缝切换、预加载、全局单例,Video组件不够用,上AVPlayer。
// 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) => voidonProgress?: (currentTime: number, duration: number) => voidonError?: (error: string) => void}export class PlayerManager {private static instance: PlayerManagerprivate player: avplayer.AVPlayer | null = nullprivate currentUrl: string = ''private state: PlayerState = 'idle'private listeners: Set<PlayerListener> = new Set()// 进度定时器private progressTimer: number | null = nullprivate 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.currentUrlthis.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'breakcase 'preparing':this.state = 'preparing'breakcase 'prepared':this.state = 'prepared'breakcase 'playing':this.state = 'playing'this.startProgressTimer()breakcase 'paused':this.state = 'paused'this.stopProgressTimer()breakcase '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 = urlthis.player.reset()this.player.url = urlthis.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}// 获取当前播放URLgetCurrentUrl(): 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.currentTimeconst duration = this.player.durationthis.notifyProgress(currentTime, duration)}}, 500) as unknown as number}// 停止进度定时器private stopProgressTimer(): void {if (this.progressTimer !== null) {clearInterval(this.progressTimer)this.progressTimer = null}}}
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 = trueprivate 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加载完成后,设置播放器的渲染surfacethis.setupSurface()})// 封面图(首帧前显示)if (this.showCover) {Image(this.coverUrl).width('100%').height('100%').objectFit(ImageFit.Cover)}}.width('100%').height('100%')}// 设置渲染Surfaceprivate 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组件更省心。
短视频Feed流的核心体验:滑动切换时视频秒开,没有黑屏。
滑动时 → 显示下一个视频的封面图 → 播放器准备好后 → 隐藏封面优点:实现简单,体验也不错
缺点:封面和视频第一帧可能有差异
当前播放器播放视频A → 同时预加载视频B的前几秒 →滑动到B时 → 直接从预加载位置开始播放 → 同时释放A,预加载C
实现思路:
// utils/DualPlayerManager.ets// 双播放器管理器,实现无缝切换export class DualPlayerManager {private activePlayer: number = 0 // 0或1private players: avplayer.AVPlayer[] = []// 切换到下一个视频switchTo(url: string): void {const nextPlayer = 1 - this.activePlayer// 下一个播放器预加载this.players[nextPlayer].reset()this.players[nextPlayer].url = urlthis.players[nextPlayer].prepare()// 准备好后切换// ...}}
提前下载下一个视频的前几帧,滑动时直接显示。
在module.json5中配置:
{”module”: {”abilities”: [{”name”: ”EntryAbility”,”orientation”: ”unspecified”, // 支持横竖屏”pages”: [...]}]}}
// utils/OrientationManager.etsimport { display } from '@kit.ArkUI'export type Orientation = 'portrait' | 'landscape'export class OrientationManager {private static instance: OrientationManagerprivate 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 = newOrientationthis.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))}}
// 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')}@BuilderLandscapeControls() {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 })}@BuilderVideoInfoPanel() {Scroll() {Column() {// 视频标题、作者、评论等...}}.width('100%').backgroundColor('#FFF')}}
// components/video/PlaybackSpeedControl.ets@Componentexport struct PlaybackSpeedControl {@State currentSpeed: number = 1.0@State showPanel: boolean = falseprivate speeds: number[] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]onSpeedChange?: (speed: number) => voidbuild() {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 = speedthis.showPanel = falsethis.onSpeedChange?.(speed)// 设置播放器倍速// playerManager.setSpeed(speed)})})}.width(80).backgroundColor('rgba(0,0,0,0.8)').borderRadius(8).position({ x: 0, y: -200 })}}}}
// 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 = 0private startY: number = 0private startTime: number = 0private isHorizontalGesture: boolean = falseonSeek?: (time: number) => voidbuild() {Stack() {// 手势检测区域Column().width('100%').height('100%').gesture(PanGesture({ direction: PanDirection.All, distance: 20 }).onActionStart((event: GestureEvent) => {this.startX = event.offsetXthis.startY = event.offsetYthis.startTime = this.currentTime}).onActionUpdate((event: GestureEvent) => {const dx = event.offsetX - this.startXconst 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 = falsethis.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 / 100const seekDelta = dx * secondsPerPixelthis.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')}`}}
module.json5中添加:
{”module”: {”abilities”: [{”name”: ”EntryAbility”,”backgroundModes”: [”audioPlayback” // 后台音频播放]}]}}
// utils/AudioFocusManager.etsimport { audio } from '@kit.AudioKit'import { Logger } from './Logger'export class AudioFocusManager {private static instance: AudioFocusManagerprivate audioRendererInfo: audio.AudioRendererInfo | null = nullprivate 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))}}}
面试题:短视频APP如何实现无缝切换播放?
参考答案:
- 封面图兜底
:滑动时先显示封面,播放器准备好后隐藏,实现最简单 - 双播放器方案
:两个播放器实例,一个播放一个预加载,切换时无缝衔接 - 关键帧预加载
:提前下载下一个视频的前几帧数据 - 播放器复用
:全局单例播放器,切换时只换数据源,减少初始化开销 - CDN优化
:使用支持Range请求的CDN,支持边下边播
短视频APP常见的登录方式:
我们实现华为账号 + 手机号验证码两种方式。
oh-package.json5中添加:
{”dependencies”: {”@hw-agconnect/auth-ohos”: ”^1.0.0”}}
然后执行Sync Project。
// utils/HuaweiAuthManager.etsimport { Logger } from './Logger'import { UserBean } from '../model/bean/UserBean'// 实际项目中需要引入AGConnect SDK// 这里演示核心逻辑export class HuaweiAuthManager {private static instance: HuaweiAuthManagerstatic 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 !== nullreturn false} catch (error) {return false}}// 登出async logout(): Promise<void> {try {// await AGCAuth.getInstance().signOut()Logger.info('华为账号登出成功')} catch (error) {Logger.error('华为账号登出失败:', JSON.stringify(error))}}}
// 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 = falseprivate timer: number | null = nullbuild() {Column() {// 顶部LogoColumn() {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) returnthis.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 = 60this.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}}}
// 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()}}}
// model/repository/UserRepository.etsimport { HttpManager, ApiResponse } from '../api/HttpManager'import { UserBean } from '../bean/UserBean'import { UserApi } from '../api/UserApi'export interface LoginResult {user: UserBeantoken: string}export class UserRepository {private static instance: UserRepositorystatic 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> {// 实际项目中需要实现文件上传// 这里返回模拟URLreturn 'https://example.com/avatar_' + Date.now() + '.png'}}
有些页面需要登录才能访问,实现一个简单的路由守卫:
// 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})}}// 检查登录,返回Promisestatic requireLogin(): Promise<boolean> {if (AppState.getInstance().isLoggedIn) {return Promise.resolve(true)}// 跳登录页router.pushUrl({ url: RouteConstants.LOGIN })return Promise.resolve(false)}}
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 = falseprivate static refreshPromise: Promise<string> | null = null// 处理401错误(token过期)static async handleTokenExpired(): Promise<boolean> {if (this.isRefreshing) {// 已经在刷新了,等结果if (this.refreshPromise) {await this.refreshPromisereturn true}return false}this.isRefreshing = truethis.refreshPromise = this.refreshToken().then((newToken: string) => {// 刷新成功,更新tokenAppState.getInstance().token = newTokenthis.isRefreshing = falsethis.refreshPromise = nullreturn newToken}).catch(() => {// 刷新失败,跳登录页this.isRefreshing = falsethis.refreshPromise = nullAppState.getInstance().logout()// router.pushUrl({ url: RouteConstants.LOGIN })throw new Error('登录已过期,请重新登录')})try {await this.refreshPromisereturn 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()}}
// 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 = 0followerCount: number = 0likeCount: number = 0workCount: number = 0favoriteCount: number = 0// 会员信息isVip: boolean = falsevipExpireTime: number = 0// 认证信息isVerified: boolean = falseverifiedType: string = ''constructor() {}}
// 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')}@BuilderUserHeader() {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')}@BuilderStatsRow() {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')}@BuilderVideoGrid() {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%')}}
面试题:如何实现无感Token刷新?
参考答案:
后端返回access_token(短时效)和refresh_token(长时效) 网络请求返回401时,拦截并触发刷新流程 多个请求同时401时,只刷新一次,其他请求等待结果(防止并发刷新) 刷新成功后,用新token重试之前失败的请求 刷新失败时,清除登录状态,跳转到登录页
鸿蒙上实现视频拍摄有几种方案:
我们用Camera组件实现,平衡自定义和开发效率。
拍摄需要相机和麦克风权限,先配置:
{”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”}]}}
// utils/PermissionManager.etsimport { abilityAccessCtrl, Permissions } from '@kit.AbilityKit'import { Logger } from './Logger'import { common } from '@kit.AbilityKit'export class PermissionManager {private static instance: PermissionManagerstatic getInstance(): PermissionManager {if (!PermissionManager.instance) {PermissionManager.instance = new PermissionManager()}return PermissionManager.instance}// 检查权限async checkPermission(permission: string): Promise<boolean> {try {const context = getContext() as common.UIAbilityContextconst 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 truetry {const context = getContext() as common.UIAbilityContextconst 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'])}}
// 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 autoprivate cameraController: CameraController = new CameraController()private recordTimer: number | null = nullaboutToAppear() {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')}@BuilderTopToolbar() {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 })}@BuilderBottomControls() {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 })}@BuilderRecordTimer() {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 = truethis.recordDuration = 0// 开始计时this.recordTimer = setInterval(() => {this.recordDuration++// 最长60秒自动停止if (this.recordDuration >= 60) {this.stopRecord()}}, 1000) as unknown as numberLogger.info('开始录制:', outputPath)} catch (error) {Logger.error('开始录制失败:', JSON.stringify(error))ToastUtils.show('录制失败')}}// 停止录制private stopRecord(): void {try {this.cameraController.stopRecord()this.isRecording = falseif (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 % 60return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`}aboutToDisappear() {if (this.recordTimer) {clearInterval(this.recordTimer)this.recordTimer = null}}}
// 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 = falseaboutToAppear() {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 = truetry {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}}}
// pages/publish/PublishViewModel.etsimport { BaseViewModel } from '../../common/BaseViewModel'import { VideoRepository } from '../../model/repository/VideoRepository'import { Logger } from '../../utils/Logger'export interface PublishData {videoPath: stringtitle: stringdescription: stringisPrivate: booleantags?: 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)// 实际项目中实现分片上传// 这里返回模拟URLreturn 'https://cdn.quickvideo.com/videos/' + Date.now() + '.mp4'}// 上传封面private async uploadCover(videoPath: string): Promise<string> {Logger.info('开始上传封面:', videoPath)// 截取视频第一帧作为封面,然后上传// 这里返回模拟URLreturn 'https://cdn.quickvideo.com/covers/' + Date.now() + '.jpg'}}
// utils/UploadManager.etsimport { Logger } from './Logger'export interface UploadTask {file: stringprogress: numberstatus: 'pending' | 'uploading' | 'success' | 'failed'}export class UploadManager {private static instance: UploadManagerstatic 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 = 0const timer = setInterval(() => {progress += Math.random() * 10if (progress >= 100) {progress = 100clearInterval(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. 返回URLreturn this.uploadFile(filePath, onProgress)}// 取消上传cancelUpload(taskId: string): void {// 实现取消逻辑Logger.info('取消上传:', taskId)}}
// utils/AlbumPicker.etsimport { picker } from '@kit.CoreFileKit'import { Logger } from './Logger'export interface PickedVideo {uri: stringname: stringsize: numberduration: number}export class AlbumPicker {// 从相册选择视频static async pickVideo(): Promise<PickedVideo | null> {try {const photoSelectOptions = new picker.PhotoSelectOptions()photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.VIDEO_TYPEphotoSelectOptions.maxSelectNumber = 1const 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_TYPEphotoSelectOptions.maxSelectNumber = maxCountconst 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 []}}}
上传前压缩视频,减少用户流量和等待时间:
// 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: numberheight: numberduration: numbersize: numberbitrate: number}> {Logger.info('获取视频信息:', videoPath)// 实际项目中解析视频元数据return {width: 1080,height: 1920,duration: 30,size: 50 * 1024 * 1024,bitrate: 3000000}}}
// components/publish/UploadProgressDialog.ets@Componentexport struct UploadProgressDialog {@State show: boolean = false@State progress: number = 0@State statusText: string = '上传中...'onCancel?: () => voidbuild() {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 = truethis.progress = 0this.statusText = '上传中...'}// 更新进度updateProgress(progress: number): void {this.progress = progress}// 隐藏hideDialog(): void {this.show = false}}
面试题:大文件上传如何优化?
参考答案:
- 分片上传
:把大文件切成小分片,逐个上传,失败了只重传失败的片 - 断点续传
:记录已上传的分片,下次接着传,不用从头来 - 并发上传
:同时传多个分片,提高速度(注意控制并发数) - 秒传
:上传前计算MD5,如果服务器已有相同文件直接返回成功 - 压缩优化
:上传前压缩视频/图片,减少文件体积 - CDN加速
:上传到离用户最近的CDN节点
一个好的网络层应该具备:
第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: numbermessage: stringdata: T}export interface RequestOptions {showLoading?: booleanshowError?: booleanretryCount?: numbertimeout?: number}export class HttpManager {private static instance: HttpManagerprivate httpRequest: http.HttpRequestprivate 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 || 0if (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 + urlif (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().tokenreturn {'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('取消所有请求')}}
短视频APP需要缓存的场景:
// utils/CacheManager.etsimport { Logger } from './Logger'import preferences from '@ohos.data.preferences'import { common } from '@kit.AbilityKit'interface CacheItem<T> {data: Ttimestamp: numberexpireTime: number // 过期时间(毫秒),0表示永不过期}export class CacheManager {private static instance: CacheManagerprivate 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 stringif (!value) return nullconst 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.UIAbilityContextreturn preferences.getPreferences(context, CacheManager.PREFS_NAME)}}
// 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: CachedVideoRepositoryprivate 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 + videoIdif (!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('清除视频列表缓存')}}
图片是短视频APP最占流量的部分,一定要做好缓存。
// utils/ImageCacheManager.etsimport { Logger } from './Logger'export class ImageCacheManager {private static instance: ImageCacheManager// 内存缓存大小限制(10MB)private static readonly MAX_MEMORY_CACHE_SIZE = 10 * 1024 * 1024private memoryCache: Map<string, ArrayBuffer> = new Map()private memoryCacheSize: number = 0private 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的md5Logger.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}}
没有网络的时候也能看视频,体验更好。
// utils/OfflineManager.etsimport { Logger } from './Logger'import { CacheManager } from './CacheManager'export class OfflineManager {private static instance: OfflineManagerstatic getInstance(): OfflineManager {if (!OfflineManager.instance) {OfflineManager.instance = new OfflineManager()}return OfflineManager.instance}// 检查是否有网络async isNetworkAvailable(): Promise<boolean> {// 实际项目中使用@ohos.net.connectionreturn 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> {// 删除本地文件和数据库记录}}
搜索、输入框等场景需要防抖,避免频繁请求。
// 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 numberthis.timers.set(key, timer)}// 节流:固定时间内只执行一次throttle(key: string, fn: () => void, interval: number = 300): void {const lastTime = this.timers.get(key) || 0const 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()}}
// 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', '音乐翻唱', '舞蹈教学']}}
评论、消息通知等需要实时更新的场景用WebSocket。
// utils/WebSocketManager.etsimport { Logger } from './Logger'export type WSMessageType = 'comment' | 'like' | 'follow' | 'notification'export interface WSMessage {type: WSMessageTypedata: anytimestamp: number}export class WebSocketManager {private static instance: WebSocketManagerprivate ws: WebSocket | null = nullprivate reconnectTimer: number | null = nullprivate heartbeatTimer: number | null = nullprivate url: string = ''private isConnected: boolean = falseprivate listeners: Map<string, Set<(data: any) => void>> = new Map()// 重连配置private reconnectAttempts: number = 0private maxReconnectAttempts: number = 5private reconnectDelay: number = 3000private 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 = urlLogger.info('WebSocket连接:', url)try {this.ws = new WebSocket(url)this.ws.onopen = () => {Logger.info('WebSocket连接成功')this.isConnected = truethis.reconnectAttempts = 0this.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 = falsethis.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 WSMessageLogger.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 = falseLogger.info('WebSocket断开连接')}// 是否已连接getIsConnected(): boolean {return this.isConnected}}
面试题:图片缓存的LRU算法怎么实现?
参考答案:
LRU(Least Recently Used)最近最少使用算法,核心思路:
用Map存储数据,保持插入顺序 每次访问数据时,把它移到最后(表示最近使用) 当缓存满了,删除最前面的(最久没使用的) 鸿蒙中可以用Map的有序特性来实现,或者用双向链表+哈希表 优化点:
按大小淘汰,不按数量(图片大小差异大) 可以分多级缓存:内存 → 磁盘 → 网络 预加载策略:根据用户行为预测可能需要的图片
性能优化不是玄学,是有方法论的。我们从四个维度入手:
┌─────────────────────────────────────────────────────┐│ 性能优化四大维度 │├──────────┬──────────┬──────────┬───────────────────┤│ 启动速度 │ 流畅度 │ 内存占用 │ 包体积 ││ - 白屏时间│ - 帧率 │ - OOM │ - 安装包大小 ││ - 首屏时间│ - 滑动 │ - 泄漏 │ - 资源压缩 ││ - 预热 │ - 响应 │ - 峰值 │ - 按需加载 │└──────────┴──────────┴──────────┴───────────────────┘
APP启动分为三个阶段:
// 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')}}
// 启动时就开始加载首页数据,等UI渲染好数据也到了export default class EntryAbility extends UIAbility {onWindowStageCreate(windowStage: window.WindowStage): void {// 开始预加载首页数据this.preloadHomeData()// 加载UIwindowStage.loadContent('pages/home/HomePage', (err, data) => {// ...})}private preloadHomeData(): void {// 异步加载,不阻塞UIimport('../model/repository/VideoRepository').then(({ VideoRepository }) => {VideoRepository.getInstance().getVideoList(1, 10).catch(err => {Logger.error('预加载失败:', err.message)})})}}
短视频Feed流是性能重灾区,必须优化。
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) // ✅ 关闭链式动画,提升性能
// 评论区、分享面板等不常用的组件,用的时候才创建@BuilderCommentPanel() {if (this.showCommentPanel) {// 评论列表组件CommentList({ videoId: this.videoId })}}// 更高级:用LazyForEach// 适合数据量大的列表
// 1. 图片尺寸要和显示尺寸匹配,不要加载大图显示小区域Image(this.video.coverUrl).width(200).height(300).objectFit(ImageFit.Cover).autoResize(true) // ✅ 自动缩放// 2. 用WebP格式,比PNG/JPG小30%// 3. 列表中的图片用缩略图,详情页再用原图// 4. 离屏图片不加载(List会自动处理,但自定义的要注意)
@Componentstruct VideoPlayer {private timer: number | null = nullaboutToAppear() {// 启动定时器this.timer = setInterval(() => {// 更新进度}, 1000) as unknown as number}aboutToDisappear() {// ✅ 页面销毁时清理定时器if (this.timer) {clearInterval(this.timer)this.timer = null}// ✅ 释放播放器PlayerManager.getInstance().release()// ✅ 取消网络请求HttpManager.getInstance().cancelAllRequests()}}
// 大图片列表,滑动时释放离屏图片// 可以用cachedCount配合,缓存之外的自动释放// 手动管理图片缓存import { ImageCacheManager } from '../utils/ImageCacheManager'// 进入页面时预加载aboutToAppear() {// 预加载当前可见的图片this.videoList.slice(0, 3).forEach(video => {// ImageCacheManager.getInstance().preload(video.coverUrl)})}// 离开页面时清理aboutToDisappear() {// 清理非核心缓存// ImageCacheManager.getInstance().trimMemory()}
// ❌ 不好的写法:每次状态变化整个组件都重绘@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: stringbuild() {Text(this.text)}}@Componentstruct Counter {@Link count: numberbuild() {Column() {Text(this.count.toString())Button('+1').onClick(() => this.count++)}}}
// ❌ 不好的写法:每次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())})}}}
包体积直接影响用户下载意愿,越小越好。
// 1. 按需加载(动态import)// 不常用的页面和功能,用的时候再加载// ❌ 顶部静态导入// import { BigFeature } from '../bigfeature/BigFeature'// ✅ 动态导入async openBigFeature() {const { BigFeature } = await import('../bigfeature/BigFeature')// 使用BigFeature}
build-profile.json5中配置:
{”apiType”: ”stageMode”,”buildOption”: {”arkOptions”: {”obfuscation”: {”enable”: true, // 开启混淆”rules”: {”removeUnusedCode”: true, // 移除无用代码”removeUnusedImport”: true // 移除无用import}}}}}
华为应用市场支持App Bundle,用户只下载自己需要的部分:
// 1. 合并请求:一个页面的多个接口合并成一个// 减少HTTP握手和TCP连接开销// 2. 接口分页:不要一次返回所有数据// 每页10-20条,滚动加载更多// 3. 数据压缩:接口返回gzip压缩的数据// 服务端开启,客户端自动解压
// 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]}
优化不能瞎猜,要有数据支撑。
// 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() - startTimeLogger.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')}
DevEco Studio自带性能分析工具:
使用方法:
用户网络环境复杂,要做好适配。
// 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: NetworkQualityprivate 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'}}
面试题:鸿蒙APP性能优化有哪些手段?
参考答案:
启动优化:
减少Application.onCreate中的耗时操作,非核心模块延迟初始化 首页数据预加载,UI和数据并行 首屏简化,复杂功能延后加载 列表流畅度优化:
List组件设置cachedCount,利用复用机制 ForEach使用唯一ID作为key,不要用index 图片压缩,使用合适的尺寸和格式 减少组件嵌套层级,扁平化布局 复杂计算移出build方法,提前计算好 内存优化:
及时清理定时器、事件监听 图片缓存限制大小,LRU淘汰 避免单例持有页面Context 大对象用完及时释放 包体积优化:
资源压缩,图片用WebP 代码混淆,移除无用代码 按需加载,动态import 删除无用资源和依赖
Build → Generate Key and CSR.p12.csr# 用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盘各存一份
上架华为应用市场需要华为颁发的正式证书。
证书有了,还需要Profile文件(配置文件):
证书和Profile都有了,配置到项目里。
在项目根目录创建 signing 文件夹,放入:
quickvideo.p12quickvideo.cerquickvideo.p7b{”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”}]}
安全提醒:不要把密码明文写在配置文件里!
更安全的做法:
用环境变量存储密码 或者在打包时手动输入 签名文件不要提交到Git
{”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”}]}
Build → Build Hap(s)/APP(s) → Build APP(s)entry/build/default/outputs/default/entry-default-signed.app# 进入项目目录cd /path/to/QuickVideo# 执行打包命令./hvigorw assembleApp --mode release# 或者用hvigor命令hvigor assembleApp -p product=production
上架应用市场传.app包,用户安装时系统会自动拆包。
不同渠道可能需要不同的配置,比如渠道号、统计key等。
// build-profile.json5{”products”: [{”name”: ”huawei”,”buildOption”: {”arkOptions”: {”define”: {”CHANNEL”: ”huawei”,”CHANNEL_NAME”: ”华为应用市场”}}}},{”name”: ”official”,”buildOption”: {”arkOptions”: {”define”: {”CHANNEL”: ”official”,”CHANNEL_NAME”: ”官网”}}}}]}
// 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)
鸿蒙APP的版本号由两部分组成:
在 AppScope/app.json5 中配置:
{”app”: {”bundleName”: ”com.quickvideo.app”,”vendor”: ”quickvideo”,”versionCode”: 1,”versionName”: ”1.0.0”,”icon”: ”$media:app_icon”,”label”: ”$string:app_name”}}
推荐用语义化版本(Semantic Versioning):
主版本号.次版本号.修订号│ │ ││ │ └── 修复bug,兼容更新│ └────────── 新增功能,兼容更新└─────────────────── 不兼容的重大更新
示例:
发布前一定要检查这些项:
打包好后,验证一下包是否正常。
# 用hdc安装到真机hdc install entry-default-signed.app# 或者安装haphdc install entry-default-signed.hap
安装后检查:
# 查看APP包信息hdc shell dumpsys package com.quickvideo.app# 或者用aapt工具(Android的,鸿蒙类似)
面试题:鸿蒙APP的签名机制是什么?
参考答案:
鸿蒙采用数字签名机制,使用RSA算法对APP进行签名 签名的作用:身份认证、防篡改、保证升级一致性 签名需要两个文件:密钥库(.p12)和证书(.cer) 还需要Profile文件(.p7b),包含证书和权限配置 调试阶段用自动生成的调试签名,发布用正式签名 上架华为应用市场需要申请华为颁发的发布证书
注意:不实名认证也能开发,但不能上架应用市场。
上架需要准备这些材料:
网址:https://developer.huawei.com/consumer/cn/service/josp/agc/index.html
重要:包名一旦创建就不能改了,一定要确认好。
创建好应用后,需要完善各种信息。
进入「应用信息」→「基本信息」:
「应用信息」→「版本信息」→「创建版本」
上传后系统会列出APP申请的所有权限,需要逐一说明用途:
注意:不要申请不需要的权限!权限越多审核越严,用户也越警惕。
根据应用内容选择分级:
短视频APP一般选12+或16+,取决于内容。
短视频类应用审核比较严,需要注意:
隐私是现在审核的重点:
所有信息都填好后,就可以提交审核了。
被拒很正常,不要慌:
常见被拒原因:
审核通过后:
发布后一般几分钟内就能在应用市场搜到了。
更新版本和首次上架流程差不多:
大版本更新建议先用灰度:
好处:
上架只是开始,运营才是重点。
让用户更容易搜到你的APP:
华为应用市场有数据分析后台:
定期看数据,发现问题及时优化。
应用市场经常有各种活动:
多参加活动,能获得免费流量。
Q: 个人开发者能上架短视频APP吗?
A: 可以,但审核会比较严,需要有内容审核机制和用户举报功能。
Q: 上架需要软著吗?
A: 个人开发者不是必须的,但有软著更容易通过审核。企业开发者建议有。
Q: 审核要多久?
A: 一般1-3个工作日,首次可能慢一些。
Q: 被拒了怎么办?
A: 看拒绝原因,改了重新提交,一般都能过。
Q: 能赚钱吗?
A: 可以,华为应用市场支持:
面试题:上架华为应用市场的流程是什么?
参考答案:
- 准备阶段
:注册开发者账号、实名认证、准备签名证书 - 创建应用
:在AppGallery Connect创建项目和应用,填写包名 - 完善信息
:上传图标、截图、填写描述、隐私协议等 - 打包签名
:用正式签名打release包 - 上传版本
:创建版本,上传安装包,填写更新日志 - 提交审核
:确认信息无误后提交审核 - 审核发布
:等待审核,通过后发布上线 - 运营迭代
:数据分析、版本更新、活动运营
症状:DevEco Studio下载SDK一直失败或超时
解决方案:
症状:点击启动模拟器,半天没反应或者黑屏
解决方案:
症状:插了USB线,DevEco Studio里不显示设备
解决方案:
症状:编译失败,错误信息看不懂
解决方案:
Build → Clean Project 然后重新编译oh_modules 文件夹,重新Sync症状:改了对象的某个属性,但界面没变
原因:@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 }
症状:数组内容变了,但列表没变
原因: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,是创建新数组
症状:加了样式但没效果
常见原因:
排查方法:用ArkUI Inspector看实际样式。
症状:调用router.pushUrl没反应
解决方案:
症状:网络请求报错,连不上
常见原因:
症状:加了header但服务端收不到
解决方案:
症状:Image组件显示空白
常见原因:
原因:渲染层没设置好
解决方案:
原因:
解决方案:
原因:播放器初始化需要时间
解决方案:
优化清单:
检查清单:
检测工具:DevEco Studio Profiler看内存曲线,一直涨不回落就有泄漏。
优化方向:
常见原因:
原因:
// 用hilog,不要用console.logimport hilog from '@ohos.hilog'hilog.info(0x0001, 'TAG', '这是info日志')hilog.error(0x0001, 'TAG', '这是error日志: %{public}s', errorMsg)// 注意:%{public}s 表示这个参数是公开的,会显示出来// 不加的话默认是private,会显示<private>
DevEco Studio支持断点调试:
# 用hdc查看日志hdc shell hilog | grep ”TAG”# 或者用DevEco Studio的Log工具# 底部面板 → HiLog
网络问题用抓包工具:
手机设置代理到电脑,就能看到所有请求了。
Q1:ArkTS和TypeScript是什么关系?
A:ArkTS是TypeScript的超集,在TS基础上增加了静态类型检查、装饰器、状态管理等特性,更适合鸿蒙UI开发。
Q2:@State、@Prop、@Link、@Provide/@Consume的区别?
A:
Q3:@Observed和@ObjectLink是干嘛的?
A:@Observed装饰类,@ObjectLink装饰变量,用来监听对象内部属性的变化。默认@State只监听引用变化,对象内部属性变了不触发更新,加了这两个才行。
Q4:鸿蒙的UI渲染原理是什么?
A:鸿蒙用声明式UI,状态驱动视图。状态变化时,框架会diff比较,只更新变化的部分,不是整个重绘。底层用ArkUI引擎渲染,性能不错。
Q5:如何做列表性能优化?
A:
Q6:MVVM架构在鸿蒙中怎么实现?
A:
Q7:短视频Feed流怎么实现无缝切换?
A:
Q8:鸿蒙的跨设备能力怎么理解?
A:鸿蒙是分布式操作系统,支持:
Q9:你觉得鸿蒙开发和Android开发最大的区别是什么?
A:(开放性问题,说自己的理解就行)
Q10:为什么选择做鸿蒙开发?前景怎么样?
A:(开放性问题,结合自己的情况说)
恭喜你看到这里!这本手册从环境搭建到上架全流程,用短视频APP作为贯穿案例,覆盖了鸿蒙开发的核心知识点。
给新手的建议:
学习路径建议:
希望这本手册能帮你入门鸿蒙开发,做出自己的APP。有问题随时可以问我,加油!
手册版本:v1.0
最后更新:2026年7月
适用版本:HarmonyOS NEXT (API 12+) / DevEco Studio 5.0+
本手册以短视频APP为案例,涵盖鸿蒙APP开发全流程。内容基于官方文档和实战经验整理,如有错误欢迎指正。