news 2026/10/27 1:39:41

【口算王|10】HarmonyOS ArkTS 语音报题实战:协调朗读、作答和页面生命周期

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【口算王|10】HarmonyOS ArkTS 语音报题实战:协调朗读、作答和页面生命周期

【口算王|10】HarmonyOS ArkTS 语音报题实战:协调朗读、作答和页面生命周期

证据边界:本文的“当前实现”来自本轮对本地口算王工程中PracticePage.ets、MathModels.ets、题库数据与模块配置的静态复核。可以确认的是 TTS 引擎创建、文字回退弹窗、播放状态和生命周期清理代码;文中并发令牌、统一状态机、真机音频质量与性能数据属于建议或待验证内容。本轮未执行构建、安装、真机朗读、网络回退或音频测量。

给练习页加一个“点击播放”入口并不难,难的是让语音状态与题目状态保持一致。用户可能在引擎初始化期间关闭弹窗,可能连续点击两道题,也可能在朗读尚未结束时退出页面。如果代码只调用一次speak(),没有处理停止、完成、错误和页面销毁,上一题的声音就可能在下一题继续播放,甚至页面退出后仍占用语音引擎。

另一个常见误解是把文字转语音、语音识别和语音作答混为一谈。文字转语音只负责把题目文本朗读出来,不会监听麦克风,也不会识别用户答案。若应用没有申请麦克风权限、没有语音识别服务和答案映射,就不能把功能描述成“语音答题”。

本文基于口算王项目本地口算王工程(脱敏路径)的真实源码,复核PracticePage.ets、MathModels.ets、ThemeConstants.ets、module.json5与题库数据。包名com.jiaweikang.one16是本文草稿核验使用的唯一标记。当前代码保留了旧audio题型的点击朗读能力:优先尝试离线 TTS,失败后尝试在线引擎,并始终显示文字提示弹窗作为降级路径;当前默认题库没有type === 'audio'或audioHint数据,所以正常口算流程不会显示该入口。本文讨论的是这条可复核的兼容链路及其工程完善方法,不把它写成默认题库已经启用的语音功能。

本文重点解决五个问题:

  • 明确当前语音入口的真实触发条件和能力边界;
  • 拆解离线优先、在线回退、文字兜底的初始化流程;
  • 协调朗读请求、弹窗、切题、作答与页面退出;
  • 用请求标识避免旧回调覆盖新题状态;
  • 建立无引擎、无网络、快速切换和生命周期测试矩阵。

一、语音入口只为旧 audio 题型渲染

练习页并不会给每一道口算题显示播放按钮。真实条件是:

if ( this.currentQ()!.type === 'audio' && this.currentQ()!.audioHint ) { // 渲染提示卡和点击播放入口 }

两个条件必须同时满足:

  1. Question.type等于audio;
  2. Question.audioHint有值。

题目模型只把audioHint声明为可选字段:

export interface Question { id: string bankId: string chapterId: string type: string stem: string audioHint?: string options: Option[] answer: string analysis: string example?: string }

对当前源码执行题库扫描时,默认 mock 题库没有发现type: 'audio',也没有实际audioHint数据。toggleAudioPreview()上方注释也明确写着“兼容旧数据的提示弹窗,当前口算题库默认不会触发”。

因此准确描述是:项目保留了旧语音提示题型的兼容实现,但默认口算题库暂未启用。不能仅凭页面里存在 TTS 代码,就宣称所有题目都支持自动报题。

二、它是文字转语音,不是语音识别

页面导入的是:

import textToSpeech from '@hms.ai.textToSpeech'

代码把文字交给语音引擎输出声音,没有创建录音器,没有读取麦克风,也没有把用户说出的内容转换成答案。module.json5中的:

"requestPermissions": []

进一步说明当前模块没有声明麦克风权限。作答仍然通过 ArkUI 选项点击完成:

private selectOption(key: string): void { if (this.mode === 'wrongAnalysis') return if (this.showAnalysis) return this.selectedKey = key this.showAnalysis = true // 记录正确或错误 }

功能边界可以整理为:

能力当前是否存在证据
点击后朗读提示文字有兼容代码ttsEngine.speak()
TTS 失败时看文字提示有showAudioDialog
自动朗读每一道普通口算题无UI 只匹配audio + audioHint
语音识别用户答案无没有 ASR 或录音链路
麦克风作答无权限列表为空
根据声纹判断儿童身份无没有采集与模型

把边界写清楚不仅是技术准确性问题,也关系到权限和隐私说明。TTS 输出与麦克风输入是两条完全不同的能力链。

三、朗读文本优先取 audioHint

页面通过一个小函数生成朗读内容:

private audioText(question: Question): string { return ( question.audioHint || question.stem ) .replace(/["“”]/g, '') .trim() }

优先使用audioHint,为空时回退到题干stem,同时去掉中英文引号并清理首尾空格。这样可以避免引擎把引号读成不自然停顿,也允许旧题目用更适合听觉表达的提示文字。

不过当前 UI 的显示条件要求audioHint存在,所以正常点击入口不会触发stem回退。这个回退属于方法级防御,不等于所有普通题干都可点击朗读。

若未来要让全部题目支持报题,可以把“是否显示入口”和“读什么文本”拆开:

private canReadQuestion( question?: Question ): boolean { return question !== undefined && this.audioText(question).length > 0 }

然后再定义题型朗读规则。例如选择题可读题干和选项,应用题只读题干,公式中的符号需转换成自然语言。不能简单把任意 ArkTS 字符串都交给 TTS,再假设可听懂。

四、引擎初始化采用离线优先、在线回退

ensureTtsEngine()先检查现有实例,避免每次点击都重新创建:

private async ensureTtsEngine(): Promise<boolean> { if (this.ttsEngine) return true try { this.ttsEngine = await textToSpeech.createEngine({ language: 'zh-CN', person: 0, online: 0 }) } catch (_) { try { this.ttsEngine = await textToSpeech.createEngine({ language: 'zh-CN', person: 0, online: 1 }) } catch (_) { this.audioStatusText = '当前设备语音引擎不可用' return false } } // 注册监听器 return true }

真实策略是:

  1. 已有引擎时直接复用;
  2. 优先创建中文离线引擎;
  3. 离线创建失败后尝试在线模式;
  4. 两次都失败时返回false并更新状态文字。

离线优先能减少网络依赖和等待,但是否成功仍取决于设备是否提供对应语言资源。在线回退也不能被理解为必然成功:当前module.json5没有声明网络权限,设备服务状态和系统能力也可能不同。源码没有执行网络能力预检,所以文章只能说“尝试在线引擎”,不能承诺离线缺失时一定可播放。

五、文字弹窗是核心降级能力

点击提示卡时,页面先更新 UI,再后台尝试 TTS:

private async toggleAudioPreview( question: Question ): Promise<void> { this.activeAudioQuestionId = question.id this.audioStatusText = '正在播放示例' this.audioDialogText = this.audioText(question) this.audioDialogStem = question.stem this.showAudioDialog = true try { const ready = await this.ensureTtsEngine() if (ready && this.ttsEngine) { if (this.ttsEngine.isBusy()) { this.ttsEngine.stop() } this.ttsEngine.speak( this.audioText(question), { requestId: `${question.id}_${Date.now()}` } ) } } catch (_) { } }

顺序很关键:弹窗不会等待引擎创建完成。即使引擎不存在、在线回退失败或speak()抛出异常,用户仍然能看到audioDialogText,不会因为语音能力不可用而失去题目提示。

这是一种“核心任务优先”的降级设计:

  • 核心任务:理解提示并继续作答;
  • 增强能力:听到语音;
  • 失败策略:增强能力失败,文字仍可用;
  • 页面结果:不阻断答题。

如果把弹窗显示放在ensureTtsEngine()成功之后,设备没有语音引擎时连文字提示也无法打开,兼容入口就会变成失败入口。

六、播放前停止旧请求

引擎已经忙碌时,代码先调用stop():

if (this.ttsEngine.isBusy()) { this.ttsEngine.stop() } this.ttsEngine.speak(text, { requestId })

这能避免两个提示同时朗读。用户重复点击“再听一次”或快速触发新内容时,旧任务先停,新任务再开始。

不过“调用 stop 后立即 speak”仍然需要关注回调顺序。旧请求可能触发onStop,新请求随后触发onStart。当前监听器不比较requestId:

onStop: (requestId: string) => { this.activeAudioQuestionId = '' this.audioStatusText = '' }

如果旧请求的onStop晚于新请求的onStart到达,就可能把新题的播放状态清空。声音仍在播放,UI 却显示未播放。

更稳的方式是保存当前请求标识:

private activeTtsRequestId: string = '' private isActiveRequest( requestId: string ): boolean { return requestId === this.activeTtsRequestId }

发起朗读时:

const requestId = `${question.id}_${Date.now()}` this.activeTtsRequestId = requestId this.ttsEngine.speak(text, { requestId })

监听器只处理当前请求:

onComplete: (requestId: string) => { if (!this.isActiveRequest(requestId)) { return } this.activeTtsRequestId = '' this.activeAudioQuestionId = '' this.audioStatusText = '' }

这不是为了增加复杂度,而是为了让异步回调不能修改已被后续操作替代的状态。

七、监听器把引擎事件映射成页面状态

当前代码注册四类事件:

this.ttsEngine.setListener({ onStart: (requestId: string) => { this.audioStatusText = '正在播放示例' }, onComplete: (requestId: string) => { this.activeAudioQuestionId = '' this.audioStatusText = '' }, onStop: (requestId: string) => { this.activeAudioQuestionId = '' this.audioStatusText = '' }, onError: ( requestId: string, errorCode: number, errorMessage: string ) => { this.activeAudioQuestionId = '' this.audioStatusText = '语音播放失败,请检查系统语音服务' } })

对应状态机可以写成:

事件活跃题目状态文字
点击当前题 ID正在播放示例
onStart当前题 ID正在播放示例
onComplete清空清空
onStop清空清空
onError清空播放失败
关闭弹窗清空清空
页面退出引擎停止并销毁页面不再接收状态

错误回调接收了errorCode和errorMessage,但当前没有记录或分级处理。发布版本不应把底层错误直接展示给儿童用户,不过开发日志可以记录错误码,便于区分引擎缺失、资源不可用、服务异常或请求参数问题。

八、关闭弹窗必须同时停止声音

文字提示弹窗提供遮罩点击、右上角“关闭”和“再听一次”。关闭动作执行:

private closeAudioDialog(): void { if (this.ttsEngine) { try { this.ttsEngine.stop() } catch (_) { } } this.showAudioDialog = false this.activeAudioQuestionId = '' this.audioStatusText = '' }

如果只把showAudioDialog设为false,声音仍会继续,用户会认为页面失控。视图状态和媒体状态必须一起收敛。

“再听一次”的源码只在引擎已存在时执行:

if (this.ttsEngine) { if (this.ttsEngine.isBusy()) { this.ttsEngine.stop() } this.ttsEngine.speak( this.audioDialogText, { requestId: `replay_${Date.now()}` } ) }

若首次初始化失败,按钮点击不会重新调用ensureTtsEngine(),也没有额外提示。更完整的实现应复用统一朗读入口,让重试能够再次初始化,并在失败时保持明确状态。

private async replayAudio(): Promise<void> { const ready = await this.ensureTtsEngine() if (!ready || !this.ttsEngine) { this.audioStatusText = '语音暂不可用,请阅读文字提示' return } // 停旧请求并发起新请求 }

九、页面退出时 stop 与 shutdown 缺一不可

aboutToDisappear()负责清理计时器和语音引擎:

aboutToDisappear(): void { if (this.timerId !== -1) { clearInterval(this.timerId) } if (this.ttsEngine) { try { this.ttsEngine.stop() this.ttsEngine.shutdown() } catch (_) { } this.ttsEngine = undefined } }

stop()终止当前朗读,shutdown()释放引擎资源,随后把引用设为undefined。下次重新进入页面时,ensureTtsEngine()会创建新实例。

只调用stop()可能留下引擎资源;只调用shutdown()而不先停止,行为更难预测。页面生命周期是媒体能力的所有权边界:页面创建或首次使用时获得引擎,页面消失时释放。

异步初始化还有一个竞态:用户点击播放后立即退出,createEngine()可能在页面消失后才完成。当前aboutToDisappear()执行时ttsEngine仍是空,随后异步结果写回页面字段。可增加页面活跃标识:

private pageActive: boolean = false aboutToAppear(): void { this.pageActive = true // 原有参数加载 } aboutToDisappear(): void { this.pageActive = false this.releaseTts() }

创建完成后检查:

const engine = await textToSpeech.createEngine(config) if (!this.pageActive) { engine.shutdown() return false } this.ttsEngine = engine

这样,页面已经退出的异步结果不会重新占用资源。

十、切题、答题和语音状态要统一

练习页不仅有“下一题”,还有答题卡跳转、自动下一题、错题解析和考试模式。当前goNext()在进入新题时会清空:

this.selectedKey = '' this.showAnalysis = false this.activeAudioQuestionId = '' this.audioStatusText = ''

答题卡点击也会清空activeAudioQuestionId。但单纯清空 UI 状态不等于停止引擎。如果未来语音弹窗不再阻塞切题,或加入自动朗读,就应把“停止当前朗读”抽成统一方法:

private stopCurrentAudio(): void { if (this.ttsEngine) { try { if (this.ttsEngine.isBusy()) { this.ttsEngine.stop() } } catch (_) { } } this.activeTtsRequestId = '' this.activeAudioQuestionId = '' this.audioStatusText = '' }

所有题目切换路径都先调用它:

  • 点击下一题;
  • 自动进入下一题;
  • 答题卡跳题;
  • 考试自动提交;
  • 返回上一页;
  • 关闭提示弹窗。

媒体状态不能散落在多个按钮里,否则新增一个导航路径就可能漏掉清理。

十一、UI 中的波形只是装饰

提示卡和弹窗使用固定高度数组绘制柱状波形:

ForEach( [3, 6, 10, 8, 5, 12, 7, 4, 9], (height: number, index: number) => { Column() .width(3) .height(height) .backgroundColor(Colors.PRIMARY) } )

这些柱形不会读取真实音频振幅,也不会随播放进度变化。它们只是视觉提示,不能描述成实时声波、音量检测或语音分析。

若要表达真实播放状态,可以使用简单、可验证的状态动画:

  • idle:静态图标;
  • preparing:加载指示;
  • speaking:循环高度动画;
  • error:错误图标和文字;
  • completed:恢复静态状态。

即使做动画,也应称为播放状态动效,而不是实时音频波形,除非确实接入了音频振幅数据。

十二、无权限不代表无合规边界

当前模块没有请求麦克风权限,这与“只输出语音、不采集用户声音”的实现一致。也没有看到录音、声纹、上传语音或语音识别链路。

但在线 TTS 回退仍需要在发布前核对:

  • 在线模式是否需要网络权限;
  • 文字是否会离开设备;
  • 使用的系统或 HMS 服务是否涉及服务声明;
  • 离线不可用、网络不可用时是否仍可完成答题;
  • 隐私政策是否与真实数据流一致。

如果无法确认在线文本处理边界,保守方案是只启用离线引擎,并保留文字弹窗。不能为了提高播放成功率而悄悄增加网络能力,再继续把应用描述成完全离线。

本文不宣称在线回退当前必然可用。源码只体现了创建参数中的online: 1尝试,实际能力仍需在目标设备和发布配置上验证。

十三、audio 题型还缺少共享标签

questionTypeLabel()目前只处理:

'add', 'sub', 'mul', 'div', 'mixed', 'word', 'speed'

没有audio分支,因此旧语音题进入练习页时,题型标签会走默认值“题目”。这不会阻止播放,但会让语义不完整。

如果决定恢复该题型,应同步补齐:

case 'audio': return '听题练习'

还要同步检查:

  • 分类目录是否需要出现该类型;
  • 搜索页是否允许筛选;
  • 题库计数是否统计它;
  • 题目图标和颜色是否有对应配置;
  • 测试数据是否真的包含audioHint;
  • 上架描述是否准确说明为 TTS 提示。

不能只在练习页增加一个分支,就认为新题型已经贯穿完整产品链。

十四、推荐的语音状态模型

当前页面用多个字符串和布尔值表达状态:

activeAudioQuestionId audioStatusText showAudioDialog audioDialogText

功能继续扩展时,可以引入明确状态:

type AudioPhase = 'idle' | 'preparing' | 'speaking' | 'stopped' | 'error' interface AudioUiState { phase: AudioPhase questionId: string requestId: string message: string }

状态转换集中处理:

private setAudioState( next: AudioUiState ): void { this.audioPhase = next.phase this.activeAudioQuestionId = next.questionId this.activeTtsRequestId = next.requestId this.audioStatusText = next.message }

这样更容易回答“按钮此刻能否点击”“旧回调能否更新 UI”“关闭弹窗后是否仍在 preparing”等问题。状态模型不是为了追求形式,而是把异步边界写成可检查规则。

十五、常见故障与排查顺序

现象优先检查修复方向
普通口算题没有播放入口题型是否为audio且有audioHint当前默认题库本就不会触发
点击后只有文字没有声音离线与在线引擎是否都失败保留文字提示并记录错误码
退出页面后仍在朗读aboutToDisappear()是否执行stop + shutdown + undefined
新题播放但 UI 显示空闲旧请求回调是否覆盖新状态比较requestId
重播按钮无反应引擎是否初始化失败重播时再次调用ensureTtsEngine()
两段语音重叠新请求前是否停止旧任务isBusy()后stop()
页面退出后引擎又创建异步初始化是否晚返回增加pageActive检查
题型标签只显示“题目”标签映射是否缺少audio补齐共享题型配置
波形不随声音变化固定高度数组是装饰不描述成实时波形
误以为可语音答题是否存在 ASR 和麦克风权限明确当前只有 TTS 输出

排查时建议记录questionId、requestId、页面是否活跃、引擎是否存在、isBusy()和最后一次回调。不要记录用户隐私文本或任何认证信息。

十六、可执行测试矩阵

真实设备测试至少覆盖:

场景操作预期
默认题库打开普通加减乘除题不出现旧 audio 提示入口
兼容题注入audio + audioHint显示提示卡
离线引擎可用点击播放朗读并显示播放状态
离线不可用、在线可用点击播放尝试在线模式
两种引擎都不可用点击播放文字弹窗仍可阅读
连续点击重复播放旧任务停止,不重叠
快速关闭播放后关闭弹窗声音立即停止
快速退出初始化期间返回不残留引擎
旧回调晚到新请求已开始不清空新请求状态
重播点击“再听一次”使用同一文本重新朗读
无语音阅读提示并选择答案答题流程完整可用

还要检查手机、平板和 2in1:

  • 弹窗底部避开系统导航区;
  • 长提示文本不会挤出按钮;
  • 窄屏内部可滚动;
  • 横屏或小窗口中关闭按钮始终可见;
  • 外接键盘焦点能到达关闭与重播操作;
  • 深浅色下文字、图标和遮罩对比度可读。

十七、发布前复核清单

面向 HarmonyOS 5.0 及以上版本发布前,应确认:

  • 当前能力准确命名为文字转语音提示,而非语音识别;
  • 默认题库没有语音题时,不在介绍中声称全题库可朗读;
  • 离线引擎优先策略经过目标设备验证;
  • 在线回退的权限、网络和隐私边界已核对;
  • TTS 失败不阻断文字阅读和选项作答;
  • 每次新朗读前停止旧任务;
  • 回调用requestId隔离旧请求;
  • 页面退出执行stop()与shutdown();
  • 异步创建结果不会在页面退出后重新挂载;
  • 关闭、切题、答题卡跳题和自动提交都能停止朗读;
  • audio题型恢复时同步补齐标签、分类和测试数据;
  • 固定柱状动效没有被描述成实时声波;
  • 无麦克风权限、无录音和无语音上传的事实与隐私材料一致;
  • 弹窗在手机、平板和 2in1 的安全区内可完整操作。

总结

语音报题不是一次speak()调用,而是一条由题目条件、文本准备、引擎创建、请求标识、回调状态、文字降级和生命周期释放共同组成的链路。口算王当前源码已经提供离线优先、在线尝试、文字弹窗、停止旧播放和页面退出销毁等基础能力,但它只服务于旧audio题型,默认题库并未触发。

继续完善时,应优先增加请求级状态隔离、页面活跃检查、统一停止入口和重试路径,再决定是否把朗读扩展到普通题型。只要声音失败时仍能理解题目、旧请求不能污染新题、页面退出后不残留资源,这项增强能力才真正服从答题主流程,而不是反过来干扰它。

十八、建议实现:用请求令牌隔离迟到回调

仅在播放前调用stop()还不能完全消除竞态。旧引擎请求可能已经进入系统服务,用户切题后,迟到的完成或失败事件仍有机会改写新题目的状态。更稳的做法是为每次朗读分配递增令牌,监听器只处理当前令牌对应的事件;题目变化、弹窗关闭和页面退出都会让旧令牌失效。

private speechToken: number = 0 private beginSpeech(question: Question): number { this.speechToken += 1 this.activeAudioQuestionId = question.id this.audioStatusText = '正在准备朗读' return this.speechToken } private acceptSpeechEvent(token: number): boolean { return token === this.speechToken } private cancelSpeech(): void { this.speechToken += 1 this.ttsEngine?.stop() this.activeAudioQuestionId = '' this.audioStatusText = '' }

令牌的职责只是判断事件是否仍属于当前操作,不替代引擎自己的requestId。页面可以继续把题目 ID 用于按钮选中态,把令牌用于异步事件归属。这样即使连续点击两道题,第一道题的迟到回调也不会把第二道题的“正在朗读”清空。上面是建议实现,当前源码尚未建立这一层令牌门禁。

十九、可访问性与失败降级应和朗读同等重要

语音题不能把“听见声音”作为唯一成功路径。当前实现先显示包含题干与朗读文本的弹窗,再尝试创建引擎,这是合理的降级顺序:设备缺少语音服务、离线音库未安装、在线能力不可用或播放失败时,用户仍能读到完整内容并继续作答。后续优化应继续保证弹窗可滚动、关闭按钮可聚焦、错误提示不会覆盖题干,且朗读按钮具有明确的可访问性描述。

真机验收至少覆盖离线引擎可用、只能在线回退、两种引擎都不可用、快速重复点击、切换题目、提交答案、关闭弹窗、按返回键和应用进入后台。每个场景都要核对声音是否停止、状态文案是否复位、旧题是否还保持高亮、返回页面后引擎是否被重复创建。若设备没有录音权限弹窗是正常的,因为这里是文字转语音而不是语音识别;如果新增语音答题,权限、隐私和交互流程必须另行设计。


CSDN-SERIES:ALL-163188689

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 1:36:14

CAPL编程:变量 - 变量的声明与初始化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 1:33:57

【机械制图与CAD实战(二十九)】弹簧的表示方法

弹簧是用途极广的零件&#xff0c;可以用于机械的运动控制、减震、夹紧、测力和储能等。 l弹簧的种类繁多&#xff0c;有螺旋弹簧、蝶形弹簧、平面蜗簧、板弹簧等。螺旋弹簧螺旋弹簧又有压缩螺旋弹簧、拉伸螺旋弹簧、扭转螺旋弹簧。1、圆柱螺旋压缩弹簧的各部分名称及尺寸关系…

作者头像 李华
网站建设 2026/10/27 1:30:37

AdGuardHome 端口冲突快速搞定:53 与 3000 默认端口一次改对

AdGuardHome 端口冲突快速搞定&#xff1a;53 与 3000 默认端口一次改对 【免费下载链接】AdGuardHome Network-wide ads & trackers blocking DNS server 项目地址: https://gitcode.com/gh_mirrors/ad/AdGuardHome 刚部署 AdGuardHome 时最常见的报错就是 address…

作者头像 李华
网站建设 2026/10/27 1:28:40

Java SE基础语法>>数据类型与变量

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华