带你实现OpenHarmony 微信聊天02
带你实现OpenHarmony 微信聊天02
接上一篇
前言
代码会统一放在码云上
案例目标
这个是安卓手机上的真正的微信聊天界面功能效果
实际效果
案例功能
上一篇,已经实现了以下功能
- 页面沉浸式
- 聊天内容滚动
- 输入框状态切换
- 聊天信息框宽度自适应
- 输入法避让
- canvas声纹 按住说话
- 发送文字
- 录音-发送语音
- 语音消息根据时长自动宽度
- 手势坐标检测取消发送-语音转文字
- 声音播放-语音消息
- AI 语音转文字
发送声音-功能演示
发送声音主要流程
发送声音结合UI交互-主要流程
声明麦克风权限
应用需要在module.json5配置文件的requestPermissions标签中声明权限。
属性名称 | 含义 | 是否可缺省 |
---|---|---|
name | 标识需要使用的权限名称。取值范围请参考应用权限列表。 | 该标签不可缺省,且必须为系统定义权限或definePermissions中定义的权限。 |
reason | 标识申请权限的原因,取值需要采用资源引用格式,以适配多语种。 | **说明:**当申请的权限为user_grant权限时,该字段必填,否则不允许在应用市场上架。 |
usedScene | 标识权限使用的场景,包含abilities和when两个子标签。- - when:表示调用时机,支持的取值包括inuse(使用时)和always(始终)。 | 当申请的权限为user_grant权限时,abilities标签在hap中必填,when标签可选。 |
\entry\src\main\module.json5
requestPermissions
{
"module": {
...
"requestPermissions": [
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:voice_reason",
"usedScene": {
"abilities": [
"FormAbility"
],
"when": "always"
}
}
],
}
}
\entry\src\main\resources\base\element\string.json
$string:voice_reason"
{
"string": [
{
"name": "module_desc",
"value": "module description"
},
{
"name": "EntryAbility_desc",
"value": "description"
},
{
"name": "EntryAbility_label",
"value": "label"
},
{
"name": "voice_reason",
"value": "用于获取用户的录音"
}
]
}
封装申请权限的工具类
权限工具类的主要功能为:
- 检测是否已经申请相关权限
- 申请相关权限
步骤:
-
新建文件
\entry\src\main\ets\utils\permissionMananger.ets
-
实现以下代码
// 导入必要的模块,包括权限管理相关的功能 import { abilityAccessCtrl, bundleManager, common, Permissions } from '@kit.AbilityKit'; export class PermissionManager { // 静态方法用于检查给定的权限是否已经被授予 static checkPermission(permissions: Permissions[]): boolean { // 创建一个访问令牌管理器实例 let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); // 初始化tokenID为0,稍后将获取真实的tokenID let tokenID: number = 0; // 获取本应用的包信息 const bundleInfo = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); // 设置tokenID为应用的访问令牌ID tokenID = bundleInfo.appInfo.accessTokenId; // 如果没有传入任何权限,则返回false表示没有权限 if (permissions.length === 0) { return false; } else { // 检查所有请求的权限是否都被授予 return permissions.every(permission => abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED === atManager.checkAccessTokenSync(tokenID, permission) ); } } // 异步静态方法用于请求用户授权指定的权限 static async requestPermission(permissions: Permissions[]): Promise<boolean> { // 创建一个访问令牌管理器实例 let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); // 获取上下文(这里假设getContext是一个可以获取到UI能力上下文的方法) let context: Context = getContext() as common.UIAbilityContext; // 请求用户授权指定的权限 const result = await atManager.requestPermissionsFromUser(context, permissions); // 检查请求结果是否成功(authResults数组中每个元素都应该是0,表示成功) return !!result.authResults.length && result.authResults.every(authResults => authResults === 0); } }
-
首页中 aboutToAppear 中调用
// 页面开始显示触发 async aboutToAppear() { // 1 检查是否有权限 // 2 如果没有权限 再去申请权限 const permissionList: Permissions[] = ["ohos.permission.MICROPHONE"] const result = await PermissionManager.checkPermission(permissionList) if (!result) { PermissionManager.requestPermission(permissionList) } }
-
最后效果
声音录制 AudioCapturer
AudioCapturer是音频采集器,用于录制PCM(Pulse Code Modulation)音频数据
封装录制声音类
根据上图的AudioCapturer使用流程,我们将封装 AudioCapturer录音类,主要有三个核心方法:
- 创建 AudioCapturer实例
- 开始录音
- 停止录音
\entry\src\main\ets\utils\AudioCapturerManager.ets
// 导入音频处理模块
import { audio } from '@kit.AudioKit';
// 导入文件系统模块
import fs from '@ohos.file.fs';
// 定义一个接口来描述录音文件的信息
export interface RecordFile {
recordFilePath: string, // 录音文件的路径
startRecordTime: number, // 开始录音的时间戳
endRecordTime: number // 结束录音的时间戳
}
// 定义一个管理音频录制的类
export class AudioCapturerManager {
// 静态属性,用于存储当前的音频捕获器实例
static audioCapturer: audio.AudioCapturer | null = null;
// 静态私有属性,用于存储录音文件的路径
private static recordFilePath: string = "";
// 静态私有属性,用于存储开始录音的时间戳
private static startRecordTime: number = 0;
// 静态私有属性,用于存储结束录音的时间戳
private static endRecordTime: number = 0;
// 静态异步方法,用于创建音频捕获器实例
static async createAudioCapturer() {
if (AudioCapturerManager.audioCapturer) {
return AudioCapturerManager.audioCapturer
}
// 设置音频流信息配置
let audioStreamInfo: audio.AudioStreamInfo = {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000, // 设置采样率为16kHz
channels: audio.AudioChannel.CHANNEL_1, // 设置单声道
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, // 设置样本格式为16位小端
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW // 设置编码类型为原始数据
};
// 设置音频捕获信息配置
let audioCapturerInfo: audio.AudioCapturerInfo = {
source: audio.SourceType.SOURCE_TYPE_MIC, // 设置麦克风为音频来源
capturerFlags: 0 // 捕获器标志,此处为默认值
};
// 创建音频捕获选项对象
let audioCapturerOptions: audio.AudioCapturerOptions = {
streamInfo: audioStreamInfo, // 使用上面定义的音频流信息
capturerInfo: audioCapturerInfo // 使用上面定义的音频捕获信息
};
// 创建音频捕获器实例
AudioCapturerManager.audioCapturer = await audio.createAudioCapturer(audioCapturerOptions);
// 返回创建的音频捕获器实例
return AudioCapturerManager.audioCapturer;
}
// 静态异步方法,用于启动录音过程
static async startRecord(fileName: string) {
await AudioCapturerManager.createAudioCapturer()
// 记录开始录音的时间戳
AudioCapturerManager.startRecordTime = Date.now();
try {
// 初始化缓冲区大小
let bufferSize: number = 0;
// 定义一个内部类来设置写入文件时的选项
class Options {
offset?: number; // 文件写入位置偏移量
length?: number; // 写入数据的长度
}
// 获取应用的文件目录路径
let path = getContext().filesDir;
// 设置录音文件的完整路径
let filePath = `${path}/${fileName}.wav`;
AudioCapturerManager.recordFilePath = filePath;
// 打开或创建录音文件
let file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
// 定义一个读取数据的回调函数
let readDataCallback = (buffer: ArrayBuffer) => {
// 创建一个写入文件的选项对象
let options: Options = {
offset: bufferSize, // 文件当前位置偏移量
length: buffer.byteLength // 数据长度
};
// 将数据写入文件
fs.writeSync(file.fd, buffer, options);
// 更新缓冲区大小
bufferSize += buffer.byteLength;
};
// 给音频捕获器实例注册读取数据的事件监听器
AudioCapturerManager.audioCapturer?.on('readData', readDataCallback);
// 开始录音
AudioCapturerManager.audioCapturer?.start();
// 返回录音文件的路径
return filePath;
} catch (e) {
// 如果出现异常,返回空字符串
return "";
}
}
// 静态异步方法,用于停止录音过程
static async stopRecord() {
// 停止音频捕获器的工作
await AudioCapturerManager.audioCapturer?.stop();
// 释放音频捕获器的资源
await AudioCapturerManager.audioCapturer?.release();
// 清除音频捕获器实例
AudioCapturerManager.audioCapturer = null;
// 记录结束录音的时间戳
AudioCapturerManager.endRecordTime = Date.now();
// 创建并返回一个包含录音文件信息的对象
const recordFile: RecordFile = {
recordFilePath: AudioCapturerManager.recordFilePath,
startRecordTime: AudioCapturerManager.startRecordTime,
endRecordTime: AudioCapturerManager.endRecordTime,
};
// 返回记录文件信息
return recordFile;
}
}
“按住说话” 发送录音
这里我们先实现最简单的录音功能,转换文本或者取消发送下一个环节再实现
- 当长按 按住说话时,便开始录音
- 当直接松开手指时,便停止录音
- 同时构造声音消息,显示在聊天面板上
定义全局录音文件名
// 录音文件名称
recordFileName: string = ""
首页中定义开始录音的方法
// 开始录音
onStartRecord = () => {
// 文件名 唯一
this.recordFileName = Date.now().toString()
AudioCapturerManager.startRecord(this.recordFileName)
}
长按 "按住说话",开始录音
声明停止录音方法
// 结束录音
stopRecord = async () => {
// res 记录录音文件的路径、时长等信息 这里返回是为了实现 发送录音消息
const res = await AudioCapturerManager.stopRecord()
return res
}
松开手指停止录音
在 onPressTalk 中的松开手指事件 TouchType.Up中停止录音
声明发送声音消息的方法
// 生成声音消息
postVoice = (res: RecordFile) => {
// 录音时长 录音结束时间-开始录音时间
const duration = Math.ceil((res.endRecordTime - res.startRecordTime) / 1000)
// 生成消息文件
const voiceChat = new ChatMessage(MessageType.voice, res.recordFilePath, duration)
// 插入到消息数组中
this.chatList.push(voiceChat)
}
定义渲染声音消息的自定义构建函数
该部分代码 可以根据声音消息的时长,动态设置消息的宽度
实现的思路为:
- 如果 80 + 时长*3 大于屏幕的一半,那么最大就是屏幕的一半
- 否则 宽度就是 80+时长*3
.width(
80 + duration * 3 > px2vp(display.getDefaultDisplaySync().width / 2) ?
px2vp(display.getDefaultDisplaySync().width / 2) :
80 + duration * 3
)
/**
* 声音消息 结构
* @param fileName 录音的路径-后续做点击播放使用
* @param time 发送消息的时间 如 22:21
* @param duration 消息的时长 如 5s
* @param index 该消息在数组中的索引 后续做声音转文本使用
*/
@Builder
chatVoiceBuilder(fileName: string, time: string, duration: number, index: number) {
Column({ space: 5 }) {
Text(time)
.width("100%")
.textAlign(TextAlign.Center)
.fontColor("#666")
.fontSize(14)
Row() {
Flex({ justifyContent: FlexAlign.End }) {
Column({ space: 10 }) {
Row() {
// 声音时长
Text(`${duration}"`)
.padding(11)
Image($r("app.media.voice"))
.width(20)
.rotate({
angle: 180
})
.margin({ right: 12 })
Text()
.width(10)
.height(10)
.backgroundColor("#93EC6C")
.position({
right: 0,
top: 15
})
.translate({
x: 5,
})
.rotate({
angle: 45
});
}
.backgroundColor("#93EC6C")
.margin({ right: 15 })
.borderRadius(5)
// 根据声音时长,动态计算声音长度
// 如果 80 + 时长*3 大于屏幕的一半,那么最大就是屏幕的一半
// 否则 宽度就是 80+时长*3
.width(80 + duration * 3 > px2vp(display.getDefaultDisplaySync().width / 2) ?
px2vp(display.getDefaultDisplaySync().width / 2) : 80 + duration * 3)
.justifyContent(FlexAlign.End)
}
.alignItems(HorizontalAlign.End)
Image($r("app.media.avatar"))
.width(40)
.aspectRatio(1)
}
.width("100%")
.padding({
left: 40
})
}
}
.width("100%")
}
遍历消息数组时,动态渲染文本消息和声音消息
if (item.type === MessageType.text) {
this.chatTextBuilder(item.content, item.time)
} else if (item.type === MessageType.voice) {
this.chatVoiceBuilder(item.content, item.time, item.duration!, index)
}
松开手指停止录音 同时发送声音消息
最后效果
录音生成的文件
生成的录音文件都放在这里了 /data/app/el2/100/base/com.example.你项目的包名/haps/entry/files
“按住说话” 取消发送
该功能主要的实现流程是:
当长按 “按住说话” ,并且判断手指是否移动到了 X(这个功能在上一章已经实现了),如果是,则什么都不做即可
播放声音消息 AudioRendererManager
AudioRenderer是音频渲染器,用于播放PCM(Pulse Code Modulation)音频数据,相比AVPlayer而言,可以在输入前添加数据预处
理,更适合有音频开发经验的开发者,以实现更灵活的播放功能。
封装声音播放类
根据上述的AudioRenderer流程图,我们将封装AudioRendererManager声音播放类,实现了核心的五个功能:
-
初始化AudioRenderer实例
-
开始播放声音
当播放完毕时,会自动停止播放和释放资源
-
暂停播放声音
-
停止播放声音
-
释放AudioRenderer相关资源
\entry\src\main\ets\utils\AudioRendererManager.ets
import { audio } from '@kit.AudioKit';
import { fileIo } from '@kit.CoreFileKit';
class Options {
offset?: number;
length?: number;
}
class AudioRendererManager {
/**
* 音频播放实例
*/
private static audioRender: audio.AudioRenderer | null = null
/**
* 初始化
*/
static async init(fileName: string) {
try {
let bufferSize: number = 0;
let audioStreamInfo: audio.AudioStreamInfo = {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000, // 采样率
channels: audio.AudioChannel.CHANNEL_1, // 通道
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, // 采样格式
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW // 编码格式
}
let audioRendererInfo: audio.AudioRendererInfo = {
usage: audio.StreamUsage.STREAM_USAGE_MUSIC, // 音频流使用类型
rendererFlags: 0 // 音频渲染器标志
}
let audioRendererOptions: audio.AudioRendererOptions = {
streamInfo: audioStreamInfo,
rendererInfo: audioRendererInfo
}
let path = getContext().filesDir;
// let filePath = `${path}/${fileName}.wav`;
let file: fileIo.File = fileIo.openSync(fileName, fileIo.OpenMode.READ_ONLY);
const fileSize = fileIo.statSync(file.path).size
let writeDataCallback = (buffer: ArrayBuffer) => {
let options: Options = {
offset: bufferSize,
length: buffer.byteLength
}
fileIo.readSync(file.fd, buffer, options);
bufferSize += buffer.byteLength;
// 自动停止
if (bufferSize >= fileSize) {
AudioRendererManager.stop()
.then(() => {
AudioRendererManager.release()
})
}
}
AudioRendererManager.audioRender = await audio.createAudioRenderer(audioRendererOptions)
AudioRendererManager.audioRender.on("writeData", writeDataCallback)
} catch (e) {
console.log("e", e.message, e.code)
}
}
/** 播放 */
static async start() {
// 当且仅当状态为prepared、paused和stopped之一时才能启动渲染
await AudioRendererManager.audioRender?.start();
}
/** 暂停播放*/
static async pause() {
await AudioRendererManager.audioRender?.pause()
}
/** 结束播放 */
static async stop() {
await AudioRendererManager.audioRender?.stop()
}
/** 释放资源 */
static async release() {
await AudioRendererManager.audioRender?.release()
}
}
export default AudioRendererManager
点击声音消息,播放声音
声明播放录音的函数
// 播放聊天记录中的录音
startPlayRecord = async (fileName: string) => {
// console.log("fileName",fileName)
// 1 播放录音的实例的初始化
await AudioRendererManager.init(fileName)
// 2 播放录音
AudioRendererManager.start()
}
给声音消息注册点击事件
事件触发了调用播放语音的方式 startPlayRecord
实时语音转文本
Core Speech Kit(基础语音服务)集成了语音类基础AI能力,包括文本转语音(TextToSpeech)及语音识别(SpeechRecognizer)能力,便于用户与设备进行互动,实现将实时输入的语音与文本之间相互转换
实时语音识别将一段音频信息(短语音模式不超过60s,长语音模式不超过8h)转换为文本。
封装语音识别类
根据以上步骤,我们可以将语音识别拆分成核心功能:
- 创建语音识别引擎
createEngine
- 设置监听的回调
setListener
- 开始监听
startListening
- 取消识别
cancel
- 结束识别
finish
- 释放资源
shutDown
其中针对实际业务,利用上述功能额外组合封装了两个方法
- 停止并且释放资源
release
- 一键开启识别
init
\entry\src\main\ets\utils\SpeechRecognizerManager.ets
import { speechRecognizer } from '@kit.CoreSpeechKit';
import { fileIo } from '@kit.CoreFileKit';
class SpeechRecognizerManager {
/**
* 语种信息
* 语音模式:长
*/
private static extraParam: Record<string, Object> = { "locate": "CN", "recognizerMode": "long" };
private static initParamsInfo: speechRecognizer.CreateEngineParams = {
/**
* 地区信息
* */
language: 'zh-CN',
/**
* 离线模式:1
*/
online: 1,
extraParams: this.extraParam
};
/**
* 引擎
*/
private static asrEngine: speechRecognizer.SpeechRecognitionEngine | null = null
/**
* 录音结果
*/
static speechResult: speechRecognizer.SpeechRecognitionResult | null = null
/**
* 会话ID
*/
private static sessionId: string = "asr" + Date.now()
/**
* 创建引擎
*/
private static async createEngine() {
// 设置创建引擎参数
SpeechRecognizerManager.asrEngine = await speechRecognizer.createEngine(SpeechRecognizerManager.initParamsInfo)
}
/**
* 设置回调
*/
private static setListener(callback: (srr: speechRecognizer.SpeechRecognitionResult) => void = () => {
}) {
// 创建回调对象
let setListener: speechRecognizer.RecognitionListener = {
// 开始识别成功回调
onStart(sessionId: string, eventMessage: string) {
},
// 事件回调
onEvent(sessionId: string, eventCode: number, eventMessage: string) {
},
// 识别结果回调,包括中间结果和最终结果
onResult(sessionId: string, result: speechRecognizer.SpeechRecognitionResult) {
SpeechRecognizerManager.speechResult = result
callback && callback(result)
},
// 识别完成回调
onComplete(sessionId: string, eventMessage: string) {
},
// 错误回调,错误码通过本方法返回
// 如:返回错误码1002200006,识别引擎正忙,引擎正在识别中
// 更多错误码请参考错误码参考
onError(sessionId: string, errorCode: number, errorMessage: string) {
},
}
// 设置回调
SpeechRecognizerManager.asrEngine?.setListener(setListener);
}
/**
* 开始监听
* */
static startListening() {
// 设置开始识别的相关参数
let recognizerParams: speechRecognizer.StartParams = {
// 会话id
sessionId: SpeechRecognizerManager.sessionId,
// 音频配置信息。
audioInfo: {
// 音频类型。 当前仅支持“pcm”
audioType: 'pcm',
// 音频的采样率。 当前仅支持16000采样率
sampleRate: 16000,
// 音频返回的通道数信息。 当前仅支持通道1。
soundChannel: 1,
// 音频返回的采样位数。 当前仅支持16位
sampleBit: 16
},
// 录音识别
extraParams: {
// 0 实时录音识别
"recognitionMode": 0,
// 最大支持音频时长
maxAudioDuration: 60000
}
}
// 调用开始识别方法
SpeechRecognizerManager.asrEngine?.startListening(recognizerParams);
};
/**
* 取消识别
*/
static cancel() {
SpeechRecognizerManager.asrEngine?.cancel(SpeechRecognizerManager.sessionId)
}
/**
* 结束识别
*/
static finish() {
SpeechRecognizerManager.asrEngine?.finish(SpeechRecognizerManager.sessionId)
}
/**
* 释放ai语音转文字引擎
*/
static shutDown() {
SpeechRecognizerManager.asrEngine?.shutdown()
}
/**
* 停止并且释放资源
*/
static async release() {
SpeechRecognizerManager.cancel()
SpeechRecognizerManager.shutDown()
}
/**
* 初始化ai语音转文字引擎 实现一键开启语音识别
*/
static async init(callback: (srr: speechRecognizer.SpeechRecognitionResult) => void = () => {
}) {
await SpeechRecognizerManager.createEngine()
SpeechRecognizerManager.setListener(callback)
SpeechRecognizerManager.startListening()
}
}
export default SpeechRecognizerManager
语音识别业务流程
从上可以看到,我们要做的流程是:
- 在开始 按住说话 时,也直接开启实时语音识别
- 当手指移向 文 时,显示实时识别的文字
- 如果这个时候松开手,那么发送的是文字而不是语音
”按住说话“ 语音识别
声明语音识别的文字状态
// 语音识别的文字
@State
voiceToText: string = ""
声明语音识别函数
// 开启ai实时转换声音
onStartSpeechRecognize = () => {
// 如果你是完整的一句话,我把它拼接到 this.voiceToText 如果不是,实时显示
// 缓存一段句子的变量
let sentence = ""
SpeechRecognizerManager.init((res) => {
// console.log("res", JSON.stringify(res))
// isFinal 这一句话 你结束了没有
// isLast 这一段语音你结束了没有
// this.voiceToText = res.result
if (res.isFinal) {
sentence += res.result
this.voiceToText = sentence
} else {
this.voiceToText = sentence + res.result
}
})
}
设置转换的文字显示在绿色容器内
在talkContainerBuilder内进行修改
按住说话 开始语音识别监听
松开手 结束语音识别
松开手 发送文字消息
本来是发送录音消息的,但是由于用户进行了语音转文字,所以此时直接松开手,便将语音转成的文字发送成文字消息
定义发送文字消息的方法
// 生成文字消息
postText = () => {
// 生成消息文件
const TextChat = new ChatMessage(MessageType.text, this.voiceToText)
// 插入到消息数组中
this.chatList.push(TextChat)
}
松开手 发送文字消息
松开手 取消发送
松开手时,一共有三种状态
- 直接发送语音
- 语音识别,发送文字
- 取消发送
现在来实现最后的 松开手,取消发送功能
总结
至此,该案例的功能已经全部完成
- 页面沉浸式
- 聊天内容滚动
- 输入框状态切换
- 聊天信息框宽度自适应
- 输入法避让
- canvas声纹 按住说话
- 发送文字
- 录音-发送语音
- 语音消息根据时长自动宽度
- 手势坐标检测取消发送-语音转文字
- 声音播放-语音消息
- AI 语音转文字
致自己,夜幕低垂,万籁俱寂,愿这份灵感之光伴随入梦,在未来的日子里,继续照亮探索之旅。感谢陪伴
源码地址
https://gitee.com/ukSir/hmchat2
参考链接
作者
作者:万少
來源:坚果派 著作权归作者所有。
商业转载请联系作者获得授权,非商业转载请注明出处。
- 0回答
- 0粉丝
- 0关注
- 带你实现 OpenHarmony 微信聊天 01
- 手把手带你实现 鸿蒙应用-键盘音乐
- 三文带你轻松上手鸿蒙的AI语音02-声音文件转文本
- OpenHarmony 实战卡片开发 02
- OpenHarmony 动画大全02-显式动画
- 三文带你轻松上手鸿蒙的AI语音01-实时语音识别
- 三文带你轻松上手鸿蒙的AI语音03-文本合成声音
- 鸿蒙Flutter实战:02-Windows环境搭建踩坑指南
- 鸿蒙原生开发手记:02-服务卡片开发
- HarmonyOS应用开发实战:半天实现知乎日报项目(六、首页轮播图的完整实现)
- 如何实现应用的屏幕自动旋转
- HarmonyOS应用开发实战:半天实现知乎日报项目(三、ArkUI页面底部导航TabBar的实现)
- HarmonyOS应用开发实战:半天实现知乎日报项目(八、知乎日报详情页的实现)
- HarmonyOS应用开发实战:半天实现知乎日报项目(四、仿知乎日报的首页轮播图实现)
- HarmonyOS应用开发实战:半天实现知乎日报项目(七、知乎日报List列表下拉刷新及上滑加载更多分页的实现)