news 2026/10/19 1:45:28

【化学实验室|10】HarmonyOS ArkTS 语音朗读服务实战:控制播放生命周期、页面返回与异常恢复

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【化学实验室|10】HarmonyOS ArkTS 语音朗读服务实战:控制播放生命周期、页面返回与异常恢复

给阅读页加朗读功能时,最容易低估的不是语音合成 API,而是异步事件。用户点击“朗读”,引擎还在初始化;紧接着又点击“停止”或切换文章,旧的延迟任务、onStart、onComplete、onStop仍可能稍后到达。如果服务只保存一个isSpeaking布尔值,页面看起来像在工作,实际却可能出现重复播放、段落跳两次、停止后重新开口或返回列表仍继续发声。

本文基于化学实验室 1.0.5的真实 ArkTS 源码,拆解ReadingSpeechService.ets、ReadingDetailPage.ets与ReadingArticle.ets。当前服务通过@kit.CoreSpeechKit创建TextToSpeechEngine,支持按文章章节播放、手动下一段、停止、状态监听和错误提示。它已经使用activeRequestId过滤部分旧完成回调,也用stopReason区分手动停止、切段与播放完成,这些都是正确方向。

源码仍有几个真实竞争窗口:ensureEngine()没有初始化中的共享任务,快速点击可能重复创建引擎;每次播放前使用 120mssetTimeout(),但定时器没有会话身份;快速从文章 A 切到文章 B 时,A 的延迟任务可能读取已经被替换成 B 的静态字段并再次调用speak()。页面退出只执行setListener(undefined),没有停止引擎,也没有全局播放器接管。创建引擎配置为online: 1,失败文案却写“当前设备不支持离线朗读”,排查信息与实际配置不一致。

更稳的设计原则是:语音朗读不是把文本交给引擎就结束,而是用会话令牌把页面、文章、段落和异步回调绑定在同一条生命周期上。按钮发出命令,控制器维护状态机,会话守卫拒绝旧任务,系统引擎只负责执行speak/stop与回调。

本文唯一校验标记:CHEM-LAB-ONE12-TTS-SESSION-LIFECYCLE-20260730

本文会完成以下工程拆解:

  • 还原当前 TTS 服务从初始化到切段的真实调用顺序。
  • 把多个布尔值整理成可验证的播放阶段与状态快照。
  • 用单例初始化任务避免并发创建引擎。
  • 用sessionId + requestId隔离旧定时器和旧回调。
  • 明确手动停止、切段、文章切换、完成和错误五类收口。
  • 让页面返回策略、按钮禁用与错误提示保持一致。
  • 增加化学文本预处理、测试替身和故障排查矩阵。

一、先还原当前服务的状态字段

现有服务使用全静态字段保存单一播放会话:

private static engine?: textToSpeech.TextToSpeechEngine private static article?: ReadingArticle private static isSpeaking: boolean = false private static currentSectionIndex: number = -1 private static speechStatus: string = '点击朗读,播放当前文章' private static listener?: ReadingSpeechStateListener private static activeRequestId: string = '' private static stopReason: string = ''

这些字段覆盖了引擎、内容、视图状态和回调隔离,但字段之间没有一个统一合法性约束。例如isSpeaking === true时,理论上必须同时满足article存在、currentSectionIndex >= 0、引擎已创建;当前代码只能依靠每个方法自觉维护。

把状态关系显式化后,才能回答“初始化中能不能再点”“停止中收到完成回调怎么办”“错误后下一段按钮是否可用”等问题。

二、一次正常朗读的真实执行链路

页面点击“朗读”后调用:

private async startSpeech(): Promise<void> { await ReadingSpeechService.startArticle(this.article) }

服务依次执行:

  1. ensureEngine()创建或复用引擎。
  2. 以switch原因停止旧播放。
  3. 保存新文章,将章节索引设为 0。
  4. 把状态改为“正在朗读”并通知页面。
  5. 延迟 120ms 调用speakCurrentSection()。
  6. 引擎完成当前段后通过onComplete推进下一段。

这条链路在单次点击下能够运行。真正的问题出现在两条链路重叠:引擎创建、停止事件、延迟任务和新请求都不是同步完成,单个isSpeaking无法标识它们属于哪一次用户操作。

三、把布尔值升级为播放阶段

可以先定义有限阶段,不急着引入大型状态机框架:

export type SpeechPhase = | 'idle' | 'preparing' | 'speaking' | 'stopping' | 'completed' | 'error' export interface SpeechSnapshot { phase: SpeechPhase articleId: string sectionIndex: number sectionCount: number sessionId: number requestId: string message: string errorCode?: number }

页面不再分别读取isSpeaking、章节索引和文字状态,而是订阅一个不可变快照。preparing时禁用重复启动,speaking时允许“停止”和“下一段”,error时显示重试入口。

四、用统一提交函数保证状态字段一起变化

状态更新集中在一个方法中:

private static snapshot: SpeechSnapshot = { phase: 'idle', articleId: '', sectionIndex: -1, sectionCount: 0, sessionId: 0, requestId: '', message: '点击朗读,播放当前文章' } private static commit(next: SpeechSnapshot): void { ReadingSpeechController.snapshot = next ReadingSpeechController.listener?.(next) }

如果 ArkTS 项目不希望每次手写完整对象,可以提供受控的updatePhase(),但不要允许页面直接修改内部字段。状态快照是控制器对 UI 的唯一输出契约。

五、引擎初始化必须共享同一个进行中任务

当前ensureEngine()只检查engine是否存在。两次快速点击都可能在引擎尚未赋值时进入createEngine()。可以保存初始化 Promise:

private static engine?: textToSpeech.TextToSpeechEngine private static engineTask?: Promise<textToSpeech.TextToSpeechEngine> private static async ensureEngine(): Promise<textToSpeech.TextToSpeechEngine> { if (ReadingSpeechController.engine) { return ReadingSpeechController.engine } if (ReadingSpeechController.engineTask) { return ReadingSpeechController.engineTask } ReadingSpeechController.engineTask = textToSpeech.createEngine({ language: 'zh-CN', person: 0, online: 1 }) try { const engine = await ReadingSpeechController.engineTask ReadingSpeechController.engine = engine ReadingSpeechController.bindEngineEvents(engine) return engine } finally { ReadingSpeechController.engineTask = undefined } }

无论多少页面命令同时到达,引擎只创建一次。失败后清空engineTask,后续重试才会创建新任务。

六、会话令牌负责取消旧意图

每次开始新文章、手动停止或页面销毁,都递增会话编号:

private static sessionSeed: number = 0 private static nextSession(): number { ReadingSpeechController.sessionSeed += 1 return ReadingSpeechController.sessionSeed } private static isCurrent(sessionId: number): boolean { return ReadingSpeechController.snapshot.sessionId === sessionId }

会话令牌不是安全凭证,只是本地递增身份。旧定时器、旧异步初始化和旧引擎事件在写状态前都要检查令牌。这样“停止”不必真的撤销已经进入 JavaScript 事件队列的回调,只要让它们失去更新权限即可。

七、启动文章时先建立会话,再等待引擎

startArticle()应在await前写入新会话:

static async startArticle(article: ReadingArticle): Promise<void> { if (article.sections.length === 0) { ReadingSpeechController.fail('文章没有可朗读段落') return } const sessionId = ReadingSpeechController.nextSession() ReadingSpeechController.commit({ phase: 'preparing', articleId: article.id, sectionIndex: 0, sectionCount: article.sections.length, sessionId, requestId: '', message: '正在准备朗读' }) try { await ReadingSpeechController.ensureEngine() if (!ReadingSpeechController.isCurrent(sessionId)) return ReadingSpeechController.article = article ReadingSpeechController.speakSection(sessionId, 0) } catch (_) { if (!ReadingSpeechController.isCurrent(sessionId)) return ReadingSpeechController.fail('朗读引擎初始化失败') } }

如果等待引擎期间用户点击停止,sessionId已变化,初始化完成后不会再启动声音。

八、120ms 延迟任务也必须携带 sessionId

原实现使用setTimeout()等待停止动作完成,但闭包里没有旧会话身份:

setTimeout(() => { ReadingSpeechService.speakCurrentSection() }, 120)

改成携带参数并保存定时器:

private static pendingTimer: number = -1 private static scheduleSection(sessionId: number, index: number): void { if (ReadingSpeechController.pendingTimer >= 0) { clearTimeout(ReadingSpeechController.pendingTimer) } ReadingSpeechController.pendingTimer = setTimeout(() => { ReadingSpeechController.pendingTimer = -1 if (!ReadingSpeechController.isCurrent(sessionId)) return ReadingSpeechController.speakSection(sessionId, index) }, 120) }

清理定时器可以减少无效任务,会话检查则是最后防线。两者都需要,不能只依赖clearTimeout()。

九、requestId 负责隔离同一会话中的段落请求

会话内还会连续朗读多个段落,因此每次speak()需要独立请求 ID:

private static requestSeed: number = 0 private static createRequestId(sessionId: number, sectionIndex: number): string { ReadingSpeechController.requestSeed += 1 return `reading_${sessionId}_${sectionIndex}_${ReadingSpeechController.requestSeed}` }

相比只使用Date.now(),递增序号不会在同一毫秒内重复。onComplete(requestId)必须与快照中的当前请求匹配,才允许推进下一段。

十、提交 speak 前再次验证文章和段落

朗读入口需要检查引擎、会话、文章和索引:

private static speakSection(sessionId: number, index: number): void { const engine = ReadingSpeechController.engine const article = ReadingSpeechController.article if (!engine || !article || !ReadingSpeechController.isCurrent(sessionId)) return const section = article.sections[index] if (!section) { ReadingSpeechController.complete(sessionId) return } const requestId = ReadingSpeechController.createRequestId(sessionId, index) const text = ReadingSpeechController.buildSpeechText(article, index) ReadingSpeechController.commit({ phase: 'speaking', articleId: article.id, sectionIndex: index, sectionCount: article.sections.length, sessionId, requestId, message: `正在朗读第 ${index + 1} 段` }) engine.speak(text, { requestId }) }

状态先进入speaking,再调用系统引擎。若speak()同步抛错,立即转入错误状态并清空当前请求。

十一、不要在每个章节重复朗读文章标题

当前buildSectionSpeechText()对每个章节都返回“文章标题 + 章节标题 + 正文”。一篇文章有三个章节,文章标题会被读三遍。可以只在首段加入文章标题:

private static buildSpeechText(article: ReadingArticle, index: number): string { const section = article.sections[index] if (!section) return '' if (index === 0) { return `${article.title}。${section.title}。${section.body}` } return `${section.title}。${section.body}` }

如果用户手动从第二段开始朗读,可以由调用参数决定是否补读文章标题,而不是把重复行为写死在文本构建器里。

十二、化学文本需要独立的朗读文本

屏幕上适合显示H₂O、CO₂↑、PV = nRT,TTS 却可能逐字符或不自然地读出符号。不要修改展示正文,可以给章节增加可选朗读文本:

export interface ReadingSection { id: string title: string body: string speechText?: string } function sectionSpeech(section: ReadingSection): string { return section.speechText ?? section.body }

例如屏幕显示“CO₂ 通入石灰水”,朗读文本可以写成“二氧化碳通入石灰水”。这种映射应由内容编辑者核对,不建议用全局字符串替换猜测所有化学式读法。

十三、onComplete 只处理当前请求

现有代码已经检查requestId,应保留并扩展为会话快照校验:

onComplete: (requestId: string, response: textToSpeech.CompleteResponse) => { const current = ReadingSpeechController.snapshot if (current.phase !== 'speaking') return if (requestId !== current.requestId) return if (!ReadingSpeechController.isCurrent(current.sessionId)) return if (response.type === 1) { ReadingSpeechController.moveToNext(current.sessionId) } }

response.type的含义应集中在适配器内,不要散落到页面。未来 SDK 事件定义变化时,只需要修改引擎适配层。

十四、onStart 没有 requestId 时更要依赖当前阶段

当前onStart只判断activeRequestId非空,然后把状态设为播放中。若旧请求的开始事件晚到,它无法直接知道属于哪个 request。控制器至少要检查当前阶段和会话是否仍等待播放:

onStart: () => { const current = ReadingSpeechController.snapshot if (current.phase !== 'preparing' && current.phase !== 'speaking') return if (current.requestId.length === 0) return ReadingSpeechController.commit({ ...current, phase: 'speaking', message: `正在朗读第 ${current.sectionIndex + 1} 段` }) }

最关键的仍是:旧会话在启动新会话时已经失效,不能再覆盖新页面状态。

十五、停止原因要使用受限类型

原实现把stopReason定义为任意字符串。可以收紧为:

type StopReason = 'manual' | 'switch_section' | 'switch_article' | 'complete' | 'page_leave' private static stopReason?: StopReason

不同原因对应不同 UI 结果:

原因最终阶段页面提示
manualidle已停止朗读
switch_sectionpreparing正在切换段落
switch_articlepreparing正在切换文章
completecompleted朗读完成
page_leaveidle不再向已离开页面通知

字符串受限后,onStop不会因为拼写错误进入错误分支。

十六、手动停止先让旧会话失效

停止命令应先递增会话,再停止引擎:

static stop(reason: StopReason = 'manual'): void { const sessionId = ReadingSpeechController.nextSession() if (ReadingSpeechController.pendingTimer >= 0) { clearTimeout(ReadingSpeechController.pendingTimer) ReadingSpeechController.pendingTimer = -1 } ReadingSpeechController.stopReason = reason try { ReadingSpeechController.engine?.stop() } catch (_) { } ReadingSpeechController.commit({ phase: reason === 'complete' ? 'completed' : 'idle', articleId: '', sectionIndex: -1, sectionCount: 0, sessionId, requestId: '', message: reason === 'complete' ? '朗读完成' : '已停止朗读' }) }

即使engine.stop()失败,旧定时器和旧回调也因 sessionId 变化而不能恢复声音状态。

十七、下一段命令要串行化

当前“下一段”按钮只在isSpeaking时启用,但连续点击仍可能连续调用playNextSection()。快照状态应在第一次点击后立刻进入preparing,按钮随即禁用:

static nextSection(): void { const current = ReadingSpeechController.snapshot if (current.phase !== 'speaking') return const nextIndex = current.sectionIndex + 1 if (nextIndex >= current.sectionCount) { ReadingSpeechController.stop('complete') return } ReadingSpeechController.commit({ ...current, phase: 'preparing', requestId: '', message: '正在切换下一段' }) ReadingSpeechController.stopEngineAndSchedule(current.sessionId, nextIndex) }

命令是否可执行由阶段决定,而不是由按钮视觉状态决定。即使自动化测试直接调用方法,也不会跳过两段。

十八、页面只订阅与当前文章匹配的快照

静态服务的setListener()会立即推送当前状态。如果服务正在朗读文章 A,页面 B 刚出现,B 可能错误高亮相同索引。快照包含articleId后,页面可以过滤:

private onSpeechState(snapshot: SpeechSnapshot): void { if (snapshot.articleId !== this.article.id && snapshot.phase !== 'idle') { this.speechPhase = 'idle' this.speakingSectionIndex = -1 this.speechStatus = '点击朗读,播放当前文章' return } this.speechPhase = snapshot.phase this.speakingSectionIndex = snapshot.sectionIndex this.speechStatus = snapshot.message }

这样页面不会消费其他文章的播放位置。若产品支持全局后台朗读,则应显示全局播放器,而不是让任意详情页冒充当前播放页。

十九、页面返回必须选择明确策略

现有aboutToDisappear()只解除监听,服务可能继续播放。当前应用没有全局播放条,更一致的策略是离开详情页即停止:

aboutToDisappear(): void { ReadingSpeechController.stop('page_leave') ReadingSpeechController.setListener(undefined) }

如果以后要支持后台听读,应增加通知栏或全局控制条、播放文章标题、停止入口和生命周期说明,再把页面退出策略改为“转交全局播放器”。不能只因为静态服务还活着,就把偶然的后台声音当成功能。

二十、在线配置与失败文案必须一致

源码创建引擎时使用online: 1,异常却提示“不支持离线朗读”。更准确的状态应区分初始化和运行阶段:

private static fail(message: string, errorCode?: number): void { const current = ReadingSpeechController.snapshot ReadingSpeechController.commit({ ...current, phase: 'error', requestId: '', message, errorCode }) }

建议文案:

  • 创建引擎失败:朗读引擎初始化失败,请检查设备能力与网络状态。
  • onError:朗读中断,错误码xxxx,可重新尝试。
  • 空文本:当前段落没有可朗读内容。
  • 文章无章节:文章暂不支持朗读。

错误码保留给日志和诊断区域,不要只吞掉异常。

二十一、按钮状态应直接由 phase 推导

页面无需再维护一份容易漂移的isSpeaking:

private canStart(): boolean { return this.speechPhase === 'idle' || this.speechPhase === 'completed' || this.speechPhase === 'error' } private canStop(): boolean { return this.speechPhase === 'preparing' || this.speechPhase === 'speaking' } private canNext(): boolean { return this.speechPhase === 'speaking' }

preparing阶段主按钮显示“准备中”,允许停止但不能再次启动;error阶段显示“重试朗读”;completed阶段显示“重新朗读”。状态文字单独占一行,避免错误信息被两个按钮挤压后省略。

二十二、监听器最好返回取消函数

单个setListener()适合当前单页面,但生命周期调用必须成对。可以把接口改成订阅形式:

export type SpeechUnsubscribe = () => void static subscribe(listener: SpeechStateListener): SpeechUnsubscribe { ReadingSpeechController.listener = listener listener(ReadingSpeechController.snapshot) return () => { if (ReadingSpeechController.listener === listener) { ReadingSpeechController.listener = undefined } } }

页面保存unsubscribeSpeech,在aboutToDisappear()调用。这样不会因为另一个页面后来替换监听器,旧页面退出时又把新监听器清空。

二十三、用引擎适配器做确定性测试

直接依赖系统 TTS 很难稳定复现事件顺序。定义最小接口:

interface SpeechEngineAdapter { speak(text: string, requestId: string): void stop(): void setEvents(events: SpeechEngineEvents): void } interface SpeechEngineEvents { onStart(): void onComplete(requestId: string, type: number): void onStop(): void onError(requestId: string, code: number): void }

生产适配器包装TextToSpeechEngine,测试适配器手动触发事件。控制器测试无需真实设备发声,也能验证旧请求是否被忽略。

二十四、必须覆盖乱序事件场景

测试不能只跑“开始到完成”的理想路径,至少覆盖:

场景事件顺序预期结果
快速开始两次start A、start B、A 初始化完成只播放 B
开始后立即停止preparing、stop、旧 timer不调用 speak
快速下一段next、next、旧 complete只推进一次
切换文章A speak、start B、A completeB 保持当前段
页面返回speak、page_leave、onStop状态为 idle
引擎报错onError(code)error,可重试
最后一段完成complete lastcompleted,不越界
空章节start empty articleerror,不创建请求

这张表就是会话令牌与状态机是否有效的验收依据。

二十五、化学朗读还要检查内容准确性

技术链路稳定后,仍需人工听测化学内容:

  1. H₂O、CO₂、KMnO₄是否按预期朗读。
  2. 上升、下降箭头是否需要转换为“气体生成”“沉淀生成”。
  3. 方程式等号、箭头和系数是否造成不自然停顿。
  4. 长标题是否只在首段出现一次。
  5. 中文与拉丁字母混排时语速是否可接受。

TTS 能成功回调不代表教学内容正确。化学式的语义朗读应通过speechText逐条核对,不能依赖自动替换推断。

二十六、常见故障与排查顺序

现象优先检查常见原因修复方向
停止后再次开口sessionId/timer旧延迟任务仍执行清 timer + 会话校验
快速点击创建多个引擎engineTask只检查已完成实例共享初始化 Promise
一次跳过两段phase/requestId连续 next 与旧 complete 同时推进preparing 阶段禁入
切换文章后高亮错位snapshot.articleId页面消费旧文章状态按文章 ID 过滤
每段重复读标题文本构建器标题无条件拼接只在首段或手动起播时加入
返回列表仍播放页面生命周期只解除监听未停止明确 page_leave 策略
错误提示方向不对引擎配置在线模式显示离线文案按阶段映射错误
错误后不能重试phase 收口引擎任务或请求未清理error 状态清空活动请求
化学式发音异常speechText直接朗读展示字符串增加人工核对文本

排查顺序应从会话 ID、请求 ID、阶段快照开始,再看引擎回调和页面显示。只看按钮颜色很难发现旧事件串台。

二十七、按最小风险顺序改造

建议分四步落地:

  1. 先增加SpeechSnapshot和SpeechPhase,页面改为从快照推导按钮状态。
  2. 再加入engineTask、sessionId和可取消定时器,解决重复初始化与旧任务。
  3. 收紧停止原因、页面退出策略和文章 ID 过滤,补齐错误反馈。
  4. 最后增加speechText、测试适配器和乱序事件测试。

每一步都能用现有三段文章验证。不要同时重写 UI、内容模型和引擎适配,否则听到重复声音时很难定位是哪层引起。

二十八、源码事实与建议实现的边界

为了避免把方案设计误写成已经交付的能力,下面把ReadingSpeechService.ets中可直接复核的事实与本文建议严格分开。

观察维度当前源码事实建议实现
引擎配置使用@kit.CoreSpeechKit的textToSpeech.createEngine,语言为zh-CN、person: 0、online: 1把引擎创建封装为共享初始化任务,并为初始化失败提供与在线配置一致的提示
服务状态以静态字段保存engine、article、isSpeaking、章节索引、状态文案、请求 ID 与停止原因输出不可变SpeechSnapshot,用明确阶段替代多个相互依赖的布尔判断
回调过滤onComplete会比较回调requestId与activeRequestId增加sessionId + requestId双重守卫,隔离文章切换、页面离开和段落切换产生的旧事件
延迟播放每段通过固定 120mssetTimeout后调用speakCurrentSection,当前没有保存定时器句柄保存并取消定时器,同时在任务执行前校验会话身份
停止语义stopReason区分manual、switch与complete把停止原因建模为受限枚举,并统一收口状态、请求 ID、章节索引和定时器
分段推进完成回调在response.type === 1且仍处于朗读状态时调用下一段在preparing/speaking/stopping阶段限制重复推进,避免手动下一段与完成事件竞态
文本构建每一段都拼接文章标题、章节标题和正文引入人工校对的speechText,并决定文章标题只在首段还是每段朗读
页面协作页面通过单一 listener 接收isSpeaking、章节索引和状态文案;离开时只解除 listener明确页面离开是停止播放还是转交全局播放器,并按文章 ID 过滤页面快照
错误提示创建失败统一显示“当前设备不支持离线朗读”,但引擎配置实际为在线模式按初始化、网络、合成、播放和参数阶段映射错误,避免错误方向误导排查
验证状态当前源码可证明服务结构和调用路径存在建议方案仍需完成编译、安装、真机朗读、乱序回调与页面返回测试后才能视为交付

本文给出的SpeechPhase、SpeechSnapshot、共享engineTask、可取消定时器、会话令牌、测试替身和化学专用speechText都是建议实现,当前仓库并未集成这些结构。本次精修没有执行 DevEco Studio 构建、真机安装或语音引擎运行测试,因此不能据此声称竞争窗口已经修复,也不能虚构设备兼容性和播放成功率。

总结

ReadingSpeechService已经具备引擎复用、请求 ID、停止原因和分段推进,是一条可工作的基础链路。真正需要补强的是“谁有权更新当前状态”:引擎初始化、120ms 延迟任务、停止事件和完成回调都可能晚于用户的新操作到达,单个布尔值无法表达这种先后关系。

更可靠的实现是用SpeechPhase表达阶段,用不可变SpeechSnapshot输出页面状态,用共享初始化任务避免重复引擎,用sessionId取消旧意图,用requestId隔离同一会话的段落请求,并明确页面离开时停止还是转交全局播放器。再配合化学专用speechText和乱序事件测试,朗读功能才不会停留在“按钮能出声”,而会成为可停止、可切换、可恢复、出错可解释的完整服务。

AI 辅助声明:本文由 AI 辅助完成源码梳理、结构校对与配图设计,技术结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布。

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

Python变量追踪全攻略:从作用域到闭包,递归探查所有隐藏变量

“追踪变量中有哪些变量”这句话&#xff0c;我这些年帮同事排查问题的时候听过太多次。乍一听有点绕&#xff0c;拆开其实就是三件事&#xff1a;当前作用域里到底声明了哪些变量、某个对象或者字典内部装了哪些字段、顺着调用关系往上层翻&#xff0c;调用栈和闭包里还压着哪…

作者头像 李华
网站建设 2026/10/19 1:43:14

西门子PLC入门指南:从扫描周期、接线到梯形图启保停程序

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

作者头像 李华
网站建设 2026/10/19 1:42:12

STM32C5与CubeMX2深度解析:从选型到避坑的实战指南

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

作者头像 李华
网站建设 2026/10/19 1:41:38

Python62:核心语法-函数基础-案例

案例# 案例1&#xff1a;定义一个函数&#xff1a;根据传入的底base和高high计算三角形triangle面积的函数&#xff08;三角形面积 底*高/2&#xff09;。 def triangle_area(b, h):"""根据传入的底base和高high计算三角形triangle面积:param b: 底长:param h:…

作者头像 李华