【鸿蒙实战开发】HarmonyOS音频播放
音频播放开发概述如何选择音频播放开发方式
系统提供了多样化的API,来帮助开发者完成音频播放的开发,不同的API适用于不同音频数据格式、音频资源来源、音频利用场景,甚至是不同开发语言。因此,选择符合的音频播放API,有助于降低开发工作量,实现更佳的音频播放效果。
[*] AudioRenderer:用于音频输出的ArkTS/JS API,仅支持PCM格式,需要应用持续写入音频数据进行工作。应用可以在输入前添加数据预处置处罚,如设定音频文件的采样率、位宽等,要求开发者具备音频处置处罚的基础知识,适用于更专业、更多样化的媒体播放应用开发。
[*] AudioHaptic:用于音振协同播放的ArkTS/JS API,适用于需要在播放音频时同步发起振动的场景,如来电铃声随振、键盘按键反馈、消息通知反馈等。
[*] OpenSL ES:一套跨平台尺度化的音频Native API,同样提供音频输出本领,仅支持PCM格式,适用于从其他嵌入式平台移植,或依赖在Native层实现音频输出功能的播放应用利用。
[*] OHAudio:用于音频输出的Native API,此API在设计上实现归一,同时支持普通音频通路和低时延通路。仅支持PCM格式,适用于依赖Native层实现音频输出功能的场景。
除上述方式外,也可以通过Media Kit实现音频播放。
[*] AVPlayer:用于音频播放的ArkTS/JS API,集成了流媒体和本地资源剖析、媒体资源解封装、音频解码和音频输出功能。可用于直接播放mp3、m4a等格式的音频文件,不支持直接播放PCM格式文件。
[*] SoundPool:低时延的短音播放ArkTS/JS API,适用于播放急促简短的音效,如相机快门音效、按键音效、游戏射击音效等。
开发音频播放应用须知
应用如果要实现后台播放或熄屏播放,需要同时满足:
[*] 利用媒领会话功能注册到系统内同一管理,否则在应用进入后台时,播放将被强制停止。具体参考AVSession Kit开发指导。
[*] 申请长时任务制止进入挂起(Suspend)状态。具体参考长时任务开发指导。
当应用进入后台,播放被停止,如果被媒领会话管控,将打印日记“pause id”;如果没有该日记,则阐明被长时任务管控。
利用AudioRenderer开发音频播放功能
AudioRenderer是音频渲染器,用于播放PCM(Pulse Code Modulation)音频数据,相比AVPlayer而言,可以在输入前添加数据预处置处罚,更适合有音频开发履历的开发者,以实现更灵活的播放功能。
开发指导
利用AudioRenderer播放音频涉及到AudioRenderer实例的创建、音频渲染参数的配置、渲染的开始与停止、资源的释放等。本开发指导将以一次渲染音频数据的过程为例,向开发者讲解如何利用AudioRenderer进行音频渲染,发起搭配AudioRenderer的API阐明阅读。
下图展示了AudioRenderer的状态变化,在创建实例后,调用对应的方法可以进入指定的状态实现对应的举动。需要注意的是在确定的状态实行不符合的方法可能导致AudioRenderer发生错误,发起开发者在调用状态转换的方法前进行状态检查,制止程序运行产生预期以外的效果。
为包管UI线程不被阻塞,大部分AudioRenderer调用都是异步的。对于每个API均提供了callback函数和Promise函数,以下示例均采用callback函数。
图1 AudioRenderer状态变化表示图
https://img-blog.csdnimg.cn/img_convert/76f126ee5963b9cf17c23916857c1b10.webp?x-oss-process=image/format,png
在进行应用开发的过程中,发起开发者通过on(‘stateChange’)方法订阅AudioRenderer的状态变更。因为针对AudioRenderer的某些操纵,仅在音频播放器在固定状态时才气实行。如果应用在音频播放器处于错误状态时实行操纵,系统可能会抛出异常或生成其他未定义的举动。
[*] prepared状态: 通过调用createAudioRenderer()方法进入到该状态。
[*] running状态: 正在进行音频数据播放,可以在prepared状态通过调用start()方法进入此状态,也可以在paused状态和stopped状态通过调用start()方法进入此状态。
[*] paused状态: 在running状态可以通过调用pause()方法停息音频数据的播放并进入paused状态,停息播放之后可以通过调用start()方法继续音频数据播放。
[*] stopped状态: 在paused/running状态可以通过stop()方法停止音频数据的播放。
[*] released状态: 在prepared、paused、stopped等状态,用户均可通过release()方法释放掉全部占用的硬件和软件资源,并且不会再进入到其他的任何一种状态了。
开发步骤及注意事项
[*]配置音频渲染参数并创建AudioRenderer实例,音频渲染参数的具体信息可以检察AudioRendererOptions。
import { audio } from '@kit.AudioKit';
let audioStreamInfo: audio.AudioStreamInfo = {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_48000, // 采样率
channels: audio.AudioChannel.CHANNEL_2, // 通道
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, // 采样格式
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW // 编码格式
};
let audioRendererInfo: audio.AudioRendererInfo = {
usage: audio.StreamUsage.STREAM_USAGE_VOICE_COMMUNICATION,
rendererFlags: 0
};
let audioRendererOptions: audio.AudioRendererOptions = {
streamInfo: audioStreamInfo,
rendererInfo: audioRendererInfo
};
audio.createAudioRenderer(audioRendererOptions, (err, data) => {
if (err) {
console.error(`Invoke createAudioRenderer failed, code is ${err.code}, message is ${err.message}`);
return;
} else {
console.info('Invoke createAudioRenderer succeeded.');
let audioRenderer = data;
}
});
[*]调用on(‘writeData’)方法,订阅监听音频数据写入回调。
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo } from '@kit.CoreFileKit';
let bufferSize: number = 0;
class Options {
offset?: number;
length?: number;
}
let path = getContext().cacheDir;
//确保该路径下存在该资源
let filePath = path + '/StarWars10s-2C-48000-4SW.wav';
let file: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
let writeDataCallback = (buffer: ArrayBuffer) => {
let options: Options = {
offset: bufferSize,
length: buffer.byteLength
}
fileIo.readSync(file.fd, buffer, options);
bufferSize += buffer.byteLength;
}
audioRenderer.on('writeData', writeDataCallback);
[*]调用start()方法进入running状态,开始渲染音频。
import { BusinessError } from '@kit.BasicServicesKit';
audioRenderer.start((err: BusinessError) => {
if (err) {
console.error(`Renderer start failed, code is ${err.code}, message is ${err.message}`);
} else {
console.info('Renderer start success.');
}
});
[*]调用stop()方法停止渲染。
import { BusinessError } from '@kit.BasicServicesKit';
audioRenderer.stop((err: BusinessError) => {
if (err) {
console.error(`Renderer stop failed, code is ${err.code}, message is ${err.message}`);
} else {
console.info('Renderer stopped.');
}
});
[*]调用release()方法烧毁实例,释放资源。
import { BusinessError } from '@kit.BasicServicesKit';
audioRenderer.release((err: BusinessError) => {
if (err) {
console.error(`Renderer release failed, code is ${err.code}, message is ${err.message}`);
} else {
console.info('Renderer released.');
}
});
选择精确的StreamUsage
创建播放器时候,开发者需要根据应用场景指定播放器的StreamUsage,选择精确的StreamUsage可以制止用户碰到不符合预期的举动。
在音频API文档StreamUsage介绍中,列举了每一种类型推荐的应用场景。比方音乐场景推荐利用STREAM_USAGE_MUSIC,电影或者视频场景推荐利用STREAM_USAGE_MOVIE,游戏场景推荐利用STREAM_USAGE_GAME,等等。
如果开发者配置了不精确的StreamUsage,可能带来一些不符合预期的举动。比方以下场景。
[*]游戏场景错误利用STREAM_USAGE_MUSIC类型,游戏应用将无法和其他音乐应用并发播放,而游戏场景通常可以与其他音乐应用并发播放。
[*]导航场景错误利用STREAM_USAGE_MUSIC类型,导航应用播报时候会导致正在播放的音乐停止播放,而导航场景我们通常期望正在播放的音乐仅仅降低音量播放。
完整示例
下面展示了利用AudioRenderer渲染音频文件的示例代码。
import { audio } from '@kit.AudioKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo } from '@kit.CoreFileKit';
const TAG = 'AudioRendererDemo';
class Options {
offset?: number;
length?: number;
}
let context = getContext(this);
let bufferSize: number = 0;
let renderModel: audio.AudioRenderer | undefined = undefined;
let audioStreamInfo: audio.AudioStreamInfo = {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_48000, // 采样率
channels: audio.AudioChannel.CHANNEL_2, // 通道
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().cacheDir;
//确保该路径下存在该资源
let filePath = path + '/StarWars10s-2C-48000-4SW.wav';
let file: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
let writeDataCallback = (buffer: ArrayBuffer) => {
let options: Options = {
offset: bufferSize,
length: buffer.byteLength
}
fileIo.readSync(file.fd, buffer, options);
bufferSize += buffer.byteLength;
}
// 初始化,创建实例,设置监听事件
function init() {
audio.createAudioRenderer(audioRendererOptions, (err, renderer) => { // 创建AudioRenderer实例
if (!err) {
console.info(`${TAG}: creating AudioRenderer success`);
renderModel = renderer;
if (renderModel !== undefined) {
(renderModel as audio.AudioRenderer).on('writeData', writeDataCallback);
}
} else {
console.info(`${TAG}: creating AudioRenderer failed, error: ${err.message}`);
}
});
}
// 开始一次音频渲染
function start() {
if (renderModel !== undefined) {
let stateGroup = ;
if (stateGroup.indexOf((renderModel as audio.AudioRenderer).state.valueOf()) === -1) { // 当且仅当状态为prepared、paused和stopped之一时才能启动渲染
console.error(TAG + 'start failed');
return;
}
// 启动渲染
(renderModel as audio.AudioRenderer).start((err: BusinessError) => {
if (err) {
console.error('Renderer start failed.');
} else {
console.info('Renderer start success.');
}
});
}
}
// 暂停渲染
function pause() {
if (renderModel !== undefined) {
// 只有渲染器状态为running的时候才能暂停
if ((renderModel as audio.AudioRenderer).state.valueOf() !== audio.AudioState.STATE_RUNNING) {
console.info('Renderer is not running');
return;
}
// 暂停渲染
(renderModel as audio.AudioRenderer).pause((err: BusinessError) => {
if (err) {
console.error('Renderer pause failed.');
} else {
console.info('Renderer pause success.');
}
});
}
}
// 停止渲染
async function stop() {
if (renderModel !== undefined) {
// 只有渲染器状态为running或paused的时候才可以停止
if ((renderModel as audio.AudioRenderer).state.valueOf() !== audio.AudioState.STATE_RUNNING && (renderModel as audio.AudioRenderer).state.valueOf() !== audio.AudioState.STATE_PAUSED) {
console.info('Renderer is not running or paused.');
return;
}
// 停止渲染
(renderModel as audio.AudioRenderer).stop((err: BusinessError) => {
if (err) {
console.error('Renderer stop failed.');
} else {
fileIo.close(file);
console.info('Renderer stop success.');
}
});
}
}
// 销毁实例,释放资源
async function release() {
if (renderModel !== undefined) {
// 渲染器状态不是released状态,才能release
if (renderModel.state.valueOf() === audio.AudioState.STATE_RELEASED) {
console.info('Renderer already released');
return;
}
// 释放资源
(renderModel as audio.AudioRenderer).release((err: BusinessError) => {
if (err) {
console.error('Renderer release failed.');
} else {
console.info('Renderer release success.');
}
});
}
}
当同优先级或高优先级音频流要利用输出设备时,当前音频流会被停止,应用可以自行相应停止事件并做出处置处罚。具体的音频并发处置处罚方式可参考处置处罚音频焦点事件
利用OHAudio开发音频播放功能(C/C++)
OHAudio是系统在API version 10中引入的一套C API,此API在设计上实现归一,同时支持普通音频通路和低时延通路。仅支持PCM格式,适用于依赖Native层实现音频输出功能的场景。
利用入门
开发者要利用OHAudio提供的播放或者录制本领,需要添加对应的头文件。
在 CMake 脚本中链接动态库
target_link_libraries(sample PUBLIC libohaudio.so)
添加头文件
开发者通过引入<native_audiostreambuilder.h>头文件,利用音频播放相干API。
#include <ohaudio/native_audiorenderer.h>
#include <ohaudio/native_audiostreambuilder.h>
音频流构造器
OHAudio提供OH_AudioStreamBuilder接口,遵循构造器设计模式,用于构建音频流。开发者需要根据业务场景,指定对应的OH_AudioStream_Type。
OH_AudioStream_Type包含两种类型:
[*]AUDIOSTREAM_TYPE_RENDERER
[*]AUDIOSTREAM_TYPE_CAPTURER
利用OH_AudioStreamBuilder_Create创建构造器示例:
OH_AudioStreamBuilder* builder;
OH_AudioStreamBuilder_Create(&builder, streamType);
在音频业务结束之后,开发者应该实行OH_AudioStreamBuilder_Destroy接口来烧毁构造器。
OH_AudioStreamBuilder_Destroy(builder);
开发步骤及注意事项
具体的API阐明请参考OHAudio API参考。
开发者可以通过以下几个步骤来实现一个简单的播放功能。
[*]创建构造器
OH_AudioStreamBuilder* builder;
OH_AudioStreamBuilder_Create(&builder, AUDIOSTREAM_TYPE_RENDERER);
[*] 配置音频流参数
创建音频播放构造器后,可以设置音频流所需要的参数,可以参考下面的案例。
// 设置音频采样率
OH_AudioStreamBuilder_SetSamplingRate(builder, 48000);
// 设置音频声道
OH_AudioStreamBuilder_SetChannelCount(builder, 2);
// 设置音频采样格式
OH_AudioStreamBuilder_SetSampleFormat(builder, AUDIOSTREAM_SAMPLE_S16LE);
// 设置音频流的编码类型
OH_AudioStreamBuilder_SetEncodingType(builder, AUDIOSTREAM_ENCODING_TYPE_RAW);
// 设置输出音频流的工作场景
OH_AudioStreamBuilder_SetRendererInfo(builder, AUDIOSTREAM_USAGE_MUSIC);
注意,播放的音频数据要通过回调接口写入,开发者要实现回调接口,使用OH_AudioStreamBuilder_SetRendererCallback设置回调函数。回调函数的声明请查看(https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V5/_o_h_audio-V5#oh_audiorenderer_callbacks) 。
[*] 设置音频回调函数
多音频并发处置处罚可参考文档处置处罚音频焦点事件,仅接口语言差异。
// 自定义写入数据函数
int32_t MyOnWriteData(
OH_AudioRenderer* renderer,
void* userData,
void* buffer,
int32_t length)
{
// 将待播放的数据,按length长度写入buffer
return 0;
}
// 自定义音频流事件函数
int32_t MyOnStreamEvent(
OH_AudioRenderer* renderer,
void* userData,
OH_AudioStream_Event event)
{
// 根据event表示的音频流事件信息,更新播放器状态和界面
return 0;
}
// 自定义音频中断事件函数
int32_t MyOnInterruptEvent(
OH_AudioRenderer* renderer,
void* userData,
OH_AudioInterrupt_ForceType type,
OH_AudioInterrupt_Hint hint)
{
// 根据type和hint表示的音频中断信息,更新播放器状态和界面
return 0;
}
// 自定义异常回调函数
int32_t MyOnError(
OH_AudioRenderer* renderer,
void* userData,
OH_AudioStream_Result error)
{
// 根据error表示的音频异常信息,做出相应的处理
return 0;
}
OH_AudioRenderer_Callbacks callbacks;
// 配置回调函数
callbacks.OH_AudioRenderer_OnWriteData = MyOnWriteData;
callbacks.OH_AudioRenderer_OnStreamEvent = MyOnStreamEvent;
callbacks.OH_AudioRenderer_OnInterruptEvent = MyOnInterruptEvent;
callbacks.OH_AudioRenderer_OnError = MyOnError;
//设置输出音频流的回调
OH_AudioStreamBuilder_SetRendererCallback(builder, callbacks, nullptr);
为了避免不可预期的行为,在设置音频回调函数时,请确保OH_AudioRenderer_Callbacks的每一个回调都被**自定义的回调方法**或**空指针**初始化。
// 自定义写入数据函数
int32_t MyOnWriteData(
OH_AudioRenderer* renderer,
void* userData,
void* buffer,
int32_t length)
{
// 将待播放的数据,按length长度写入buffer
return 0;
}
// 自定义音频中断事件函数
int32_t MyOnInterruptEvent(
OH_AudioRenderer* renderer,
void* userData,
OH_AudioInterrupt_ForceType type,
OH_AudioInterrupt_Hint hint)
{
// 根据type和hint表示的音频中断信息,更新播放器状态和界面
return 0;
}
OH_AudioRenderer_Callbacks callbacks;
// 配置回调函数,如果需要监听,则赋值
callbacks.OH_AudioRenderer_OnWriteData = MyOnWriteData;
callbacks.OH_AudioRenderer_OnInterruptEvent = MyOnInterruptEvent;
// (必选)如果不需要监听,使用空指针初始化
callbacks.OH_AudioRenderer_OnStreamEvent = nullptr;
callbacks.OH_AudioRenderer_OnError = nullptr;
[*]构造播放音频流
OH_AudioRenderer* audioRenderer;
OH_AudioStreamBuilder_GenerateRenderer(builder, &audioRenderer);
[*] 利用音频流
音频流包含下面接口,用来实现对音频流的控制。
接口阐明OH_AudioStream_Result OH_AudioRenderer_Start(OH_AudioRenderer* renderer)开始播放OH_AudioStream_Result OH_AudioRenderer_Pause(OH_AudioRenderer* renderer)停息播放OH_AudioStream_Result OH_AudioRenderer_Stop(OH_AudioRenderer* renderer)停止播放OH_AudioStream_Result OH_AudioRenderer_Flush(OH_AudioRenderer* renderer)释放缓存数据OH_AudioStream_Result OH_AudioRenderer_Release(OH_AudioRenderer* renderer)释放播放实例
[*] 释放构造器
构造器不再利用时,需要释放相干资源。
OH_AudioStreamBuilder_Destroy(builder);
设置低时延模式
当设备支持低时延通路时,开发者可以利用低时延模式创建播放器,获得更高质量的音频体验。
开发流程与普通播放场景同等,仅需要在创建音频流构造器时,调用OH_AudioStreamBuilder_SetLatencyMode()设置低时延模式。
开发示例
OH_AudioStreamBuilder_SetChannelLayout(builder, CH_LAYOUT_STEREO);
设置音频声道布局
播放音频文件时,可以通过设置音频的声道布局信息,指定渲染或播放时的扬声器摆位,使得渲染和播放效果更佳,获得更高质量的音频体验。
开发流程与普通播放场景同等,仅需要在创建音频流构造器时,调用OH_AudioStreamBuilder_SetChannelLayout()设置声道布局信息。
当声道布局与声道数不匹配时,创建音频流会失败。发起在设置声道布局时,确认下发的声道布局信息是精确的。
如果不知道准确的声道布局信息,或者开发者需要利用默认声道布局,可以不调用设置声道布局接口,或者下发CH_LAYOUT_UNKNOWN,以利用基于声道数的默认声道布局。
对于HOA格式的音频,想要获得精确的渲染和播放效果,必须指定声道布局信息。
开发示例
OH_AudioStreamBuilder_SetChannelLayout(builder, CH_LAYOUT_STEREO);
播放AudioVivid格式音源
播放AudioVivid格式音频文件时,需要利用与普通播放不同的数据写入回调函数,该回调可以同时写入PCM数据与元数据。
开发流程与普通播放场景同等,仅需要在创建音频流构造器时,调用OH_AudioStreamBuilder_SetWriteDataWithMetadataCallback()设置PCM数据与元数据同时写入的回调函数,同时调用OH_AudioStreamBuilder_SetEncodingType()设置编码类型为AUDIOSTREAM_ENCODING_TYPE_AUDIOVIVID。
在播放AudioVivid时,帧长是固定的,不可通过OH_AudioStreamBuilder_SetFrameSizeInCallback()设置回调帧长。同时,在设置播放声道数和声道布局时,需要将写入音源的声床数和对象数相加后进行设置。
开发示例
// 自定义同时写入PCM数据和元数据函数
int32_t MyOnWriteDataWithMetadata(
OH_AudioRenderer* renderer,
void* userData,
void* audioData,
int32_t audioDataSize,
void* metadata,
int32_t metadataSize)
{
// 将待播放的PCM数据和元数据,分别按audioDataSize和metadataSize写入buffer
return 0;
}
// 设置编码类型
OH_AudioStreamBuilder_SetEncodingType(builder, AUDIOSTREAM_ENCODING_TYPE_AUDIOVIVID);
// 配置回调函数
OH_AudioRenderer_WriteDataWithMetadataCallback metadataCallback = MyOnWriteDataWithMetadata;
// 设置同时写入PCM数据和元数据的回调
OH_AudioStreamBuilder_SetWriteDataWithMetadataCallback(builder, metadataCallback, nullptr);
写在末了
有很多小同伴不知道学习哪些鸿蒙开发技能?不知道需要重点掌握哪些鸿蒙应用开发知识点?而且学习时频仍踩坑,终极浪费大量时间。所以有一份实用的鸿蒙(HarmonyOS NEXT)文档用来跟着学习黑白常有须要的。
盼望这一份鸿蒙学习文档能够给大家带来帮助,有需要的小同伴自行领取,限时开源,先到先得~无套路领取!!
如果你是一名有履历的资深Android移动开发、Java开发、前端开发、对鸿蒙感兴趣以及转行职员,可以直接领取这份资料
请点击→纯血版全套鸿蒙HarmonyOS学习文档
鸿蒙(HarmonyOS NEXT)5.0最新学习路线
https://i-blog.csdnimg.cn/direct/40a4d3fcacc24584a5751a12d902d3f2.png#pic_center
路线图适合人群:
IT开发职员:想要拓展职业边界
零基础小白:鸿蒙爱好者,盼望从0到1学习,增长一项技能。
技能提拔/进阶跳槽:发展瓶颈期,提拔职场竞争力,快速掌握鸿蒙技能
获取以上完整版高清学习路线,请点击→纯血版全套鸿蒙HarmonyOS学习文档
《鸿蒙 (HarmonyOS)开发入门教学视频》
https://i-blog.csdnimg.cn/direct/e0f05cea48414fd5a834e46420b8fa2d.png
《鸿蒙生态应用开发V3.0白皮书》
https://i-blog.csdnimg.cn/direct/34d01bd9badd4a768a5fc829c1ac47fd.png
《鸿蒙 (OpenHarmony)开发基础到实战手册》
OpenHarmony北向、南向开发环境搭建
https://i-blog.csdnimg.cn/direct/9d7d0ae6b4a14593bda423fd133cc9fd.png
《鸿蒙开发基础》
●ArkTS语言
●安装DevEco Studio
●运用你的第一个ArkTS应用
●ArkUI声明式UI开发
.……
https://i-blog.csdnimg.cn/direct/488ff5552f2045279af13b9862aebf32.png
《鸿蒙开发进阶》
●Stage模子入门
●网络管理
●数据管理
●电话服务
●分布式应用开发
●通知与窗口管理
●多媒体技能
●安全技能
●任务管理
●WebGL
●国际化开发
●应用测试
●DFX面向未来设计
●鸿蒙系统移植和裁剪定制
……
https://i-blog.csdnimg.cn/direct/8821b6e4982f423aab8e7fe90ad04a8e.png
《鸿蒙进阶实战》
●ArkTS实践
●UIAbility应用
●网络案例
……
https://i-blog.csdnimg.cn/direct/a59ccf893979405f8a86790f8b10a51f.png
获取以上完整鸿蒙HarmonyOS学习文档,请点击→纯血版全套鸿蒙HarmonyOS学习文档
https://i-blog.csdnimg.cn/direct/ce33dc3461054645ae1dd5ebf26f6f93.jpeg#pic_center
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页:
[1]