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

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

  • 2026-07-03 10:35:12
鸿蒙APP从入门到上架实战手册

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

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


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

1.1 鸿蒙开发体系全景图

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

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

核心概念速记

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

1.2 电脑配置要求

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

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

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

1.3 DevEco Studio下载与安装

1.3.1 下载地址

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

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

1.3.2 安装步骤(Windows版)

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

1.3.3 安装步骤(Mac版)

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

1.4 Node.js环境配置

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

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

验证安装:

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

1.5 SDK下载与配置

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

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

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

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

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

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

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

1.7 模拟器配置与启动

1.7.1 创建模拟器

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

1.7.2 启动模拟器

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

避坑提醒

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

1.8 真机调试配置(推荐)

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

1.8.1 开启开发者模式

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

1.8.2 连接手机

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

1.8.3 无线调试(HarmonyOS NEXT支持)

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

1.9 第一个Hello World项目

1.9.1 创建项目

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

1.9.2 项目目录结构

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

1.9.3 运行项目

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

1.10 常见环境问题排查

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

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

参考答案

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

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

2.1 ArkTS是什么

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

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

核心特点

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

2.2 基础语法速览

2.2.1 变量与类型

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

2.2.2 函数

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

2.2.3 类与继承

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

2.3 声明式UI核心概念

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

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

2.3.1 基础组件结构

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

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

2.4 常用布局容器

2.4.1 Column(垂直布局)

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

2.4.2 Row(水平布局)

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

2.4.3 Stack(堆叠布局)

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

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

2.4.4 Flex布局

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

2.4.5 List(列表)

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

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

2.5 状态管理装饰器详解

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

2.5.1 @State(组件内部状态)

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

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

2.5.2 @Prop(父传子,单向)

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

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

2.5.3 @Link(父子双向绑定)

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

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

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

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

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

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

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

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

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

2.6 自定义组件封装

2.6.1 基础封装

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

2.6.2 使用自定义组件

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

2.7 页面路由与导航

2.7.1 路由配置(module.json5)

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

2.7.2 页面跳转

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

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

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

2.8 生命周期钩子

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

2.9 资源管理与主题

2.9.1 资源目录结构

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

2.9.2 字符串资源(string.json)

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

使用:

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

2.9.3 颜色资源(color.json)

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

2.10 本章避坑指南

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

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

参考答案

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

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

3.1 为什么要做架构设计

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

好架构的标准

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

3.2 MVVM架构选型

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

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

3.3 完整目录结构

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

3.4 网络层封装(HttpManager)

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

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

3.5 数据仓库层(Repository)

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

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

3.6 ViewModel基类封装

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

3.7 全局状态管理(AppState)

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

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

3.8 工具类封装

3.8.1 日志工具

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

3.8.2 时间格式化

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

3.8.3 本地存储

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

3.9 常量定义

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

3.10 本章避坑指南

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

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

参考答案

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

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

4.1 Feed流交互设计

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

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

4.2 数据模型定义

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

4.3 HomeViewModel实现

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

4.4 首页Feed流主页面

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

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

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

4.6 VideoItem单条视频组件

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

4.7 右侧操作栏组件

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

4.8 视频信息组件

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

4.9 点赞动画组件

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

4.10 本章避坑指南

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

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

参考答案

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

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

5.1 鸿蒙视频播放能力概览

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

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

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

5.2 Video组件快速上手

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

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

5.3 AVPlayer高级播放器封装

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

5.3.1 播放器单例封装

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

5.4 视频渲染组件(XComponent)

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

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

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

5.5 Feed流无缝切换优化方案

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

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

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

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

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

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

实现思路:

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

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

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

5.6 横竖屏切换实现

5.6.1 页面配置支持横竖屏

在module.json5中配置:

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

5.6.2 监听屏幕方向变化

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

5.6.3 视频详情页横竖屏适配

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

5.7 倍速播放实现

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

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

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

5.9 后台播放与音频焦点

5.9.1 配置后台播放权限

module.json5中添加:

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

5.9.2 音频焦点管理

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

5.10 本章避坑指南

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

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

参考答案

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

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

6.1 登录方案选型

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

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

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

6.2 华为账号一键登录集成

6.2.1 前置准备

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

6.2.2 安装依赖

oh-package.json5中添加:

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

然后执行Sync Project。

6.2.3 登录封装

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

6.3 手机号验证码登录

6.3.1 登录页面UI

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

6.4 LoginViewModel实现

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

6.5 UserRepository数据层

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

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

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

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

6.7 Token刷新机制

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

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

6.8 用户信息模型

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

6.9 个人中心页面

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

6.10 本章避坑指南

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

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

参考答案

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

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

7.1 拍摄功能技术选型

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

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

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

7.2 权限申请

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

7.2.1 module.json5配置权限

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

7.2.2 运行时权限申请

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

7.3 相机预览页面

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

7.4 视频编辑页面

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

7.5 PublishViewModel实现

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

7.6 文件上传封装

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

7.7 相册选择视频

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

7.8 视频压缩(可选优化)

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

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

7.9 发布进度展示

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

7.10 本章避坑指南

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

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

参考答案

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

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

8.1 网络层架构设计

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

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

8.2 完善的HttpManager封装

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

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

8.3 缓存策略设计

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

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

8.3.1 缓存工具类

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

8.4 带缓存的Repository

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

8.5 图片缓存策略

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

8.5.1 图片缓存工具

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

8.6 离线缓存策略

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

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

8.7 请求防抖与节流

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

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

8.8 搜索功能(带防抖)

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

8.9 WebSocket实时通信

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

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

8.10 本章避坑指南

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

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

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

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

优化点:

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


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

9.1 性能优化全景图

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

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

9.2 启动性能优化

9.2.1 启动阶段拆解

APP启动分为三个阶段:

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

9.2.2 优化手段

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

9.2.3 首页数据预加载

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

9.3 列表滑动性能优化

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

9.3.1 List组件优化

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

9.3.2 组件懒加载

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

9.3.3 图片优化

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

9.4 内存优化

9.4.1 常见内存泄漏场景

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

9.4.2 正确清理资源

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

9.4.3 图片内存优化

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

9.5 渲染性能优化

9.5.1 减少不必要的重绘

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

9.5.2 避免在build中做计算

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

9.6 包体积瘦身

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

9.6.1 资源优化

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

9.6.2 代码优化

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

9.6.3 混淆与压缩

build-profile.json5中配置:

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

9.6.4 按需分发(App Bundle)

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

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

9.7 网络性能优化

9.7.1 请求优化

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

9.7.2 图片CDN优化

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

9.8 性能监控与分析

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

9.8.1 性能埋点

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

9.8.2 DevEco Studio性能分析工具

DevEco Studio自带性能分析工具:

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

使用方法:

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

9.9 弱网与降级策略

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

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

9.10 本章避坑指南

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

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

参考答案

启动优化

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

列表流畅度优化

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

内存优化

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

包体积优化

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

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

10.1 签名基础知识

10.1.1 为什么要签名

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

10.1.2 签名类型

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

10.2 生成签名证书

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

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

10.2.2 方式二:命令行生成

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

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

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

10.3 申请华为发布证书

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

10.3.1 申请流程

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

10.3.2 配置Profile文件

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

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

10.4 配置签名信息

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

10.4.1 项目结构配置

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

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

10.4.2 build-profile.json5配置

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

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

更安全的做法:

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

10.4.3 多环境签名配置

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

10.5 打包构建

10.5.1 可视化打包

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

10.5.2 命令行打包

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

10.5.3 打包产物说明

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

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

10.6 多渠道打包

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

10.6.1 多渠道配置

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

10.6.2 代码中使用渠道信息

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

10.7 版本号管理

10.7.1 版本号规则

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

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

10.7.2 配置版本号

在 AppScope/app.json5 中配置:

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

10.7.3 版本号命名规范

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

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

示例:

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

10.8 打包前检查清单

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

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

10.9 安装包验证

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

10.9.1 安装测试

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

安装后检查:

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

10.9.2 查看包信息

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

10.10 本章避坑指南

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

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

参考答案

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

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

11.1 上架前准备

11.1.1 账号准备

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

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

11.1.2 材料准备

上架需要准备这些材料:

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

11.2 创建应用

11.2.1 进入AppGallery Connect

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

11.2.2 创建项目

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

11.2.3 创建应用

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

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

11.3 配置应用信息

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

11.3.1 基本信息

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

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

11.3.2 截图与视频

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

11.3.3 隐私与协议

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

11.4 上传安装包

11.4.1 进入版本管理

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

11.4.2 填写版本信息

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

11.4.3 上传软件包

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

11.4.4 权限说明

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

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

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

11.5 内容审核与合规

11.5.1 内容分级

根据应用内容选择分级:

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

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

11.5.2 内容安全

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

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

11.5.3 隐私合规

隐私是现在审核的重点:

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

11.6 提交审核

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

11.6.1 提交前检查清单

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

11.6.2 提交审核

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

11.6.3 审核时间

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

11.6.4 审核被拒怎么办

被拒很正常,不要慌:

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

常见被拒原因:

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

11.7 发布与更新

11.7.1 审核通过后发布

审核通过后:

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

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

11.7.2 版本更新

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

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

11.7.3 灰度发布

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

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

好处:

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

11.8 应用商店运营

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

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

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

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

11.8.2 数据分析

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

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

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

11.8.3 活动运营

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

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

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

11.9 常见问题FAQ

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

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

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

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

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

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

11.10 本章避坑指南

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

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

参考答案

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

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

12.1 开发环境常见坑

12.1.1 SDK下载失败

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

解决方案

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

12.1.2 模拟器启动不了

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

解决方案

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

12.1.3 真机连不上

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

解决方案

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

12.1.4 编译报各种奇怪错误

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

解决方案

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

12.2 ArkUI开发常见坑

12.2.1 对象属性变了UI不更新

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

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

解决方案

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

12.2.2 ForEach列表不更新

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

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

解决方案

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

12.2.3 样式不生效

症状:加了样式但没效果

常见原因

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

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

12.2.4 页面跳转没反应

症状:调用router.pushUrl没反应

解决方案

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

12.3 网络请求常见坑

12.3.1 HTTP请求失败

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

常见原因

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

12.3.2 请求头设置不生效

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

解决方案

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

12.3.3 图片加载不出来

症状:Image组件显示空白

常见原因

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

12.4 视频播放常见坑

12.4.1 视频只有声音没有画面

原因:渲染层没设置好

解决方案

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

12.4.2 视频播放卡顿

原因

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

解决方案

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

12.4.3 切换视频有黑屏

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

解决方案

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

12.5 性能常见坑

12.5.1 列表滑动卡顿

优化清单

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

12.5.2 内存泄漏

检查清单

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

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

12.5.3 启动太慢

优化方向

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

12.6 上架常见坑

12.6.1 审核被拒Top5原因

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

12.6.2 包上传失败

常见原因

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

12.6.3 搜不到应用

原因

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

12.7 调试技巧

12.7.1 日志调试

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

12.7.2 断点调试

DevEco Studio支持断点调试:

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

12.7.3 真机调试日志查看

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

12.7.4 抓包调试

网络问题用抓包工具:

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

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

12.8 鸿蒙开发面试题精选

基础题

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

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

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

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

进阶题

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

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

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

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

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

实战题

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

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

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

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

开放题

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

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

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

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

12.9 学习资源推荐

官方资源

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

社区资源

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

书籍推荐

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

12.10 写在最后

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

给新手的建议

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

学习路径建议

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

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


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

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

最新文章

随机文章

基本 文件 流程 错误 SQL 调试
  1. 请求信息 : 2026-07-03 12:46:37 HTTP/2.0 GET : https://c.mffb.com.cn/a/498475.html
  2. 运行时间 : 0.134361s [ 吞吐率:7.44req/s ] 内存消耗:5,230.02kb 文件加载:140
  3. 缓存信息 : 0 reads,0 writes
  4. 会话信息 : SESSION_ID=616946d00a51294cbbac558b00512435
  1. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/public/index.php ( 0.79 KB )
  2. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/autoload.php ( 0.17 KB )
  3. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/composer/autoload_real.php ( 2.49 KB )
  4. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/composer/platform_check.php ( 0.90 KB )
  5. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/composer/ClassLoader.php ( 14.03 KB )
  6. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/composer/autoload_static.php ( 4.90 KB )
  7. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/helper.php ( 8.34 KB )
  8. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-validate/src/helper.php ( 2.19 KB )
  9. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/helper.php ( 1.47 KB )
  10. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/stubs/load_stubs.php ( 0.16 KB )
  11. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Exception.php ( 1.69 KB )
  12. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-container/src/Facade.php ( 2.71 KB )
  13. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/symfony/deprecation-contracts/function.php ( 0.99 KB )
  14. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/symfony/polyfill-mbstring/bootstrap.php ( 8.26 KB )
  15. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/symfony/polyfill-mbstring/bootstrap80.php ( 9.78 KB )
  16. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/symfony/var-dumper/Resources/functions/dump.php ( 1.49 KB )
  17. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-dumper/src/helper.php ( 0.18 KB )
  18. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/symfony/var-dumper/VarDumper.php ( 4.30 KB )
  19. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/App.php ( 15.30 KB )
  20. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-container/src/Container.php ( 15.76 KB )
  21. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/psr/container/src/ContainerInterface.php ( 1.02 KB )
  22. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/provider.php ( 0.19 KB )
  23. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Http.php ( 6.04 KB )
  24. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/helper/Str.php ( 7.29 KB )
  25. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Env.php ( 4.68 KB )
  26. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/common.php ( 0.03 KB )
  27. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/helper.php ( 18.78 KB )
  28. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Config.php ( 5.54 KB )
  29. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/app.php ( 0.95 KB )
  30. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/cache.php ( 0.78 KB )
  31. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/console.php ( 0.23 KB )
  32. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/cookie.php ( 0.56 KB )
  33. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/database.php ( 2.48 KB )
  34. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/facade/Env.php ( 1.67 KB )
  35. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/filesystem.php ( 0.61 KB )
  36. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/lang.php ( 0.91 KB )
  37. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/log.php ( 1.35 KB )
  38. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/middleware.php ( 0.19 KB )
  39. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/route.php ( 1.89 KB )
  40. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/session.php ( 0.57 KB )
  41. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/trace.php ( 0.34 KB )
  42. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/view.php ( 0.82 KB )
  43. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/event.php ( 0.25 KB )
  44. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Event.php ( 7.67 KB )
  45. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/service.php ( 0.13 KB )
  46. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/AppService.php ( 0.26 KB )
  47. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Service.php ( 1.64 KB )
  48. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Lang.php ( 7.35 KB )
  49. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/lang/zh-cn.php ( 13.70 KB )
  50. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/initializer/Error.php ( 3.31 KB )
  51. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/initializer/RegisterService.php ( 1.33 KB )
  52. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/services.php ( 0.14 KB )
  53. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/service/PaginatorService.php ( 1.52 KB )
  54. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/service/ValidateService.php ( 0.99 KB )
  55. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/service/ModelService.php ( 2.04 KB )
  56. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-trace/src/Service.php ( 0.77 KB )
  57. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Middleware.php ( 6.72 KB )
  58. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/initializer/BootService.php ( 0.77 KB )
  59. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/Paginator.php ( 11.86 KB )
  60. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-validate/src/Validate.php ( 63.20 KB )
  61. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/Model.php ( 23.55 KB )
  62. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/Attribute.php ( 21.05 KB )
  63. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/AutoWriteData.php ( 4.21 KB )
  64. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/Conversion.php ( 6.44 KB )
  65. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/DbConnect.php ( 5.16 KB )
  66. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/ModelEvent.php ( 2.33 KB )
  67. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/RelationShip.php ( 28.29 KB )
  68. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/contract/Arrayable.php ( 0.09 KB )
  69. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/contract/Jsonable.php ( 0.13 KB )
  70. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/contract/Modelable.php ( 0.09 KB )
  71. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Db.php ( 2.88 KB )
  72. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/DbManager.php ( 8.52 KB )
  73. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Log.php ( 6.28 KB )
  74. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Manager.php ( 3.92 KB )
  75. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/psr/log/src/LoggerTrait.php ( 2.69 KB )
  76. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/psr/log/src/LoggerInterface.php ( 2.71 KB )
  77. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Cache.php ( 4.92 KB )
  78. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/psr/simple-cache/src/CacheInterface.php ( 4.71 KB )
  79. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/helper/Arr.php ( 16.63 KB )
  80. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/cache/driver/File.php ( 7.84 KB )
  81. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/cache/Driver.php ( 9.03 KB )
  82. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/contract/CacheHandlerInterface.php ( 1.99 KB )
  83. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/Request.php ( 0.09 KB )
  84. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Request.php ( 55.78 KB )
  85. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/middleware.php ( 0.25 KB )
  86. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Pipeline.php ( 2.61 KB )
  87. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-trace/src/TraceDebug.php ( 3.40 KB )
  88. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/middleware/SessionInit.php ( 1.94 KB )
  89. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Session.php ( 1.80 KB )
  90. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/session/driver/File.php ( 6.27 KB )
  91. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/contract/SessionHandlerInterface.php ( 0.87 KB )
  92. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/session/Store.php ( 7.12 KB )
  93. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Route.php ( 23.73 KB )
  94. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/RuleName.php ( 5.75 KB )
  95. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/Domain.php ( 2.53 KB )
  96. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/RuleGroup.php ( 22.43 KB )
  97. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/Rule.php ( 26.95 KB )
  98. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/RuleItem.php ( 9.78 KB )
  99. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/route/app.php ( 1.72 KB )
  100. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/facade/Route.php ( 4.70 KB )
  101. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/dispatch/Controller.php ( 4.74 KB )
  102. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/Dispatch.php ( 10.44 KB )
  103. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/controller/Index.php ( 4.81 KB )
  104. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/BaseController.php ( 2.05 KB )
  105. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/facade/Db.php ( 0.93 KB )
  106. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/connector/Mysql.php ( 5.44 KB )
  107. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/PDOConnection.php ( 52.47 KB )
  108. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/Connection.php ( 8.39 KB )
  109. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/ConnectionInterface.php ( 4.57 KB )
  110. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/builder/Mysql.php ( 16.58 KB )
  111. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/Builder.php ( 24.06 KB )
  112. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/BaseBuilder.php ( 27.50 KB )
  113. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/Query.php ( 15.71 KB )
  114. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/BaseQuery.php ( 45.13 KB )
  115. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/TimeFieldQuery.php ( 7.43 KB )
  116. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/AggregateQuery.php ( 3.26 KB )
  117. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/ModelRelationQuery.php ( 20.07 KB )
  118. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/ParamsBind.php ( 3.66 KB )
  119. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/ResultOperation.php ( 7.01 KB )
  120. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/WhereQuery.php ( 19.37 KB )
  121. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/JoinAndViewQuery.php ( 7.11 KB )
  122. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/TableFieldInfo.php ( 2.63 KB )
  123. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/Transaction.php ( 2.77 KB )
  124. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/log/driver/File.php ( 5.96 KB )
  125. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/contract/LogHandlerInterface.php ( 0.86 KB )
  126. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/log/Channel.php ( 3.89 KB )
  127. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/event/LogRecord.php ( 1.02 KB )
  128. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/Collection.php ( 16.47 KB )
  129. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/facade/View.php ( 1.70 KB )
  130. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/View.php ( 4.39 KB )
  131. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Response.php ( 8.81 KB )
  132. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/response/View.php ( 3.29 KB )
  133. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Cookie.php ( 6.06 KB )
  134. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-view/src/Think.php ( 8.38 KB )
  135. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/contract/TemplateHandlerInterface.php ( 1.60 KB )
  136. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-template/src/Template.php ( 46.61 KB )
  137. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-template/src/template/driver/File.php ( 2.41 KB )
  138. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-template/src/template/contract/DriverInterface.php ( 0.86 KB )
  139. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/runtime/temp/cefbf809ba1a84190cb04b0cb7abcf79.php ( 11.98 KB )
  140. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-trace/src/Html.php ( 4.42 KB )
  1. CONNECT:[ UseTime:0.000628s ] mysql:host=127.0.0.1;port=3306;dbname=c_mffb;charset=utf8mb4
  2. SHOW FULL COLUMNS FROM `fenlei` [ RunTime:0.000773s ]
  3. SELECT * FROM `fenlei` WHERE `fid` = 0 [ RunTime:0.000336s ]
  4. SELECT * FROM `fenlei` WHERE `fid` = 63 [ RunTime:0.000245s ]
  5. SHOW FULL COLUMNS FROM `set` [ RunTime:0.000494s ]
  6. SELECT * FROM `set` [ RunTime:0.000254s ]
  7. SHOW FULL COLUMNS FROM `article` [ RunTime:0.000780s ]
  8. SELECT * FROM `article` WHERE `id` = 498475 LIMIT 1 [ RunTime:0.001541s ]
  9. UPDATE `article` SET `lasttime` = 1783053997 WHERE `id` = 498475 [ RunTime:0.019347s ]
  10. SELECT * FROM `fenlei` WHERE `id` = 66 LIMIT 1 [ RunTime:0.002224s ]
  11. SELECT * FROM `article` WHERE `id` < 498475 ORDER BY `id` DESC LIMIT 1 [ RunTime:0.008601s ]
  12. SELECT * FROM `article` WHERE `id` > 498475 ORDER BY `id` ASC LIMIT 1 [ RunTime:0.000616s ]
  13. SELECT * FROM `article` WHERE `id` < 498475 ORDER BY `id` DESC LIMIT 10 [ RunTime:0.022744s ]
  14. SELECT * FROM `article` WHERE `id` < 498475 ORDER BY `id` DESC LIMIT 10,10 [ RunTime:0.000860s ]
  15. SELECT * FROM `article` WHERE `id` < 498475 ORDER BY `id` DESC LIMIT 20,10 [ RunTime:0.001431s ]
0.135928s