<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>Leguan</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://blog.leguans.cn/</id>
  <link href="https://blog.leguans.cn/" rel="alternate"/>
  <link href="https://blog.leguans.cn/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, Leguan</rights>
  <subtitle>
    <![CDATA[Leguan的技术&生活博客]]>
  </subtitle>
  <title>月明星稀</title>
  <updated>2026-08-31T13:43:56.524Z</updated>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="iOS" scheme="https://blog.leguans.cn/tags/iOS/"/>
    <category term="App Store" scheme="https://blog.leguans.cn/tags/App-Store/"/>
    <category term="Xcode Cloud" scheme="https://blog.leguans.cn/tags/Xcode-Cloud/"/>
    <category term="审核" scheme="https://blog.leguans.cn/tags/%E5%AE%A1%E6%A0%B8/"/>
    <category term="踩坑" scheme="https://blog.leguans.cn/tags/%E8%B8%A9%E5%9D%91/"/>
    <content>
      <![CDATA[<p>今天被 App Store Connect 折腾得有点没脾气。</p><p>本来只是给棱镜音乐提交一个新包，心里想的是：代码能跑，Archive 成功，签名也没报错，那应该就是上传、等审核、然后睡觉。结果 Apple 反手给我来了一句：</p><blockquote><p>ITMS-90111: Unsupported SDK or Xcode version - App submissions must use the latest Xcode and SDK Release Candidates (RC).</p></blockquote><p>更烦的是，在 App Store Connect 页面上，它最开始给人的感觉就只有“二进制文件无效”。没有特别清楚地告诉你是哪个文件错了，也没有指出是哪一个配置不对。于是我就开始了一轮非常典型的 iOS 开发者自我怀疑：是不是 build 号？是不是签名？是不是扩展？是不是内购？是不是又被什么奇怪的私有 API 扫到了？</p><p><img src="/images/posts/app-store-invalid-binary/failed-list.png" alt="App Store Connect 里一串失败的构建记录"></p><p>最后确实解决了，但中间走了不少弯路。记录一下，免得下次又被自己坑一遍，顺便给后来者提供一点思路。</p><h2 id="一开始我以为只是-build-号不对"><a href="#一开始我以为只是-build-号不对" class="headerlink" title="一开始我以为只是 build 号不对"></a>一开始我以为只是 build 号不对</h2><p>最开始查包的时候，发现主 App 和 Live Activity 扩展的 build 号不一致。</p><p>主 App 是一个号，扩展又是另一个号。App Store 对这种东西很敏感，尤其是主包、扩展、内嵌 framework 一起提交的时候，只要有一个地方版本号不统一，就很容易被判成无效二进制。</p><p>所以第一步就是把所有 <code>CURRENT_PROJECT_VERSION</code> 统一。</p><p>一开始改到了 <code>11</code>，后来因为 App Store Connect 已经记录过失败的 build，又继续改到 <code>12</code>，最后为了重新走 Xcode Cloud，又加到 <code>13</code>。</p><p>这一步是必要的，但不是最终根因。</p><p>这个坑给我的第一条经验是：<strong>以后 iOS 项目只要带扩展，build 号一定要主 App、扩展、framework 全部统一，不要只看主 target。</strong></p><h2 id="然后我开始怀疑隐私清单"><a href="#然后我开始怀疑隐私清单" class="headerlink" title="然后我开始怀疑隐私清单"></a>然后我开始怀疑隐私清单</h2><p>接着又查到工程里用了文件时间戳、UserDefaults 之类的 API，但没有隐私清单。</p><p>现在 Apple 对 privacy manifest 查得越来越严，尤其是 Required Reason API，如果包里用了但是没声明，很容易在上传阶段就被拦。</p><p>于是我加了 <code>PrivacyInfo.xcprivacy</code>，声明了：</p><ul><li>File Timestamp</li><li>UserDefaults</li><li>不收集数据</li><li>不追踪用户</li></ul><p>这一步也应该做，而且是迟早要补的。但这次的 <code>ITMS-90111</code> 不是它导致的。</p><p>这也是最折磨人的地方：你会发现很多地方“看起来都可能有问题”，于是每个都值得修，但每个修完以后错误还在。</p><h2 id="中间还误会了一次内购"><a href="#中间还误会了一次内购" class="headerlink" title="中间还误会了一次内购"></a>中间还误会了一次内购</h2><p>还有一个很容易误判的地方：主 App 声明了 In-App Purchase 能力，也在 <code>Info.plist</code> 里放了 <code>PremiumProductIDs</code>，但看 entitlements 的时候发现里面没有 In-App Purchase。</p><p>当时我第一反应是：是不是内购能力声明和 entitlements 不一致，所以被 Apple 判无效？</p><p>于是中间还动过一个很危险的念头：把内购相关声明删掉。</p><p>后来冷静下来才发现，这是误会。In-App Purchase 本来就不像 Push、Apple Pay 那样一定会出现在 app entitlements 里。内购能力更多是 App ID &#x2F; App Store Connect &#x2F; StoreKit 商品配置层面的东西，不能简单用“entitlements 里有没有”判断。</p><p>所以后面又把这些东西恢复了：</p><ul><li><code>PremiumProductIDs</code></li><li>In-App Purchase capability</li><li>代码里读取商品 ID 的逻辑</li></ul><p>顺手还修了一个真实问题：因为工程用了 <code>GENERATE_INFOPLIST_FILE=YES</code>，最终打进 IPA 的 <code>Info.plist</code> 一开始并没有带上 <code>PremiumProductIDs</code>。也就是说源码里写了，但包里没有。</p><p>最后我把商品 ID 放进了生成 Info.plist 的 build setting，并且让代码同时兼容数组和字符串两种形式。</p><p>这个问题虽然不是“二进制文件无效”的根因，但如果不修，内购运行时肯定会出问题。</p><h2 id="看到一篇-Reveal-的帖子，又去查私有-API"><a href="#看到一篇-Reveal-的帖子，又去查私有-API" class="headerlink" title="看到一篇 Reveal 的帖子，又去查私有 API"></a>看到一篇 Reveal 的帖子，又去查私有 API</h2><p>后来我翻到一篇老文章，说作者 App Store 上传后一直提示二进制文件无效，最后发现是工程里带了 <code>Reveal.framework</code>，被 Apple 当成私有 API 或调试框架处理。</p><p>这类文章很容易让人重新燃起希望，因为它给了一个很具体的方向：是不是我包里也混进了什么调试框架？</p><p>于是我又去扫了一遍：</p><ul><li>Reveal</li><li>FLEX</li><li>DoraemonKit</li><li>Injection</li><li>Cycript</li><li><code>LSApplicationWorkspace</code></li><li><code>prefs:root</code></li><li><code>PrivateFrameworks</code></li></ul><p>结果都没有。</p><p>源码里倒是出现过 <code>hasReveal</code> 这种变量名，但那只是歌词逐字高亮里的“显示进度”语义，不是 Reveal 调试工具。</p><p>所以这条路也排除了。</p><p>这一步的收获是：<strong>私有 API 方向确实值得查，但不能看到一个关键词就吓自己。要看最终 IPA 里有没有真正的 framework 和符号。</strong></p><h2 id="本地打包看起来一切都对"><a href="#本地打包看起来一切都对" class="headerlink" title="本地打包看起来一切都对"></a>本地打包看起来一切都对</h2><p>中间我还把整个 archive&#x2F;export 流程重新走了一遍。</p><p>这里又踩了一个小坑：Xcode archive 出来的 <code>.xcarchive</code> 不等于可以直接上传的 App Store 包。</p><p>本地 archive 的时候，它可能还是 Apple Development 签名，<code>get-task-allow=true</code>。真正要上传 App Store Connect 的，是用 <code>xcodebuild -exportArchive</code> 导出的 IPA。</p><p>导出以后才会变成：</p><ul><li>Apple Distribution 签名</li><li><code>get-task-allow=false</code></li><li>包含 App Store provisioning profile</li><li>主 App 和扩展都重新签好</li></ul><p>另外还有一个细节：Xcode 导出时默认可能会自动管理 build 号，导致工程里是 <code>11</code>，导出的 IPA 变成 <code>12</code>。后来我把 <code>manageAppVersionAndBuildNumber</code> 关掉，才让最终 IPA 的 build 号和工程一致。</p><p>当时本地检查结果看起来非常漂亮：</p><ul><li>build 号统一</li><li>Apple Distribution 签名</li><li>arm64 架构</li><li>privacy manifest 存在</li><li>内购商品 ID 存在</li><li>没有 Reveal&#x2F;FLEX</li></ul><p>然后上传，还是失败。</p><p>这时候就真的有点烦了。因为你手里所有本地工具都告诉你“这个包没问题”，但 Apple 那边就是一句“Unsupported SDK or Xcode version”。</p><h2 id="换-Xcode-26-6，也还是不行"><a href="#换-Xcode-26-6，也还是不行" class="headerlink" title="换 Xcode 26.6，也还是不行"></a>换 Xcode 26.6，也还是不行</h2><p>后来我按 Apple 提示去看 releases 页面，发现当前应该用 Xcode 26.6，也就是 <code>17F113</code>。</p><p>本机原来的 Xcode 是 26.5，于是下载了 Xcode 26.6，然后用 <code>DEVELOPER_DIR</code> 指到新 Xcode 重新打包。</p><p>本地查出来也确实变成了：</p><pre><code class="hljs text">DTXcode = 2660DTXcodeBuild = 17F113</code></pre><p>看起来这次应该稳了。</p><p>结果上传以后，还是 <code>ITMS-90111</code>。</p><p>这一步最让人崩，因为错误信息明明说 Xcode 版本不支持，我已经换成支持版本了，它还是不认。</p><p>后来继续扒 IPA 的 <code>Info.plist</code>，才看到另一个字段：</p><pre><code class="hljs text">BuildMachineOSBuild = 26A5368g</code></pre><p>这下终于对上了。</p><p>我的机器是 macOS 27 beta。</p><p>也就是说，即使用了正式 Xcode 26.6，只要它运行在 beta macOS 上，最终包里还是会留下 beta 系统的构建环境信息。App Store Connect 很可能把这个也归到“不支持的 SDK 或 Xcode 版本”里一起拒掉。</p><p>这就很坑，因为错误文案没有直接说“你用了 beta macOS”。它只说 Unsupported SDK or Xcode version。</p><h2 id="真正的转折：评论区一句话"><a href="#真正的转折：评论区一句话" class="headerlink" title="真正的转折：评论区一句话"></a>真正的转折：评论区一句话</h2><p>后面我在帖子评论里看到有人提到一个方向：不要在 beta 系统上折腾提交包，换干净环境，或者直接用 Xcode Cloud。</p><p><img src="/images/posts/app-store-invalid-binary/xcode-cloud-comment.png" alt="评论区里提到 Xcode Cloud 的那条提醒"></p><p>这句话一下把前面的线串起来了。</p><p>我本地不管怎么换 Xcode，本质上还是在同一台 beta macOS 上打包。只要构建机器环境不被 Apple 接收，本地继续修代码、改 plist、改 build 号，都只是在绕圈。</p><p>所以最后决定：转 Xcode Cloud。</p><p>不是因为 Xcode Cloud 神奇，而是因为它的构建环境是 Apple 自己提供的干净环境，不会带我本机这个 beta macOS 的 <code>BuildMachineOSBuild</code>。</p><h2 id="Xcode-Cloud-第一次也没一次成功"><a href="#Xcode-Cloud-第一次也没一次成功" class="headerlink" title="Xcode Cloud 第一次也没一次成功"></a>Xcode Cloud 第一次也没一次成功</h2><p>不过转 Xcode Cloud 以后也不是一键结束。</p><p>第一次我下载到的是一个 zip，解压以后发现里面像是 Debug 包。</p><p>当时又懵了一下：不是说云端打包吗？怎么给我一个 debug 产物？</p><p>后来才搞明白：Xcode Cloud 页面里下载到的 zip，很多时候只是 build artifacts 或日志归档，不是给你手动上传 App Store 的 IPA。</p><p>真正正确的方式不是下载 zip 再上传，而是在 workflow 里配置：</p><ul><li>Archive</li><li>Release</li><li>iOS</li><li>Xcode 最新正式版</li><li>上传到 App Store Connect &#x2F; TestFlight</li></ul><p>也就是说，Xcode Cloud 应该直接把 archive 后的 build 上传到 App Store Connect。你在本地下载那个 zip，多半只是为了调试构建过程，不是最终提交物。</p><p>后来把 workflow 改对以后，重新跑，build 13 出来了。</p><p>这次终于正常了。</p><p><img src="/images/posts/app-store-invalid-binary/submitted-for-review.png" alt="build 13 终于成功提交审核"></p><h2 id="这次完整流程大概长这样"><a href="#这次完整流程大概长这样" class="headerlink" title="这次完整流程大概长这样"></a>这次完整流程大概长这样</h2><pre><code class="hljs mermaid">flowchart TD    A[App Store 提示二进制文件无效] --&gt; B[先怀疑 build 号]    B --&gt; C[统一主 App / 扩展 / framework 的 CURRENT_PROJECT_VERSION]    C --&gt; D[补 PrivacyInfo.xcprivacy]    D --&gt; E[误以为 IAP entitlements 异常]    E --&gt; F[恢复内购并修 PremiumProductIDs 打包丢失]    F --&gt; G[看到 Reveal 私有 API 帖子]    G --&gt; H[扫描 IPA: 没有 Reveal / FLEX / 私有 API]    H --&gt; I[用 Xcode 26.6 重新 archive/export]    I --&gt; J[仍然 ITMS-90111]    J --&gt; K[发现 BuildMachineOSBuild 是 macOS beta]    K --&gt; L[转 Xcode Cloud]    L --&gt; M[第一次拿到 zip/debug artifacts]    M --&gt; N[改 workflow: Archive + Distribute]    N --&gt; O[云端生成 build 13]    O --&gt; P[终于搞定]</code></pre><h2 id="最后真正有用的结论"><a href="#最后真正有用的结论" class="headerlink" title="最后真正有用的结论"></a>最后真正有用的结论</h2><p>这次折腾下来，我觉得最有用的结论有几个。</p><p>第一，看到“二进制文件无效”不要只盯着代码。</p><p>很多时候它不是代码逻辑错，而是构建环境、签名、SDK、Archive&#x2F;Export 流程的问题。</p><p>第二，<code>ITMS-90111</code> 不一定只是 Xcode 版本。</p><p>它的文案写的是 Unsupported SDK or Xcode version，但实际可能包含：</p><ul><li>Xcode 版本不对</li><li>SDK 版本不对</li><li>用了 beta Xcode</li><li>用了 beta macOS 构建</li><li>包里残留旧工具链信息</li></ul><p>第三，带扩展的 App，build 号要全包统一。</p><p>主 App、extension、framework 不统一，迟早出事。</p><p>第四，In-App Purchase 不要用 entitlements 有没有来判断。</p><p>这次我差点把内购删掉，幸好后来恢复了。内购本来就不是那种一定会写进 entitlements 的能力。</p><p>第五，Xcode Cloud 的 zip 不等于上架包。</p><p>如果目标是上传 App Store Connect，workflow 要走 Archive + Distribute。不要下载一个 zip，解压看到 Debug，就以为那是最终 IPA。</p><p>第六，如果本机是 beta macOS，尽量别拿它打 App Store 提交包。</p><p>调试可以，开发可以，自己装着玩也可以。但真正提交审核，最好用正式 macOS + 正式 Xcode，或者干脆交给 Xcode Cloud。</p><h2 id="结尾"><a href="#结尾" class="headerlink" title="结尾"></a>结尾</h2><p>这次最烦的地方不是问题多，而是每一步都像真问题。</p><p>build 号不统一，确实要修。</p><p>privacy manifest 没补，确实要补。</p><p>内购商品 ID 没进最终 IPA，确实会影响功能。</p><p>Reveal 私有 API 的方向，也确实有人踩过。</p><p>Xcode 版本不对，也确实会被拒。</p><p>但真正让 build 过不去的，是我一直忽略的构建机器环境：macOS beta。</p><p>回头看，这就是一次很典型的 Apple 审核排查：错误信息给得很省，开发者自己把所有可能性扫一遍，最后靠一个评论区提醒，才发现根因不在代码里。</p><p>好在最后 build 13 终于过了。</p><p>这次记住了：以后要提交 App Store，别在 beta 系统上硬打包。真的容易把人折腾到怀疑人生。</p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/app-store-invalid-binary-xcode-cloud/</id>
    <link href="https://blog.leguans.cn/posts/app-store-invalid-binary-xcode-cloud/"/>
    <published>2026-07-03T14:10:00.000Z</published>
    <summary>记录一次 App Store Connect 一直报二进制文件无效的排查过程：改 build、补隐私清单、误删内购、查私有 API、换 Xcode，最后才发现是 beta macOS 构建环境的问题，转到 Xcode Cloud 才真正解决。</summary>
    <title>App Store 一直提示“二进制文件无效”，从本地乱改到 Xcode Cloud 终于过了</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="科研记录" scheme="https://blog.leguans.cn/categories/%E7%A7%91%E7%A0%94%E8%AE%B0%E5%BD%95/"/>
    <category term="科研记录" scheme="https://blog.leguans.cn/tags/%E7%A7%91%E7%A0%94%E8%AE%B0%E5%BD%95/"/>
    <category term="失败复盘" scheme="https://blog.leguans.cn/tags/%E5%A4%B1%E8%B4%A5%E5%A4%8D%E7%9B%98/"/>
    <content>
      <![CDATA[<p>这几天我基本都在做同一件事：想把一个已经发表过的模型继续往上改一点。</p><p>一开始我其实挺有信心的。论文看了，代码也翻了，里面确实有一些看起来可以动手的地方。比如几个专家模块各自负责不同的信息，路由器决定用谁，另外还有时间信息、频率信息、题目信息之类的分支。乍一看，能改的地方很多，甚至有点让人兴奋。</p><p>但真正做下来以后，我发现最折磨人的不是代码跑不通，而是它能跑通，结果也看起来有一点变化，可最后就是没有用。</p><h2 id="第一阶段：我以为只要把模块换强一点就行"><a href="#第一阶段：我以为只要把模块换强一点就行" class="headerlink" title="第一阶段：我以为只要把模块换强一点就行"></a>第一阶段：我以为只要把模块换强一点就行</h2><p>最开始的想法很朴素：既然原模型里面有几个专家，那我能不能把这些专家换得更强一点？</p><p>比如注意力模块换成更强的变体，卷积专家改成多尺度结构，循环专家加一点时间感知，状态空间那一路也做一点增强。这个方向听起来很合理，因为原模型本来就在讲“不同专家负责不同模式”，那我把专家本身加强，模型不就应该更强吗？</p><p>结果并没有。</p><p>有些改法训练指标看起来还行，有些改法甚至在前几轮有一点点让人误会的上升。但一到真正关心的窗口级评估，基本又掉回去了。最烦的是它不是大崩，而是那种差几个千分点的波动。你很难第一时间判断它到底是有效，还是纯粹随机抖了一下。</p><p>后来我慢慢意识到，专家变强不等于系统变强。原来的框架不是简单缺一个更强的模块，而是很多模块之间的分工本身就不够稳定。你把其中一个专家换强了，路由器未必真的会更会用它。甚至有时候专家更强，反而会让原本就不清楚的分工更乱。</p><p>这一步给我的教训是：<strong>不要把“模块更复杂”自动理解成“方法更好”。</strong></p><h2 id="第二阶段：我差点被一个好看的结果骗了"><a href="#第二阶段：我差点被一个好看的结果骗了" class="headerlink" title="第二阶段：我差点被一个好看的结果骗了"></a>第二阶段：我差点被一个好看的结果骗了</h2><p>中间有一段时间，我确实跑出过一个挺好看的结果。</p><p>那时候我非常激动，因为指标终于接近我想要的线了。那种感觉真的很容易上头。你会开始想：是不是终于找到了？是不是可以不用继续折腾了？是不是这条路能写成一个方法？</p><p>但冷静下来一拆，问题就来了。</p><p>那个提升更多来自工程层面的校准、融合和统计先验。它确实能把最终预测调得更顺，但它不像一个干净的模型框架创新。放在实验里可能好看，写到论文里就很尴尬。别人一问“你的核心方法是什么”，我不能只说我在输出后面又调了一层。</p><p>更麻烦的是，我后来尝试把这套有效的工程策略搬进模型内部，结果也没能复现那个提升。</p><p>原因现在想想也不奇怪。外部校准是直接作用在最终概率上的，它不需要被主干网络理解，也不需要经过训练过程稀释。可一旦塞进模型内部，它就要和主干表示、损失函数、早停策略、dropout、门控一起博弈。最后模型可能学会忽略它，也可能把它吸收到别的偏置里。总之，外面好用，不代表里面也好用。</p><p>这一步给我的教训是：<strong>一个技巧能提升指标，不代表它就是一个能讲清楚的方法。</strong></p><h2 id="第三阶段：我开始怀疑原框架的几个假设"><a href="#第三阶段：我开始怀疑原框架的几个假设" class="headerlink" title="第三阶段：我开始怀疑原框架的几个假设"></a>第三阶段：我开始怀疑原框架的几个假设</h2><p>后来我把思路拉回原始代码，重新看它到底在假设什么。</p><p>它有一个很关键的叙事：不同频率的信息应该交给不同专家处理。这个说法本身挺漂亮，但代码里并没有特别强的约束去保证这件事真的发生。也就是说，论文里讲的是“专家分工”，但训练过程中专家到底有没有按这个分工学，其实不一定。</p><p>于是我做了一个更干净的尝试：让路由器显式看到频率信息，再加一点很弱的专家专门化约束，让不同专家尽量靠近自己该处理的频带。</p><p>这个改法比单纯换专家更像方法，也确实比原始版本好一点。</p><p>但也只是好一点。</p><p>它没有成为真正的突破。加得太强，模型会被约束拖住；加得太弱，作用又不明显。最后最好的情况，也只是比干净基线多一点点，离我真正想要的结果还有距离。</p><p>这一步给我的教训是：<strong>论文叙事里的“应该如此”，不一定能直接变成有效训练信号。</strong></p><h2 id="第四阶段：我试着对齐评估指标，结果更差"><a href="#第四阶段：我试着对齐评估指标，结果更差" class="headerlink" title="第四阶段：我试着对齐评估指标，结果更差"></a>第四阶段：我试着对齐评估指标，结果更差</h2><p>因为最终看的不是普通逐点指标，而是窗口级、题目级的聚合结果，所以我又想：那训练目标是不是也应该更靠近这个评估方式？</p><p>于是我做了一个题目聚合辅助目标。大概意思是，不只让每个位置预测对，还让同一题目的平均预测和平均正确率更接近。这个想法听起来也很合理，甚至比前面的频率约束更贴近最终指标。</p><p>但结果更差。</p><p>这个失败让我挺难受的，因为它不是一个拍脑袋的方向。它确实是从评估指标倒推出来的。可问题在于，batch 里的题目聚合太粗糙了，信号不够干净。它想修正最终窗口指标，但训练时看到的是局部 batch 里的近似统计，噪声很大。最后不仅没帮上忙，还把原本还算稳定的预测拉偏了。</p><p>这一步给我的教训是：<strong>对齐指标不是把指标形式搬进 loss 里那么简单。</strong></p><h2 id="第五阶段：我开始做删减，而不是继续堆东西"><a href="#第五阶段：我开始做删减，而不是继续堆东西" class="headerlink" title="第五阶段：我开始做删减，而不是继续堆东西"></a>第五阶段：我开始做删减，而不是继续堆东西</h2><p>前面几轮失败以后，我终于不太想继续“加模块”了。</p><p>原模型本来就有两条分支，一条处理主要序列信息，一条处理时间信息，最后再融合。我一开始觉得这很完整，但跑多了以后开始怀疑：这两条分支是不是有点重复？时间分支是不是没有必要再走一套完整的大模型？</p><p>所以我做了一个删减版：保留主分支，把时间信息改成轻量的残差校正。也就是不再让时间分支单独跑完整主干，而是让它只负责对主表示做一点修正。</p><p>这个方向至少有一个好处：显存明显降了，结构也更清楚。结果上也比干净基线好一点。</p><p>但还是不够。</p><p>它证明原来的双分支可能确实有冗余，但也证明“删掉冗余”不等于“拿到突破”。一个更轻的结构如果只能带来一点点提升，那它可以作为工程优化，也可以作为消融观察，但还撑不起我想要的那种论文级故事。</p><p>这一步给我的教训是：<strong>删减是好事，但删完以后必须带来足够明确的收益，否则它只是一个更省的版本。</strong></p><h2 id="最折磨的地方：每一步都不是完全没道理"><a href="#最折磨的地方：每一步都不是完全没道理" class="headerlink" title="最折磨的地方：每一步都不是完全没道理"></a>最折磨的地方：每一步都不是完全没道理</h2><p>这几天最折磨我的地方在于，很多失败方向并不是一眼就知道不行。</p><p>换专家，有道理。</p><p>加强路由，有道理。</p><p>频率专门化，有道理。</p><p>对齐窗口指标，有道理。</p><p>删掉冗余分支，也有道理。</p><p>但科研不是“有道理”就行。最后要看结果，要看能不能稳定复现，要看是不是能讲成一个干净的方法，还要看它是不是比原方法真的强。</p><p>我之前很容易被“这个想法合理”骗进去，然后在一个方向上继续磨。磨到最后发现，它只是合理，不是有效。</p><h2 id="我现在对这件事的判断"><a href="#我现在对这件事的判断" class="headerlink" title="我现在对这件事的判断"></a>我现在对这件事的判断</h2><p>现在回头看，我觉得真正的问题可能不是某一个模块不够强，而是这套系统已经被调到一个比较难动的位置了。</p><p>它不是那种随便加一个注意力变体、加一个先验、加一个辅助 loss 就能明显上涨的模型。它的很多提升空间都被训练策略、数据切分、评估方式和输出校准绑在一起。只改模型内部一小块，很容易被整体系统吞掉。</p><p>所以我现在不太相信“再换一个模块就能起飞”了。</p><p>如果后面还要继续，我觉得应该只做两类事情：</p><p>第一类是非常干净的结构重写。不是给原模型继续贴补丁，而是明确指出原框架哪里冗余、哪里假设不成立，然后做一个更简洁的替代结构。</p><p>第二类是承认有效信号来自预测校准或统计先验，然后把它设计成一个训练阶段可解释、推理阶段仍然干净的机制。否则就会一直卡在“结果好看但方法不好讲”的尴尬位置。</p><h2 id="这几天最大的收获"><a href="#这几天最大的收获" class="headerlink" title="这几天最大的收获"></a>这几天最大的收获</h2><p>听起来有点丧，但这几天不是完全白跑。</p><p>至少我确认了几件事：</p><ul><li>单纯增强专家模块，基本不是突破口；</li><li>外部校准有效，但不等于模型创新；</li><li>把外部技巧硬塞进模型内部，提升会被稀释；</li><li>频率专门化有一点价值，但不够强；</li><li>题目聚合辅助目标看似对齐评估，实际很容易引入噪声；</li><li>删掉冗余分支能让结构更轻，但目前还不足以带来大提升；</li><li>真正该警惕的是“看起来合理但结果不稳定”的方向。</li></ul><p>以前我总觉得失败是因为还没找到那个正确的 trick。现在我稍微清醒一点了：有时候不是 trick 没找对，而是问题本身就不在 trick 那里。</p><p>接下来如果还要做，我应该少一点“再试一个”的冲动，多一点“这个假设到底能不能被证明”的耐心。</p><p>不然就是继续跑，继续等，继续看到一个差不多的数字，然后继续失望。</p><p>这几天已经够了。</p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/three-days-of-failed-experiments/</id>
    <link href="https://blog.leguans.cn/posts/three-days-of-failed-experiments/"/>
    <published>2026-06-29T16:00:00.000Z</published>
    <summary>记录一次连续几天做实验但始终没有真正突破的过程。不是技术教程，就是一次很真实的失败复盘。</summary>
    <title>这几天我一直在失败</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="默认分类" scheme="https://blog.leguans.cn/categories/%E9%BB%98%E8%AE%A4%E5%88%86%E7%B1%BB/"/>
    <content>
      <![CDATA[<p>最近在使用Swift原生开发重构一个 iOS 本地全格式音乐播放器，碰到了一个迷惑人的问题：</p><ul><li>在 App 内点暂停，音频确实停了；</li><li>控制中心 &#x2F; 通知中心 &#x2F; 锁屏 &#x2F; 灵动岛里的<strong>进度条也停了</strong>；</li><li>但播放&#x2F;暂停按钮的图标<strong>还一直显示 playing</strong>，点它系统才想起来该切成暂停。</li></ul><p>最迷惑的是：日志那一侧完全正确。<code>MPNowPlayingInfoCenter.playbackState = .paused</code> 写了，<code>MPNowPlayingInfoPropertyPlaybackRate = 0</code> 也写了，重复写了好几遍，系统就是不更新按钮。</p><p>这篇记录一下三轮修复、最终定位到的真正根因，以及为什么前两轮看起来很对的修改没有解决问题。</p><hr><h2 id="先说最终结论"><a href="#先说最终结论" class="headerlink" title="先说最终结论"></a>先说最终结论</h2><p>如果你用 <code>AVAudioEngine + AVAudioPlayerNode</code>（不是 <code>AVPlayer</code>）做播放，又遇到「Control Center 进度停了但按钮不变」这种错位，大概率不是你 <code>MPNowPlayingInfoCenter</code> 那一套写错了——是 <strong>你暂停的时候只 <code>playerNode.pause()</code>，<code>AVAudioEngine</code> 本身还在 running</strong>。</p><p>iOS 系统 Now Playing 界面的播放&#x2F;暂停按钮图标，不只是看你写给 <code>playbackState</code> 的值，还看<strong>你的 app 当前是不是还在向音频图实际输出音频</strong>。<code>playerNode.pause()</code> 会让声音停下，但整个 engine 从系统看依然是”活跃的音频生产者”。于是：</p><ul><li><code>rate = 0</code> 会被系统认（所以进度条停）；</li><li><code>playbackState = .paused</code> 不会立刻反映到按钮图标上（因为它跟当前音频输出活跃度冲突）。</li></ul><p>真正的修复只有一行：暂停时同时 <code>engine.pause()</code>，恢复时 <code>engine.start()</code>。</p><p>但我走了好几步才到这里，中间还尝试了一些”听起来很对”但并没有解决问题的改动。完整记一下。</p><hr><h2 id="1-现象和复现条件"><a href="#1-现象和复现条件" class="headerlink" title="1. 现象和复现条件"></a>1. 现象和复现条件</h2><ul><li>平台：iOS（真机26、iOS 17&#x2F;18 均复现）</li><li><code>UIBackgroundModes: audio</code> 已配，<code>UIApplication.shared.beginReceivingRemoteControlEvents()</code> 也调了</li><li><code>MPRemoteCommandCenter</code> 里 play &#x2F; pause &#x2F; togglePlayPause &#x2F; nextTrack &#x2F; previousTrack &#x2F; changePlaybackPosition 都有 target</li><li><code>MPNowPlayingInfoCenter</code> 正常写：title &#x2F; artist &#x2F; duration &#x2F; elapsed &#x2F; rate &#x2F; playbackState</li></ul><p>复现：播一首歌，在 App 内点暂停，然后锁屏或下拉控制中心。</p><p>结果：进度条在暂停那一秒就停住了，但播放按钮图标依然是”playing 时显示的暂停图标”（也就是那个让你点了能暂停的图标），不会切成”paused 时显示的播放图标”。再点一次按钮，系统会正确分发 <code>play</code> 命令，说明系统是认你这个 Now Playing owner 的，只是 UI 没同步。</p><hr><h2 id="2-第一轮：怀疑-nowPlayingInfo-playbackState-的写入顺序"><a href="#2-第一轮：怀疑-nowPlayingInfo-playbackState-的写入顺序" class="headerlink" title="2. 第一轮：怀疑 nowPlayingInfo &#x2F; playbackState 的写入顺序"></a>2. 第一轮：怀疑 nowPlayingInfo &#x2F; playbackState 的写入顺序</h2><p>第一反应是一个广为流传的老规律：<strong>先写 <code>nowPlayingInfo</code>，再写 <code>playbackState</code></strong>，反过来写系统有时会把 playbackState 的修改吃掉。</p><p>原来的代码确实是反的：</p><pre><code class="hljs swift"><span class="hljs-comment">// 旧</span>nowPlayingCenter.playbackState <span class="hljs-operator">=</span> state.isPlaying <span class="hljs-operator">?</span> .playing : .pausednowPlayingCenter.nowPlayingInfo <span class="hljs-operator">=</span> info</code></pre><p>改成：</p><pre><code class="hljs swift"><span class="hljs-comment">// 新</span>nowPlayingCenter.nowPlayingInfo <span class="hljs-operator">=</span> infonowPlayingCenter.playbackState <span class="hljs-operator">=</span> state.isPlaying <span class="hljs-operator">?</span> .playing : .paused</code></pre><p>这个修改<strong>是对的</strong>，但并没有解决我的问题。装上去症状完全一致。</p><hr><h2 id="3-第二轮：怀疑-playCommand-pauseCommand-的可用性开关"><a href="#3-第二轮：怀疑-playCommand-pauseCommand-的可用性开关" class="headerlink" title="3. 第二轮：怀疑 playCommand &#x2F; pauseCommand 的可用性开关"></a>3. 第二轮：怀疑 playCommand &#x2F; pauseCommand 的可用性开关</h2><p>继续查资料，看到另一个说法：如果你根据播放状态去动态切 <code>playCommand.isEnabled</code> &#x2F; <code>pauseCommand.isEnabled</code>，iOS 在这一瞬间会和 Control Center UI 的刷新抢时序，按钮图标可能卡在旧状态。</p><p>我原本的代码正是这么写的：</p><pre><code class="hljs swift"><span class="hljs-comment">// 旧</span><span class="hljs-keyword">private</span> <span class="hljs-keyword">func</span> <span class="hljs-title function_">updateCommandAvailability</span>(<span class="hljs-params">hasItem</span>: <span class="hljs-type">Bool</span>, <span class="hljs-params">isPlaying</span>: <span class="hljs-type">Bool</span>) &#123;    commandCenter.playCommand.isEnabled <span class="hljs-operator">=</span> hasItem <span class="hljs-operator">&amp;&amp;</span> <span class="hljs-operator">!</span>isPlaying    commandCenter.pauseCommand.isEnabled <span class="hljs-operator">=</span> hasItem <span class="hljs-operator">&amp;&amp;</span> isPlaying    commandCenter.togglePlayPauseCommand.isEnabled <span class="hljs-operator">=</span> hasItem    commandCenter.changePlaybackPositionCommand.isEnabled <span class="hljs-operator">=</span> hasItem&#125;</code></pre><p>改成<strong>只要有曲目就全都 enable</strong>，图标完全交给 <code>playbackState + rate</code> 去决定：</p><pre><code class="hljs swift"><span class="hljs-comment">// 新</span><span class="hljs-keyword">private</span> <span class="hljs-keyword">func</span> <span class="hljs-title function_">updateCommandAvailability</span>(<span class="hljs-params">hasItem</span>: <span class="hljs-type">Bool</span>) &#123;    commandCenter.playCommand.isEnabled <span class="hljs-operator">=</span> hasItem    commandCenter.pauseCommand.isEnabled <span class="hljs-operator">=</span> hasItem    commandCenter.togglePlayPauseCommand.isEnabled <span class="hljs-operator">=</span> hasItem    commandCenter.changePlaybackPositionCommand.isEnabled <span class="hljs-operator">=</span> hasItem&#125;</code></pre><p>这也是 Apple 示例代码的推荐方式——两个命令都注册了 handler，iOS 会根据 <code>playbackState</code> 自己去挑合适的图标，不需要你把 enabled 来回切。</p><p>这个修改<strong>也是对的</strong>，但装上去<strong>还是</strong>一样。进度条停、按钮不变。</p><p>至此我两次修改都”理论上对”但问题没变化。这种时候必须停下来，先证明链路到底哪一步出了问题，而不是继续往相邻的地方打补丁。</p><hr><h2 id="4-第三轮：先证明，再改"><a href="#4-第三轮：先证明，再改" class="headerlink" title="4. 第三轮：先证明，再改"></a>4. 第三轮：先证明，再改</h2><p>加全链路日志，把”用户操作 → coordinator 状态 → 写给系统的值”每一步都打出来：</p><pre><code class="hljs swift"><span class="hljs-comment">// PlaybackCoordinator</span><span class="hljs-keyword">public</span> <span class="hljs-keyword">func</span> <span class="hljs-title function_">togglePlayback</span>() &#123;    <span class="hljs-type">PlaybackLogger</span>.coordinator.log(<span class="hljs-string">&quot;togglePlayback: called, isPlaying=<span class="hljs-subst">\(<span class="hljs-keyword">self</span>.state.isPlaying)</span>&quot;</span>)    <span class="hljs-comment">// ...</span>&#125;<span class="hljs-keyword">public</span> <span class="hljs-keyword">func</span> <span class="hljs-title function_">pauseCurrent</span>() &#123;    <span class="hljs-type">PlaybackLogger</span>.coordinator.log(<span class="hljs-string">&quot;pauseCurrent: called, hasItem=<span class="hljs-subst">\(<span class="hljs-keyword">self</span>.state.currentItem <span class="hljs-operator">!=</span> <span class="hljs-literal">nil</span>)</span> isPlaying=<span class="hljs-subst">\(<span class="hljs-keyword">self</span>.state.isPlaying)</span>&quot;</span>)    <span class="hljs-comment">// ...</span>&#125;<span class="hljs-keyword">private</span> <span class="hljs-keyword">func</span> <span class="hljs-title function_">setPaused</span>(<span class="hljs-keyword">_</span> <span class="hljs-params">paused</span>: <span class="hljs-type">Bool</span>) <span class="hljs-keyword">async</span> &#123;    <span class="hljs-type">PlaybackLogger</span>.coordinator.log(<span class="hljs-string">&quot;setPaused: request paused=<span class="hljs-subst">\(paused)</span> currentIsPlaying=<span class="hljs-subst">\(<span class="hljs-keyword">self</span>.state.isPlaying)</span>&quot;</span>)    <span class="hljs-keyword">await</span> activeEngine.setPaused(paused)    state.isPlaying <span class="hljs-operator">=</span> <span class="hljs-operator">!</span>paused    <span class="hljs-type">PlaybackLogger</span>.coordinator.log(<span class="hljs-string">&quot;setPaused: engine done, state.isPlaying=<span class="hljs-subst">\(<span class="hljs-keyword">self</span>.state.isPlaying)</span>&quot;</span>)    <span class="hljs-comment">// ...</span>    syncRemoteMetadata()&#125;<span class="hljs-keyword">private</span> <span class="hljs-keyword">func</span> <span class="hljs-title function_">syncRemoteMetadata</span>() &#123;    <span class="hljs-type">PlaybackLogger</span>.coordinator.log(<span class="hljs-string">&quot;syncRemoteMetadata: isPlaying=<span class="hljs-subst">\(<span class="hljs-keyword">self</span>.state.isPlaying)</span> elapsed=<span class="hljs-subst">\(<span class="hljs-keyword">self</span>.state.currentTime)</span> duration=<span class="hljs-subst">\(<span class="hljs-keyword">self</span>.state.totalDuration)</span>&quot;</span>)    remoteBridge.update(state: state)&#125;</code></pre><pre><code class="hljs swift"><span class="hljs-comment">// SystemRemoteTransportBridge</span><span class="hljs-keyword">func</span> <span class="hljs-title function_">update</span>(<span class="hljs-params">state</span>: <span class="hljs-type">NowPlayingState</span>) &#123;    <span class="hljs-comment">// ... 写 info、写 playbackState ...</span>    <span class="hljs-type">PlaybackLogger</span>.remote.log(<span class="hljs-string">&quot;bridge.update: title=<span class="hljs-subst">\(item.title, privacy: .public)</span> isPlaying=<span class="hljs-subst">\(state.isPlaying)</span> rate=<span class="hljs-subst">\(state.isPlaying <span class="hljs-operator">?</span> <span class="hljs-number">1.0</span> : <span class="hljs-number">0.0</span>)</span> elapsed=<span class="hljs-subst">\(state.currentTime)</span> duration=<span class="hljs-subst">\(state.totalDuration)</span> playbackState=<span class="hljs-subst">\(state.isPlaying <span class="hljs-operator">?</span> <span class="hljs-string">&quot;playing&quot;</span> : <span class="hljs-string">&quot;paused&quot;</span>, privacy: .public)</span>&quot;</span>)&#125;</code></pre><p>然后在真机上暂停一次，截了一段日志（精简）：</p><pre><code class="hljs plaintext">togglePlayback: called, isPlaying=truesetPaused: request paused=true currentIsPlaying=truesetPaused: engine done, state.isPlaying=falsesyncRemoteMetadata: isPlaying=false elapsed=5.65 duration=312.99bridge.update: title=不将就 isPlaying=false rate=0.0 elapsed=5.65 duration=312.99 playbackState=paused</code></pre><p>之后<strong>没有</strong>任何 stray 的 <code>isPlaying=true</code> 再把状态写回去。也就是说：</p><ul><li>我们写给系统的值是 100% 正确的；</li><li>系统也确实”部分收到了”——因为进度条对得上、rate&#x3D;0 也生效了（进度不再往前走）；</li><li>但按钮图标就是卡在 playing。</li></ul><p>到这里排除了”写入顺序”、”命令可用性”、”被别的链路覆盖”这几种可能。既然写是对的，那只能是<strong>系统对这个 App 有额外的判定条件</strong>，在那个条件上我们写的值被忽略了。</p><hr><h2 id="5-定位到关键点：AVAudioEngine-还在-running"><a href="#5-定位到关键点：AVAudioEngine-还在-running" class="headerlink" title="5. 定位到关键点：AVAudioEngine 还在 running"></a>5. 定位到关键点：AVAudioEngine 还在 running</h2><p>回到播放内核那边看 <code>setPaused</code>：</p><pre><code class="hljs swift"><span class="hljs-comment">// 旧（*PlaybackEngine / NativeAudioEngine 二者都一样）</span><span class="hljs-keyword">public</span> <span class="hljs-keyword">func</span> <span class="hljs-title function_">setPaused</span>(<span class="hljs-keyword">_</span> <span class="hljs-params">paused</span>: <span class="hljs-type">Bool</span>) <span class="hljs-keyword">async</span> &#123;    <span class="hljs-keyword">if</span> paused &#123;        playerNode.pause()    &#125; <span class="hljs-keyword">else</span> &#123;        <span class="hljs-keyword">try?</span> <span class="hljs-keyword">Self</span>.activatePlaybackSessionIfAvailable()        <span class="hljs-keyword">if</span> <span class="hljs-operator">!</span>engine.isRunning &#123;            <span class="hljs-keyword">try?</span> engine.start()        &#125;        playerNode.play()    &#125;&#125;</code></pre><p>注意：暂停只调了 <code>playerNode.pause()</code>，<code>engine</code> 从没 <code>pause()</code> 过。<code>AVAudioEngine</code> 是整个音频图的宿主，player node 只是挂在它上面的一个节点。player node 暂停了意味着”我这个节点不再往 mainMixerNode 送 buffer”，但 engine 依然在跑 IO、依然从系统的角度看是一个活跃的 realtime audio producer。</p><p>iOS 的 Now Playing UI 在决定”到底该显示 play 还是 pause 图标”的时候，会结合两个信号：</p><ol><li><code>MPNowPlayingInfoCenter.playbackState</code> &#x2F; <code>nowPlayingInfo[playbackRate]</code>（我们写的）</li><li>当前音频会话 owner 的<strong>实际音频活动</strong>（AVAudioEngine 是不是在往系统送 buffer）</li></ol><p>这两个信号冲突时，系统会偏向第二个——因为它不信任 app 可能乱写的 metadata，它信任自己底层 I&#x2F;O 图看到的现实。于是就出现了「metadata 说 paused（进度条停），但你 engine 还在 run（按钮还 playing）」这种撕裂。</p><p><code>AVPlayer</code> 路径不会有这个问题，因为 <code>AVPlayer.pause()</code> 会同时把播放和底层队列都停下来。只有 <code>AVAudioEngine + AVAudioPlayerNode</code> 这种自己管音频图的路径需要显式地 pause engine。</p><hr><h2 id="6-修复：暂停时同步-pause-整个-engine"><a href="#6-修复：暂停时同步-pause-整个-engine" class="headerlink" title="6. 修复：暂停时同步 pause 整个 engine"></a>6. 修复：暂停时同步 pause 整个 engine</h2><p>改动非常小：</p><pre><code class="hljs swift"><span class="hljs-comment">// 新</span><span class="hljs-keyword">public</span> <span class="hljs-keyword">func</span> <span class="hljs-title function_">setPaused</span>(<span class="hljs-keyword">_</span> <span class="hljs-params">paused</span>: <span class="hljs-type">Bool</span>) <span class="hljs-keyword">async</span> &#123;    <span class="hljs-keyword">if</span> paused &#123;        playerNode.pause()        engine.pause()                <span class="hljs-comment">// ← 关键这一行</span>    &#125; <span class="hljs-keyword">else</span> &#123;        <span class="hljs-keyword">try?</span> <span class="hljs-keyword">Self</span>.activatePlaybackSessionIfAvailable()        <span class="hljs-keyword">if</span> <span class="hljs-operator">!</span>engine.isRunning &#123;            <span class="hljs-keyword">try?</span> engine.start()       <span class="hljs-comment">// 恢复时重启 engine</span>        &#125;        playerNode.play()    &#125;&#125;</code></pre><p><code>NativeAudioEngine</code> 和 <code>*PlaybackEngine</code> 同样改一次。恢复路径本来就有 <code>engine.start()</code> 的 fallback，所以不用再改。</p><p>这一行加上去，控制中心&#x2F;通知中心&#x2F;锁屏&#x2F;灵动岛的按钮图标立刻跟着暂停状态同步了。</p><hr><h2 id="7-三轮修改的价值分布"><a href="#7-三轮修改的价值分布" class="headerlink" title="7. 三轮修改的价值分布"></a>7. 三轮修改的价值分布</h2><p>最终生效的是第三轮（<code>engine.pause()</code>）。但前两轮修改我<strong>没有回滚</strong>，因为它们本身就是更正确的写法：</p><ol><li><strong><code>nowPlayingInfo</code> 先写、<code>playbackState</code> 后写</strong>：Apple 官方 sample 和论坛里的多年共识。反着写虽然大多数时候也能跑，但会在某些边界条件下掉状态。这个改动没副作用，留着。</li><li><strong><code>playCommand</code> &#x2F; <code>pauseCommand</code> 始终 enable</strong>：Apple 推荐做法。动态切 enabled 是一种反模式，容易和系统 UI 的刷新抢时序。这个改动也没副作用，留着。</li><li><strong><code>engine.pause()</code> 在 pause 时同步停图</strong>：本 Bug 的<strong>真正</strong>修复点。</li></ol><p>加起来就是一套比较干净、可预期的 Now Playing 同步实现。</p><hr><h2 id="8-经验总结"><a href="#8-经验总结" class="headerlink" title="8. 经验总结"></a>8. 经验总结</h2><p>这次排查里让我印象深的几点：</p><p><strong>1）日志先行，别在相邻的地方打补丁</strong></p><p>前两轮改的东西都”理论上对”，但因为没有先证明链路哪里出了问题，我其实一直在改”不是根因”的地方。第三次上来老老实实把每一步 <code>togglePlayback → setPaused → state.isPlaying → syncRemoteMetadata → bridge.update</code> 打出来，日志完全干净了，才能非常确定地排除”我们写得不对”这条路，把方向转到系统侧。</p><p><strong>2）iOS 的 Now Playing 界面不是一个纯 metadata 驱动的 UI</strong></p><p>很容易把 <code>MPNowPlayingInfoCenter</code> 当成一个 key-value 字典：”我设什么系统就显什么”。真实情况是系统还会交叉验证你的<strong>实际音频行为</strong>——尤其是 <code>AVAudioEngine</code> 路径下。metadata 和 engine 状态必须配套。</p><p><strong>3）<code>AVAudioEngine</code> 的 pause 有两层</strong></p><p><code>playerNode.pause()</code> 只是节点级别的。<code>engine.pause()</code> 才是图级别的。如果你的 App 会把 Now Playing 让给系统（锁屏、控制中心、灵动岛、CarPlay、AirPods 耳机控制…），这两层都要一起切。</p><p><strong>4）症状要细读</strong></p><p>「进度停了但按钮不变」这种错位，和「按钮变了但进度还在走」是完全不同的两类问题。前者是 metadata 生效了但 UI 主信号没生效，后者是 metadata 没生效但 UI 被其他途径刷了。分清楚之后，排查方向会完全不同。</p><hr><h2 id="附：最终-diff（节选）"><a href="#附：最终-diff（节选）" class="headerlink" title="附：最终 diff（节选）"></a>附：最终 diff（节选）</h2><p><code>*/*PlaybackEngine.swift</code>：</p><pre><code class="hljs diff"> public func setPaused(_ paused: Bool) async &#123;     isPaused = paused     if paused &#123;         playerNode.pause()<span class="hljs-addition">+        engine.pause()</span>     &#125; else &#123;         try? Self.activatePlaybackSessionIfAvailable()         if !engine.isRunning &#123;             try? engine.start()         &#125;         playerNode.play()     &#125; &#125;</code></pre><p><code>*/NativeAudioEngine.swift</code>：</p><pre><code class="hljs diff"> public func setPaused(_ paused: Bool) async &#123;     if paused &#123;         playerNode.pause()<span class="hljs-addition">+        engine.pause()</span>     &#125; else &#123;         try? Self.activatePlaybackSessionIfAvailable()         if !engine.isRunning &#123;             try? engine.start()         &#125;         playerNode.play()     &#125; &#125;</code></pre><p><code>*/SystemRemoteTransportBridge.swift</code>：</p><pre><code class="hljs diff"><span class="hljs-deletion">-nowPlayingCenter.playbackState = state.isPlaying ? .playing : .paused</span> nowPlayingCenter.nowPlayingInfo = info<span class="hljs-addition">+nowPlayingCenter.playbackState = state.isPlaying ? .playing : .paused</span><span class="hljs-deletion">-updateCommandAvailability(hasItem: true, isPlaying: state.isPlaying)</span><span class="hljs-addition">+updateCommandAvailability(hasItem: true)</span></code></pre><pre><code class="hljs diff"><span class="hljs-deletion">-private func updateCommandAvailability(hasItem: Bool, isPlaying: Bool) &#123;</span><span class="hljs-deletion">-    commandCenter.playCommand.isEnabled = hasItem &amp;&amp; !isPlaying</span><span class="hljs-deletion">-    commandCenter.pauseCommand.isEnabled = hasItem &amp;&amp; isPlaying</span><span class="hljs-addition">+private func updateCommandAvailability(hasItem: Bool) &#123;</span><span class="hljs-addition">+    commandCenter.playCommand.isEnabled = hasItem</span><span class="hljs-addition">+    commandCenter.pauseCommand.isEnabled = hasItem</span>     commandCenter.togglePlayPauseCommand.isEnabled = hasItem     commandCenter.changePlaybackPositionCommand.isEnabled = hasItem &#125;</code></pre><p>最终验证：</p><ul><li>App 内暂停 → 控制中心&#x2F;通知中心&#x2F;锁屏&#x2F;灵动岛按钮图标立刻切到”paused”图标，进度条停止；</li><li>App 内继续 → 图标立刻切回”playing”图标，进度继续；</li><li>控制中心 &#x2F; 灵动岛 &#x2F; AirPods 上点暂停 → 同步；</li><li>切歌、seek、切内核都不破坏这个行为。</li></ul>]]>
    </content>
    <id>https://blog.leguans.cn/posts/ios-nowplaying-pause-button-stuck-fix/</id>
    <link href="https://blog.leguans.cn/posts/ios-nowplaying-pause-button-stuck-fix/"/>
    <published>2026-05-10T05:07:00.000Z</published>
    <summary>
      <![CDATA[iOS 控制中心媒体播放状态与软件内不同步的解决办法&排查思路：AVAudioEngine 暂停只关 playerNode 的坑最近在使用Swift原生开发重构一个 iOS 本地全格式音乐播放器...]]>
    </summary>
    <title>Swift：iOS 控制中心媒体播放状态与软件内不同步的解决办法和排查思路</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="Flutter" scheme="https://blog.leguans.cn/tags/Flutter/"/>
    <category term="iOS" scheme="https://blog.leguans.cn/tags/iOS/"/>
    <category term="just_audio" scheme="https://blog.leguans.cn/tags/just-audio/"/>
    <category term="BugFix" scheme="https://blog.leguans.cn/tags/BugFix/"/>
    <category term="audio_service" scheme="https://blog.leguans.cn/tags/audio-service/"/>
    <content>
      <![CDATA[<p>前段时间在重构播放器内核时，我顺手把一类最烦的 iOS 播放问题彻底收了一遍：<strong>后台自动切歌不稳定、锁屏&#x2F;通知栏点“下一首”偶发失灵，或者声音切了但元数据还停留在上一首</strong>。</p><p>这个问题最恶心的地方不在于“完全坏掉”，而在于它<strong>经常是 80% 正常，20% 抽风</strong>：</p><ul><li>前台切歌基本正常；</li><li>进后台以后，播完自动下一首偶发不切；</li><li>通知栏点下一首，有时能切，有时像没点到；</li><li>更诡异的是，有时声音已经切过去了，但歌名、封面、按钮绑定的歌曲还是旧的。</li></ul><p>涉及核心文件：</p><ul><li><code>lib/core/services/audio_manager.dart</code></li><li><code>ios/Runner/AppDelegate.swift</code></li><li><code>lib/core/services/ios_carplay_service.dart</code></li></ul><hr><h2 id="先说最终结论"><a href="#先说最终结论" class="headerlink" title="先说最终结论"></a>先说最终结论</h2><p>这次修完之后，我对这个问题的总结非常明确：</p><blockquote><p>iOS 后台自动切歌和通知栏“下一首”是否稳定，关键不在于你有没有写 <code>skipToNext()</code>，而在于你有没有把“切歌”当成一个完整的状态机来管理。</p></blockquote><p>真正稳定的实现，至少要满足这几条：</p><ul><li><strong>所有切歌入口统一收口</strong>：播放器按钮、通知栏、锁屏、CarPlay、自动切歌，最后都走同一条主链路。</li><li><strong>在线切歌必须串行化</strong>：任意时刻只能有一个活跃切歌任务。</li><li><strong>旧异步任务不能回写新状态</strong>：要有 <code>token</code> 防串写。</li><li><strong>切歌中再次点击不能简单吞掉</strong>：要记录“最后一次用户意图”，当前切歌结束后自动 drain。</li><li><strong>在线播放列表启动流程也要有独立 token</strong>：否则它会和手动切歌抢状态。</li><li><strong>iOS 后台缓存切歌不要傻等 <code>play()</code> Future</strong>：它可能很晚才 resolve，但音频其实已经播了。</li><li><strong>通知栏的 queueIndex 和 mediaItem 不能完全相信播放器内部 index</strong>：必须优先使用你自己维护的当前索引。</li></ul><p>下面按排查过程慢慢讲。</p><blockquote><p>需注意，本文仅供技术性学习参考，不代表你的软件完全适用，具体问题具体分析，如果遇到问题，欢迎评论区留言交流。 -by Leguan</p></blockquote><hr><h2 id="1-问题现象：看起来像“偶发失灵”，本质是多条状态链路抢写"><a href="#1-问题现象：看起来像“偶发失灵”，本质是多条状态链路抢写" class="headerlink" title="1. 问题现象：看起来像“偶发失灵”，本质是多条状态链路抢写"></a>1. 问题现象：看起来像“偶发失灵”，本质是多条状态链路抢写</h2><p>我最早观察到的现象有四类。</p><h3 id="1-1-自动切歌偶发不触发"><a href="#1-1-自动切歌偶发不触发" class="headerlink" title="1.1 自动切歌偶发不触发"></a>1.1 自动切歌偶发不触发</h3><p>歌曲在前台播放完时大多能自动切下一首，但一旦切到后台，尤其是在线歌曲，就会出现：</p><ul><li>明明还有下一首；</li><li>进度已经到结尾；</li><li>但就是停在那里不走。</li></ul><h3 id="1-2-通知栏-锁屏点下一首偶发没反应"><a href="#1-2-通知栏-锁屏点下一首偶发没反应" class="headerlink" title="1.2 通知栏&#x2F;锁屏点下一首偶发没反应"></a>1.2 通知栏&#x2F;锁屏点下一首偶发没反应</h3><p>这个现象在“当前歌曲刚切完、或者正处于切歌中”时更明显：</p><ul><li>点一次 next，没反应；</li><li>再点一次，又突然跳到后一首；</li><li>有时还会出现“第二次操作覆盖第一次”的错觉。</li></ul><h3 id="1-3-声音切过去了，但元数据还是旧的"><a href="#1-3-声音切过去了，但元数据还是旧的" class="headerlink" title="1.3 声音切过去了，但元数据还是旧的"></a>1.3 声音切过去了，但元数据还是旧的</h3><p>这是最迷惑人的一种。</p><p>用户主观体验是：</p><ul><li>耳朵听到已经是下一首；</li><li>但锁屏显示还是上一首；</li><li>播放页里某些区域也还没更新；</li><li>有时暂停一下，信息又“自己好了”。</li></ul><p>这类问题最容易让人误以为是 UI 层刷新 bug。</p><h3 id="1-4-日志多数正确，但体验依然错"><a href="#1-4-日志多数正确，但体验依然错" class="headerlink" title="1.4 日志多数正确，但体验依然错"></a>1.4 日志多数正确，但体验依然错</h3><p>更坑的是，很多日志看起来都很健康：</p><ul><li><code>targetIndex</code> 是对的；</li><li><code>setAudioSource</code> 是成功的；</li><li><code>play()</code> 也发起了；</li><li><code>SWITCH success</code> 也打出来了。</li></ul><p>但用户体验仍然不稳定。</p><p>这通常意味着：<strong>你看到的不是“某一步完全失败”，而是多个异步流程竞争状态，最后谁晚回来谁覆盖谁。</strong></p><hr><h2 id="2-第一轮误判：以为只是“切歌函数写得不够严谨”"><a href="#2-第一轮误判：以为只是“切歌函数写得不够严谨”" class="headerlink" title="2. 第一轮误判：以为只是“切歌函数写得不够严谨”"></a>2. 第一轮误判：以为只是“切歌函数写得不够严谨”</h2><p>最开始我以为这是个很普通的切歌重入问题。</p><p>于是第一轮修法很朴素：</p><ul><li>加 <code>_isSwitchingOnlineTrack</code> 锁；</li><li>切歌中直接忽略新的 next&#x2F;prev；</li><li>给 <code>skipToNext()</code> 加节流。</li></ul><p>看起来很合理，但很快发现两个副作用：</p><ul><li>用户连续点两下 next，第二下被吞了，体感上就是“按钮不灵”；</li><li>下一次计算目标索引时，有时还是基于旧的 <code>_currentIndex</code>，结果方向也会错。</li></ul><p>也就是说，只靠一个“正在切歌就 return”的入口锁，最多只是降低混乱，并没有真正解决竞争。</p><hr><h2 id="3-第二轮误判：加了-token，为什么还会被旧状态拉回去？"><a href="#3-第二轮误判：加了-token，为什么还会被旧状态拉回去？" class="headerlink" title="3. 第二轮误判：加了 token，为什么还会被旧状态拉回去？"></a>3. 第二轮误判：加了 token，为什么还会被旧状态拉回去？</h2><p>随后我把在线切歌链路加上了 <code>switchToken</code>。</p><p>这个思路本身是对的：</p><ul><li>每次切歌时递增 token；</li><li>在 <code>resolve -&gt; setAudioSource -&gt; play -&gt; commit</code> 的各个阶段检查 token；</li><li>发现 token 过期就立刻放弃，不允许旧任务再写 <code>_currentIndex / mediaItem / queue</code>。</li></ul><p>理论上这已经能挡住大部分“旧 Future 晚回来”的问题。</p><p>但实测仍然有一类异常：</p><ul><li>手动点 next 之后，音频已经切对了；</li><li>几百毫秒后，UI 又被“拉回去”；</li><li>日志里会混入 <code>ONLINE_START success</code> 一类晚到消息。</li></ul><p>这时候我才意识到：<strong>切歌并不是唯一一条会写播放状态的链路。</strong></p><hr><h2 id="4-真正根因：不是一个-race，而是四条链路在竞争"><a href="#4-真正根因：不是一个-race，而是四条链路在竞争" class="headerlink" title="4. 真正根因：不是一个 race，而是四条链路在竞争"></a>4. 真正根因：不是一个 race，而是四条链路在竞争</h2><p>最后梳理下来，真正互相竞争的其实是四条链路：</p><ol><li><strong>手动切歌链路</strong><br>  <code>skipToNext / skipToPrevious -&gt; playAtIndex -&gt; setAudioSource -&gt; play -&gt; commit</code></li><li><strong>自动切歌链路</strong><br>  <code>ProcessingState.completed -&gt; _handlePlaybackCompleted -&gt; skipToNext -&gt; playAtIndex</code></li><li><strong>在线播放列表启动链路</strong><br>  <code>setOnlinePlaylist -&gt; setAudioSource -&gt; play -&gt; commit</code></li><li><strong>系统通知栏状态链路</strong><br>  <code>PlaybackEvent -&gt; PlaybackState(queueIndex/mediaItem/controls) -&gt; iOS Now Playing</code></li></ol><p>只要这四条链路没有被统一收口，就一定会出现下面这些问题：</p><ul><li>某条旧链路晚回来回写状态；</li><li>当前歌曲索引和系统通知栏索引脱节；</li><li>音频已切到下一首，但系统仍显示上一首；</li><li>背景场景下 <code>play()</code> Future 很慢，结果元数据提交被拖住。</li></ul><p>所以后来我的修法也很明确了：</p><blockquote><p>不再把“自动切歌”“通知栏 next”“播放器按钮 next”当成三个问题，而是统一看成“切歌状态机”的三个入口。</p></blockquote><hr><h2 id="5-最终方案：把所有入口统一收口到一条主链路"><a href="#5-最终方案：把所有入口统一收口到一条主链路" class="headerlink" title="5. 最终方案：把所有入口统一收口到一条主链路"></a>5. 最终方案：把所有入口统一收口到一条主链路</h2><p>最终稳定下来的结构很简单：</p><pre><code class="hljs text">播放器按钮 next / prev锁屏、通知栏 next / prevCarPlay next / prev自动切歌 completed  -&gt; skipToNext() / skipToPrevious()  -&gt; playAtIndex(index)  -&gt; resolve / cache / setAudioSource / play  -&gt; commit currentIndex / mediaItem / queue / playbackState</code></pre><p>关键点只有一句话：</p><blockquote><p>自动切歌不要另写一套，通知栏点击也不要另写一套，最后都统一走 <code>playAtIndex(index)</code>。</p></blockquote><p>这样做的好处是：</p><ul><li>排查路径简单；</li><li>修一次逻辑，所有入口一起受益；</li><li>不会出现“前台按钮正常，通知栏异常，自动切歌又是另一套”的维护地狱。</li></ul><hr><h2 id="6-先解决自动切歌：完成事件只负责转发，不直接切-source"><a href="#6-先解决自动切歌：完成事件只负责转发，不直接切-source" class="headerlink" title="6. 先解决自动切歌：完成事件只负责转发，不直接切 source"></a>6. 先解决自动切歌：完成事件只负责转发，不直接切 source</h2><p>自动切歌这部分，我最后保留得非常克制。</p><p>监听 <code>processingStateStream</code>，状态进 <code>completed</code> 后，统一走 <code>_handlePlaybackCompleted()</code>：</p><pre><code class="hljs dart"><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">_handlePlaybackCompleted</span>() <span class="hljs-keyword">async</span> &#123;  <span class="hljs-comment">// Guard against duplicate completion notifications (can happen when</span>  <span class="hljs-comment">// switching sources quickly) to avoid racing setAudioSource/play calls.</span>  <span class="hljs-keyword">if</span> (_isHandlingCompletion) <span class="hljs-keyword">return</span>;  _isHandlingCompletion = <span class="hljs-keyword">true</span>;  <span class="hljs-keyword">try</span> &#123;    <span class="hljs-keyword">if</span> (_repeatMode == <span class="hljs-title class_">RepeatMode</span>.one) &#123;      <span class="hljs-keyword">await</span> <span class="hljs-title function_">seek</span>(<span class="hljs-title class_">Duration</span>.zero);      <span class="hljs-keyword">await</span> <span class="hljs-title function_">play</span>();    &#125; <span class="hljs-keyword">else</span> &#123;      <span class="hljs-keyword">await</span> <span class="hljs-title function_">skipToNext</span>();    &#125;  &#125; <span class="hljs-keyword">catch</span> (e, st) &#123;    <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[AudioManager] _handlePlaybackCompleted error: <span class="hljs-subst">$e</span>&#x27;</span>);    <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[AudioManager] _handlePlaybackCompleted stack: <span class="hljs-subst">$st</span>&#x27;</span>);  &#125; <span class="hljs-keyword">finally</span> &#123;    _isHandlingCompletion = <span class="hljs-keyword">false</span>;  &#125;&#125;</code></pre><p>这里的重点不是代码长短，而是职责边界：</p><ul><li><code>_handlePlaybackCompleted()</code> 不直接做 URL 解析；</li><li>不直接 set source；</li><li>不直接提交新歌曲元数据；</li><li>它只负责把“播放完成”这个事件，转成一次标准的 <code>skipToNext()</code>。</li></ul><p>这一步非常重要。因为一旦自动切歌另走一套私有逻辑，你后面就一定会出现：</p><ul><li>手动 next 修好了；</li><li>但自动 next 还是会有旧 bug。</li></ul><hr><h2 id="7-再解决通知栏-锁屏-next：原生只桥接，Flutter-统一处理"><a href="#7-再解决通知栏-锁屏-next：原生只桥接，Flutter-统一处理" class="headerlink" title="7. 再解决通知栏&#x2F;锁屏 next：原生只桥接，Flutter 统一处理"></a>7. 再解决通知栏&#x2F;锁屏 next：原生只桥接，Flutter 统一处理</h2><p>我这边的做法是让原生 iOS 尽量“薄”。</p><h3 id="7-1-Swift-侧不写业务逻辑，只桥接命令"><a href="#7-1-Swift-侧不写业务逻辑，只桥接命令" class="headerlink" title="7.1 Swift 侧不写业务逻辑，只桥接命令"></a>7.1 Swift 侧不写业务逻辑，只桥接命令</h3><p><code>AppDelegate.swift</code> 里基本就是这样：</p><pre><code class="hljs swift"><span class="hljs-keyword">func</span> <span class="hljs-title function_">skipToNext</span>(<span class="hljs-params">completion</span>: <span class="hljs-keyword">@escaping</span> (<span class="hljs-type">Result</span>&lt;<span class="hljs-type">Void</span>, <span class="hljs-type">Error</span>&gt;) -&gt; <span class="hljs-type">Void</span>) &#123;  invokeVoid(<span class="hljs-string">&quot;skipToNext&quot;</span>, completion: completion)&#125;<span class="hljs-keyword">func</span> <span class="hljs-title function_">skipToPrevious</span>(<span class="hljs-params">completion</span>: <span class="hljs-keyword">@escaping</span> (<span class="hljs-type">Result</span>&lt;<span class="hljs-type">Void</span>, <span class="hljs-type">Error</span>&gt;) -&gt; <span class="hljs-type">Void</span>) &#123;  invokeVoid(<span class="hljs-string">&quot;skipToPrevious&quot;</span>, completion: completion)&#125;</code></pre><p>也就是说：</p><ul><li>原生层不负责计算该跳到哪一首；</li><li>也不负责切 source；</li><li>所有核心逻辑仍然收敛到 Flutter 侧。</li></ul><h3 id="7-2-Flutter-里的媒体按钮也统一走-skip-方法"><a href="#7-2-Flutter-里的媒体按钮也统一走-skip-方法" class="headerlink" title="7.2 Flutter 里的媒体按钮也统一走 skip 方法"></a>7.2 Flutter 里的媒体按钮也统一走 skip 方法</h3><p><code>click()</code> 这一层也不做特殊逻辑，直接转发：</p><pre><code class="hljs dart"><span class="hljs-meta">@override</span><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">click</span>([<span class="hljs-title class_">MediaButton</span> button = <span class="hljs-title class_">MediaButton</span>.media]) <span class="hljs-keyword">async</span> &#123;  <span class="hljs-title function_">_logNotificationDebug</span>(<span class="hljs-string">&#x27;click() requested button=<span class="hljs-subst">$&#123;button.name&#125;</span>&#x27;</span>);  <span class="hljs-keyword">switch</span> (button) &#123;    <span class="hljs-keyword">case</span> <span class="hljs-title class_">MediaButton</span>.media:      <span class="hljs-keyword">if</span> (_player.playing) &#123;        <span class="hljs-keyword">await</span> <span class="hljs-title function_">pause</span>();      &#125; <span class="hljs-keyword">else</span> &#123;        <span class="hljs-keyword">await</span> <span class="hljs-title function_">play</span>();      &#125;      <span class="hljs-keyword">break</span>;    <span class="hljs-keyword">case</span> <span class="hljs-title class_">MediaButton</span>.next:      <span class="hljs-keyword">await</span> <span class="hljs-title function_">skipToNext</span>();      <span class="hljs-keyword">break</span>;    <span class="hljs-keyword">case</span> <span class="hljs-title class_">MediaButton</span>.previous:      <span class="hljs-keyword">await</span> <span class="hljs-title function_">skipToPrevious</span>();      <span class="hljs-keyword">break</span>;  &#125;  <span class="hljs-title function_">_logNotificationDebug</span>(<span class="hljs-string">&#x27;click() finished button=<span class="hljs-subst">$&#123;button.name&#125;</span>&#x27;</span>);&#125;</code></pre><p>这样一来，“播放器里的 next”和“通知栏点 next”就没有分叉了。</p><hr><h2 id="8-真正的核心：在线切歌状态机必须同时解决-3-件事"><a href="#8-真正的核心：在线切歌状态机必须同时解决-3-件事" class="headerlink" title="8. 真正的核心：在线切歌状态机必须同时解决 3 件事"></a>8. 真正的核心：在线切歌状态机必须同时解决 3 件事</h2><p>在线切歌比本地切歌麻烦很多，因为它天然有异步阶段：</p><ul><li>可能命中缓存；</li><li>可能要拿预加载 URL；</li><li>可能要重新 resolve；</li><li>可能还夹着歌词、封面、元数据更新。</li></ul><p>所以我最后把在线切歌状态机稳定下来，依赖的是这几个字段：</p><pre><code class="hljs dart"><span class="hljs-built_in">bool</span> _isSwitchingOnlineTrack = <span class="hljs-keyword">false</span>;<span class="hljs-title class_">DateTime</span>? _onlineTrackSwitchStartedAt;<span class="hljs-built_in">int</span> _onlineSwitchToken = <span class="hljs-number">0</span>;<span class="hljs-built_in">int?</span> _pendingOnlineSwitchIndex;<span class="hljs-built_in">int?</span> _queuedOnlineSwitchIndex;</code></pre><p>它们分别解决不同问题：</p><ul><li><code>_isSwitchingOnlineTrack</code>：当前是否有切歌任务在跑。</li><li><code>_pendingOnlineSwitchIndex</code>：正在执行的目标 index。</li><li><code>_queuedOnlineSwitchIndex</code>：用户切歌过程中又点了一次，最后想去哪里。</li><li><code>_onlineSwitchToken</code>：旧任务晚回来时，是否还有资格写状态。</li></ul><h3 id="8-1-skipToNext：切歌中不忽略，而是入队"><a href="#8-1-skipToNext：切歌中不忽略，而是入队" class="headerlink" title="8.1 skipToNext：切歌中不忽略，而是入队"></a>8.1 skipToNext：切歌中不忽略，而是入队</h3><p>这是最关键的一步之一。</p><p>以前很多实现会写成：</p><pre><code class="hljs dart"><span class="hljs-keyword">if</span> (_isSwitching) <span class="hljs-keyword">return</span>;</code></pre><p>这会让用户的第二次点击直接消失。</p><p>我的做法改成了“按 pending index 计算 + 只保留最后一次用户意图”：</p><pre><code class="hljs dart"><span class="hljs-meta">@override</span><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">skipToNext</span>() <span class="hljs-keyword">async</span> &#123;  <span class="hljs-keyword">if</span> (_playlist.isEmpty) <span class="hljs-keyword">return</span>;  <span class="hljs-keyword">final</span> now = <span class="hljs-title class_">DateTime</span>.<span class="hljs-title function_">now</span>();  <span class="hljs-keyword">var</span> baseIndex = (_isOnlinePlaylist &amp;&amp; _isSwitchingOnlineTrack)      ? (_pendingOnlineSwitchIndex ?? _currentIndex)      : _currentIndex;  <span class="hljs-keyword">if</span> (_isOnlinePlaylist &amp;&amp; _isSwitchingOnlineTrack) &#123;    <span class="hljs-keyword">final</span> startedAt = _onlineTrackSwitchStartedAt;    <span class="hljs-keyword">final</span> isTimedOut = startedAt != <span class="hljs-keyword">null</span> &amp;&amp;        now.<span class="hljs-title function_">difference</span>(startedAt) &gt; _onlineSwitchLockTimeout;    <span class="hljs-keyword">if</span> (isTimedOut) &#123;      <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[AudioManager] online switch lock timed out, reset lock&#x27;</span>);      _isSwitchingOnlineTrack = <span class="hljs-keyword">false</span>;      _onlineTrackSwitchStartedAt = <span class="hljs-keyword">null</span>;      _pendingOnlineSwitchIndex = <span class="hljs-keyword">null</span>;      baseIndex = _currentIndex;    &#125; <span class="hljs-keyword">else</span> &#123;      <span class="hljs-keyword">final</span> queuedIndex = <span class="hljs-title function_">_resolveNextIndex</span>(        baseIndex: baseIndex,        reshuffleOnWrap: <span class="hljs-keyword">true</span>,      );      <span class="hljs-keyword">if</span> (queuedIndex &lt; <span class="hljs-number">0</span>) <span class="hljs-keyword">return</span>;      <span class="hljs-title function_">debugPrint</span>(          <span class="hljs-string">&#x27;[AudioManager][CMD] skipToNext currentIndex=<span class="hljs-subst">$_currentIndex</span> baseIndex=<span class="hljs-subst">$baseIndex</span> pending=<span class="hljs-subst">$_pendingOnlineSwitchIndex</span> queued=<span class="hljs-subst">$_queuedOnlineSwitchIndex</span> playerIndex=<span class="hljs-subst">$&#123;_player.currentIndex&#125;</span> pos=<span class="hljs-subst">$&#123;_player.position.inMilliseconds&#125;</span>ms currentTitle=<span class="hljs-subst">$&#123;currentSong?.title&#125;</span>&#x27;</span>);      _queuedOnlineSwitchIndex = queuedIndex;      <span class="hljs-title function_">debugPrint</span>(          <span class="hljs-string">&#x27;[AudioManager][CMD] skipToNext queued targetIndex=<span class="hljs-subst">$queuedIndex</span> queued=<span class="hljs-subst">$_queuedOnlineSwitchIndex</span> baseIndex=<span class="hljs-subst">$baseIndex</span>&#x27;</span>);      <span class="hljs-keyword">return</span>;    &#125;  &#125;  <span class="hljs-keyword">final</span> nextIndex = <span class="hljs-title function_">_resolveNextIndex</span>(    baseIndex: baseIndex,    reshuffleOnWrap: <span class="hljs-keyword">true</span>,  );  <span class="hljs-keyword">if</span> (nextIndex &lt; <span class="hljs-number">0</span>) <span class="hljs-keyword">return</span>;  <span class="hljs-keyword">if</span> (_lastSkipToNextAt != <span class="hljs-keyword">null</span> &amp;&amp;      now.<span class="hljs-title function_">difference</span>(_lastSkipToNextAt!) &lt; _skipToNextThrottle) &#123;    <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[AudioManager] skipToNext throttled&#x27;</span>);    <span class="hljs-keyword">return</span>;  &#125;  _lastSkipToNextAt = now;  <span class="hljs-keyword">await</span> <span class="hljs-title function_">playAtIndex</span>(nextIndex);&#125;</code></pre><p>这一段的意义是：</p><ul><li>当前切歌没完成时，新的点击不会立即执行；</li><li>但也不会被吞掉；</li><li>当前切歌结束后，会自动接管 <code>_queuedOnlineSwitchIndex</code>。</li></ul><p>用户体感会从“按钮不灵”变成“虽然忙，但会接着响应我最后一次操作”。</p><hr><h2 id="9-playAtIndex：这才是整个系统真正的中心"><a href="#9-playAtIndex：这才是整个系统真正的中心" class="headerlink" title="9. playAtIndex：这才是整个系统真正的中心"></a>9. playAtIndex：这才是整个系统真正的中心</h2><p>所有修复最终都落在 <code>playAtIndex(int index)</code> 上。</p><p>这段逻辑里，我最后确认必须处理好三件事：</p><ol><li>如果在线播放列表启动流程还没结束，先取消旧启动会话。</li><li>如果当前已经在切歌，不再直接执行，而是记录 queued target。</li><li>真正执行切歌时，整个过程都要受 <code>switchToken</code> 保护。</li></ol><p>核心代码如下：</p><pre><code class="hljs dart"><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">playAtIndex</span>(<span class="hljs-built_in">int</span> index) <span class="hljs-keyword">async</span> &#123;  <span class="hljs-keyword">if</span> (index &lt; <span class="hljs-number">0</span> || index &gt;= _playlist.length) <span class="hljs-keyword">return</span>;  <span class="hljs-comment">// Cancel any ongoing fallback recovery for a previous track.</span>  _fallbackManager.<span class="hljs-title function_">cancelCurrentFallback</span>();  <span class="hljs-comment">// Handle online playlist - need to resolve URL and create new audio source</span>  <span class="hljs-keyword">if</span> (_isOnlinePlaylist &amp;&amp; _onlineSongList != <span class="hljs-keyword">null</span> &amp;&amp; _urlResolver != <span class="hljs-keyword">null</span>) &#123;    _onlineRecoveryShouldResumePlayback = <span class="hljs-keyword">true</span>;    <span class="hljs-keyword">final</span> boardSong = _onlineSongList![index];    <span class="hljs-keyword">if</span> (_isSettingOnlinePlaylist) &#123;      <span class="hljs-keyword">final</span> canceledToken = _onlinePlaylistSessionToken;      _onlinePlaylistSessionToken++;      _isSettingOnlinePlaylist = <span class="hljs-keyword">false</span>;      <span class="hljs-title function_">debugPrint</span>(          <span class="hljs-string">&#x27;[AudioManager][SWITCH] cancel pending online start token=<span class="hljs-subst">$canceledToken</span> by manual switch target=<span class="hljs-subst">$index</span>&#x27;</span>);    &#125;    <span class="hljs-keyword">if</span> (_isSwitchingOnlineTrack) &#123;      _queuedOnlineSwitchIndex = index;      _pendingOnlineSwitchIndex = index;      <span class="hljs-title function_">_updatePlaybackState</span>();      <span class="hljs-title function_">debugPrint</span>(          <span class="hljs-string">&#x27;[AudioManager][SWITCH] switching in progress, queue target index=<span class="hljs-subst">$index</span> queued=<span class="hljs-subst">$_queuedOnlineSwitchIndex</span> pending=<span class="hljs-subst">$_pendingOnlineSwitchIndex</span> current=<span class="hljs-subst">$_currentIndex</span>&#x27;</span>);      <span class="hljs-keyword">return</span>;    &#125;    _isSwitchingOnlineTrack = <span class="hljs-keyword">true</span>;    _onlineTrackSwitchStartedAt = <span class="hljs-title class_">DateTime</span>.<span class="hljs-title function_">now</span>();    _pendingOnlineSwitchIndex = index;    <span class="hljs-title function_">_updatePlaybackState</span>();    <span class="hljs-keyword">final</span> switchToken = ++_onlineSwitchToken;    <span class="hljs-keyword">try</span> &#123;      <span class="hljs-comment">// ... resolve / cached source / network source / play</span>      <span class="hljs-keyword">if</span> (switchToken != _onlineSwitchToken) &#123;        <span class="hljs-title function_">debugPrint</span>(            <span class="hljs-string">&#x27;[AudioManager][SWITCH] stale network switch ignored token=<span class="hljs-subst">$switchToken</span> latest=<span class="hljs-subst">$_onlineSwitchToken</span> index=<span class="hljs-subst">$index</span>&#x27;</span>);        <span class="hljs-keyword">return</span>;      &#125;      _currentIndex = index;      <span class="hljs-title function_">_updateNowPlayingMediaItem</span>(mediaItems[index], force: <span class="hljs-keyword">true</span>);      <span class="hljs-title function_">_updatePlaybackState</span>();    &#125; <span class="hljs-keyword">finally</span> &#123;      <span class="hljs-keyword">if</span> (switchToken == _onlineSwitchToken) &#123;        _isSwitchingOnlineTrack = <span class="hljs-keyword">false</span>;        _onlineTrackSwitchStartedAt = <span class="hljs-keyword">null</span>;        _pendingOnlineSwitchIndex = <span class="hljs-keyword">null</span>;        <span class="hljs-keyword">final</span> queuedIndex = _queuedOnlineSwitchIndex;        <span class="hljs-keyword">if</span> (queuedIndex != <span class="hljs-keyword">null</span> &amp;&amp; queuedIndex != _currentIndex) &#123;          _queuedOnlineSwitchIndex = <span class="hljs-keyword">null</span>;          <span class="hljs-title function_">debugPrint</span>(              <span class="hljs-string">&#x27;[AudioManager][SWITCH] drain queued switch queued=<span class="hljs-subst">$queuedIndex</span> current=<span class="hljs-subst">$_currentIndex</span> token=<span class="hljs-subst">$switchToken</span>&#x27;</span>);          <span class="hljs-title function_">unawaited</span>(<span class="hljs-title function_">playAtIndex</span>(queuedIndex));        &#125; <span class="hljs-keyword">else</span> &#123;          _queuedOnlineSwitchIndex = <span class="hljs-keyword">null</span>;        &#125;      &#125;    &#125;  &#125;&#125;</code></pre><p>这一段基本把问题全部收口了。</p><p>它解决的是三个最现实的 bug：</p><ul><li><strong>旧 start 会话晚到回写</strong></li><li><strong>旧切歌任务晚到回写</strong></li><li><strong>切歌中用户再次点击被吞掉</strong></li></ul><hr><h2 id="10-iOS-后台最关键的坑：缓存切歌时，不要等-play-Future-返回"><a href="#10-iOS-后台最关键的坑：缓存切歌时，不要等-play-Future-返回" class="headerlink" title="10. iOS 后台最关键的坑：缓存切歌时，不要等 play() Future 返回"></a>10. iOS 后台最关键的坑：缓存切歌时，不要等 <code>play()</code> Future 返回</h2><p>这一步是我觉得最“值钱”的结论。</p><p>日志里我反复看到这种现象：</p><ul><li><code>iOS cached source set ok</code> 很快出现；</li><li>但 <code>await _player.play()</code> 可能几秒，甚至十几秒后才返回；</li><li>更离谱的是，有时音频已经播了，<code>play()</code> Future 还没 resolve。</li></ul><p>如果你这时的代码顺序是：</p><pre><code class="hljs dart"><span class="hljs-keyword">await</span> _player.<span class="hljs-title function_">setAudioSource</span>(source);<span class="hljs-keyword">await</span> _player.<span class="hljs-title function_">play</span>();_currentIndex = index;mediaItem.<span class="hljs-title function_">add</span>(...);</code></pre><p>那就等于把“元数据切换”绑死在 <code>play()</code> Future 的完成时机上。</p><p>在 iOS 后台场景下，这个绑定非常危险。</p><p>所以我最后改成了：</p><pre><code class="hljs dart"><span class="hljs-keyword">if</span> (<span class="hljs-title class_">Platform</span>.isIOS) &#123;  <span class="hljs-title function_">debugPrint</span>(      <span class="hljs-string">&#x27;[AudioManager] iOS: Full audio reset for background track switch&#x27;</span>);  <span class="hljs-keyword">try</span> &#123;    <span class="hljs-comment">// Stop completely first</span>    <span class="hljs-keyword">await</span> _player.<span class="hljs-title function_">stop</span>();    <span class="hljs-keyword">await</span> <span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt;.<span class="hljs-title function_">delayed</span>(<span class="hljs-keyword">const</span> <span class="hljs-title class_">Duration</span>(milliseconds: <span class="hljs-number">50</span>));    <span class="hljs-comment">// Re-activate audio session</span>    <span class="hljs-keyword">final</span> session = <span class="hljs-keyword">await</span> <span class="hljs-title class_">AudioSession</span>.instance;    <span class="hljs-keyword">await</span> session.<span class="hljs-title function_">setActive</span>(<span class="hljs-keyword">true</span>);    <span class="hljs-keyword">final</span> fileUri = <span class="hljs-title class_">Uri</span>.<span class="hljs-title function_">file</span>(cachedAudio);    <span class="hljs-keyword">final</span> audioSource = <span class="hljs-title function_">_buildOnlineProgressiveSource</span>(      fileUri,      song: updatedSong,      duration: <span class="hljs-title class_">Duration</span>(milliseconds: updatedSong.duration),    );    <span class="hljs-keyword">await</span> <span class="hljs-title function_">_setOnlineSingleSourceForSwitch</span>(      audioSource,      playlistIndex: index,      reason: <span class="hljs-string">&#x27;switch_ios_cached:index=<span class="hljs-subst">$index</span>&#x27;</span>,    );    <span class="hljs-title function_">debugPrint</span>(        <span class="hljs-string">&#x27;[AudioManager][SWITCH] iOS cached source set ok index=<span class="hljs-subst">$index</span>&#x27;</span>);    <span class="hljs-keyword">final</span> playFuture = _player.<span class="hljs-title function_">play</span>();    <span class="hljs-title function_">unawaited</span>(playFuture.<span class="hljs-title function_">then</span>((_) &#123;      <span class="hljs-title function_">debugPrint</span>(          <span class="hljs-string">&#x27;[AudioManager][SWITCH] iOS cached play completed token=<span class="hljs-subst">$switchToken</span> index=<span class="hljs-subst">$index</span>&#x27;</span>);    &#125;).<span class="hljs-title function_">catchError</span>((<span class="hljs-title class_">Object</span> e, <span class="hljs-title class_">StackTrace</span> st) &#123;      <span class="hljs-title function_">debugPrint</span>(          <span class="hljs-string">&#x27;[AudioManager][SWITCH] iOS cached play failed token=<span class="hljs-subst">$switchToken</span> index=<span class="hljs-subst">$index</span> error=<span class="hljs-subst">$e</span>&#x27;</span>);    &#125;));    <span class="hljs-title function_">debugPrint</span>(        <span class="hljs-string">&#x27;[AudioManager][SWITCH] iOS cached play requested token=<span class="hljs-subst">$switchToken</span> index=<span class="hljs-subst">$index</span>&#x27;</span>);    <span class="hljs-keyword">if</span> (switchToken != _onlineSwitchToken) &#123;      <span class="hljs-title function_">debugPrint</span>(          <span class="hljs-string">&#x27;[AudioManager][SWITCH] stale iOS cached switch ignored token=<span class="hljs-subst">$switchToken</span> latest=<span class="hljs-subst">$_onlineSwitchToken</span> index=<span class="hljs-subst">$index</span>&#x27;</span>);      <span class="hljs-keyword">return</span>;    &#125;  &#125; <span class="hljs-keyword">catch</span> (e) &#123;    <span class="hljs-title function_">debugPrint</span>(        <span class="hljs-string">&#x27;[AudioManager][SWITCH] iOS background audio reset failed index=<span class="hljs-subst">$index</span> error=<span class="hljs-subst">$e</span>&#x27;</span>);    <span class="hljs-keyword">rethrow</span>;  &#125;&#125;</code></pre><p>这里真正关键的不是“加了 stop()”或者“加了 50ms delay”。</p><p>真正关键的是这句：</p><pre><code class="hljs dart"><span class="hljs-keyword">final</span> playFuture = _player.<span class="hljs-title function_">play</span>();<span class="hljs-title function_">unawaited</span>(playFuture)</code></pre><p>换句话说：</p><ul><li><code>play()</code> 要发起；</li><li>但<strong>状态提交不要被 <code>play()</code> Future 的完成时机绑架</strong>。</li></ul><p>这一步改完之后，iOS 后台“声音已经切了，但系统元数据还没切”的问题明显少了很多。</p><hr><h2 id="11-只修切歌还不够：在线播放列表启动流程也要防“晚到回写”"><a href="#11-只修切歌还不够：在线播放列表启动流程也要防“晚到回写”" class="headerlink" title="11. 只修切歌还不够：在线播放列表启动流程也要防“晚到回写”"></a>11. 只修切歌还不够：在线播放列表启动流程也要防“晚到回写”</h2><p>前面说过，<code>playAtIndex</code> 不是唯一会写播放状态的链路。</p><p>如果你的项目也有 <code>setOnlinePlaylist(...)</code> 这种“加载列表并自动播第一首”的入口，那它本身也必须带 token。</p><p>我这边是这样处理的：</p><pre><code class="hljs dart"><span class="hljs-keyword">final</span> sessionToken = ++_onlinePlaylistSessionToken;<span class="hljs-keyword">await</span> _player.<span class="hljs-title function_">setAudioSource</span>(  _onlineConcatenatingSource!,  initialIndex: <span class="hljs-number">0</span>,);<span class="hljs-keyword">if</span> (sessionToken != _onlinePlaylistSessionToken) &#123;  <span class="hljs-title function_">debugPrint</span>(      <span class="hljs-string">&#x27;[AudioManager][ONLINE_START] stale after setAudioSource token=<span class="hljs-subst">$sessionToken</span> latest=<span class="hljs-subst">$_onlinePlaylistSessionToken</span> ignored&#x27;</span>);  <span class="hljs-keyword">return</span>;&#125;<span class="hljs-title function_">_updateNowPlayingMediaItem</span>(mediaItems[_currentIndex], force: <span class="hljs-keyword">true</span>);<span class="hljs-title function_">_updatePlaybackState</span>();<span class="hljs-keyword">if</span> (autoPlay) &#123;  <span class="hljs-keyword">await</span> <span class="hljs-title function_">play</span>();  <span class="hljs-keyword">if</span> (sessionToken != _onlinePlaylistSessionToken) &#123;    <span class="hljs-title function_">debugPrint</span>(        <span class="hljs-string">&#x27;[AudioManager][ONLINE_START] stale after play token=<span class="hljs-subst">$sessionToken</span> latest=<span class="hljs-subst">$_onlinePlaylistSessionToken</span> ignored&#x27;</span>);    <span class="hljs-keyword">return</span>;  &#125;&#125;</code></pre><p>这样做的意义是：</p><ul><li>如果用户在在线播放列表启动过程中手动切歌；</li><li>旧启动流程即使晚回来，也会因为 token 过期被直接丢弃；</li><li>不会再把当前状态“拉回初始化那首歌”。</li></ul><p>很多“明明切歌成功，过一会儿又回去了”的问题，本质都在这里。</p><hr><h2 id="12-通知栏为什么会显示错歌？因为系统的-queueIndex-未必可信"><a href="#12-通知栏为什么会显示错歌？因为系统的-queueIndex-未必可信" class="headerlink" title="12. 通知栏为什么会显示错歌？因为系统的 queueIndex 未必可信"></a>12. 通知栏为什么会显示错歌？因为系统的 queueIndex 未必可信</h2><p>这一步是很多人容易忽略的。</p><p><code>audio_service</code> 最终给 iOS Now Playing 的，不只是“播放&#x2F;暂停状态”，还包括：</p><ul><li>当前的 <code>mediaItem</code></li><li>当前的 <code>queueIndex</code></li><li>对应的 controls</li></ul><p>如果 <code>queueIndex</code> 落后，或者 <code>mediaItem</code> 更新滞后，系统通知栏就会出现明显错位。</p><p>我最后做了两件事。</p><h3 id="12-1-queueIndex-优先使用自己维护的-currentIndex"><a href="#12-1-queueIndex-优先使用自己维护的-currentIndex" class="headerlink" title="12.1 queueIndex 优先使用自己维护的 _currentIndex"></a>12.1 queueIndex 优先使用自己维护的 <code>_currentIndex</code></h3><pre><code class="hljs dart"><span class="hljs-title class_">PlaybackState</span> <span class="hljs-title function_">_transformEvent</span>(<span class="hljs-title class_">PlaybackEvent</span> event) &#123;  <span class="hljs-keyword">return</span> <span class="hljs-title class_">PlaybackState</span>(    controls: [      <span class="hljs-title class_">MediaControl</span>.skipToPrevious,      <span class="hljs-title class_">Platform</span>.isAndroid          ? playPauseControl          : (_player.playing ? <span class="hljs-title class_">MediaControl</span>.pause : <span class="hljs-title class_">MediaControl</span>.play),      <span class="hljs-title class_">MediaControl</span>.skipToNext,    ],    systemActions: <span class="hljs-keyword">const</span> &#123;      <span class="hljs-title class_">MediaAction</span>.play,      <span class="hljs-title class_">MediaAction</span>.pause,      <span class="hljs-title class_">MediaAction</span>.playPause,      <span class="hljs-title class_">MediaAction</span>.seek,      <span class="hljs-title class_">MediaAction</span>.seekForward,      <span class="hljs-title class_">MediaAction</span>.seekBackward,      <span class="hljs-title class_">MediaAction</span>.skipToNext,      <span class="hljs-title class_">MediaAction</span>.skipToPrevious,      <span class="hljs-title class_">MediaAction</span>.setShuffleMode,      <span class="hljs-title class_">MediaAction</span>.setRepeatMode,    &#125;,    androidCompactActionIndices: <span class="hljs-keyword">const</span> [<span class="hljs-number">0</span>, <span class="hljs-number">1</span>, <span class="hljs-number">2</span>],    processingState: <span class="hljs-keyword">const</span> &#123;      <span class="hljs-title class_">ProcessingState</span>.idle: <span class="hljs-title class_">AudioProcessingState</span>.loading,      <span class="hljs-title class_">ProcessingState</span>.loading: <span class="hljs-title class_">AudioProcessingState</span>.loading,      <span class="hljs-title class_">ProcessingState</span>.buffering: <span class="hljs-title class_">AudioProcessingState</span>.buffering,      <span class="hljs-title class_">ProcessingState</span>.ready: <span class="hljs-title class_">AudioProcessingState</span>.ready,      <span class="hljs-title class_">ProcessingState</span>.completed: <span class="hljs-title class_">AudioProcessingState</span>.completed,    &#125;[_player.processingState]!,    playing: _player.playing,    updatePosition: _player.position,    bufferedPosition: _player.bufferedPosition,    speed: _player.speed,    queueIndex: _currentIndex &gt;= <span class="hljs-number">0</span> ? _currentIndex : event.currentIndex,  );&#125;</code></pre><p>这句：</p><pre><code class="hljs dart">queueIndex: _currentIndex &gt;= <span class="hljs-number">0</span> ? _currentIndex : event.currentIndex</code></pre><p>非常关键。</p><p>因为在某些在线场景里，播放器内部的 <code>event.currentIndex</code> 并不等于你业务上的当前歌曲索引。</p><h3 id="12-2-切歌成功后主动推送新的-mediaItem"><a href="#12-2-切歌成功后主动推送新的-mediaItem" class="headerlink" title="12.2 切歌成功后主动推送新的 mediaItem"></a>12.2 切歌成功后主动推送新的 mediaItem</h3><p>我没有完全依赖播放器事件自己同步，而是在切歌成功后主动调用：</p><pre><code class="hljs dart"><span class="hljs-title function_">_updateNowPlayingMediaItem</span>(mediaItems[index], force: <span class="hljs-keyword">true</span>);</code></pre><p>这样做的好处是：</p><ul><li>一旦业务层已经确认“当前歌就是这首”；</li><li>就立即把它推给系统；</li><li>不再被动等待底层事件什么时候更新到位。</li></ul><p>这一步对锁屏元数据一致性非常重要。</p><hr><h2 id="13-一个容易忽略的细节：idle-不一定应该映射成系统-idle"><a href="#13-一个容易忽略的细节：idle-不一定应该映射成系统-idle" class="headerlink" title="13. 一个容易忽略的细节：idle 不一定应该映射成系统 idle"></a>13. 一个容易忽略的细节：idle 不一定应该映射成系统 idle</h2><p>我这里还顺手修了一个很隐蔽的问题。</p><p>在某些切歌瞬间，播放器会短暂进入 <code>ProcessingState.idle</code>。如果这时你直接把它映射成系统的 <code>AudioProcessingState.idle</code>，iOS 可能会认为当前 Now Playing 会话已经结束。</p><p>所以最终我在系统状态映射里故意做了这个处理：</p><pre><code class="hljs dart">processingState: <span class="hljs-keyword">const</span> &#123;  <span class="hljs-comment">// <span class="hljs-doctag">NOTE:</span> Map idle→loading (not idle) to prevent iOS from killing the</span>  <span class="hljs-comment">// Now Playing session during track transitions.</span>  <span class="hljs-title class_">ProcessingState</span>.idle: <span class="hljs-title class_">AudioProcessingState</span>.loading,  <span class="hljs-title class_">ProcessingState</span>.loading: <span class="hljs-title class_">AudioProcessingState</span>.loading,  <span class="hljs-title class_">ProcessingState</span>.buffering: <span class="hljs-title class_">AudioProcessingState</span>.buffering,  <span class="hljs-title class_">ProcessingState</span>.ready: <span class="hljs-title class_">AudioProcessingState</span>.ready,  <span class="hljs-title class_">ProcessingState</span>.completed: <span class="hljs-title class_">AudioProcessingState</span>.completed,&#125;[_player.processingState]!,</code></pre><p>这个改动不大，但对 iOS 后台切歌过程的稳定性是有帮助的。</p><p>因为从系统视角看，切歌瞬间更接近“正在 loading 下一首”，而不是“播放会话结束了”。</p><hr><h2 id="14-复测时我主要盯哪些日志"><a href="#14-复测时我主要盯哪些日志" class="headerlink" title="14. 复测时我主要盯哪些日志"></a>14. 复测时我主要盯哪些日志</h2><p>这类问题如果没有日志，基本只能靠猜。</p><p>我后来重点盯的是这些信号：</p><ul><li><code>cancel pending online start token=...</code></li><li><code>switching in progress, queue target index=...</code></li><li><code>iOS cached source set ok</code></li><li><code>iOS cached play requested</code></li><li><code>stale ... ignored</code></li><li><code>drain queued switch queued=...</code></li></ul><p>如果这些日志顺序是健康的，通常状态链路就是对的。</p><p>一个比较理想的切歌日志序列，大概会长这样：</p><pre><code class="hljs text">[AudioManager][CMD] skipToNext targetIndex=12[AudioManager][SWITCH] cancel pending online start token=7 by manual switch target=12[AudioManager][SWITCH] start token=21 index=12 current=11 title=...[AudioManager][SWITCH] iOS cached source set ok index=12[AudioManager][SWITCH] iOS cached play requested token=21 index=12[AudioManager][SYNC] switched(cached) currentIndex=12 ...</code></pre><p>如果用户在切歌中又点了一次 next，还会看到：</p><pre><code class="hljs text">[AudioManager][CMD] skipToNext queued targetIndex=13[AudioManager][SWITCH] drain queued switch queued=13 current=12 token=21</code></pre><p>这个“drain queued switch”非常关键，它代表第二次点击没有丢。</p><hr><h2 id="15-本方法要点"><a href="#15-本方法要点" class="headerlink" title="15. 本方法要点"></a>15. 本方法要点</h2><h3 id="15-1-自动切歌最终调用-skipToNext"><a href="#15-1-自动切歌最终调用-skipToNext" class="headerlink" title="15.1 自动切歌最终调用 skipToNext()"></a>15.1 自动切歌最终调用 <code>skipToNext()</code></h3><p>不要自己另写一套自动切歌逻辑。</p><h3 id="15-2-在线切歌增加这四个状态字段"><a href="#15-2-在线切歌增加这四个状态字段" class="headerlink" title="15.2 在线切歌增加这四个状态字段"></a>15.2 在线切歌增加这四个状态字段</h3><pre><code class="hljs dart"><span class="hljs-built_in">bool</span> _isSwitchingOnlineTrack = <span class="hljs-keyword">false</span>;<span class="hljs-title class_">DateTime</span>? _onlineTrackSwitchStartedAt;<span class="hljs-built_in">int</span> _onlineSwitchToken = <span class="hljs-number">0</span>;<span class="hljs-built_in">int?</span> _pendingOnlineSwitchIndex;<span class="hljs-built_in">int?</span> _queuedOnlineSwitchIndex;</code></pre><h3 id="15-3-切歌中不要简单-return，要记录-queued-target"><a href="#15-3-切歌中不要简单-return，要记录-queued-target" class="headerlink" title="15.3 切歌中不要简单 return，要记录 queued target"></a>15.3 切歌中不要简单 return，要记录 queued target</h3><p>否则按钮会“像坏了一样”。</p><h3 id="15-4-如果有在线播放列表初始化流程，也必须带-session-token"><a href="#15-4-如果有在线播放列表初始化流程，也必须带-session-token" class="headerlink" title="15.4 如果有在线播放列表初始化流程，也必须带 session token"></a>15.4 如果有在线播放列表初始化流程，也必须带 session token</h3><p>否则旧启动流程会回写状态。</p><h3 id="15-5-iOS-后台缓存切歌时，不要等-await-player-play"><a href="#15-5-iOS-后台缓存切歌时，不要等-await-player-play" class="headerlink" title="15.5 iOS 后台缓存切歌时，不要等 await player.play()"></a>15.5 iOS 后台缓存切歌时，不要等 <code>await player.play()</code></h3><p>这是解决“声音切了但元数据还没切”的关键之一。</p><h3 id="15-6-queueIndex-优先用自己维护的业务索引"><a href="#15-6-queueIndex-优先用自己维护的业务索引" class="headerlink" title="15.6 queueIndex 优先用自己维护的业务索引"></a>15.6 <code>queueIndex</code> 优先用自己维护的业务索引</h3><p>不要完全依赖 <code>event.currentIndex</code>。</p><hr><h2 id="16-小结"><a href="#16-小结" class="headerlink" title="16. 小结"></a>16. 小结</h2><p>回头看，这次问题最有意思的地方是：</p><p>你一开始会以为它是：</p><ul><li>某个按钮监听没接对；</li><li>某次 <code>skipToNext()</code> 没执行；</li><li>或者某个 UI 刷新晚了。</li></ul><p>但真正的根因其实是：</p><blockquote><p>播放器、通知栏、自动切歌、在线播放启动，这几条链路都能改同一份状态，但之前没有统一的切歌状态机去收口它们。</p></blockquote><p>这次最终稳定下来，靠的不是某个神奇 hack，而是把职责重新拉直了：</p><ul><li>入口统一；</li><li>切歌串行；</li><li>旧任务失效；</li><li>用户意图排队；</li><li>系统状态由业务索引主导；</li><li>iOS 后台的 <code>play()</code> 慢返回不再拖住元数据提交。</li></ul><p>如果你在做 Flutter 音乐播放器，卡在 iOS 后台自动切歌或通知栏 next 这类问题上，我最建议先检查的，不是 UI，而是：</p><ol><li>你的自动切歌和手动切歌是不是同一条主链路；</li><li>你的在线切歌是不是有 <code>token + queued target</code>；</li><li>你的通知栏 <code>queueIndex</code> 和 <code>mediaItem</code> 是不是由业务层真实当前歌曲驱动。</li></ol><p>把这三件事处理好，很多“看起来很玄学”的 iOS 后台播放 bug，都会一下子变得非常具体，也非常好修。</p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/Flutter-iOS-skipToNext-fix/</id>
    <link href="https://blog.leguans.cn/posts/Flutter-iOS-skipToNext-fix/"/>
    <published>2026-04-23T06:22:00.000Z</published>
    <summary>Flutter/iOS 后台自动切歌与通知栏“下一首”稳定实现方法前段时间在重构播放器内核时，我顺手把一类最烦的 iOS 播放问题彻底收了一遍：后台自动切歌不稳定、锁屏/通知栏点“下一首”偶发失...</summary>
    <title>Flutter/iOS 后台自动切歌与通知栏手动“下一首”稳定实现方法</title>
    <updated>2026-08-31T13:43:56.523Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="搞七捻三" scheme="https://blog.leguans.cn/categories/%E6%90%9E%E4%B8%83%E6%8D%BB%E4%B8%89/"/>
    <category term="SollinPlayer" scheme="https://blog.leguans.cn/tags/SollinPlayer/"/>
    <category term="开发日记" scheme="https://blog.leguans.cn/tags/%E5%BC%80%E5%8F%91%E6%97%A5%E8%AE%B0/"/>
    <content>
      <![CDATA[<p><img src="/images/posts/ai-replace/appstore-review-reject.webp" alt="iShot_2026-04-19_22.42.32.png"><br>这事儿已经过去一个月了，今天翻到当时的审核邮件，突然想记下来。大半年耗在一个音乐App上，UI改了八版，bug调得快吐了，提交审核的时候还跟朋友吹，说这简洁度Apple肯定爱，结果反手就收到三封拒绝邮件，当时看完人直接傻了。</p><p>当时直接存了审核邮件原文，没删没改，现在贴出来，大家随便看看：</p><blockquote><p>Guideline 1.1.4 - Safety - Objectionable Content<br>The app includes content that is considered pornographic.<br>Apps with sexually explicit content and themes are not appropriate for the App Store.</p></blockquote><p>说白了就是：你这App涉黄，不能上，回去改。现在回头看这句话，还是觉得离谱。</p><p>当时我盯着这行字看了三分钟，先去核对了提交的包，又怀疑审核员看串了项目——我这是音乐App啊，纯听歌的，界面素得不能再素，怎么就跟涉黄挂上钩了？</p><p>说实话，为了过审，我界面做得比Apple官网还素，半点儿擦边的东西都没有，社交功能不敢加，歌词只敢用官方纯文本，动态特效全砍了，就怕审核挑刺。核心就三个功能：听歌、建歌单、导本地音乐，干净得能反光，至今想不通问题出在哪。</p><p>当时更离谱的是，除了涉黄，还有两个拒绝理由：Guideline 2.3.1说我有隐藏功能，Guideline 1.1说我有冒犯性内容。</p><p>我当时把代码翻来覆去看了好几遍，连注释都没放过，也没找着什么隐藏功能。难不成我写的播放暂停键，在审核员眼里是啥隐藏涉黄开关？现在想起来，还觉得好笑。</p><p>当时还忍不住自我怀疑：是不是App图标有问题？那是我自己画的简单音符，纯色背景，连渐变色都不敢用；还是歌单名字踩雷了？“深夜治愈”“通勤必听”“学习专注”，这要是算冒犯，Apple音乐里的歌单不得全下架？</p><p>后来跟几个做iOS开发的朋友吐了个槽，才知道我不是唯一一个冤种。有个朋友做工具App，按钮颜色亮了点，被说可能让用户不适；还有个做读书App，就因为有夜间模式，被怀疑在深色模式里藏违规内容。合着Apple审核，当年全看审核员当天心情好坏？</p><p>当时最讽刺的是，我去App Store搜了下，真正擦边、伪装成工具的涉黄App，反而能正常上架，有的还能上排行榜。乔布斯当年说“想要色情内容就买Android”，结果那时候Apple Store里漏网之鱼一堆，我这个纯音乐App，倒成了重点打击对象，现在想起来，还是觉得迷惑。</p><p>我当时特意去翻了Apple的审核指南，里面说“拒绝越界内容”，但什么是越界，没任何明确标准，就一句“出现了我就知道”。合着全凭主观判断？他说你涉黄，你就涉黄，哪怕你只是个听歌的；他说你有隐藏功能，你就有，哪怕你连多余按钮都没有。</p><p>当时更气的是，审核邮件只说有问题，没说具体哪有问题。我总不能把整个App拆了重写吧？总不能把音符图标改成黑白的，歌单名字全改成“歌单1”“歌单2”吧？最后也没辙。</p><p>当时没别的招，只能瞎改一通再提交，赌审核员当天心情好点。说出去都没人信——一个连广告都不敢加的音乐App，居然被Apple扣了涉黄的帽子。</p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/SollinPlayerYellow/</id>
    <link href="https://blog.leguans.cn/posts/SollinPlayerYellow/"/>
    <published>2026-04-19T14:43:00.000Z</published>
    <summary>记一次离谱的App Store审核：纯音乐App被判定涉黄这事儿已经过去一个月了，今天翻到当时的审核邮件，突然想记下来。大半年耗在一个音乐App上，UI改了八版，bug调得快吐了，提交审核的时候...</summary>
    <title>我的音乐App-SollinPlayer，被Apple审核员判“涉黄”了</title>
    <updated>2026-08-31T13:43:56.523Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="搞七捻三" scheme="https://blog.leguans.cn/categories/%E6%90%9E%E4%B8%83%E6%8D%BB%E4%B8%89/"/>
    <category term="iOS" scheme="https://blog.leguans.cn/tags/iOS/"/>
    <category term="LiveContainer" scheme="https://blog.leguans.cn/tags/LiveContainer/"/>
    <category term="自签" scheme="https://blog.leguans.cn/tags/%E8%87%AA%E7%AD%BE/"/>
    <content>
      <![CDATA[<h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>最近体验了 <a href="https://github.com/LiveContainer/LiveContainer">LiveContainer&#x2F;LiveContainer</a>，体验很不错，之前的安装教程很繁琐，要装好多东西才能配置好，最近LiveContainer更新了，本想按原来老方式更新，但我在官方教程中发现了这个：<a href="https://github.com/nab138/iloader">nab138&#x2F;iloader</a>，发现这更是个神级软件，竟然可以一键安装并且不需要导入修复文件！！！</p><p>如果你也想在iPhone上无限制安装应用，请跟着我的教程来吧。</p><h2 id="第一步：下载沙漏"><a href="#第一步：下载沙漏" class="headerlink" title="第一步：下载沙漏"></a>第一步：下载沙漏</h2><p>首先下载沙漏（比较方便，配置驱动啥的）<br><img src="/images/posts/ai-replace/livecontainer-sideloadly.webp" alt="沙漏"></p><h2 id="第二步：开启开发者模式"><a href="#第二步：开启开发者模式" class="headerlink" title="第二步：开启开发者模式"></a>第二步：开启开发者模式</h2><p>安装好驱动后，先打开你手机的开发者模式，该如何打开呢？还是网上搜一下吧，很简单的。</p><h2 id="第三步：下载iloader"><a href="#第三步：下载iloader" class="headerlink" title="第三步：下载iloader"></a>第三步：下载iloader</h2><p>开启开发者模式后，那么就可以下载 iloader 进行下一步了。</p><p><img src="/images/posts/ai-replace/livecontainer-iloader.webp" alt="iloader"></p><h2 id="第四步：登录id后安装软件"><a href="#第四步：登录id后安装软件" class="headerlink" title="第四步：登录id后安装软件"></a>第四步：登录id后安装软件</h2><p>这界面就都很明了了，登陆一个你appleid小号，然后连接iPhone，点击LiveContainer + SideStore（稳定版），静等安装成功就好了。</p><p><img src="/images/posts/ai-replace/livecontainer-login.webp" alt="iloader"></p><h2 id="第五步：安装过程"><a href="#第五步：安装过程" class="headerlink" title="第五步：安装过程"></a>第五步：安装过程</h2><p>第一次设置的话可能会安装失败，请不要着急，打开设置，进入 可以在 设置–通用–描述文件与设备管理里，找到刚刚用于签名的ID点击刚刚安装好的LiveContainer对它点击信任，然后回到iloader重新装一遍就好了。<br>安装完成后，需要从 SideStore 导入证书，打开LiveContainer的右下角–设置，点击–从SideStore导入证书，然后回到App页面，点左上角的SideStore按钮切回到SideStore</p><p><img src="/images/posts/ai-replace/livecontainer-trust.webp"></p><h2 id="第六步：使用说明"><a href="#第六步：使用说明" class="headerlink" title="第六步：使用说明"></a>第六步：使用说明</h2><p>进入到SideStore界面，在设置里登录上你前面的appleid，此时我发现我忘记写了很重要的一部分，那就是你需要一个美区appleid（很好注册的： <a href="https://account.apple.com/account">创建你的 Apple 账户</a>），在apple store 下载LocalDevXXX，至于xxx是什么你一搜就知道了，这个软件后面续签也会用到，下载之后打开，然后回到SideStore界面，点击My Apps，点Refresh All，等待成功即可。<br>至此，安装教程便结束了，你找到心仪的.ipa文件直接用LiveContainer打开就可以安装了</p><p><img src="/images/posts/ai-replace/livecontainer-sidestore.webp"></p><h2 id="注意事项（自动续签说明）"><a href="#注意事项（自动续签说明）" class="headerlink" title="注意事项（自动续签说明）"></a>注意事项（自动续签说明）</h2><p>这个软件要每七天续签一次，不然就得重新像刚刚那样用电脑重新安装了，下面分享一下用快捷指令 来达到无感知自动续签，很方便的，锁屏下也可以自动续签。</p><p><img src="/images/posts/ai-replace/livecontainer-shortcut.webp"></p><p>把StosVXX换成LocalDevXXX，另外，如果显示这个</p><p><img src="/images/posts/ai-replace/livecontainer-error.webp"></p><p>可以删掉这个操作，自己搜索 Refresh All Apps 加进去，在自动化里设置每周续签一两次就好了（续签的时候一定要连着WiFi）。<br>教程到此就结束了，很多有趣的功能等待你自己挖掘！</p><h2 id="虚拟定位"><a href="#虚拟定位" class="headerlink" title="虚拟定位"></a>虚拟定位</h2><p>今天发现虚拟定位软件<a href="https://github.com/StephenDev0/StikDebug">StephenDev0&#x2F;StikDebug</a> .直接在LiveContainer内装就可以改位置，这样又省掉一个自签位置捏</p><p><strong>本文转载至</strong>：<a href="https://linux.do/t/topic/1641850">[LiveContainer] IOS无限制安装应用教程</a><br><strong>已获得作者授权。</strong></p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/LiveContainer/</id>
    <link href="https://blog.leguans.cn/posts/LiveContainer/"/>
    <published>2026-03-16T07:38:00.000Z</published>
    <summary>前言最近体验了 LiveContainer/LiveContainer，体验很不错，之前的安装教程很繁琐，要装好多东西才能配置好，最近LiveContainer更新了，本想按原来老方式更新，但我...</summary>
    <title>[LiveContainer] IOS无限制安装应用教程</title>
    <updated>2026-08-31T13:43:56.523Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="Flutter" scheme="https://blog.leguans.cn/tags/Flutter/"/>
    <category term="just_audio" scheme="https://blog.leguans.cn/tags/just-audio/"/>
    <category term="BugFix" scheme="https://blog.leguans.cn/tags/BugFix/"/>
    <category term="audio_service" scheme="https://blog.leguans.cn/tags/audio-service/"/>
    <category term="MusicPlayer" scheme="https://blog.leguans.cn/tags/MusicPlayer/"/>
    <category term="Android" scheme="https://blog.leguans.cn/tags/Android/"/>
    <content>
      <![CDATA[<p>这篇偏技术细节，重点放在：<strong>问题如何被误导、如何用日志证明、最终是怎么改代码稳住的</strong>。</p><p>涉及核心文件：<code>lib/core/services/audio_manager.dart</code></p><hr><h2 id="1-问题现象与复现"><a href="#1-问题现象与复现" class="headerlink" title="1. 问题现象与复现"></a>1. 问题现象与复现</h2><p>复现环境：</p><ul><li>Android：Flyme 12.6.0.0A</li><li>App：1.0.5+2010~1.0.5+2014 逐版验证</li><li>构建：<code>flutter build apk --release --split-per-abi</code></li></ul><p>问题路径：</p><ol><li>通知栏点暂停；</li><li>再点继续播放；</li><li>部分机型无反应。</li></ol><p>附带异常：有些通知样式会出现一个方形按钮（本质是 <code>stop/custom action</code> 显示路径差异）。</p><hr><h2 id="2-第一阶段：先清表层问题"><a href="#2-第一阶段：先清表层问题" class="headerlink" title="2. 第一阶段：先清表层问题"></a>2. 第一阶段：先清表层问题</h2><h3 id="2-1-去掉-stop-按钮，固定-3-个控制位"><a href="#2-1-去掉-stop-按钮，固定-3-个控制位" class="headerlink" title="2.1 去掉 stop 按钮，固定 3 个控制位"></a>2.1 去掉 stop 按钮，固定 3 个控制位</h3><p>先把通知栏按钮收敛为三键：<code>prev / play-pause / next</code>。</p><pre><code class="hljs dart">controls: [  <span class="hljs-title class_">MediaControl</span>.skipToPrevious,  <span class="hljs-keyword">if</span> (_player.playing) <span class="hljs-title class_">MediaControl</span>.pause <span class="hljs-keyword">else</span> <span class="hljs-title class_">MediaControl</span>.play,  <span class="hljs-title class_">MediaControl</span>.skipToNext,],androidCompactActionIndices: <span class="hljs-keyword">const</span> [<span class="hljs-number">0</span>, <span class="hljs-number">1</span>, <span class="hljs-number">2</span>],</code></pre><p>同时移除 <code>MediaControl.stop</code>，避免 ROM 显示方形动作位导致误触 stop。</p><h3 id="2-2-click-不再依赖-playbackState-playing"><a href="#2-2-click-不再依赖-playbackState-playing" class="headerlink" title="2.2 click() 不再依赖 playbackState.playing"></a>2.2 click() 不再依赖 playbackState.playing</h3><p><code>BaseAudioHandler.click()</code> 默认根据 <code>playbackState</code> 判断切换，某些时刻可能滞后。改成看 <code>_player.playing</code>：</p><pre><code class="hljs dart"><span class="hljs-meta">@override</span><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">click</span>([<span class="hljs-title class_">MediaButton</span> button = <span class="hljs-title class_">MediaButton</span>.media]) <span class="hljs-keyword">async</span> &#123;  <span class="hljs-keyword">switch</span> (button) &#123;    <span class="hljs-keyword">case</span> <span class="hljs-title class_">MediaButton</span>.media:      <span class="hljs-keyword">if</span> (_player.playing) &#123;        <span class="hljs-keyword">await</span> <span class="hljs-title function_">pause</span>();      &#125; <span class="hljs-keyword">else</span> &#123;        <span class="hljs-keyword">await</span> <span class="hljs-title function_">play</span>();      &#125;      <span class="hljs-keyword">break</span>;    <span class="hljs-keyword">case</span> <span class="hljs-title class_">MediaButton</span>.next:      <span class="hljs-keyword">await</span> <span class="hljs-title function_">skipToNext</span>();      <span class="hljs-keyword">break</span>;    <span class="hljs-keyword">case</span> <span class="hljs-title class_">MediaButton</span>.previous:      <span class="hljs-keyword">await</span> <span class="hljs-title function_">skipToPrevious</span>();      <span class="hljs-keyword">break</span>;  &#125;&#125;</code></pre><h3 id="2-3-onNotificationDeleted-不再-stop"><a href="#2-3-onNotificationDeleted-不再-stop" class="headerlink" title="2.3 onNotificationDeleted() 不再 stop"></a>2.3 onNotificationDeleted() 不再 stop</h3><p>默认实现会 <code>stop()</code>，在 Flyme 上可能导致暂停状态下通知被系统清理后队列丢失。</p><pre><code class="hljs dart"><span class="hljs-meta">@override</span><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">onNotificationDeleted</span>() <span class="hljs-keyword">async</span> &#123;  <span class="hljs-keyword">if</span> (_player.playing) &#123;    <span class="hljs-keyword">await</span> <span class="hljs-title function_">pause</span>();  &#125; <span class="hljs-keyword">else</span> &#123;    <span class="hljs-title function_">_savePlaybackState</span>();  &#125;&#125;</code></pre><p>这一步避免了 <code>idx=-1 / playlist=0</code> 这类“状态被清空”的问题。</p><hr><h2 id="3-第二阶段：建立可观测性（先证明再改）"><a href="#3-第二阶段：建立可观测性（先证明再改）" class="headerlink" title="3. 第二阶段：建立可观测性（先证明再改）"></a>3. 第二阶段：建立可观测性（先证明再改）</h2><p>为了避免“你测的不是我改的包”，增加双版本锚点：</p><ul><li>启动版本：<code>STARTUP_VERSION app=...+build</code></li><li>构建探针：<code>BUILD_PROBE ...</code></li></ul><pre><code class="hljs dart"><span class="hljs-keyword">static</span> <span class="hljs-keyword">const</span> <span class="hljs-title class_">String</span> buildProbe =    <span class="hljs-string">&#x27;AUDIO_MANAGER_BUILD_2026_02_27_NOTIFDBG_V7_PREPARE_B14&#x27;</span>;<span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[AudioManager][BUILD_PROBE] <span class="hljs-subst">$buildProbe</span>&#x27;</span>);</code></pre><p>并加 <code>NOTIF_DBG</code> 全链路日志：</p><ul><li><code>click() requested ...</code></li><li><code>play() requested ...</code></li><li><code>play() dispatch ...</code></li><li><code>play() post-check ...</code></li><li><code>play() future resolved ...</code></li></ul><p>这样就能看清楚“到底有没有收到系统命令”“命令收到后有没有真正进入播放”。</p><hr><h2 id="4-真正根因：await-player-play-语义误用"><a href="#4-真正根因：await-player-play-语义误用" class="headerlink" title="4. 真正根因：await _player.play() 语义误用"></a>4. 真正根因：<code>await _player.play()</code> 语义误用</h2><p>旧代码核心问题是把 <code>await _player.play()</code> 当成“播放立即开始”的同步点。</p><h3 id="4-1-旧写法（有风险）"><a href="#4-1-旧写法（有风险）" class="headerlink" title="4.1 旧写法（有风险）"></a>4.1 旧写法（有风险）</h3><pre><code class="hljs dart"><span class="hljs-meta">@override</span><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">play</span>() <span class="hljs-keyword">async</span> &#123;  <span class="hljs-keyword">await</span> <span class="hljs-title function_">_ensureAudioSessionConfigured</span>();  <span class="hljs-keyword">await</span> session.<span class="hljs-title function_">setActive</span>(<span class="hljs-keyword">true</span>);  <span class="hljs-keyword">await</span> _player.<span class="hljs-title function_">play</span>(); <span class="hljs-comment">// 这里会被长时间阻塞</span>  <span class="hljs-comment">// 后续状态提交逻辑被拖延</span>&#125;</code></pre><h3 id="4-2-日志证据"><a href="#4-2-日志证据" class="headerlink" title="4.2 日志证据"></a>4.2 日志证据</h3><p>实际日志里经常出现：</p><ul><li><code>play() requested</code> 在 <code>T0</code></li><li><code>play() future resolved</code> 在 <code>T0 + 8s / 30s / 39s</code></li><li>且 resolve 发生时可能已经 pause 了</li></ul><p>这说明 <code>play()</code> Future 更多是底层生命周期结束点，不适合作为 UI&#x2F;通知链路的“立即成功”判定点。</p><hr><h2 id="5-核心修复：改成“快速派发-异步观察”"><a href="#5-核心修复：改成“快速派发-异步观察”" class="headerlink" title="5. 核心修复：改成“快速派发 + 异步观察”"></a>5. 核心修复：改成“快速派发 + 异步观察”</h2><h3 id="5-1-新增派发器-dispatchPlayerPlay"><a href="#5-1-新增派发器-dispatchPlayerPlay" class="headerlink" title="5.1 新增派发器 _dispatchPlayerPlay"></a>5.1 新增派发器 <code>_dispatchPlayerPlay</code></h3><pre><code class="hljs dart"><span class="hljs-keyword">void</span> <span class="hljs-title function_">_dispatchPlayerPlay</span>(<span class="hljs-title class_">String</span> reason) &#123;  <span class="hljs-title function_">_logNotificationDebug</span>(<span class="hljs-string">&#x27;play() dispatch reason=<span class="hljs-subst">$reason</span>&#x27;</span>);  <span class="hljs-keyword">final</span> startedAt = <span class="hljs-title class_">DateTime</span>.<span class="hljs-title function_">now</span>();  <span class="hljs-title function_">unawaited</span>(    _player.<span class="hljs-title function_">play</span>().<span class="hljs-title function_">then</span>((_) &#123;      <span class="hljs-keyword">final</span> elapsedMs = <span class="hljs-title class_">DateTime</span>.<span class="hljs-title function_">now</span>().<span class="hljs-title function_">difference</span>(startedAt).inMilliseconds;      <span class="hljs-title function_">_logNotificationDebug</span>(        <span class="hljs-string">&#x27;play() future resolved reason=<span class="hljs-subst">$reason</span> elapsed=<span class="hljs-subst">$&#123;elapsedMs&#125;</span>ms&#x27;</span>,      );    &#125;).<span class="hljs-title function_">catchError</span>((<span class="hljs-title class_">Object</span> e, <span class="hljs-title class_">StackTrace</span> st) &#123;      <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[AudioManager] play() failed reason=<span class="hljs-subst">$reason</span>: <span class="hljs-subst">$e</span>&#x27;</span>);    &#125;),  );  <span class="hljs-title function_">unawaited</span>(<span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt;.<span class="hljs-title function_">delayed</span>(<span class="hljs-keyword">const</span> <span class="hljs-title class_">Duration</span>(milliseconds: <span class="hljs-number">180</span>), () &#123;    <span class="hljs-title function_">_logNotificationDebug</span>(<span class="hljs-string">&#x27;play() post-check reason=<span class="hljs-subst">$reason</span>&#x27;</span>);  &#125;));&#125;</code></pre><h3 id="5-2-play-改造"><a href="#5-2-play-改造" class="headerlink" title="5.2 play() 改造"></a>5.2 <code>play()</code> 改造</h3><pre><code class="hljs dart"><span class="hljs-meta">@override</span><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">play</span>() <span class="hljs-keyword">async</span> &#123;  <span class="hljs-keyword">await</span> <span class="hljs-title function_">_ensureAudioSessionConfigured</span>();  <span class="hljs-keyword">final</span> session = <span class="hljs-keyword">await</span> <span class="hljs-title class_">AudioSession</span>.instance;  <span class="hljs-keyword">await</span> session.<span class="hljs-title function_">setActive</span>(<span class="hljs-keyword">true</span>);  <span class="hljs-keyword">if</span> (_player.playing) <span class="hljs-keyword">return</span>;  <span class="hljs-keyword">final</span> processing = _player.processingState;  <span class="hljs-keyword">if</span> (processing == <span class="hljs-title class_">ProcessingState</span>.completed) &#123;    <span class="hljs-keyword">await</span> _player.<span class="hljs-title function_">seek</span>(<span class="hljs-title class_">Duration</span>.zero);    <span class="hljs-title function_">_dispatchPlayerPlay</span>(<span class="hljs-string">&#x27;completed_seek0&#x27;</span>);    <span class="hljs-keyword">return</span>;  &#125;  <span class="hljs-keyword">if</span> (processing == <span class="hljs-title class_">ProcessingState</span>.idle) &#123;    <span class="hljs-keyword">if</span> (_isOnlinePlaylist) &#123;      <span class="hljs-keyword">await</span> <span class="hljs-title function_">_recoverOnlinePlayback</span>(<span class="hljs-string">&#x27;play_from_idle&#x27;</span>);      <span class="hljs-keyword">return</span>;    &#125;    <span class="hljs-keyword">if</span> (_concatenatingSource != <span class="hljs-keyword">null</span> &amp;&amp; _playlist.isNotEmpty) &#123;      <span class="hljs-keyword">final</span> targetIndex = _currentIndex.<span class="hljs-title function_">clamp</span>(<span class="hljs-number">0</span>, _playlist.length - <span class="hljs-number">1</span>);      _currentIndex = targetIndex;      <span class="hljs-keyword">await</span> _player.<span class="hljs-title function_">setAudioSource</span>(_concatenatingSource!, initialIndex: targetIndex);      <span class="hljs-title function_">_dispatchPlayerPlay</span>(<span class="hljs-string">&#x27;idle_local_reset&#x27;</span>);      <span class="hljs-keyword">return</span>;    &#125;  &#125;  <span class="hljs-title function_">_dispatchPlayerPlay</span>(<span class="hljs-string">&#x27;primary&#x27;</span>);&#125;</code></pre><p>关键点：<code>play()</code> 现在是“命令立即下发”，不再被底层 Future 完成时间绑架。</p><hr><h2 id="6-Flyme-兼容层：把所有入口收敛到同一恢复链路"><a href="#6-Flyme-兼容层：把所有入口收敛到同一恢复链路" class="headerlink" title="6. Flyme 兼容层：把所有入口收敛到同一恢复链路"></a>6. Flyme 兼容层：把所有入口收敛到同一恢复链路</h2><p>在 Android MediaSession 中，ROM 可能走：</p><ul><li><code>click</code></li><li><code>playFrom*</code></li><li><code>prepareFrom*</code></li><li><code>customAction</code></li></ul><p>因此新增统一入口：</p><pre><code class="hljs dart"><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">_resumeFromExternalCommand</span>(<span class="hljs-title class_">String</span> command) <span class="hljs-keyword">async</span> &#123;  <span class="hljs-title function_">_logNotificationDebug</span>(<span class="hljs-string">&#x27;<span class="hljs-subst">$command</span> received&#x27;</span>);  <span class="hljs-keyword">if</span> (_player.playing) <span class="hljs-keyword">return</span>;  <span class="hljs-keyword">if</span> (!<span class="hljs-title function_">_ensureManagedIndexForExternalResume</span>(command)) <span class="hljs-keyword">return</span>;  <span class="hljs-keyword">await</span> <span class="hljs-title function_">play</span>();&#125;</code></pre><p>并将这些方法全部接入：</p><pre><code class="hljs dart"><span class="hljs-meta">@override</span><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">prepare</span>() <span class="hljs-keyword">async</span> =&gt; <span class="hljs-title function_">_resumeFromExternalCommand</span>(<span class="hljs-string">&#x27;prepare()&#x27;</span>);<span class="hljs-meta">@override</span><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">prepareFromMediaId</span>(<span class="hljs-title class_">String</span> mediaId, [<span class="hljs-title class_">Map</span>&lt;<span class="hljs-title class_">String</span>, <span class="hljs-built_in">dynamic</span>&gt;? extras])  <span class="hljs-keyword">async</span> =&gt; <span class="hljs-title function_">_resumeFromExternalCommand</span>(<span class="hljs-string">&#x27;prepareFromMediaId() mediaId=<span class="hljs-subst">$mediaId</span> extras=<span class="hljs-subst">$extras</span>&#x27;</span>);<span class="hljs-meta">@override</span><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">playFromMediaId</span>(<span class="hljs-title class_">String</span> mediaId, [<span class="hljs-title class_">Map</span>&lt;<span class="hljs-title class_">String</span>, <span class="hljs-built_in">dynamic</span>&gt;? extras])  <span class="hljs-keyword">async</span> =&gt; <span class="hljs-title function_">_resumeFromExternalCommand</span>(<span class="hljs-string">&#x27;playFromMediaId() mediaId=<span class="hljs-subst">$mediaId</span> extras=<span class="hljs-subst">$extras</span>&#x27;</span>);</code></pre><h3 id="索引自愈"><a href="#索引自愈" class="headerlink" title="索引自愈"></a>索引自愈</h3><pre><code class="hljs dart"><span class="hljs-built_in">bool</span> <span class="hljs-title function_">_ensureManagedIndexForExternalResume</span>(<span class="hljs-title class_">String</span> source) &#123;  <span class="hljs-keyword">if</span> (_playlist.isEmpty) <span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>;  <span class="hljs-keyword">if</span> (_currentIndex &gt;= <span class="hljs-number">0</span> &amp;&amp; _currentIndex &lt; _playlist.length) <span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;  <span class="hljs-built_in">int?</span> recoveredIndex;  <span class="hljs-keyword">if</span> (_isOnlinePlaylist) &#123;    <span class="hljs-keyword">final</span> playerIndex = _player.currentIndex;    <span class="hljs-keyword">if</span> (playerIndex != <span class="hljs-keyword">null</span> &amp;&amp;        playerIndex &gt;= <span class="hljs-number">0</span> &amp;&amp;        playerIndex &lt; _onlinePlayerToPlaylistIndex.length) &#123;      <span class="hljs-keyword">final</span> mappedIndex = _onlinePlayerToPlaylistIndex[playerIndex];      <span class="hljs-keyword">if</span> (mappedIndex &gt;= <span class="hljs-number">0</span> &amp;&amp; mappedIndex &lt; _playlist.length) &#123;        recoveredIndex = mappedIndex;      &#125;    &#125;  &#125;  recoveredIndex ??= _player.currentIndex;  recoveredIndex = (recoveredIndex == <span class="hljs-keyword">null</span> || recoveredIndex &lt; <span class="hljs-number">0</span> || recoveredIndex &gt;= _playlist.length)      ? <span class="hljs-number">0</span>      : recoveredIndex;  _currentIndex = recoveredIndex;  <span class="hljs-title function_">_updatePlaybackState</span>();  <span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;&#125;</code></pre><hr><h2 id="7-最终稳定点：Android-中间键改成单一-playPause"><a href="#7-最终稳定点：Android-中间键改成单一-playPause" class="headerlink" title="7. 最终稳定点：Android 中间键改成单一 playPause"></a>7. 最终稳定点：Android 中间键改成单一 <code>playPause</code></h2><p>为了规避 Flyme 在 <code>pause -&gt; play</code> 动作切换过程中的 PendingIntent 差异，中间控制改为 <code>playPause</code>，只切图标。</p><pre><code class="hljs dart"><span class="hljs-keyword">final</span> playPauseControl = <span class="hljs-title class_">MediaControl</span>(  androidIcon: _player.playing      ? <span class="hljs-string">&#x27;drawable/audio_service_pause&#x27;</span>      : <span class="hljs-string">&#x27;drawable/audio_service_play_arrow&#x27;</span>,  label: _player.playing ? <span class="hljs-string">&#x27;Pause&#x27;</span> : <span class="hljs-string">&#x27;Play&#x27;</span>,  action: <span class="hljs-title class_">MediaAction</span>.playPause,);controls: [  <span class="hljs-title class_">MediaControl</span>.skipToPrevious,  <span class="hljs-title class_">Platform</span>.isAndroid      ? playPauseControl      : (_player.playing ? <span class="hljs-title class_">MediaControl</span>.pause : <span class="hljs-title class_">MediaControl</span>.play),  <span class="hljs-title class_">MediaControl</span>.skipToNext,],</code></pre><p>最终日志从 <code>controls=[...,play,...]</code> 变成 <code>controls=[...,playPause,...]</code>，并在 Flyme 上稳定。</p><hr><h2 id="8-最终验证（B14）"><a href="#8-最终验证（B14）" class="headerlink" title="8. 最终验证（B14）"></a>8. 最终验证（B14）</h2><p>最终验证版本：</p><ul><li><code>app=1.0.5+2014</code></li><li><code>probe=AUDIO_MANAGER_BUILD_2026_02_27_NOTIFDBG_V7_PREPARE_B14</code></li></ul><p>关键验证链路（通知栏）：</p><ul><li><code>click(media) -&gt; pause()</code> 成功</li><li>再次 <code>click(media) -&gt; play() dispatch -&gt; playing=true</code> 成功</li><li>多轮 pause&#x2F;play 成功</li><li><code>click(next)</code> 切歌成功</li></ul><p>这说明问题已经从“偶发不可控”转为“可复现可观测且已稳定修复”。</p><hr><h2 id="9-经验总结（面向工程）"><a href="#9-经验总结（面向工程）" class="headerlink" title="9. 经验总结（面向工程）"></a>9. 经验总结（面向工程）</h2><ol><li><strong>先观测，后猜测</strong>：跨 ROM 问题没有日志就没有真相。</li><li><strong>明确 Future 语义</strong>：<code>play()</code> 的 Future 不等于“开始播放成功”。</li><li><strong>命令链路收敛</strong>：<code>click/playFrom/prepare/customAction</code> 必须兜底到统一恢复路径。</li><li><strong>通知动作尽量稳定</strong>：Android 上 <code>playPause</code> 常比动态切 <code>play/pause</code> 更兼容。</li><li><strong>版本探针必须跟每轮修复绑定</strong>：避免“测错包”让排障退化为玄学。</li></ol>]]>
    </content>
    <id>https://blog.leguans.cn/posts/android-flyme-notification-playback-resume-fix/</id>
    <link href="https://blog.leguans.cn/posts/android-flyme-notification-playback-resume-fix/"/>
    <published>2026-03-04T16:00:00.000Z</published>
    <summary>一次真实线上故障的技术复盘：Flyme 机型通知栏暂停后无法继续播放。包含时序根因、关键代码改动、日志证据、兼容策略与验证结果。</summary>
    <title>Android/Flyme 通知栏“暂停后继续播放无响应”技术复盘：根因、关键代码与最终稳定方案</title>
    <updated>2026-08-31T13:43:56.523Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="Flutter" scheme="https://blog.leguans.cn/tags/Flutter/"/>
    <category term="MusicPlayer" scheme="https://blog.leguans.cn/tags/MusicPlayer/"/>
    <category term="动画" scheme="https://blog.leguans.cn/tags/%E5%8A%A8%E7%94%BB/"/>
    <category term="渲染" scheme="https://blog.leguans.cn/tags/%E6%B8%B2%E6%9F%93/"/>
    <category term="技术复盘" scheme="https://blog.leguans.cn/tags/%E6%8A%80%E6%9C%AF%E5%A4%8D%E7%9B%98/"/>
    <content>
      <![CDATA[<p>这篇是上一版复盘的技术加强版，重点补上：</p><ul><li>具体代码点位</li><li>为什么前几轮“看起来合理”的修复无效</li><li>最终有效方案背后的渲染机制</li></ul><hr><h2 id="1-问题定义（精确到动画阶段）"><a href="#1-问题定义（精确到动画阶段）" class="headerlink" title="1. 问题定义（精确到动画阶段）"></a>1. 问题定义（精确到动画阶段）</h2><p>问题不是“默认封面偶发闪”，而是：</p><ul><li><strong>仅默认封面</strong>（无 artworkPath）会闪；</li><li>真实歌曲封面不闪；</li><li>闪烁发生在动画临界点：<ul><li>大封面开始缩小时（<code>t</code> 从 0 往上）</li><li>小封面回大封面结束时（<code>t</code> 回到 0）</li></ul></li></ul><p>这类现象在 Flutter 里通常优先怀疑：</p><ol><li>动画树结构在阈值发生插拔（<code>if (t &gt; 0)</code>）；</li><li>同一视觉对象走了两条不同渲染链路；</li><li>资源首帧不可用（次要）。</li></ol><hr><h2 id="2-关键代码背景"><a href="#2-关键代码背景" class="headerlink" title="2. 关键代码背景"></a>2. 关键代码背景</h2><p>播放器核心动画在：</p><ul><li><code>lib/features/player/presentation/screens/player_screen.dart</code></li><li>方法：<code>_buildAnimatedLayout(...)</code></li><li>驱动：<code>AnimatedBuilder(animation: _layoutAnimation, ...)</code></li></ul><p>封面渲染组件在：</p><ul><li><code>lib/core/widgets/artwork_widget.dart</code></li></ul><p>默认封面资源：</p><ul><li><code>assets/images/fengmiantu.png</code></li></ul><hr><h2 id="3-失败方案（为什么失败）"><a href="#3-失败方案（为什么失败）" class="headerlink" title="3. 失败方案（为什么失败）"></a>3. 失败方案（为什么失败）</h2><h3 id="方案-A：仅做默认图预缓存"><a href="#方案-A：仅做默认图预缓存" class="headerlink" title="方案 A：仅做默认图预缓存"></a>方案 A：仅做默认图预缓存</h3><p>做法：</p><pre><code class="hljs dart"><span class="hljs-keyword">await</span> <span class="hljs-title function_">precacheImage</span>(<span class="hljs-keyword">const</span> <span class="hljs-title class_">AssetImage</span>(<span class="hljs-string">&#x27;assets/images/fengmiantu.png&#x27;</span>), context);</code></pre><p>结论：无效（或仅轻微改善）。</p><p>原因：</p><ul><li>预缓存只能解决“资源首帧缺失”；</li><li>解决不了动画临界点的节点替换&#x2F;重建。</li></ul><hr><h3 id="方案-B：固定默认图-provider-DecorationImage"><a href="#方案-B：固定默认图-provider-DecorationImage" class="headerlink" title="方案 B：固定默认图 provider + DecorationImage"></a>方案 B：固定默认图 provider + DecorationImage</h3><p>做法：</p><pre><code class="hljs dart"><span class="hljs-keyword">const</span> <span class="hljs-title class_">AssetImage</span> _kDefaultCoverProvider = <span class="hljs-title class_">AssetImage</span>(_kDefaultCoverAsset);</code></pre><p>并用 <code>DecorationImage</code> 渲染默认封面。</p><p>结论：改善但不根治。</p><p>原因：</p><ul><li>资源流稳定了，但动画结构仍在临界点变化。</li></ul><hr><h3 id="方案-C：默认封面单独分支优化（RepaintBoundary-等）"><a href="#方案-C：默认封面单独分支优化（RepaintBoundary-等）" class="headerlink" title="方案 C：默认封面单独分支优化（RepaintBoundary 等）"></a>方案 C：默认封面单独分支优化（RepaintBoundary 等）</h3><p>做法：给默认封面做独立分支组件，尝试压缩重绘。</p><p>结论：仍闪。</p><p>原因：</p><ul><li>真实封面和默认封面路径差异仍在；</li><li>动画临界点依旧可能触发分支切换。</li></ul><hr><h2 id="4-真正根因（双重）"><a href="#4-真正根因（双重）" class="headerlink" title="4. 真正根因（双重）"></a>4. 真正根因（双重）</h2><h3 id="根因-1：动画时存在结构插拔"><a href="#根因-1：动画时存在结构插拔" class="headerlink" title="根因 1：动画时存在结构插拔"></a>根因 1：动画时存在结构插拔</h3><p>播放器动画里歌词层最初是类似：</p><pre><code class="hljs dart"><span class="hljs-keyword">if</span> (t &gt; <span class="hljs-number">0</span>) <span class="hljs-title class_">Positioned</span>(...)</code></pre><p>当 <code>t</code> 在 0 附近跳变时，<code>Stack</code> 子节点会插入&#x2F;移除，容易出现一帧闪动。</p><h3 id="根因-2：默认封面与真实封面走了不同渲染路径"><a href="#根因-2：默认封面与真实封面走了不同渲染路径" class="headerlink" title="根因 2：默认封面与真实封面走了不同渲染路径"></a>根因 2：默认封面与真实封面走了不同渲染路径</h3><ul><li>真实封面：<code>ArtworkWidget</code> 正常路径</li><li>默认封面：专用分支路径</li></ul><p>只要路径不一致，动画临界点就更容易出现“仅某一类素材闪”的问题。</p><hr><h2 id="5-最终有效方案（代码级）"><a href="#5-最终有效方案（代码级）" class="headerlink" title="5. 最终有效方案（代码级）"></a>5. 最终有效方案（代码级）</h2><h3 id="5-1-歌词层常驻，禁止阈值插拔"><a href="#5-1-歌词层常驻，禁止阈值插拔" class="headerlink" title="5.1 歌词层常驻，禁止阈值插拔"></a>5.1 歌词层常驻，禁止阈值插拔</h3><p>把：</p><pre><code class="hljs dart"><span class="hljs-keyword">if</span> (t &gt; <span class="hljs-number">0</span>) <span class="hljs-title class_">Positioned</span>(...)</code></pre><p>改为：</p><pre><code class="hljs dart"><span class="hljs-title class_">Positioned</span>(  ...  child: <span class="hljs-title class_">IgnorePointer</span>(    ignoring: lyricsOpacity &lt; <span class="hljs-number">0.01</span>,    child: <span class="hljs-title class_">Opacity</span>(      opacity: lyricsOpacity,      child: <span class="hljs-title function_">_buildLyricsSection</span>(context, state),    ),  ),)</code></pre><p>效果：<code>Stack</code> 子节点数量在动画全过程保持稳定。</p><hr><h3 id="5-2-统一封面渲染链路：ArtworkWidget-增加强制默认图开关"><a href="#5-2-统一封面渲染链路：ArtworkWidget-增加强制默认图开关" class="headerlink" title="5.2 统一封面渲染链路：ArtworkWidget 增加强制默认图开关"></a>5.2 统一封面渲染链路：<code>ArtworkWidget</code> 增加强制默认图开关</h3><p>在 <code>ArtworkWidget</code> 新增参数：</p><pre><code class="hljs dart"><span class="hljs-keyword">final</span> <span class="hljs-built_in">bool</span> forceDefaultArtwork;</code></pre><p>构造参数默认值：</p><pre><code class="hljs dart"><span class="hljs-keyword">this</span>.forceDefaultArtwork = <span class="hljs-keyword">false</span>,</code></pre><p>在 <code>_buildArtwork</code> 顶部短路：</p><pre><code class="hljs dart"><span class="hljs-keyword">if</span> (forceDefaultArtwork) &#123;  <span class="hljs-keyword">return</span> placeholder ?? <span class="hljs-title class_">_DefaultArtwork</span>(size: size);&#125;</code></pre><p>然后在播放器动画里，<strong>无论有无封面都走 <code>ArtworkWidget</code></strong>，只是无封面时启用：</p><pre><code class="hljs dart"><span class="hljs-title class_">ArtworkWidget</span>(  id: song?.id,  artworkPath: song?.artworkPath,  ...  allowQueryArtworkFallback: <span class="hljs-keyword">false</span>,  forceDefaultArtwork: song?.artworkPath == <span class="hljs-keyword">null</span> || song!.artworkPath!.isEmpty,)</code></pre><p>这一步是关键：把“默认封面和真实封面”收敛到一套布局&#x2F;裁剪&#x2F;动画容器。</p><hr><h3 id="5-3-保留资源稳定性措施（作为配套，不是主因）"><a href="#5-3-保留资源稳定性措施（作为配套，不是主因）" class="headerlink" title="5.3 保留资源稳定性措施（作为配套，不是主因）"></a>5.3 保留资源稳定性措施（作为配套，不是主因）</h3><ul><li>默认图固定 provider：</li></ul><pre><code class="hljs dart"><span class="hljs-keyword">const</span> <span class="hljs-title class_">AssetImage</span> _kDefaultCoverProvider = <span class="hljs-title class_">AssetImage</span>(_kDefaultCoverAsset);</code></pre><ul><li>播放器 init 后预缓存：</li></ul><pre><code class="hljs dart"><span class="hljs-title class_">WidgetsBinding</span>.instance.<span class="hljs-title function_">addPostFrameCallback</span>((_) &#123;  <span class="hljs-title function_">_precacheDefaultCoverIfNeeded</span>();&#125;);</code></pre><hr><h2 id="6-关键文件变更点（便于回看）"><a href="#6-关键文件变更点（便于回看）" class="headerlink" title="6. 关键文件变更点（便于回看）"></a>6. 关键文件变更点（便于回看）</h2><ul><li>默认封面常量与 provider：<ul><li><code>lib/core/widgets/artwork_widget.dart</code></li></ul></li><li><code>forceDefaultArtwork</code> 参数与 <code>_buildArtwork</code> 短路逻辑：<ul><li><code>lib/core/widgets/artwork_widget.dart</code></li></ul></li><li>播放器动画中的封面统一渲染调用：<ul><li><code>lib/features/player/presentation/screens/player_screen.dart</code></li></ul></li><li>歌词层改为常驻透明：<ul><li><code>lib/features/player/presentation/screens/player_screen.dart</code></li></ul></li></ul><hr><h2 id="7-可复用排查模板（建议保存）"><a href="#7-可复用排查模板（建议保存）" class="headerlink" title="7. 可复用排查模板（建议保存）"></a>7. 可复用排查模板（建议保存）</h2><p>遇到“只在某类素材&#x2F;状态闪烁”的动画问题时，按这个顺序：</p><ol><li><strong>先定位闪烁时刻</strong>：开始、中间、结束？</li><li><strong>查结构插拔</strong>：是否有 <code>if (t &gt; x)</code> 控制子树挂载？</li><li><strong>查渲染路径分叉</strong>：同一视觉对象是否有多分支实现？</li><li><strong>再做资源优化</strong>：precache、固定 provider、filterQuality。</li></ol><p>经验上，前两步通常决定成败。</p><hr><h2 id="8-这次的错误与改进"><a href="#8-这次的错误与改进" class="headerlink" title="8. 这次的错误与改进"></a>8. 这次的错误与改进</h2><h3 id="错误"><a href="#错误" class="headerlink" title="错误"></a>错误</h3><ul><li>先入为主把问题当成“默认图加载慢”；</li><li>前几轮都在做资源层补丁，没有第一时间稳定动画结构。</li></ul><h3 id="改进"><a href="#改进" class="headerlink" title="改进"></a>改进</h3><ul><li>以后先看“节点是否在临界点插拔”；</li><li>优先统一渲染链路，再做性能细化。</li></ul><hr><h2 id="9-一句话总结"><a href="#9-一句话总结" class="headerlink" title="9. 一句话总结"></a>9. 一句话总结</h2><p>这次闪烁不是“图片慢”，而是“动画树不稳”。</p><p>把节点变常驻、把路径变统一，问题自然消失。</p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/default-cover-flicker-player-transition-retro-technical/</id>
    <link href="https://blog.leguans.cn/posts/default-cover-flicker-player-transition-retro-technical/"/>
    <published>2026-02-10T16:00:00.000Z</published>
    <summary>技术细节版复盘：记录播放器在“无封面歌曲”场景下切换歌词页时闪烁的问题，包含日志观察、错误方案、最终代码改动与可复用排查模板。</summary>
    <title>播放页默认封面切换闪烁（技术细节版）：从资源猜测到动画结构稳定性的完整修复</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="Flutter" scheme="https://blog.leguans.cn/tags/Flutter/"/>
    <category term="iOS" scheme="https://blog.leguans.cn/tags/iOS/"/>
    <category term="just_audio" scheme="https://blog.leguans.cn/tags/just-audio/"/>
    <category term="MusicPlayer" scheme="https://blog.leguans.cn/tags/MusicPlayer/"/>
    <category term="音频播放" scheme="https://blog.leguans.cn/tags/%E9%9F%B3%E9%A2%91%E6%92%AD%E6%94%BE/"/>
    <content>
      <![CDATA[<p>这个 Bug 折腾了我一整个下午：</p><ul><li>在播放器里点上一首&#x2F;下一首，<strong>声音切过去了</strong>；</li><li>但封面、歌名、评论按钮绑定的歌曲有时还是旧的；</li><li>有时点一下暂停，信息又“突然正常”。</li></ul><p>最容易复现的路径是：</p><ol><li>点播放 A（正常）</li><li>切到 B（正常）</li><li>再切回 A（声音回去了，但 UI 还显示 B）</li></ol><p>这不是单点 bug，而是几个并发问题叠在一起。</p><h2 id="现象：日志看起来“多数正确”，但体验不稳定"><a href="#现象：日志看起来“多数正确”，但体验不稳定" class="headerlink" title="现象：日志看起来“多数正确”，但体验不稳定"></a>现象：日志看起来“多数正确”，但体验不稳定</h2><p>先看表面，很多日志都很“健康”：</p><ul><li><code>targetIndex</code> 对</li><li><code>SWITCH success</code> 对</li><li><code>currentIndex/mediaItem</code> 也经常对</li></ul><p>但偶发时会出现两类关键信号：</p><ol><li><code>iOS cached source set ok</code> 很快出现，但 <code>iOS cached play ok</code> 可能晚几秒甚至十几秒；</li><li>切歌过程中又出现 <code>Online playlist started with ConcatenatingAudioSource</code> 这种“启动流程晚到”日志。</li></ol><p>这两类一叠加，就会造成：</p><ul><li>用户已经继续点了切歌；</li><li>旧异步流程晚回来后又写状态；</li><li>UI 和音频出现错位，直到下一次播放状态事件（比如 pause）才被动刷新。</li></ul><h2 id="第一轮碰壁：只做“入口防重入”不够"><a href="#第一轮碰壁：只做“入口防重入”不够" class="headerlink" title="第一轮碰壁：只做“入口防重入”不够"></a>第一轮碰壁：只做“入口防重入”不够</h2><p>最开始做的是常规处理：</p><ul><li>加 <code>_isSwitchingOnlineTrack</code> 锁；</li><li>正在切歌时忽略新点击；</li><li>加切歌节流。</li></ul><p>这能减少乱点，但有两个副作用：</p><ul><li>用户点击被吞掉，体感“按钮不灵”；</li><li>索引计算还基于旧 <code>currentIndex</code>，后续点击方向会错。</li></ul><p><strong>结论</strong>：只靠入口锁，治标不治本。</p><h2 id="第二轮碰壁：只做-token-防串写，仍有漏网"><a href="#第二轮碰壁：只做-token-防串写，仍有漏网" class="headerlink" title="第二轮碰壁：只做 token 防串写，仍有漏网"></a>第二轮碰壁：只做 token 防串写，仍有漏网</h2><p>随后给 <code>playAtIndex</code> 加了 <code>switchToken</code>，防止旧切歌晚回来覆盖新状态。</p><p>这一步是对的，但仍有残留：</p><ul><li><code>setOnlinePlaylist</code> 自身也是异步链路；</li><li>用户手动切歌后，旧的“启动在线播放列表”流程可能继续完成并回写状态。</li></ul><p>于是出现了一个很迷惑的现象：</p><blockquote><p>切歌明明成功了，过一会儿 UI 又被“拉回去”或延迟刷新。</p></blockquote><h2 id="真正根因：不是一个-race，是三条状态链路在竞争"><a href="#真正根因：不是一个-race，是三条状态链路在竞争" class="headerlink" title="真正根因：不是一个 race，是三条状态链路在竞争"></a>真正根因：不是一个 race，是三条状态链路在竞争</h2><p>最终确认是三层并发竞争：</p><ol><li><strong>手动切歌链路</strong>（<code>skip -&gt; playAtIndex -&gt; setAudioSource/play -&gt; commit</code>）</li><li><strong>在线播放启动链路</strong>（<code>setOnlinePlaylist -&gt; setAudioSource/play -&gt; commit</code>）</li><li><strong>UI 数据源链路</strong>（一部分组件读 stream state，一部分直接读 <code>audioManager.currentSong</code>）</li></ol><p>只要这三条不统一，某个链路晚到就可能覆盖另一个链路，导致“声音和信息不同步”。</p><h2 id="最终方案（分层修复）"><a href="#最终方案（分层修复）" class="headerlink" title="最终方案（分层修复）"></a>最终方案（分层修复）</h2><h3 id="1）切歌链路：token-排队，不再简单-ignore"><a href="#1）切歌链路：token-排队，不再简单-ignore" class="headerlink" title="1）切歌链路：token + 排队，不再简单 ignore"></a>1）切歌链路：token + 排队，不再简单 ignore</h3><p>在 <code>AudioManager.playAtIndex</code> 的在线分支里：</p><ul><li>每次切歌递增 <code>switchToken</code>；</li><li>在 <code>setSource/play/commit</code> 前后都检查 token；</li><li>过期任务直接 <code>stale ignored</code>，不允许写 <code>_currentIndex/mediaItem/queue</code>。</li></ul><p>同时增加了“切歌排队”而不是“切歌忽略”：</p><ul><li>切歌进行中收到新的 next&#x2F;prev，不丢弃，写入 <code>_queuedOnlineSwitchIndex</code>；</li><li>当前切歌完成后自动 drain 队列，执行最后一次用户意图。</li></ul><p>这样既避免了串写，又保留了交互连续性。</p><h3 id="2）在线播放启动链路：增加-session-token，防晚到回写"><a href="#2）在线播放启动链路：增加-session-token，防晚到回写" class="headerlink" title="2）在线播放启动链路：增加 session token，防晚到回写"></a>2）在线播放启动链路：增加 session token，防晚到回写</h3><p>给 <code>setOnlinePlaylist</code> 单独加了 <code>online start session token</code>：</p><ul><li>启动时记录 <code>sessionToken</code>；</li><li><code>setAudioSource</code> 后、<code>play</code> 后都校验是否过期；</li><li>过期则打印 <code>stale ... ignored</code> 并退出，不再提交状态。</li></ul><p>再加一条关键策略：</p><ul><li>用户手动切歌时，主动取消 pending 的 online start session。</li></ul><p>这一步直接解决了“切歌中又出现 Online playlist started 并污染状态”的问题。</p><h3 id="3）iOS-缓存切歌：不再阻塞等待-play-Future-完成"><a href="#3）iOS-缓存切歌：不再阻塞等待-play-Future-完成" class="headerlink" title="3）iOS 缓存切歌：不再阻塞等待 play() Future 完成"></a>3）iOS 缓存切歌：不再阻塞等待 <code>play()</code> Future 完成</h3><p>日志里反复出现：</p><ul><li><code>iOS cached source set ok</code> 很快</li><li>但 <code>await _player.play()</code> 可能很久才返回</li></ul><p>如果等它返回再提交 UI，信息会明显滞后。</p><p>所以改成：</p><ul><li>发起 <code>play()</code> 请求（异步监听成功&#x2F;失败日志）；</li><li>状态提交不再被 <code>play()</code> Future 阻塞。</li></ul><p>这让 UI 不会因为 iOS 的慢返回而“卡住旧歌信息”。</p><h3 id="4）UI-层收口：统一从-playbackState-读当前歌曲"><a href="#4）UI-层收口：统一从-playbackState-读当前歌曲" class="headerlink" title="4）UI 层收口：统一从 playbackState 读当前歌曲"></a>4）UI 层收口：统一从 <code>playbackState</code> 读当前歌曲</h3><p>播放器页面之前有双数据源：</p><ul><li>有些组件读 <code>playbackState.currentSong</code></li><li>有些组件直接读 <code>audioManager.currentSong</code></li></ul><p>这会导致同一帧内不同组件看的是不同快照。</p><p>最终把播放页关键区域收口到同一来源：</p><ul><li>额外控制区按钮（评论&#x2F;喜欢&#x2F;加入&#x2F;队列）</li><li>歌词当前 song id</li><li>队列高亮当前歌曲</li></ul><p>统一由 <code>AudioPlaybackState</code> 驱动，避免“局部刷新好了，局部还旧”的视觉撕裂。</p><h2 id="为什么“点暂停就会刷新”"><a href="#为什么“点暂停就会刷新”" class="headerlink" title="为什么“点暂停就会刷新”"></a>为什么“点暂停就会刷新”</h2><p>这个现象很有迷惑性，其实是线索：</p><ul><li>点暂停会触发一轮新的播放状态事件（<code>playing=false</code>）；</li><li>UI 依赖的 stream 被强制推进一次；</li><li>之前没对齐的局部状态在这次重建里碰巧对齐了。</li></ul><p>所以“暂停能修好”不是修复，而是<strong>状态链路竞争被下一次事件掩盖</strong>。</p><h2 id="最终验证（复测路径）"><a href="#最终验证（复测路径）" class="headerlink" title="最终验证（复测路径）"></a>最终验证（复测路径）</h2><p>重点复测了这几条：</p><ul><li>A -&gt; B -&gt; A（最容易复现）</li><li>连续快速上一首&#x2F;下一首</li><li>在线缓存命中切歌</li><li>切歌与在线播放列表启动并发</li></ul><p>关键日志特征变为：</p><ul><li>旧流程：<code>stale ... ignored</code></li><li>手动切歌会取消旧 start：<code>cancel pending online start ...</code></li><li>切歌中点击：<code>queued targetIndex=...</code></li><li>切歌完成自动接管：<code>drain queued switch ...</code></li></ul><p>最终表现恢复稳定：音频与歌名&#x2F;封面&#x2F;功能按钮绑定一致，不再需要“点暂停触发刷新”。</p><h2 id="关键代码（节选）"><a href="#关键代码（节选）" class="headerlink" title="关键代码（节选）"></a>关键代码（节选）</h2><p>下面贴的是这次修复里最关键的几段代码，都是“去竞态”的核心点。</p><h3 id="A-在线切歌-token-防串写"><a href="#A-在线切歌-token-防串写" class="headerlink" title="A. 在线切歌 token 防串写"></a>A. 在线切歌 token 防串写</h3><p>文件：<code>lib/core/services/audio_manager.dart</code></p><pre><code class="hljs dart"><span class="hljs-comment">// fields</span><span class="hljs-built_in">int</span> _onlineSwitchToken = <span class="hljs-number">0</span>;<span class="hljs-built_in">int?</span> _pendingOnlineSwitchIndex;<span class="hljs-built_in">int?</span> _queuedOnlineSwitchIndex;<span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">playAtIndex</span>(<span class="hljs-built_in">int</span> index) <span class="hljs-keyword">async</span> &#123;  <span class="hljs-keyword">if</span> (_isOnlinePlaylist &amp;&amp; _onlineSongList != <span class="hljs-keyword">null</span> &amp;&amp; _urlResolver != <span class="hljs-keyword">null</span>) &#123;    <span class="hljs-keyword">if</span> (_isSwitchingOnlineTrack) &#123;      _queuedOnlineSwitchIndex = index;      <span class="hljs-title function_">debugPrint</span>(        <span class="hljs-string">&#x27;[AudioManager][SWITCH] switching in progress, queue target index=<span class="hljs-subst">$index</span>&#x27;</span>,      );      <span class="hljs-keyword">return</span>;    &#125;    _isSwitchingOnlineTrack = <span class="hljs-keyword">true</span>;    _pendingOnlineSwitchIndex = index;    <span class="hljs-keyword">final</span> switchToken = ++_onlineSwitchToken;    <span class="hljs-keyword">try</span> &#123;      <span class="hljs-comment">// ... resolve / set source / play</span>      <span class="hljs-keyword">if</span> (switchToken != _onlineSwitchToken) &#123;        <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[AudioManager][SWITCH] stale switch ignored token=<span class="hljs-subst">$switchToken</span>&#x27;</span>);        <span class="hljs-keyword">return</span>;      &#125;      _currentIndex = index;      mediaItem.<span class="hljs-title function_">add</span>(queue.value[index]);      <span class="hljs-title function_">_updatePlaybackState</span>();    &#125; <span class="hljs-keyword">finally</span> &#123;      <span class="hljs-keyword">if</span> (switchToken == _onlineSwitchToken) &#123;        _isSwitchingOnlineTrack = <span class="hljs-keyword">false</span>;        _pendingOnlineSwitchIndex = <span class="hljs-keyword">null</span>;        <span class="hljs-keyword">final</span> queued = _queuedOnlineSwitchIndex;        <span class="hljs-keyword">if</span> (queued != <span class="hljs-keyword">null</span> &amp;&amp; queued != _currentIndex) &#123;          _queuedOnlineSwitchIndex = <span class="hljs-keyword">null</span>;          <span class="hljs-title function_">unawaited</span>(<span class="hljs-title function_">playAtIndex</span>(queued));        &#125; <span class="hljs-keyword">else</span> &#123;          _queuedOnlineSwitchIndex = <span class="hljs-keyword">null</span>;        &#125;      &#125;    &#125;  &#125;&#125;</code></pre><h3 id="B-切歌期间不忽略点击，改为“按-pending-index-计算-入队”"><a href="#B-切歌期间不忽略点击，改为“按-pending-index-计算-入队”" class="headerlink" title="B. 切歌期间不忽略点击，改为“按 pending index 计算 + 入队”"></a>B. 切歌期间不忽略点击，改为“按 pending index 计算 + 入队”</h3><p>文件：<code>lib/core/services/audio_manager.dart</code></p><pre><code class="hljs dart"><span class="hljs-meta">@override</span><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">skipToNext</span>() <span class="hljs-keyword">async</span> &#123;  <span class="hljs-keyword">final</span> baseIndex = (_isOnlinePlaylist &amp;&amp; _isSwitchingOnlineTrack)      ? (_pendingOnlineSwitchIndex ?? _currentIndex)      : _currentIndex;  <span class="hljs-keyword">final</span> nextIndex = baseIndex &lt; _playlist.length - <span class="hljs-number">1</span> ? baseIndex + <span class="hljs-number">1</span> : <span class="hljs-number">0</span>;  <span class="hljs-keyword">if</span> (_isOnlinePlaylist &amp;&amp; _isSwitchingOnlineTrack) &#123;    _queuedOnlineSwitchIndex = nextIndex;    <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[AudioManager][CMD] skipToNext queued targetIndex=<span class="hljs-subst">$nextIndex</span>&#x27;</span>);    <span class="hljs-keyword">return</span>;  &#125;  <span class="hljs-keyword">await</span> <span class="hljs-title function_">playAtIndex</span>(nextIndex);&#125;</code></pre><h3 id="C-在线播放启动流程加-session-token，防“晚到回写”"><a href="#C-在线播放启动流程加-session-token，防“晚到回写”" class="headerlink" title="C. 在线播放启动流程加 session token，防“晚到回写”"></a>C. 在线播放启动流程加 session token，防“晚到回写”</h3><p>文件：<code>lib/core/services/audio_manager.dart</code></p><pre><code class="hljs dart"><span class="hljs-built_in">int</span> _onlinePlaylistSessionToken = <span class="hljs-number">0</span>;<span class="hljs-built_in">bool</span> _isSettingOnlinePlaylist = <span class="hljs-keyword">false</span>;<span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">setOnlinePlaylist</span>(...) <span class="hljs-keyword">async</span> &#123;  <span class="hljs-keyword">final</span> sessionToken = ++_onlinePlaylistSessionToken;  _isSettingOnlinePlaylist = <span class="hljs-keyword">true</span>;  <span class="hljs-keyword">try</span> &#123;    <span class="hljs-keyword">await</span> _player.<span class="hljs-title function_">setAudioSource</span>(_onlineConcatenatingSource!, initialIndex: <span class="hljs-number">0</span>);    <span class="hljs-keyword">if</span> (sessionToken != _onlinePlaylistSessionToken) <span class="hljs-keyword">return</span>;    mediaItem.<span class="hljs-title function_">add</span>(mediaItems[_currentIndex]);    <span class="hljs-title function_">_updatePlaybackState</span>();    <span class="hljs-keyword">await</span> <span class="hljs-title function_">play</span>();    <span class="hljs-keyword">if</span> (sessionToken != _onlinePlaylistSessionToken) <span class="hljs-keyword">return</span>;  &#125; <span class="hljs-keyword">finally</span> &#123;    <span class="hljs-keyword">if</span> (sessionToken == _onlinePlaylistSessionToken) &#123;      _isSettingOnlinePlaylist = <span class="hljs-keyword">false</span>;    &#125;  &#125;&#125;<span class="hljs-comment">// 手动切歌时，取消旧启动会话</span><span class="hljs-keyword">if</span> (_isSettingOnlinePlaylist) &#123;  _onlinePlaylistSessionToken++;  _isSettingOnlinePlaylist = <span class="hljs-keyword">false</span>;&#125;</code></pre><h3 id="D-iOS-缓存切歌不再阻塞等-play-Future"><a href="#D-iOS-缓存切歌不再阻塞等-play-Future" class="headerlink" title="D. iOS 缓存切歌不再阻塞等 play() Future"></a>D. iOS 缓存切歌不再阻塞等 play() Future</h3><p>文件：<code>lib/core/services/audio_manager.dart</code></p><pre><code class="hljs dart"><span class="hljs-keyword">await</span> <span class="hljs-title function_">_setOnlineSingleSource</span>(audioSource, playlistIndex: index);<span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[AudioManager][SWITCH] iOS cached source set ok index=<span class="hljs-subst">$index</span>&#x27;</span>);<span class="hljs-comment">// 不 await，避免 iOS 后台场景 play() 返回很慢导致 UI 卡旧状态</span><span class="hljs-keyword">final</span> playFuture = _player.<span class="hljs-title function_">play</span>();<span class="hljs-title function_">unawaited</span>(playFuture.<span class="hljs-title function_">then</span>((_) &#123;  <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[AudioManager][SWITCH] iOS cached play completed index=<span class="hljs-subst">$index</span>&#x27;</span>);&#125;));<span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[AudioManager][SWITCH] iOS cached play requested index=<span class="hljs-subst">$index</span>&#x27;</span>);<span class="hljs-comment">// 继续提交 currentIndex / mediaItem / playbackState</span>_currentIndex = index;mediaItem.<span class="hljs-title function_">add</span>(queue.value[index]);<span class="hljs-title function_">_updatePlaybackState</span>();</code></pre><h3 id="E-播放页统一从-playbackState-读当前歌曲"><a href="#E-播放页统一从-playbackState-读当前歌曲" class="headerlink" title="E. 播放页统一从 playbackState 读当前歌曲"></a>E. 播放页统一从 <code>playbackState</code> 读当前歌曲</h3><p>文件：<code>lib/features/player/presentation/screens/player_screen.dart</code></p><pre><code class="hljs dart"><span class="hljs-title class_">Widget</span> <span class="hljs-title function_">_buildAdditionalControls</span>(  <span class="hljs-title class_">BuildContext</span> context,  <span class="hljs-title class_">AudioPlaybackState</span> state,) &#123;  <span class="hljs-keyword">final</span> currentSong = state.currentSong; <span class="hljs-comment">// 不再读 audioManager.currentSong</span>  <span class="hljs-comment">// ...</span>&#125;<span class="hljs-title class_">Widget</span> <span class="hljs-title function_">_buildLyricsSection</span>(<span class="hljs-title class_">BuildContext</span> context, <span class="hljs-title class_">AudioPlaybackState</span> state) &#123;  <span class="hljs-keyword">final</span> currentSongId = state.currentSong?.id;  <span class="hljs-comment">// ...</span>&#125;<span class="hljs-keyword">void</span> <span class="hljs-title function_">_showPlayQueueSheet</span>(<span class="hljs-title class_">BuildContext</span> context, <span class="hljs-title class_">AudioPlaybackState</span> state) &#123;  <span class="hljs-built_in">int</span> currentIndex = state.currentIndex ?? -<span class="hljs-number">1</span>;  <span class="hljs-comment">// fallback 再按 state.currentSong 匹配</span>&#125;</code></pre><p>这几段加起来，才真正把“音频切了、UI没切”这种不一致压下去。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>这个问题最大的坑是：</p><blockquote><p>你以为是“切歌函数有 bug”，其实是“多条异步状态通道没有收口”。</p></blockquote><p>经验总结：</p><ul><li>音频播放器的稳定性，核心不在某个 API，而在状态流是否单一、可判定、可取消；</li><li>“忽略点击”通常只是临时止血，真正可用的是“排队 + 去重 + 过期丢弃”；</li><li>UI 必须尽量只吃一个状态源，避免同屏多个真相。</li></ul><p>这次修完后，切歌日志终于从“看不懂谁覆盖谁”变成了“每次状态变化都有因果链”。后续再出类似问题，定位成本会低很多。</p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/online-switch-desync-fix/</id>
    <link href="https://blog.leguans.cn/posts/online-switch-desync-fix/"/>
    <published>2026-02-08T16:00:00.000Z</published>
    <summary>记录一次 Flutter 音乐播放器在线切歌错位问题：音频已切到下一首，但封面/歌名滞后，甚至要点暂停才刷新。包含踩坑方案、失败原因和最终稳定修复。</summary>
    <title>在线播放切歌“声音和信息不一致”：一次从入口补丁到状态链路重构的排查</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="Flutter" scheme="https://blog.leguans.cn/tags/Flutter/"/>
    <category term="iOS" scheme="https://blog.leguans.cn/tags/iOS/"/>
    <category term="MusicPlayer" scheme="https://blog.leguans.cn/tags/MusicPlayer/"/>
    <category term="Android" scheme="https://blog.leguans.cn/tags/Android/"/>
    <category term="数据迁移" scheme="https://blog.leguans.cn/tags/%E6%95%B0%E6%8D%AE%E8%BF%81%E7%A7%BB/"/>
    <content>
      <![CDATA[<p>最近有个很典型、也很容易被忽略的问题：<strong>应用重装之后，收藏的歌曲、喜欢的歌曲看起来“全没了”</strong>。</p><p>更准确地说是：收藏数据其实还在本地存储里，但 UI 匹配不到对应的歌曲，于是喜欢列表&#x2F;歌单展示为空。</p><h2 id="现象与日志"><a href="#现象与日志" class="headerlink" title="现象与日志"></a>现象与日志</h2><p>用户侧的表现很直观：</p><ul><li>重装 App</li><li>重新扫描本地歌曲（或重新授权）</li><li>「我喜欢的音乐」变成空</li><li>收藏&#x2F;喜欢按钮状态也全变回未收藏</li></ul><p>日志非常“打脸”：一边说 favorites 确实加载出来了，另一边却说匹配结果为 0。</p><pre><code class="hljs plaintext">[Favorites] Loaded 17 favorites: &#123;804604524, 749312547, ...&#125;[Match Debug] First 3 song IDs: [1414957450, 147442901, 1927087886][Match Debug] First 3 favorites: [804604524, 749312547, 37290334][Match Debug] Matched 0 songs out of 17 favorites</code></pre><p>这说明两件事：</p><ol><li>favorites 的持久化没坏（能读到 17 个 id）  </li><li>songs 列表也没坏（扫描出了歌曲 id）  </li><li>但 <strong>favorites 里存的 id，和当前扫描出来的 song.id 已经不是同一套体系</strong></li></ol><h2 id="排查路径：到底是谁变了？"><a href="#排查路径：到底是谁变了？" class="headerlink" title="排查路径：到底是谁变了？"></a>排查路径：到底是谁变了？</h2><p>收藏&#x2F;喜欢的存储很简单：本地只存 <code>Set&lt;int&gt;</code> 的 songId（<code>shared_preferences</code>），UI 展示时用：</p><pre><code class="hljs dart"><span class="hljs-keyword">final</span> favoriteSongs = songs.<span class="hljs-title function_">where</span>((s) =&gt; favorites.<span class="hljs-title function_">contains</span>(s.id)).<span class="hljs-title function_">toList</span>();</code></pre><p>因此只要 <code>s.id</code> 的生成规则发生变化（或同一首歌在重装后拿到的 id 变了），favorites 就会“全部失效”。</p><p>我把排查重点放到两类常见不稳定来源：</p><h3 id="1）系统媒体库-id-不稳定"><a href="#1）系统媒体库-id-不稳定" class="headerlink" title="1）系统媒体库 id 不稳定"></a>1）系统媒体库 id 不稳定</h3><p>Android&#x2F;iOS 的系统媒体库可能返回一个“看起来像主键”的 id（例如 <code>on_audio_query</code> 的 <code>SongModel.id</code>），但它未必承诺跨重装、跨版本、跨扫描一致。</p><h3 id="2）用「文件绝对路径」生成-id，会被-iOS-重装打爆"><a href="#2）用「文件绝对路径」生成-id，会被-iOS-重装打爆" class="headerlink" title="2）用「文件绝对路径」生成 id，会被 iOS 重装打爆"></a>2）用「文件绝对路径」生成 id，会被 iOS 重装打爆</h3><p>iOS 卸载重装后，App 的沙盒容器路径会变（例如 <code>.../Application/&lt;UUID&gt;/Documents/...</code> 里的 <code>&lt;UUID&gt;</code> 变了）。</p><p>如果 id 是 <code>hash(绝对路径)</code>，那同一个文件在新容器里就会产生完全不同的 id。</p><p>更糟的是：我之前为了修别的问题，把 id 从一种 hash 改成了另一种（比如 <code>String.hashCode</code> vs 自定义 hash），这也会让旧数据瞬间“断链”。</p><h2 id="定位到关键点：收藏-歌单依赖「稳定-songId」"><a href="#定位到关键点：收藏-歌单依赖「稳定-songId」" class="headerlink" title="定位到关键点：收藏&#x2F;歌单依赖「稳定 songId」"></a>定位到关键点：收藏&#x2F;歌单依赖「稳定 songId」</h2><p>播放器里有三处会依赖 songId：</p><ul><li>喜欢&#x2F;收藏（<code>favorite_songs</code>）</li><li>本地歌单（歌单里存 songIds）</li><li>播放状态恢复（缓存 playlist songIds + index）</li></ul><p>只要 songId 不稳定，这三个功能都会在“升级&#x2F;重装&#x2F;换机”时出现类似问题。</p><p>所以修复目标不是“让匹配代码更聪明”，而是：</p><ol><li><strong>定义一套跨重装稳定的 songId 规则</strong>  </li><li><strong>对历史版本产生的旧 id 做迁移</strong></li></ol><h2 id="解决方案一：canonical-path-稳定-hash"><a href="#解决方案一：canonical-path-稳定-hash" class="headerlink" title="解决方案一：canonical path + 稳定 hash"></a>解决方案一：canonical path + 稳定 hash</h2><p>我新增了一个 <code>SongId</code> 工具（<code>lib/core/services/song_id.dart</code>），做两件事：</p><ol><li>对文件路径做 canonicalize：  <ul><li>如果文件位于 app 的 <code>Documents</code> 下，把“安装相关”的前缀剥离掉  </li><li>把路径变成 <code>&quot;&lt;DOCS&gt;/Music/xxx.flac&quot;</code> 这种相对且稳定的形式</li></ul></li><li>对 canonical path 做确定性 hash（djb2，并限定 31-bit 正整数）</li></ol><p>核心思路：<strong>同一首歌只要相对路径不变，重装后 id 也不变</strong>。</p><h2 id="解决方案二：启动时自动迁移旧数据"><a href="#解决方案二：启动时自动迁移旧数据" class="headerlink" title="解决方案二：启动时自动迁移旧数据"></a>解决方案二：启动时自动迁移旧数据</h2><p>光有新规则还不够：用户重装&#x2F;升级后，preferences 里存的还是旧 id。</p><p>因此我在“扫描歌曲完成后”（<code>LocalSongsNotifier.scanSongs()</code>）追加了迁移步骤（<code>lib/core/services/providers.dart</code>）：</p><ol><li>用当前扫描到的 <code>songs</code> 建一张映射表：  <ul><li>key：旧算法可能生成的 legacyId（例如 <code>filePath.hashCode</code>、旧的 hash）  </li><li>value：当前 canonicalId（新规则算出来的 <code>song.id</code>）</li></ul></li><li>用这张表批量改写：<ul><li>favorites（<code>FavoritesService.migrateSongIds</code>）</li><li>playlists（<code>PlaylistService.migrateSongIds</code>，并保持原顺序去重）</li></ul></li></ol><p>迁移策略里有个小细节：如果同一个 legacyId 映射到多个 canonicalId（冲突），我会丢弃这个 legacyId 的映射，避免误把 A 歌迁到 B 歌。</p><h2 id="验证与结果"><a href="#验证与结果" class="headerlink" title="验证与结果"></a>验证与结果</h2><p>我补了一个最小单测，专门验证“iOS 容器路径变了，id 仍然一致”（<code>test/song_id_test.dart</code>）：</p><ul><li><code>.../Application/AAA/Documents/Music/foo.mp3</code></li><li><code>.../Application/BBB/Documents/Music/foo.mp3</code></li></ul><p>canonicalize 后都应变成 <code>&quot;&lt;DOCS&gt;/Music/foo.mp3&quot;</code>，因此 id 相同。</p><p>最终效果：</p><ul><li>重装&#x2F;升级后，favorites 仍然能匹配到歌曲</li><li>喜欢列表不再空</li><li>本地歌单不会因为 id 体系变化而“全失效”</li></ul><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>这类问题的根本原因通常不是“收藏没存上”，而是 <strong>你存的是一个不稳定的引用（songId）</strong>。</p><p>经验总结：</p><ul><li>只要你把业务数据（收藏&#x2F;歌单&#x2F;播放缓存）建立在某个 id 上，就要把这个 id 当作“长期协议”来设计  </li><li>iOS 沙盒路径在重装后会变，绝对路径直接参与 id 计算会天然不稳定  </li><li>一旦你不得不改 id 规则，务必提供迁移，否则用户数据会在升级后“看起来丢了”</li></ul>]]>
    </content>
    <id>https://blog.leguans.cn/posts/favorites-reinstall-fix/</id>
    <link href="https://blog.leguans.cn/posts/favorites-reinstall-fix/"/>
    <published>2026-01-15T16:00:00.000Z</published>
    <summary>Flutter 本地音乐播放器在重装/升级后出现“收藏还在，但喜欢列表空了”的问题。本文记录从日志定位到根因（songId 不稳定）以及如何通过 canonical path + 迁移把用户数据救回来。</summary>
    <title>重装后收藏/喜欢 歌曲不见了：一次「ID 稳定性 + 数据迁移」的修复记录</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="MusicPlayer" scheme="https://blog.leguans.cn/tags/MusicPlayer/"/>
    <category term="软件开发" scheme="https://blog.leguans.cn/tags/%E8%BD%AF%E4%BB%B6%E5%BC%80%E5%8F%91/"/>
    <content>
      <![CDATA[<p>最近在重写一个 Flutter 本地音乐播放器，播放器内核用的是 <code>just_audio + audio_service</code>。本来一切都挺顺的，直到我开始认真测试「拖动播放界面的进度条」：<strong>UI 上的进度跳过去了，日志也说 seek 成功了，但耳朵听到的位置明显不对</strong>。</p><p>更离谱的是：哪怕你把 slider 直接拉到 100%，实际也没有播放到结尾，而是停在一个“看起来差不多但就是不对”的位置。<br><img src="/images/posts/bofang_1290x849.webp" alt="直接拖到结束还在播放"></p><p>这个问题只在 iOS 的本地 FLAC 上稳定复现（文件放在应用沙盒里，不走系统媒体库）。</p><h2 id="现象与复现条件"><a href="#现象与复现条件" class="headerlink" title="现象与复现条件"></a>现象与复现条件</h2><ul><li>平台：iOS（应用沙盒文件）</li><li>音频格式：FLAC</li><li>表现：<ul><li>seek 日志显示 position 已到目标值</li><li>实际听感偏前，拖到结尾仍未到结尾</li><li>歌曲时长无异常</li></ul></li></ul><p>日志示例（从 UI slider -&gt; seek -&gt; UI 认为“对齐”）：</p><pre><code class="hljs plaintext">[SLIDER] onChangeEnd: sliderValue=1.0, duration=247153ms, targetPosition=247153ms, actualPosition=5674ms[SEEK] Request: target=247153ms, current=5869ms, duration=247153ms[EFFECTIVE_POS] Aligned! actual=247153ms, pending=247153ms, delta=0ms[SEEK] After seek(): position=247153ms</code></pre><p>你看日志，完全没毛病：目标&#x3D;247153ms、seek 后 position&#x3D;247153ms、甚至 UI 的 “effectivePosition” 还打印了 <code>Aligned!</code>。但声音就是没到那儿。</p><h2 id="排查路径（以及为什么这些路走不通）"><a href="#排查路径（以及为什么这些路走不通）" class="headerlink" title="排查路径（以及为什么这些路走不通）"></a>排查路径（以及为什么这些路走不通）</h2><p>我一开始的直觉是“时长算错了 &#x2F; 元数据不准”。毕竟拖到 100% 还不到尾部，很像 duration 出问题。</p><p>但很快就排掉了：同一首歌，无论用 on_audio_query 拿的 duration，还是解析 metadata 得到的 duration，显示都正常，且播放从头到尾的总时长也没问题。</p><p>接下来我开始怀疑是“UI 算法”或“状态更新延迟”，于是做了两件事：</p><h3 id="1）把-slider-侧的-targetPosition-打印清楚"><a href="#1）把-slider-侧的-targetPosition-打印清楚" class="headerlink" title="1）把 slider 侧的 targetPosition 打印清楚"></a>1）把 slider 侧的 targetPosition 打印清楚</h3><p>进度条松手时我会算目标毫秒数并调用 seek（<code>lib/features/player/presentation/screens/player_screen.dart</code>），类似这样：</p><pre><code class="hljs dart">onChangeEnd: (value) <span class="hljs-keyword">async</span> &#123;  <span class="hljs-keyword">final</span> targetPosition = <span class="hljs-title class_">Duration</span>(    milliseconds: (value * duration.inMilliseconds).<span class="hljs-title function_">toInt</span>(),  );  <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[SLIDER] onChangeEnd: sliderValue=<span class="hljs-subst">$value</span>, &#x27;</span>      <span class="hljs-string">&#x27;duration=<span class="hljs-subst">$&#123;duration.inMilliseconds&#125;</span>ms, &#x27;</span>      <span class="hljs-string">&#x27;targetPosition=<span class="hljs-subst">$&#123;targetPosition.inMilliseconds&#125;</span>ms, &#x27;</span>      <span class="hljs-string">&#x27;actualPosition=<span class="hljs-subst">$&#123;state.position.inMilliseconds&#125;</span>ms&#x27;</span>);  <span class="hljs-keyword">await</span> audioManager?.<span class="hljs-title function_">seek</span>(targetPosition);&#125;</code></pre><p>这一步确认：targetPosition 绝对算对了，而且和 UI 上显示的时间一致。</p><h3 id="2）把-AudioManager-seek-的前后状态也打出来"><a href="#2）把-AudioManager-seek-的前后状态也打出来" class="headerlink" title="2）把 AudioManager.seek 的前后状态也打出来"></a>2）把 AudioManager.seek 的前后状态也打出来</h3><p>我在 <code>lib/core/services/audio_manager.dart</code> 的 <code>seek</code> 里加了日志，并且等 <code>ProcessingState.ready</code>：</p><pre><code class="hljs dart"><span class="hljs-meta">@override</span><span class="hljs-title class_">Future</span>&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title function_">seek</span>(<span class="hljs-title class_">Duration</span> position) <span class="hljs-keyword">async</span> &#123;  <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[SEEK] Request: target=<span class="hljs-subst">$&#123;position.inMilliseconds&#125;</span>ms, &#x27;</span>      <span class="hljs-string">&#x27;current=<span class="hljs-subst">$&#123;_player.position.inMilliseconds&#125;</span>ms, &#x27;</span>      <span class="hljs-string">&#x27;duration=<span class="hljs-subst">$&#123;_player.duration?.inMilliseconds&#125;</span>ms&#x27;</span>);  <span class="hljs-keyword">await</span> _player.<span class="hljs-title function_">seek</span>(position);  <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[SEEK] After seek(): position=<span class="hljs-subst">$&#123;_player.position.inMilliseconds&#125;</span>ms&#x27;</span>);  <span class="hljs-keyword">await</span> _player.processingStateStream      .<span class="hljs-title function_">firstWhere</span>((state) =&gt;          state == <span class="hljs-title class_">ProcessingState</span>.ready ||          state == <span class="hljs-title class_">ProcessingState</span>.completed)      .<span class="hljs-title function_">timeout</span>(<span class="hljs-keyword">const</span> <span class="hljs-title class_">Duration</span>(seconds: <span class="hljs-number">2</span>),          onTimeout: () =&gt; <span class="hljs-title class_">ProcessingState</span>.ready);  <span class="hljs-title function_">debugPrint</span>(<span class="hljs-string">&#x27;[SEEK] After ready: position=<span class="hljs-subst">$&#123;_player.position.inMilliseconds&#125;</span>ms, &#x27;</span>      <span class="hljs-string">&#x27;processingState=<span class="hljs-subst">$&#123;_player.processingState&#125;</span>&#x27;</span>);&#125;</code></pre><p>结果依然很诡异：<strong>position 的数值确实跳到了目标位置</strong>，ready 也到了，但实际听到的位置还是偏前。</p><p>这时候我才意识到：这不是“UI&#x2F;状态没更新”，而是更底层的东西——<strong>iOS 对这个 FLAC 的 seek 本身不精确</strong>，而 just_audio 上报的 position 也不代表你耳朵听到的那一帧一定已经对齐。</p><h2 id="定位到关键点：iOS-的精确时长-定位选项"><a href="#定位到关键点：iOS-的精确时长-定位选项" class="headerlink" title="定位到关键点：iOS 的精确时长&#x2F;定位选项"></a>定位到关键点：iOS 的精确时长&#x2F;定位选项</h2><p>继续往下挖，我去翻了 just_audio 的 Darwin（iOS&#x2F;macOS）实现。它本质上是用 <code>AVURLAsset</code> 创建 <code>AVPlayerItem</code>。在 Apple 的世界里，<strong>“快”和“准”是可以二选一的</strong>：默认情况下系统并不一定会用最精确的方式去解析时长与时间轴（尤其是本地文件、尤其是某些格式）。</p><p>just_audio 其实提供了一个开关，把它传到 <code>AVURLAssetPreferPreciseDurationAndTimingKey</code> 上：也就是 <code>preferPreciseDurationAndTiming</code>。</p><p>解决思路是：<strong>为 iOS 的 FLAC 文件启用精确时长&#x2F;时间轴选项</strong>。</p><p>在 just_audio 里，这个选项对应：</p><pre><code class="hljs plaintext">DarwinAssetOptions(preferPreciseDurationAndTiming: true)</code></pre><h2 id="代码改动"><a href="#代码改动" class="headerlink" title="代码改动"></a>代码改动</h2><p>核心修改在 <code>lib/core/services/audio_manager.dart</code>，我最终做了三件事（这三件事组合起来才稳）： </p><ol><li>播放列表构建 AudioSource 时，不再用默认的 <code>AudioSource.uri(...)</code>（它会根据 URI 判断类型，但我需要更明确地控制 options）。</li><li>改用 <code>ProgressiveAudioSource(...)</code>，并把 <code>ProgressiveAudioSourceOptions</code> 填进去（只对 iOS&#x2F;macOS + <code>.flac</code> 启用精确模式）。  </li><li>同时把 <code>duration</code> 也传给 source（<code>song.durationAsDuration</code>），作为一个更稳定的兜底（避免某些文件解析 duration 走近似路径）。</li></ol><p>关键实现（节选）：</p><pre><code class="hljs dart"><span class="hljs-title class_">AudioSource</span> <span class="hljs-title function_">_buildAudioSource</span>(<span class="hljs-title class_">LocalSongModel</span> song) &#123;  <span class="hljs-keyword">final</span> filePath = song.filePath;  <span class="hljs-keyword">if</span> (filePath != <span class="hljs-keyword">null</span> &amp;&amp; filePath.isNotEmpty) &#123;    <span class="hljs-keyword">final</span> file = <span class="hljs-title class_">File</span>(filePath);    <span class="hljs-keyword">if</span> (file.<span class="hljs-title function_">existsSync</span>()) &#123;      <span class="hljs-keyword">return</span> <span class="hljs-title function_">_buildProgressiveSource</span>(        <span class="hljs-title class_">Uri</span>.<span class="hljs-title function_">file</span>(filePath),        song,        isFlac: filePath.<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">endsWith</span>(<span class="hljs-string">&#x27;.flac&#x27;</span>),      );    &#125;  &#125;  <span class="hljs-keyword">final</span> uri = <span class="hljs-title class_">Uri</span>.<span class="hljs-title function_">parse</span>(song.uri);  <span class="hljs-keyword">return</span> <span class="hljs-title function_">_buildProgressiveSource</span>(    uri,    song,    isFlac: uri.path.<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">endsWith</span>(<span class="hljs-string">&#x27;.flac&#x27;</span>),  );&#125;<span class="hljs-title class_">AudioSource</span> <span class="hljs-title function_">_buildProgressiveSource</span>(  <span class="hljs-title class_">Uri</span> uri,  <span class="hljs-title class_">LocalSongModel</span> song, &#123;  <span class="hljs-keyword">required</span> <span class="hljs-built_in">bool</span> isFlac,&#125;) &#123;  <span class="hljs-keyword">final</span> usePreciseTiming =      isFlac &amp;&amp; (<span class="hljs-title class_">Platform</span>.isIOS || <span class="hljs-title class_">Platform</span>.isMacOS);  <span class="hljs-keyword">final</span> options = usePreciseTiming      ? <span class="hljs-keyword">const</span> <span class="hljs-title class_">ProgressiveAudioSourceOptions</span>(          darwinAssetOptions:              <span class="hljs-title class_">DarwinAssetOptions</span>(preferPreciseDurationAndTiming: <span class="hljs-keyword">true</span>),        )      : <span class="hljs-keyword">null</span>;  <span class="hljs-keyword">return</span> <span class="hljs-title class_">ProgressiveAudioSource</span>(    uri,    tag: song,    duration: song.durationAsDuration,    options: options,  );&#125;</code></pre><p>然后在 <code>setPlaylist</code> 里统一用 <code>_buildAudioSource</code> 生成 playlist 的 children：</p><pre><code class="hljs dart"><span class="hljs-keyword">final</span> audioSources = songs.<span class="hljs-title function_">map</span>(_buildAudioSource).<span class="hljs-title function_">toList</span>();<span class="hljs-keyword">await</span> _player.<span class="hljs-title function_">setAudioSource</span>(  <span class="hljs-title class_">ConcatenatingAudioSource</span>(children: audioSources),  initialIndex: _currentIndex,);</code></pre><p>这次修复的关键不是“再等一等”“再对齐一下 position”，而是<strong>让 iOS 在解析这个 FLAC 的时间轴时走精确路径</strong>。否则你在 Dart 层做再多校验，最终音频解码器&#x2F;播放器层面还是会“跳到一个差不多的位置”。 </p><h2 id="结果"><a href="#结果" class="headerlink" title="结果"></a>结果</h2><ul><li>FLAC 拖动进度条与实际听感一致</li><li>拖到尾部能真正到尾部</li><li>日志与听感完全对齐</li></ul><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>这个问题的难点在于：<strong>你能拿到的一切“状态”都在告诉你它对了，但最终用户体验仍然是错的</strong>。<br>从“检查 duration&#x2F;元数据”到“怀疑 UI 算法&#x2F;状态延迟”再到“翻平台实现”，最后才发现真正的开关在 iOS 的 AVURLAsset 上。</p><p>如果你遇到类似情况（iOS + FLAC + seek 不准），优先试：</p><pre><code class="hljs plaintext">DarwinAssetOptions(preferPreciseDurationAndTiming: true)</code></pre><p>最后补一句经验：播放器这种东西，UI 层能做的“看起来正确”很有限；一旦出现「UI 正确但耳朵不对」，多半是平台层（解码&#x2F;seek&#x2F;时基）的问题，不要在 Dart 层硬耗太久。</p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/seedbugfix/</id>
    <link href="https://blog.leguans.cn/posts/seedbugfix/"/>
    <published>2026-01-14T16:00:00.000Z</published>
    <summary>最近在重写一个音乐播放器软件，拖动进度条时歌曲的实际播放进度和进度条显示的不一致，记录一下 Bug 的修复过程。</summary>
    <title>iOS 本地 FLAC 拖动进度条不准：一次「日志正确但耳朵不对」的排查</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="plugin" scheme="https://blog.leguans.cn/tags/plugin/"/>
    <content>
      <![CDATA[<blockquote><p>本文分享如何为你的 App 实现一个自动化激活码领取系统，支持 WordPress、Typecho 和静态博客（Astro&#x2F;Hugo）。</p></blockquote><h2 id="一、后端-API（Flask）"><a href="#一、后端-API（Flask）" class="headerlink" title="一、后端 API（Flask）"></a>一、后端 API（Flask）</h2><p>后端负责：生成激活码、验证请求签名、防止重复领取。</p><h3 id="1-1-激活码模型"><a href="#1-1-激活码模型" class="headerlink" title="1.1 激活码模型"></a>1.1 激活码模型</h3><pre><code class="hljs python"><span class="hljs-keyword">from</span> flask_sqlalchemy <span class="hljs-keyword">import</span> SQLAlchemy<span class="hljs-keyword">import</span> random  db = SQLAlchemy()  <span class="hljs-keyword">class</span> <span class="hljs-title class_">ActivationCode</span>(db.Model):<span class="hljs-built_in">id</span> = db.Column(db.Integer, primary_key=<span class="hljs-literal">True</span>)code = db.Column(db.String(<span class="hljs-number">20</span>), unique=<span class="hljs-literal">True</span>, nullable=<span class="hljs-literal">False</span>)device_id = db.Column(db.String(<span class="hljs-number">64</span>)) <span class="hljs-comment"># 绑定的设备</span>note = db.Column(db.String(<span class="hljs-number">200</span>)) <span class="hljs-comment"># 领取信息</span>is_active = db.Column(db.Boolean, default=<span class="hljs-literal">True</span>)created_at = db.Column(db.DateTime, default=datetime.utcnow)  <span class="hljs-keyword">def</span> <span class="hljs-title function_">generate_activation_code</span>():<span class="hljs-string">&quot;&quot;&quot;生成格式化激活码：XXXX-XXXX-XXXX-XXXX&quot;&quot;&quot;</span>chars = <span class="hljs-string">&#x27;ABCDEFGHJKLMNPQRSTUVWXYZ0123456789&#x27;</span>segments = [<span class="hljs-string">&#x27;&#x27;</span>.join(random.choices(chars, k=<span class="hljs-number">4</span>)) <span class="hljs-keyword">for</span> _ <span class="hljs-keyword">in</span> <span class="hljs-built_in">range</span>(<span class="hljs-number">4</span>)]<span class="hljs-keyword">return</span> <span class="hljs-string">&#x27;-&#x27;</span>.join(segments)</code></pre><h3 id="1-2-领取接口"><a href="#1-2-领取接口" class="headerlink" title="1.2 领取接口"></a>1.2 领取接口</h3><pre><code class="hljs python"><span class="hljs-meta">@app.route(<span class="hljs-params"><span class="hljs-string">&#x27;/api/get-code&#x27;</span>, methods=[<span class="hljs-string">&#x27;POST&#x27;</span>]</span>)</span><span class="hljs-keyword">def</span> <span class="hljs-title function_">claim_code</span>():data = request.get_json()query = data.get(<span class="hljs-string">&#x27;query&#x27;</span>, <span class="hljs-string">&#x27;&#x27;</span>).strip() <span class="hljs-comment"># 用户唯一标识</span>secret = data.get(<span class="hljs-string">&#x27;secret&#x27;</span>, <span class="hljs-string">&#x27;&#x27;</span>) <span class="hljs-comment"># 密钥</span>timestamp = data.get(<span class="hljs-string">&#x27;timestamp&#x27;</span>) <span class="hljs-comment"># 时间戳</span>signature = data.get(<span class="hljs-string">&#x27;signature&#x27;</span>, <span class="hljs-string">&#x27;&#x27;</span>) <span class="hljs-comment"># HMAC 签名</span><span class="hljs-comment"># 1. 验证密钥</span><span class="hljs-keyword">if</span> secret != app.config[<span class="hljs-string">&#x27;CLAIM_SECRET&#x27;</span>]:<span class="hljs-keyword">return</span> jsonify(&#123;<span class="hljs-string">&#x27;success&#x27;</span>: <span class="hljs-literal">False</span>, <span class="hljs-string">&#x27;message&#x27;</span>: <span class="hljs-string">&#x27;密钥错误&#x27;</span>&#125;), <span class="hljs-number">401</span><span class="hljs-comment"># 2. 验证时间戳（5分钟内有效，防止重放攻击）</span><span class="hljs-keyword">if</span> <span class="hljs-built_in">abs</span>(time.time() - <span class="hljs-built_in">int</span>(timestamp)) &gt; <span class="hljs-number">300</span>:<span class="hljs-keyword">return</span> jsonify(&#123;<span class="hljs-string">&#x27;success&#x27;</span>: <span class="hljs-literal">False</span>, <span class="hljs-string">&#x27;message&#x27;</span>: <span class="hljs-string">&#x27;请求已过期&#x27;</span>&#125;), <span class="hljs-number">400</span><span class="hljs-comment"># 3. 验证签名</span>expected_sig = hmac.new(secret.encode(),<span class="hljs-string">f&quot;<span class="hljs-subst">&#123;query&#125;</span>|<span class="hljs-subst">&#123;timestamp&#125;</span>|<span class="hljs-subst">&#123;secret&#125;</span>&quot;</span>.encode(),hashlib.sha256).hexdigest()<span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> hmac.compare_digest(signature, expected_sig):<span class="hljs-keyword">return</span> jsonify(&#123;<span class="hljs-string">&#x27;success&#x27;</span>: <span class="hljs-literal">False</span>, <span class="hljs-string">&#x27;message&#x27;</span>: <span class="hljs-string">&#x27;签名验证失败&#x27;</span>&#125;), <span class="hljs-number">401</span><span class="hljs-comment"># 4. 检查是否已领取</span>existing = ActivationCode.query.<span class="hljs-built_in">filter</span>(ActivationCode.note.like(<span class="hljs-string">f&quot;Claimed by: <span class="hljs-subst">&#123;query&#125;</span> |%&quot;</span>)).first()<span class="hljs-keyword">if</span> existing:<span class="hljs-keyword">return</span> jsonify(&#123;<span class="hljs-string">&#x27;success&#x27;</span>: <span class="hljs-literal">True</span>, <span class="hljs-string">&#x27;code&#x27;</span>: existing.code&#125;)<span class="hljs-comment"># 5. 生成新激活码</span>code = generate_activation_code()new_code = ActivationCode(code=code,note=<span class="hljs-string">f&quot;Claimed by: <span class="hljs-subst">&#123;query&#125;</span> | <span class="hljs-subst">&#123;data.get(<span class="hljs-string">&#x27;username&#x27;</span>)&#125;</span> | <span class="hljs-subst">&#123;data.get(<span class="hljs-string">&#x27;email&#x27;</span>)&#125;</span>&quot;</span>)db.session.add(new_code)db.session.commit()<span class="hljs-keyword">return</span> jsonify(&#123;<span class="hljs-string">&#x27;success&#x27;</span>: <span class="hljs-literal">True</span>, <span class="hljs-string">&#x27;code&#x27;</span>: code&#125;)</code></pre><h3 id="1-3-安全措施"><a href="#1-3-安全措施" class="headerlink" title="1.3 安全措施"></a>1.3 安全措施</h3><table><thead><tr><th>威胁</th><th>防御措施</th></tr></thead><tbody><tr><td>接口被滥用</td><td>HMAC-SHA256 签名验证</td></tr><tr><td>重放攻击</td><td>时间戳 5 分钟有效期</td></tr><tr><td>重复领取</td><td>数据库 <code>note</code> 字段记录用户标识</td></tr></tbody></table><hr><h2 id="二、WordPress-插件"><a href="#二、WordPress-插件" class="headerlink" title="二、WordPress 插件"></a>二、WordPress 插件</h2><p>WordPress 可以自动获取登录用户信息，实现”一键领取”。</p><h3 id="2-1-插件结构"><a href="#2-1-插件结构" class="headerlink" title="2.1 插件结构"></a>2.1 插件结构</h3><p>文件：<code>wp-content/plugins/activation-claimer/activation-claimer.php</code></p><pre><code class="hljs php"><span class="hljs-meta">&lt;?php</span><span class="hljs-comment">/*</span><span class="hljs-comment"></span><span class="hljs-comment">Plugin Name: Activation Code Claimer</span><span class="hljs-comment"></span><span class="hljs-comment">Description: 允许已登录用户一键领取激活码</span><span class="hljs-comment"></span><span class="hljs-comment">Version: 2.0</span><span class="hljs-comment"></span><span class="hljs-comment">*/</span>  <span class="hljs-keyword">if</span> (!<span class="hljs-title function_ invoke__">defined</span>(<span class="hljs-string">&#x27;ABSPATH&#x27;</span>)) <span class="hljs-keyword">exit</span>;  <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ActivationCodeClaimer</span> </span>&#123;<span class="hljs-keyword">private</span> <span class="hljs-variable">$api_url</span> = <span class="hljs-string">&#x27;https://your-api.com/api/get-code&#x27;</span>;<span class="hljs-keyword">private</span> <span class="hljs-variable">$api_secret</span> = <span class="hljs-string">&#x27;your-secret-key&#x27;</span>;<span class="hljs-keyword">private</span> <span class="hljs-variable">$claimed_meta_key</span> = <span class="hljs-string">&#x27;activation_code_claimed&#x27;</span>;  <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span>(<span class="hljs-params"></span>) </span>&#123;<span class="hljs-title function_ invoke__">add_shortcode</span>(<span class="hljs-string">&#x27;claim_activation&#x27;</span>, [<span class="hljs-variable">$this</span>, <span class="hljs-string">&#x27;render_form&#x27;</span>]);&#125;  <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">render_form</span>(<span class="hljs-params"><span class="hljs-variable">$atts</span></span>) </span>&#123;<span class="hljs-comment">// 检查登录状态</span><span class="hljs-keyword">if</span> (!<span class="hljs-title function_ invoke__">is_user_logged_in</span>()) &#123;<span class="hljs-variable">$login_url</span> = <span class="hljs-title function_ invoke__">wp_login_url</span>(<span class="hljs-title function_ invoke__">get_permalink</span>());<span class="hljs-keyword">return</span> <span class="hljs-string">&#x27;&lt;div class=&quot;acc-error&quot;&gt;请先&lt;a href=&quot;&#x27;</span>.<span class="hljs-variable">$login_url</span>.<span class="hljs-string">&#x27;&quot;&gt;登录&lt;/a&gt;&lt;/div&gt;&#x27;</span>;&#125;  <span class="hljs-variable">$user</span> = <span class="hljs-title function_ invoke__">wp_get_current_user</span>();<span class="hljs-variable">$user_id</span> = <span class="hljs-variable">$user</span>-&gt;ID;  <span class="hljs-comment">// 检查是否已领取</span><span class="hljs-variable">$claimed_code</span> = <span class="hljs-title function_ invoke__">get_user_meta</span>(<span class="hljs-variable">$user_id</span>, <span class="hljs-variable">$this</span>-&gt;claimed_meta_key, <span class="hljs-literal">true</span>);<span class="hljs-keyword">if</span> (<span class="hljs-variable">$claimed_code</span>) &#123;<span class="hljs-keyword">return</span> <span class="hljs-string">&#x27;&lt;div class=&quot;acc-success&quot;&gt;您的激活码：&lt;code&gt;&#x27;</span>.<span class="hljs-variable">$claimed_code</span>.<span class="hljs-string">&#x27;&lt;/code&gt;&lt;/div&gt;&#x27;</span>;&#125;  <span class="hljs-comment">// 处理表单提交</span><span class="hljs-keyword">if</span> (<span class="hljs-variable">$_SERVER</span>[<span class="hljs-string">&#x27;REQUEST_METHOD&#x27;</span>] === <span class="hljs-string">&#x27;POST&#x27;</span> &amp;&amp; <span class="hljs-keyword">isset</span>(<span class="hljs-variable">$_POST</span>[<span class="hljs-string">&#x27;claim_submit&#x27;</span>])) &#123;<span class="hljs-keyword">return</span> <span class="hljs-variable language_">$this</span>-&gt;<span class="hljs-title function_ invoke__">handle_claim</span>(<span class="hljs-variable">$user</span>);&#125;  <span class="hljs-comment">// 渲染表单</span><span class="hljs-keyword">return</span> <span class="hljs-variable language_">$this</span>-&gt;<span class="hljs-title function_ invoke__">render_claim_form</span>(<span class="hljs-variable">$user</span>);&#125;  <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handle_claim</span>(<span class="hljs-params"><span class="hljs-variable">$user</span></span>) </span>&#123;<span class="hljs-comment">// 验证 Nonce</span><span class="hljs-keyword">if</span> (!<span class="hljs-title function_ invoke__">wp_verify_nonce</span>(<span class="hljs-variable">$_POST</span>[<span class="hljs-string">&#x27;acc_nonce&#x27;</span>], <span class="hljs-string">&#x27;acc_claim_action&#x27;</span>)) &#123;<span class="hljs-keyword">return</span> <span class="hljs-string">&#x27;&lt;div class=&quot;acc-error&quot;&gt;安全验证失败&lt;/div&gt;&#x27;</span>;&#125;  <span class="hljs-comment">// 生成签名</span><span class="hljs-variable">$timestamp</span> = <span class="hljs-title function_ invoke__">time</span>();<span class="hljs-variable">$user_identifier</span> = <span class="hljs-string">&#x27;wp_user_&#x27;</span> . <span class="hljs-variable">$user</span>-&gt;ID;<span class="hljs-variable">$signature</span> = <span class="hljs-title function_ invoke__">hash_hmac</span>(<span class="hljs-string">&#x27;sha256&#x27;</span>,<span class="hljs-string">&quot;<span class="hljs-subst">$user_identifier</span>|<span class="hljs-subst">$timestamp</span>|<span class="hljs-subst">&#123;$this-&gt;api_secret&#125;</span>&quot;</span>,<span class="hljs-variable">$this</span>-&gt;api_secret);  <span class="hljs-comment">// 调用后端 API</span><span class="hljs-variable">$response</span> = <span class="hljs-title function_ invoke__">wp_remote_post</span>(<span class="hljs-variable">$this</span>-&gt;api_url, [<span class="hljs-string">&#x27;body&#x27;</span> =&gt; <span class="hljs-title function_ invoke__">json_encode</span>([<span class="hljs-string">&#x27;query&#x27;</span> =&gt; <span class="hljs-variable">$user_identifier</span>,<span class="hljs-string">&#x27;username&#x27;</span> =&gt; <span class="hljs-variable">$user</span>-&gt;user_login,<span class="hljs-string">&#x27;email&#x27;</span> =&gt; <span class="hljs-variable">$user</span>-&gt;user_email,<span class="hljs-string">&#x27;timestamp&#x27;</span> =&gt; <span class="hljs-variable">$timestamp</span>,<span class="hljs-string">&#x27;signature&#x27;</span> =&gt; <span class="hljs-variable">$signature</span>,<span class="hljs-string">&#x27;secret&#x27;</span> =&gt; <span class="hljs-variable">$this</span>-&gt;api_secret]),<span class="hljs-string">&#x27;headers&#x27;</span> =&gt; [<span class="hljs-string">&#x27;Content-Type&#x27;</span> =&gt; <span class="hljs-string">&#x27;application/json&#x27;</span>],<span class="hljs-string">&#x27;timeout&#x27;</span> =&gt; <span class="hljs-number">15</span>]);  <span class="hljs-variable">$data</span> = <span class="hljs-title function_ invoke__">json_decode</span>(<span class="hljs-title function_ invoke__">wp_remote_retrieve_body</span>(<span class="hljs-variable">$response</span>), <span class="hljs-literal">true</span>);<span class="hljs-keyword">if</span> (<span class="hljs-variable">$data</span>[<span class="hljs-string">&#x27;success&#x27;</span>]) &#123;<span class="hljs-title function_ invoke__">update_user_meta</span>(<span class="hljs-variable">$user</span>-&gt;ID, <span class="hljs-variable">$this</span>-&gt;claimed_meta_key, <span class="hljs-variable">$data</span>[<span class="hljs-string">&#x27;code&#x27;</span>]);<span class="hljs-keyword">return</span> <span class="hljs-string">&#x27;&lt;div class=&quot;acc-success&quot;&gt;领取成功：&lt;code&gt;&#x27;</span>.<span class="hljs-variable">$data</span>[<span class="hljs-string">&#x27;code&#x27;</span>].<span class="hljs-string">&#x27;&lt;/code&gt;&lt;/div&gt;&#x27;</span>;&#125;<span class="hljs-keyword">return</span> <span class="hljs-string">&#x27;&lt;div class=&quot;acc-error&quot;&gt;&#x27;</span>.<span class="hljs-variable">$data</span>[<span class="hljs-string">&#x27;message&#x27;</span>].<span class="hljs-string">&#x27;&lt;/div&gt;&#x27;</span>;&#125;&#125;  <span class="hljs-keyword">new</span> <span class="hljs-title class_">ActivationCodeClaimer</span>();</code></pre><h3 id="2-2-使用方法"><a href="#2-2-使用方法" class="headerlink" title="2.2 使用方法"></a>2.2 使用方法</h3><p>在文章或页面中插入短代码：</p><pre><code class="hljs plaintext">[claim_activation]</code></pre><hr><h2 id="三、Typecho-插件"><a href="#三、Typecho-插件" class="headerlink" title="三、Typecho 插件"></a>三、Typecho 插件</h2><h3 id="3-1-插件结构"><a href="#3-1-插件结构" class="headerlink" title="3.1 插件结构"></a>3.1 插件结构</h3><p>文件夹：<code>usr/plugins/ClaimActivation/Plugin.php</code></p><pre><code class="hljs php"><span class="hljs-meta">&lt;?php</span><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ClaimActivation_Plugin</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">Typecho_Plugin_Interface</span> </span>&#123;<span class="hljs-keyword">public</span> <span class="hljs-built_in">static</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">activate</span>(<span class="hljs-params"></span>) </span>&#123;<span class="hljs-comment">// 注册内容过滤器</span><span class="hljs-title class_">Typecho_Plugin</span>::<span class="hljs-title function_ invoke__">factory</span>(<span class="hljs-string">&#x27;Widget_Abstract_Contents&#x27;</span>)-&gt;contentEx = [<span class="hljs-string">&#x27;ClaimActivation_Plugin&#x27;</span>, <span class="hljs-string">&#x27;contentFilter&#x27;</span>];<span class="hljs-keyword">return</span> <span class="hljs-title function_ invoke__">_t</span>(<span class="hljs-string">&#x27;插件已激活，使用 &lt;!--claim--&gt; 标记插入领取表单&#x27;</span>);&#125;  <span class="hljs-keyword">public</span> <span class="hljs-built_in">static</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">deactivate</span>(<span class="hljs-params"></span>) </span>&#123;&#125;  <span class="hljs-keyword">public</span> <span class="hljs-built_in">static</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">config</span>(<span class="hljs-params">Typecho_Widget_Helper_Form <span class="hljs-variable">$form</span></span>) </span>&#123;<span class="hljs-variable">$form</span>-&gt;<span class="hljs-title function_ invoke__">addInput</span>(<span class="hljs-keyword">new</span> <span class="hljs-title class_">Typecho_Widget_Helper_Form_Element_Text</span>(<span class="hljs-string">&#x27;apiUrl&#x27;</span>, <span class="hljs-literal">NULL</span>, <span class="hljs-string">&#x27;https://your-api.com/api/get-code&#x27;</span>,<span class="hljs-title function_ invoke__">_t</span>(<span class="hljs-string">&#x27;后端 API 地址&#x27;</span>)));<span class="hljs-variable">$form</span>-&gt;<span class="hljs-title function_ invoke__">addInput</span>(<span class="hljs-keyword">new</span> <span class="hljs-title class_">Typecho_Widget_Helper_Form_Element_Text</span>(<span class="hljs-string">&#x27;apiSecret&#x27;</span>, <span class="hljs-literal">NULL</span>, <span class="hljs-string">&#x27;your-secret-key&#x27;</span>,<span class="hljs-title function_ invoke__">_t</span>(<span class="hljs-string">&#x27;API 密钥&#x27;</span>)));&#125;  <span class="hljs-keyword">public</span> <span class="hljs-built_in">static</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">personalConfig</span>(<span class="hljs-params">Typecho_Widget_Helper_Form <span class="hljs-variable">$form</span></span>) </span>&#123;&#125;  <span class="hljs-comment">// 内容过滤器：替换 &lt;!--claim--&gt; 标记</span><span class="hljs-keyword">public</span> <span class="hljs-built_in">static</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">contentFilter</span>(<span class="hljs-params"><span class="hljs-variable">$content</span>, <span class="hljs-variable">$widget</span>, <span class="hljs-variable">$lastResult</span></span>) </span>&#123;<span class="hljs-variable">$content</span> = <span class="hljs-keyword">empty</span>(<span class="hljs-variable">$lastResult</span>) ? <span class="hljs-variable">$content</span> : <span class="hljs-variable">$lastResult</span>;<span class="hljs-keyword">if</span> (<span class="hljs-title function_ invoke__">strpos</span>(<span class="hljs-variable">$content</span>, <span class="hljs-string">&#x27;&lt;!--claim--&gt;&#x27;</span>) !== <span class="hljs-literal">false</span>) &#123;<span class="hljs-variable">$form</span> = <span class="hljs-built_in">self</span>::<span class="hljs-title function_ invoke__">generateClaimForm</span>();<span class="hljs-variable">$content</span> = <span class="hljs-title function_ invoke__">str_replace</span>(<span class="hljs-string">&#x27;&lt;!--claim--&gt;&#x27;</span>, <span class="hljs-variable">$form</span>, <span class="hljs-variable">$content</span>);&#125;<span class="hljs-keyword">return</span> <span class="hljs-variable">$content</span>;&#125;  <span class="hljs-keyword">private</span> <span class="hljs-built_in">static</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">generateClaimForm</span>(<span class="hljs-params"></span>) </span>&#123;<span class="hljs-variable">$user</span> = <span class="hljs-title class_">Typecho_Widget</span>::<span class="hljs-title function_ invoke__">widget</span>(<span class="hljs-string">&#x27;Widget_User&#x27;</span>);<span class="hljs-variable">$options</span> = <span class="hljs-title class_">Typecho_Widget</span>::<span class="hljs-title function_ invoke__">widget</span>(<span class="hljs-string">&#x27;Widget_Options&#x27;</span>);<span class="hljs-variable">$pluginOptions</span> = <span class="hljs-variable">$options</span>-&gt;<span class="hljs-title function_ invoke__">plugin</span>(<span class="hljs-string">&#x27;ClaimActivation&#x27;</span>);  <span class="hljs-comment">// 未登录</span><span class="hljs-keyword">if</span> (!<span class="hljs-variable">$user</span>-&gt;<span class="hljs-title function_ invoke__">hasLogin</span>()) &#123;<span class="hljs-keyword">return</span> <span class="hljs-string">&#x27;&lt;div class=&quot;acc-error&quot;&gt;请先&lt;a href=&quot;&#x27;</span>.<span class="hljs-variable">$options</span>-&gt;adminUrl.<span class="hljs-string">&#x27;&quot;&gt;登录&lt;/a&gt;&lt;/div&gt;&#x27;</span>;&#125;  <span class="hljs-comment">// ... 其余逻辑与 WordPress 类似</span><span class="hljs-comment">// 检查是否已领取 → 生成签名 → 调用 API → 显示结果</span>&#125;  <span class="hljs-keyword">private</span> <span class="hljs-built_in">static</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">claimCode</span>(<span class="hljs-params"><span class="hljs-variable">$userId</span>, <span class="hljs-variable">$username</span>, <span class="hljs-variable">$email</span>, <span class="hljs-variable">$apiUrl</span>, <span class="hljs-variable">$apiSecret</span></span>) </span>&#123;<span class="hljs-variable">$timestamp</span> = <span class="hljs-title function_ invoke__">time</span>();<span class="hljs-variable">$userIdentifier</span> = <span class="hljs-string">&#x27;typecho_user_&#x27;</span> . <span class="hljs-variable">$userId</span>;<span class="hljs-variable">$signature</span> = <span class="hljs-title function_ invoke__">hash_hmac</span>(<span class="hljs-string">&#x27;sha256&#x27;</span>,<span class="hljs-string">&quot;<span class="hljs-subst">$userIdentifier</span>|<span class="hljs-subst">$timestamp</span>|<span class="hljs-subst">$apiSecret</span>&quot;</span>,<span class="hljs-variable">$apiSecret</span>);  <span class="hljs-comment">// 使用 cURL 发送请求</span><span class="hljs-variable">$ch</span> = <span class="hljs-title function_ invoke__">curl_init</span>(<span class="hljs-variable">$apiUrl</span>);<span class="hljs-title function_ invoke__">curl_setopt_array</span>(<span class="hljs-variable">$ch</span>, [CURLOPT_POST =&gt; <span class="hljs-literal">true</span>,CURLOPT_POSTFIELDS =&gt; <span class="hljs-title function_ invoke__">json_encode</span>([<span class="hljs-string">&#x27;query&#x27;</span> =&gt; <span class="hljs-variable">$userIdentifier</span>,<span class="hljs-string">&#x27;username&#x27;</span> =&gt; <span class="hljs-variable">$username</span>,<span class="hljs-string">&#x27;email&#x27;</span> =&gt; <span class="hljs-variable">$email</span>,<span class="hljs-string">&#x27;timestamp&#x27;</span> =&gt; <span class="hljs-variable">$timestamp</span>,<span class="hljs-string">&#x27;signature&#x27;</span> =&gt; <span class="hljs-variable">$signature</span>,<span class="hljs-string">&#x27;secret&#x27;</span> =&gt; <span class="hljs-variable">$apiSecret</span>]),CURLOPT_HTTPHEADER =&gt; [<span class="hljs-string">&#x27;Content-Type: application/json&#x27;</span>],CURLOPT_RETURNTRANSFER =&gt; <span class="hljs-literal">true</span>,CURLOPT_TIMEOUT =&gt; <span class="hljs-number">15</span>]);<span class="hljs-variable">$response</span> = <span class="hljs-title function_ invoke__">curl_exec</span>(<span class="hljs-variable">$ch</span>);<span class="hljs-title function_ invoke__">curl_close</span>(<span class="hljs-variable">$ch</span>);<span class="hljs-keyword">return</span> <span class="hljs-title function_ invoke__">json_decode</span>(<span class="hljs-variable">$response</span>, <span class="hljs-literal">true</span>);&#125;  <span class="hljs-comment">// 领取记录存储在 Typecho 的 options 表</span><span class="hljs-keyword">private</span> <span class="hljs-built_in">static</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getClaimedCodes</span>(<span class="hljs-params"></span>) </span>&#123;<span class="hljs-variable">$db</span> = <span class="hljs-title class_">Typecho_Db</span>::<span class="hljs-title function_ invoke__">get</span>();<span class="hljs-variable">$row</span> = <span class="hljs-variable">$db</span>-&gt;<span class="hljs-title function_ invoke__">fetchRow</span>(<span class="hljs-variable">$db</span>-&gt;<span class="hljs-title function_ invoke__">select</span>(<span class="hljs-string">&#x27;value&#x27;</span>)-&gt;<span class="hljs-keyword">from</span>(<span class="hljs-variable">$db</span>-&gt;<span class="hljs-title function_ invoke__">getPrefix</span>() . <span class="hljs-string">&#x27;options&#x27;</span>)-&gt;<span class="hljs-title function_ invoke__">where</span>(<span class="hljs-string">&#x27;name = ?&#x27;</span>, <span class="hljs-string">&#x27;plugin_ClaimActivation_claimed&#x27;</span>));<span class="hljs-keyword">return</span> <span class="hljs-variable">$row</span> ? <span class="hljs-title function_ invoke__">json_decode</span>(<span class="hljs-variable">$row</span>[<span class="hljs-string">&#x27;value&#x27;</span>], <span class="hljs-literal">true</span>) : [];&#125;&#125;</code></pre><h3 id="3-2-使用方法"><a href="#3-2-使用方法" class="headerlink" title="3.2 使用方法"></a>3.2 使用方法</h3><p>在文章中插入 HTML 注释标记：</p><pre><code class="hljs html"><span class="hljs-comment">&lt;!--claim--&gt;</span></code></pre><hr><h2 id="四、静态博客（Astro）"><a href="#四、静态博客（Astro）" class="headerlink" title="四、静态博客（Astro）"></a>四、静态博客（Astro）</h2><p>静态博客无服务端，需要纯前端 JavaScript + 后端 API。</p><h3 id="4-1-Astro-组件"><a href="#4-1-Astro-组件" class="headerlink" title="4.1 Astro 组件"></a>4.1 Astro 组件</h3><p>文件：<code>src/components/ClaimCode.astro</code></p><pre><code class="hljs astro">---interface Props &#123;apiUrl?: string;&#125;const &#123; apiUrl = &#x27;https://your-api.com/api/claim-with-twikoo&#x27; &#125; = Astro.props;---  &lt;div class=&quot;claim-wrapper&quot; data-api-url=&#123;apiUrl&#125;&gt;&lt;div class=&quot;form-header&quot;&gt;&lt;span class=&quot;icon&quot;&gt;🎁&lt;/span&gt;&lt;h3&gt;领取激活码&lt;/h3&gt;&lt;p&gt;请输入您评论时使用的邮箱&lt;/p&gt;&lt;/div&gt;&lt;div class=&quot;form-body&quot;&gt;&lt;input type=&quot;email&quot; id=&quot;claim-email&quot; placeholder=&quot;your@email.com&quot; /&gt;&lt;button id=&quot;claim-btn&quot;&gt;领取激活码&lt;/button&gt;&lt;p class=&quot;hint&quot;&gt;💬 请先在下方评论区留言，使用相同邮箱即可领取&lt;/p&gt;&lt;/div&gt;&lt;div id=&quot;result&quot; style=&quot;display: none;&quot;&gt;&lt;/div&gt;&lt;/div&gt;  &lt;style&gt;.claim-wrapper &#123;max-width: 420px;padding: 1.5rem;border-radius: 16px;background: linear-gradient(135deg, #667eea10, #764ba210);border: 1px solid #e0e0e0;&#125;/* ... 更多样式 */&lt;/style&gt;  &lt;script&gt;const wrapper = document.querySelector(&#x27;.claim-wrapper&#x27;);const emailInput = document.getElementById(&#x27;claim-email&#x27;);const claimBtn = document.getElementById(&#x27;claim-btn&#x27;);const resultEl = document.getElementById(&#x27;result&#x27;);const apiUrl = wrapper?.dataset.apiUrl;  claimBtn?.addEventListener(&#x27;click&#x27;, async () =&gt; &#123;const email = emailInput.value.trim();if (!email || !email.includes(&#x27;@&#x27;)) &#123;showResult(&#x27;error&#x27;, &#x27;请输入有效的邮箱地址&#x27;);return;&#125;  claimBtn.disabled = true;claimBtn.textContent = &#x27;验证中...&#x27;;  try &#123;const res = await fetch(apiUrl, &#123;method: &#x27;POST&#x27;,headers: &#123; &#x27;Content-Type&#x27;: &#x27;application/json&#x27; &#125;,body: JSON.stringify(&#123; email &#125;)&#125;);const data = await res.json();  if (data.success) &#123;showResult(&#x27;success&#x27;, `🎉 领取成功！&lt;br&gt;&lt;code&gt;$&#123;data.code&#125;&lt;/code&gt;`);emailInput.disabled = true;claimBtn.style.display = &#x27;none&#x27;;&#125; else &#123;showResult(&#x27;error&#x27;, data.message);claimBtn.disabled = false;claimBtn.textContent = &#x27;领取激活码&#x27;;&#125;&#125; catch (e) &#123;showResult(&#x27;error&#x27;, &#x27;网络错误，请稍后重试&#x27;);&#125;&#125;);  function showResult(type, message) &#123;resultEl.className = type;resultEl.innerHTML = message;resultEl.style.display = &#x27;block&#x27;;&#125;&lt;/script&gt;</code></pre><h3 id="4-2-评论验证（可选）"><a href="#4-2-评论验证（可选）" class="headerlink" title="4.2 评论验证（可选）"></a>4.2 评论验证（可选）</h3><p>如果使用 Twikoo 评论系统，可以在后端验证用户是否评论过：</p><pre><code class="hljs python"><span class="hljs-keyword">def</span> <span class="hljs-title function_">verify_twikoo_comment</span>(<span class="hljs-params">email</span>):<span class="hljs-keyword">from</span> pymongo <span class="hljs-keyword">import</span> MongoClientclient = MongoClient(<span class="hljs-string">&#x27;mongodb+srv://...&#x27;</span>)db = client[<span class="hljs-string">&#x27;twikoo&#x27;</span>]comment = db.comment.find_one(&#123;<span class="hljs-string">&#x27;mail&#x27;</span>: &#123;<span class="hljs-string">&#x27;$regex&#x27;</span>: <span class="hljs-string">f&#x27;^<span class="hljs-subst">&#123;email&#125;</span>$&#x27;</span>, <span class="hljs-string">&#x27;$options&#x27;</span>: <span class="hljs-string">&#x27;i&#x27;</span>&#125;&#125;)client.close()<span class="hljs-keyword">return</span> comment <span class="hljs-keyword">is</span> <span class="hljs-keyword">not</span> <span class="hljs-literal">None</span></code></pre><h3 id="4-3-在页面中使用"><a href="#4-3-在页面中使用" class="headerlink" title="4.3 在页面中使用"></a>4.3 在页面中使用</h3><pre><code class="hljs astro">---import ClaimCode from &#x27;../components/ClaimCode.astro&#x27;;---  &lt;ClaimCode apiUrl=&quot;https://your-api.com/api/claim-with-twikoo&quot; /&gt;  &lt;!-- Twikoo 评论区 --&gt;&lt;div id=&quot;tcomment&quot;&gt;&lt;/div&gt;</code></pre><hr><h2 id="五、UI-样式参考"><a href="#五、UI-样式参考" class="headerlink" title="五、UI 样式参考"></a>五、UI 样式参考</h2><p>三种插件都使用了统一的样式设计：</p><pre><code class="hljs css"><span class="hljs-selector-class">.acc-wrapper</span> &#123;<span class="hljs-attribute">max-width</span>: <span class="hljs-number">420px</span>;<span class="hljs-attribute">padding</span>: <span class="hljs-number">25px</span>;<span class="hljs-attribute">border</span>: <span class="hljs-number">1px</span> solid <span class="hljs-number">#e0e0e0</span>;<span class="hljs-attribute">border-radius</span>: <span class="hljs-number">12px</span>;<span class="hljs-attribute">background</span>: <span class="hljs-number">#fafafa</span>;&#125;  <span class="hljs-selector-class">.acc-btn</span> &#123;<span class="hljs-attribute">background</span>: <span class="hljs-built_in">linear-gradient</span>(<span class="hljs-number">135deg</span>, <span class="hljs-number">#667eea</span>, <span class="hljs-number">#764ba2</span>);<span class="hljs-attribute">color</span>: white;<span class="hljs-attribute">border</span>: none;<span class="hljs-attribute">padding</span>: <span class="hljs-number">14px</span> <span class="hljs-number">28px</span>;<span class="hljs-attribute">border-radius</span>: <span class="hljs-number">8px</span>;<span class="hljs-attribute">width</span>: <span class="hljs-number">100%</span>;<span class="hljs-attribute">font-size</span>: <span class="hljs-number">16px</span>;<span class="hljs-attribute">cursor</span>: pointer;&#125;  <span class="hljs-selector-class">.acc-btn</span><span class="hljs-selector-pseudo">:hover</span> &#123;<span class="hljs-attribute">transform</span>: <span class="hljs-built_in">translateY</span>(-<span class="hljs-number">2px</span>);<span class="hljs-attribute">box-shadow</span>: <span class="hljs-number">0</span> <span class="hljs-number">4px</span> <span class="hljs-number">12px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">102</span>, <span class="hljs-number">126</span>, <span class="hljs-number">234</span>, <span class="hljs-number">0.4</span>);&#125;  <span class="hljs-selector-class">.acc-success</span> &#123;<span class="hljs-attribute">background</span>: <span class="hljs-number">#d4edda</span>;<span class="hljs-attribute">border</span>: <span class="hljs-number">1px</span> solid <span class="hljs-number">#c3e6cb</span>;<span class="hljs-attribute">color</span>: <span class="hljs-number">#155724</span>;<span class="hljs-attribute">padding</span>: <span class="hljs-number">20px</span>;<span class="hljs-attribute">border-radius</span>: <span class="hljs-number">8px</span>;<span class="hljs-attribute">text-align</span>: center;&#125;  <span class="hljs-selector-class">.acc-code-display</span> &#123;<span class="hljs-attribute">font-family</span>: <span class="hljs-string">&#x27;Courier New&#x27;</span>, monospace;<span class="hljs-attribute">font-size</span>: <span class="hljs-number">1.4em</span>;<span class="hljs-attribute">font-weight</span>: bold;<span class="hljs-attribute">background</span>: <span class="hljs-number">#fff</span>;<span class="hljs-attribute">padding</span>: <span class="hljs-number">10px</span> <span class="hljs-number">15px</span>;<span class="hljs-attribute">border</span>: <span class="hljs-number">2px</span> dashed <span class="hljs-number">#28a745</span>;<span class="hljs-attribute">border-radius</span>: <span class="hljs-number">6px</span>;<span class="hljs-attribute">letter-spacing</span>: <span class="hljs-number">2px</span>;&#125;</code></pre><hr><h2 id="六、部署清单"><a href="#六、部署清单" class="headerlink" title="六、部署清单"></a>六、部署清单</h2><table><thead><tr><th>步骤</th><th>说明</th></tr></thead><tbody><tr><td>1. 部署后端</td><td>Flask API 部署到服务器</td></tr><tr><td>2. 配置 CORS</td><td>允许前端域名跨域访问</td></tr><tr><td>3. 设置密钥</td><td>前后端使用相同的 <code>CLAIM_SECRET</code></td></tr><tr><td>4. 安装插件</td><td>根据博客类型选择对应插件</td></tr><tr><td>5. 测试验证</td><td>使用不同用户测试领取流程</td></tr></tbody></table><hr><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><table><thead><tr><th>博客类型</th><th>用户身份来源</th><th>使用方式</th></tr></thead><tbody><tr><td>WordPress</td><td>登录用户</td><td><code>[claim_activation]</code> 短代码</td></tr><tr><td>Typecho</td><td>登录用户</td><td><code>&lt;!--claim--&gt;</code> 标记</td></tr><tr><td>Astro&#x2F;静态</td><td>用户输入邮箱</td><td><code>&lt;ClaimCode /&gt;</code> 组件</td></tr></tbody></table><p>核心安全机制：</p><ul><li><p><strong>HMAC-SHA256</strong> 签名验证请求来源</p></li><li><p><strong>时间戳</strong> 防止重放攻击</p></li><li><p><strong>用户标识</strong> 防止重复领取</p></li></ul><p>希望这篇教程对你有所帮助，欢迎评论区讨论！</p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/makeCodeplugin/</id>
    <link href="https://blog.leguans.cn/posts/makeCodeplugin/"/>
    <published>2026-01-07T16:00:00.000Z</published>
    <summary>本文分享如何为你的网站实现一个自动化激活码领取系统，支持 WordPress、Typecho 和静态博客（Astro/Hugo）。</summary>
    <title>制作一个&quot;登录或者评论领取激活码&quot;插件</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="SollinPlayer" scheme="https://blog.leguans.cn/tags/SollinPlayer/"/>
    <category term="todo" scheme="https://blog.leguans.cn/tags/todo/"/>
    <content>
      <![CDATA[<p><strong>本文章为长期更新，列出后期的更新计划。</strong></p><p><strong>各位有什么意见反馈也可以在本页面留言。</strong></p><p><strong>有想对我说的话可以在留言板留言。</strong></p><h3 id="安卓端："><a href="#安卓端：" class="headerlink" title="安卓端："></a>安卓端：</h3><ul><li><input disabled="" type="checkbox"> 添加在线播放音质选择</li><li><input disabled="" type="checkbox"> 同步桌面端备份数据</li><li><input disabled="" type="checkbox"> 添加错误码具体信息</li><li><input disabled="" type="checkbox"> 添加找回密码功能</li></ul><h3 id="iOS端"><a href="#iOS端" class="headerlink" title="iOS端:"></a>iOS端:</h3><ul><li><input disabled="" type="checkbox"> 修复navidrome歌曲只能显示200首</li><li><input disabled="" type="checkbox"> 添加在线功能的开关</li><li><input checked="" disabled="" type="checkbox"> 无法支持完整CarPlay (没有开发者账号，仅靠自签名无法实现CarPlay，需要 Apple的授权，目前可以通过 CarPlay 的系统”正在播放”应用控制你的音乐)</li><li><input disabled="" type="checkbox"> 同步桌面端备份数据</li><li><input disabled="" type="checkbox"> 导入歌单</li></ul><h3 id="桌面端："><a href="#桌面端：" class="headerlink" title="桌面端："></a>桌面端：</h3><ul><li><input disabled="" type="checkbox"> 修复在播放界面时下一首了歌词不及时跳转到第一句的问题</li><li><input disabled="" type="checkbox"> 添加快捷键，支持快速播放暂停下一曲</li></ul>]]>
    </content>
    <id>https://blog.leguans.cn/posts/updatetodo/</id>
    <link href="https://blog.leguans.cn/posts/updatetodo/"/>
    <published>2026-01-06T16:00:00.000Z</published>
    <summary>本文章用来接收用户反馈和功能建议，文章长期更新，给用户查看最新的更新计划。</summary>
    <title>SollinPlayer更新计划（长期更新）</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="抖音" scheme="https://blog.leguans.cn/tags/%E6%8A%96%E9%9F%B3/"/>
    <category term="视频下载" scheme="https://blog.leguans.cn/tags/%E8%A7%86%E9%A2%91%E4%B8%8B%E8%BD%BD/"/>
    <content>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>这两天在整理项目，突然发现去年上半年开源的一个抖音视频下载工具居然还能继续解析使用，那就贴上仓库地址吧。<br>其实这个项目是给我家里人用的，因为他们喜欢把收藏的一些视频下载下来，放到车机上面去，我一个一个下载太复杂了，就诞生了该项目。 ::aru:shutup::<br><strong>声明：本项目所有接口均来源于网络分享，仅可开发学习使用，请下载后24H内删除相关所有代码，勿用于违规违法等场景！如有侵权、联系删除！</strong></p><p><strong>仓库地址：[抖音视频下载工具Github链接][1]</strong></p><h2 id="演示图"><a href="#演示图" class="headerlink" title="演示图"></a>演示图</h2><p><img src="/images/posts/ai-replace/dydownload-home.webp" alt="首页.png">[2]</p><p><img src="/images/posts/ai-replace/dydownload-downloads.webp" alt="下载中心.png">[3]</p><p>下面是Readme.md。</p><h1 id="抖音视频下载工具"><a href="#抖音视频下载工具" class="headerlink" title="抖音视频下载工具"></a>抖音视频下载工具</h1><p>一个基于PyQt5开发的桌面应用程序，支持下载抖音收藏、用户主页和喜欢的视频。</p><h2 id="功能特点"><a href="#功能特点" class="headerlink" title="功能特点"></a>功能特点</h2><ul><li>🎯 支持多种下载模式：<ul><li>下载收藏夹中的视频</li><li>下载用户主页发布的视频</li><li>下载用户喜欢的视频</li></ul></li><li>📥 批量下载视频，支持断点续传</li><li>🎨 现代化深色主题界面</li><li>📊 实时显示下载进度</li><li>🔄 支持暂停&#x2F;继续&#x2F;取消下载</li><li>📁 可自定义下载目录</li><li>🎮 全局下载任务控制（全部开始&#x2F;暂停&#x2F;取消）</li><li>💾 支持导出视频链接列表</li></ul><h2 id="安装说明"><a href="#安装说明" class="headerlink" title="安装说明"></a>安装说明</h2><h3 id="环境要求"><a href="#环境要求" class="headerlink" title="环境要求"></a>环境要求</h3><ul><li>Python 3.7+</li><li>PyQt5</li><li>PyQtWebEngine</li></ul><h3 id="依赖安装"><a href="#依赖安装" class="headerlink" title="依赖安装"></a>依赖安装</h3><pre><code class="hljs bash">pip install PyQt5 PyQtWebEngine requests</code></pre><h3 id="运行程序"><a href="#运行程序" class="headerlink" title="运行程序"></a>运行程序</h3><pre><code class="hljs bash">python app.py</code></pre><h2 id="使用说明"><a href="#使用说明" class="headerlink" title="使用说明"></a>使用说明</h2><ol><li>启动程序后，选择要使用的功能：<ul><li>下载收藏视频：输入收藏页面URL</li><li>下载用户主页视频：输入用户ID或主页URL</li><li>下载喜欢视频：输入用户ID或喜欢页面URL</li></ul></li><li>登录您的抖音账号（如果需要）</li><li>点击”提取数据到下载中心”按钮开始抓取视频信息</li><li>在下载中心可以：<ul><li>选择下载目录</li><li>控制单个视频的下载（开始&#x2F;暂停&#x2F;取消）</li><li>使用全局控制按钮管理所有下载任务</li><li>查看下载进度和状态</li><li>打开下载目录查看已下载的视频</li></ul></li></ol><h2 id="注意事项"><a href="#注意事项" class="headerlink" title="注意事项"></a>注意事项</h2><ul><li>请确保您有足够的磁盘空间存储视频</li><li>下载速度可能受网络条件和抖音服务器限制影响</li><li>建议在稳定的网络环境下使用</li><li>请遵守抖音的使用条款和版权规定</li><li>对于需要用户ID的功能，您可以：<ul><li>直接输入用户ID</li><li>输入用户主页URL，程序会自动提取用户ID</li></ul></li></ul><h2 id="技术特点"><a href="#技术特点" class="headerlink" title="技术特点"></a>技术特点</h2><ul><li>使用PyQt5构建现代化GUI界面</li><li>实现多线程下载，避免界面卡顿</li><li>采用深色主题，提供舒适的视觉体验</li><li>支持视频信息的本地保存和导出</li><li>实现断点续传功能，支持下载任务的暂停和继续</li><li>智能URL解析，自动提取用户ID</li></ul><h2 id="更新日志"><a href="#更新日志" class="headerlink" title="更新日志"></a>更新日志</h2><h3 id="v1-1-0"><a href="#v1-1-0" class="headerlink" title="v1.1.0"></a>v1.1.0</h3><ul><li>新增用户主页视频下载功能</li><li>新增用户喜欢视频下载功能</li><li>优化用户界面，添加功能选择按钮</li><li>支持用户ID和URL两种输入方式</li></ul><h3 id="v1-0-0"><a href="#v1-0-0" class="headerlink" title="v1.0.0"></a>v1.0.0</h3><ul><li>实现基本的视频抓取和下载功能</li><li>添加下载管理功能</li><li>实现深色主题界面</li><li>添加批量任务控制功能</li><li>支持自定义下载目录</li></ul><h2 id="许可证"><a href="#许可证" class="headerlink" title="许可证"></a>许可证</h2><p>本项目基于MIT许可证开源。</p><h2 id="贡献"><a href="#贡献" class="headerlink" title="贡献"></a>贡献</h2><p>欢迎提交Issue和Pull Request来帮助改进这个项目。</p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/dydownload/</id>
    <link href="https://blog.leguans.cn/posts/dydownload/"/>
    <published>2026-01-04T16:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><p>这两天在整理项目，突然发现去年上半年开源的一个抖音视频下载工具居然还能继续解析使用，那就贴上仓库地址吧。<br>其实这个项目是给我家里人用的]]>
    </summary>
    <title>抖音视频下载工具 - 可以一键下载无水印的收藏、主页、喜欢的所有视频</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="FlaskHttpStudio" scheme="https://blog.leguans.cn/tags/FlaskHttpStudio/"/>
    <content>
      <![CDATA[<h2 id="FlaskHTTPStudio-介绍"><a href="#FlaskHTTPStudio-介绍" class="headerlink" title="FlaskHTTPStudio 介绍"></a>FlaskHTTPStudio 介绍</h2><p>一个功能强大的 HTTP 请求测试工具，支持 cURL 解析、请求编辑、预设规则、历史记录等功能。基于 Flask + 原生 JavaScript 构建，提供现代化的 Web 界面和完善的安全防护。</p><h2 id="开源地址"><a href="#开源地址" class="headerlink" title="开源地址"></a>开源地址</h2><p>[FlaskHTTPStudio-Github地址][1]<br>欢迎各位Star、Fork。</p><h2 id="功能演示"><a href="#功能演示" class="headerlink" title="功能演示"></a>功能演示</h2><h3 id="cURL-解析"><a href="#cURL-解析" class="headerlink" title="cURL 解析"></a>cURL 解析</h3><p>从浏览器开发者工具直接粘贴 cURL 命令，自动解析为可编辑的请求表单。</p><p><img src="/images/posts/ai-replace/httpstudio-curl.webp" alt="curl 解析.png">[2]</p><h3 id="请求编辑器"><a href="#请求编辑器" class="headerlink" title="请求编辑器"></a>请求编辑器</h3><p>支持多种 HTTP 方法、Query 参数、Headers 和 Body 类型的可视化编辑。</p><p><img src="/images/posts/ai-replace/httpstudio-editor.webp" alt="请求编辑器.png">[3]</p><h3 id="预设规则系统"><a href="#预设规则系统" class="headerlink" title="预设规则系统"></a>预设规则系统</h3><p>配置智能预设规则，支持 KV 改值和 JSON Path 深层修改，可按域名和路径过滤作用域。</p><p><img src="/images/posts/ai-replace/httpstudio-rules.webp" alt="规则编辑器.png">[4]</p><h3 id="历史记录与收藏"><a href="#历史记录与收藏" class="headerlink" title="历史记录与收藏"></a>历史记录与收藏</h3><p>自动保存请求历史和响应数据，支持收藏、搜索和一键回放。</p><p><img src="/images/posts/ai-replace/httpstudio-history.webp" alt="历史记录收藏.png">[5]</p><h2 id="核心特性"><a href="#核心特性" class="headerlink" title="核心特性"></a>核心特性</h2><h3 id="1-cURL-解析"><a href="#1-cURL-解析" class="headerlink" title="1. cURL 解析"></a>1. cURL 解析</h3><ul><li>支持从浏览器开发者工具直接粘贴 cURL 命令（Copy as cURL bash）</li><li>自动解析 URL、请求方法、Headers、Query 参数、Body 等</li><li>支持多种 cURL 选项：<ul><li><code>-X</code> &#x2F; <code>--request</code>: HTTP 方法</li><li><code>-H</code> &#x2F; <code>--header</code>: 请求头</li><li><code>-d</code> &#x2F; <code>--data</code> &#x2F; <code>--data-raw</code> &#x2F; <code>--data-binary</code>: 请求体</li><li><code>--data-urlencode</code>: URL 编码数据</li><li><code>--json</code>: JSON 数据（自动设置 Content-Type）</li><li><code>-F</code> &#x2F; <code>--form</code>: multipart&#x2F;form-data</li><li><code>-b</code> &#x2F; <code>--cookie</code>: Cookies</li><li><code>-u</code> &#x2F; <code>--user</code>: Basic 认证</li><li><code>-x</code> &#x2F; <code>--proxy</code>: 代理设置</li><li><code>-k</code> &#x2F; <code>--insecure</code>: 跳过 SSL 验证</li><li><code>-L</code> &#x2F; <code>--location</code>: 跟随重定向</li><li><code>-m</code> &#x2F; <code>--max-time</code>: 超时设置</li><li><code>-G</code> &#x2F; <code>--get</code>: 强制使用 GET 方法</li><li><code>-I</code> &#x2F; <code>--head</code>: HEAD 请求</li><li><code>--compressed</code>: 压缩传输</li></ul></li></ul><h3 id="2-请求编辑器"><a href="#2-请求编辑器" class="headerlink" title="2. 请求编辑器"></a>2. 请求编辑器</h3><h4 id="基础配置"><a href="#基础配置" class="headerlink" title="基础配置"></a>基础配置</h4><ul><li><strong>HTTP 方法</strong>: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS</li><li><strong>URL 编辑</strong>: 支持完整 URL 输入</li><li><strong>Query 参数</strong>: <ul><li>可视化 KV 编辑器（实时同步）</li><li>文本模式编辑（key&#x3D;value 格式）</li><li>双向同步功能</li></ul></li><li><strong>Headers 管理</strong>:<ul><li>可视化 KV 编辑器（实时同步）</li><li>文本模式编辑（Key: Value 格式）</li><li>双向同步功能</li></ul></li></ul><h4 id="Body-类型支持"><a href="#Body-类型支持" class="headerlink" title="Body 类型支持"></a>Body 类型支持</h4><ul><li><strong>none</strong>: 无请求体</li><li><strong>json</strong>: JSON 格式（支持 JSON Path 预设修改）</li><li><strong>form-urlencoded</strong>: 表单 URL 编码（key&#x3D;value 每行一个）</li><li><strong>multipart</strong>: 文件上传（支持 key&#x3D;@file 语法）</li><li><strong>raw</strong>: 原始数据（支持文本或文件上传）</li></ul><h3 id="3-预设规则系统"><a href="#3-预设规则系统" class="headerlink" title="3. 预设规则系统"></a>3. 预设规则系统</h3><p>强大的参数预设和自动化修改系统，支持：</p><h4 id="规则类型"><a href="#规则类型" class="headerlink" title="规则类型"></a>规则类型</h4><ul><li><strong>KV 改值</strong>: 修改 Query 参数、Headers 或 Body 中的键值对</li><li><strong>JSON Path 改值</strong>: 使用路径语法修改 JSON Body 中的深层字段<ul><li>支持点号路径：<code>data.realtime</code></li><li>支持数组索引：<code>items[0].id</code></li></ul></li></ul><h4 id="规则配置"><a href="#规则配置" class="headerlink" title="规则配置"></a>规则配置</h4><ul><li><strong>规则名</strong>: 唯一标识符</li><li><strong>目标</strong>: Query &#x2F; Headers &#x2F; Body(kv)</li><li><strong>匹配 key&#x2F;path</strong>: 要修改的字段名或 JSON 路径</li><li><strong>默认替换值</strong>: 预设的默认值</li><li><strong>弹窗输入</strong>: 应用时是否弹窗让用户输入</li><li><strong>作用域过滤</strong>:<ul><li>域名匹配（支持包含匹配）</li><li>路径匹配（支持包含匹配）</li></ul></li><li><strong>启用&#x2F;禁用</strong>: 控制规则是否生效</li><li><strong>自动应用</strong>: cURL 解析后自动应用该规则</li></ul><h4 id="规则管理"><a href="#规则管理" class="headerlink" title="规则管理"></a>规则管理</h4><ul><li>保存&#x2F;更新规则</li><li>编辑现有规则</li><li>启用&#x2F;禁用切换</li><li>单独应用规则</li><li>批量应用（命中作用域的所有规则）</li><li>导出&#x2F;导入 JSON（规则迁移和备份）</li><li>清空全部规则</li></ul><h3 id="4-历史记录与收藏"><a href="#4-历史记录与收藏" class="headerlink" title="4. 历史记录与收藏"></a>4. 历史记录与收藏</h3><ul><li><strong>自动保存</strong>: 每次发送请求自动保存到历史记录（保留响应数据）</li><li><strong>收藏功能</strong>: 标记重要请求为收藏</li><li><strong>搜索过滤</strong>: 按 URL、Method、时间搜索</li><li><strong>完整快照</strong>: 保存请求的所有参数和配置</li><li><strong>回放功能</strong>:<ul><li>加载历史请求到编辑器</li><li>直接回放并发送</li><li>查看保存的响应数据</li></ul></li><li><strong>记录管理</strong>:<ul><li>删除单条记录</li><li>清空全部历史</li><li>最多保存 80 条历史记录</li></ul></li></ul><h3 id="5-高级选项"><a href="#5-高级选项" class="headerlink" title="5. 高级选项"></a>5. 高级选项</h3><ul><li><strong>Timeout</strong>: 请求超时时间（1-120 秒）</li><li><strong>Redirects</strong>: 是否跟随重定向</li><li><strong>Verify SSL</strong>: SSL 证书验证开关</li><li><strong>Proxy</strong>: HTTP&#x2F;HTTPS 代理设置</li><li><strong>Basic Auth</strong>: HTTP Basic 认证（user:pass 格式）</li><li><strong>Cookies</strong>: Cookie 字符串（a&#x3D;1; b&#x3D;2 格式）</li></ul><h3 id="6-响应查看器"><a href="#6-响应查看器" class="headerlink" title="6. 响应查看器"></a>6. 响应查看器</h3><h4 id="响应抽屉（移动端友好）"><a href="#响应抽屉（移动端友好）" class="headerlink" title="响应抽屉（移动端友好）"></a>响应抽屉（移动端友好）</h4><ul><li><strong>状态信息</strong>: HTTP 状态码、原因短语、最终 URL</li><li><strong>性能指标</strong>: 请求耗时（毫秒）、响应大小（字节）</li><li><strong>Headers 展示</strong>: 完整响应头</li><li><strong>Body 展示</strong>: <ul><li>源码模式（纯文本&#x2F;JSON 格式化）</li><li>HTML 预览模式（仅 text&#x2F;html 响应）</li><li>模式切换按钮</li></ul></li><li><strong>下载功能</strong>: 下载完整响应体（支持二进制文件）</li><li><strong>截断提示</strong>: 超过 2MB 的响应会显示预览截断提示</li></ul><h3 id="7-安全防护"><a href="#7-安全防护" class="headerlink" title="7. 安全防护"></a>7. 安全防护</h3><h4 id="SSRF-防护（核心安全模块）"><a href="#SSRF-防护（核心安全模块）" class="headerlink" title="SSRF 防护（核心安全模块）"></a>SSRF 防护（核心安全模块）</h4><ul><li><strong>协议限制</strong>: 仅允许 http&#x2F;https 协议</li><li><strong>端口白名单</strong>: 默认仅允许 80 和 443 端口</li><li><strong>内网地址阻断</strong>:<ul><li>127.0.0.0&#x2F;8 (localhost)</li><li>10.0.0.0&#x2F;8 (私有网络 A)</li><li>172.16.0.0&#x2F;12 (私有网络 B)</li><li>192.168.0.0&#x2F;16 (私有网络 C)</li><li>169.254.0.0&#x2F;16 (链路本地)</li><li>IPv6 本地地址 (::1, fc00::&#x2F;7, fe80::&#x2F;10)</li></ul></li><li><strong>DNS 解析验证</strong>: 检查域名解析的所有 IP 地址</li><li><strong>重定向二次验证</strong>: 跟随重定向后再次验证最终 URL</li><li><strong>单标签域名阻断</strong>: 禁止无点域名（如 localhost）</li><li><strong>.local 域阻断</strong>: 禁止 mDNS 域名</li></ul><h4 id="其他安全措施"><a href="#其他安全措施" class="headerlink" title="其他安全措施"></a>其他安全措施</h4><ul><li><strong>响应大小限制</strong>: 预览限制 2MB，防止内存溢出</li><li><strong>上传大小限制</strong>: 最大 50MB</li><li><strong>临时存储</strong>: 响应下载 ID 有效期 5 分钟，最多 100 条</li><li><strong>自动清理</strong>: 过期下载链接自动清理</li></ul><h3 id="8-数据持久化"><a href="#8-数据持久化" class="headerlink" title="8. 数据持久化"></a>8. 数据持久化</h3><p>所有数据保存在浏览器 localStorage：</p><ul><li><strong>预设规则</strong>: <code>httpstudio_presets_v2</code></li><li><strong>历史记录</strong>: <code>httpstudio_history_v1</code></li><li><strong>收藏记录</strong>: <code>httpstudio_star_v1</code></li><li><strong>服务端不落盘</strong>: 所有数据仅在客户端存储</li></ul><h3 id="9-用户界面"><a href="#9-用户界面" class="headerlink" title="9. 用户界面"></a>9. 用户界面</h3><h4 id="响应式设计"><a href="#响应式设计" class="headerlink" title="响应式设计"></a>响应式设计</h4><ul><li><strong>桌面端</strong>: 手风琴面板，支持点击和悬停展开</li><li><strong>移动端</strong>: 底部操作栏，抽屉式响应查看</li><li><strong>自适应布局</strong>: 根据屏幕尺寸自动调整</li></ul><h4 id="交互特性"><a href="#交互特性" class="headerlink" title="交互特性"></a>交互特性</h4><ul><li><strong>实时同步</strong>: KV 编辑器与文本框实时双向同步</li><li><strong>Tab 切换</strong>: Request &#x2F; Presets &#x2F; Advanced 标签页</li><li><strong>面板折叠</strong>: 手风琴式面板，支持悬停延迟展开（300ms）</li><li><strong>快捷操作</strong>: <ul><li>一键导出 cURL</li><li>一键清空表单</li><li>一键应用预设</li><li>快速收藏当前请求</li></ul></li></ul><h4 id="视觉反馈"><a href="#视觉反馈" class="headerlink" title="视觉反馈"></a>视觉反馈</h4><ul><li><strong>状态提示</strong>: 成功&#x2F;失败&#x2F;警告消息</li><li><strong>加载状态</strong>: 发送中显示加载提示</li><li><strong>取消功能</strong>: 发送中可点击按钮取消请求</li><li><strong>颜色标识</strong>: <ul><li>成功响应（绿色）</li><li>失败响应（红色）</li><li>提示信息（灰色）</li></ul></li></ul><h3 id="10-工具功能"><a href="#10-工具功能" class="headerlink" title="10. 工具功能"></a>10. 工具功能</h3><ul><li><strong>示例填充</strong>: 一键填充示例 cURL 命令</li><li><strong>导出 cURL</strong>: 将当前请求导出为 cURL 命令（复制到剪贴板）</li><li><strong>清空表单</strong>: 重置所有输入字段</li><li><strong>文件上传</strong>: <ul><li>multipart 模式支持多文件上传</li><li>raw 模式支持单文件作为 Body</li></ul></li></ul><h2 id="技术架构"><a href="#技术架构" class="headerlink" title="技术架构"></a>技术架构</h2><h3 id="后端（Flask）"><a href="#后端（Flask）" class="headerlink" title="后端（Flask）"></a>后端（Flask）</h3><ul><li><strong>框架</strong>: Flask 2.3+</li><li><strong>HTTP 客户端</strong>: requests 2.31+</li><li><strong>核心模块</strong>:<ul><li><code>curl_parser.py</code>: cURL 命令解析器（支持 shlex 词法分析）</li><li><code>sender.py</code>: HTTP 请求发送器（支持流式响应）</li><li><code>response_store.py</code>: 响应临时存储（内存缓存，TTL 5 分钟）</li><li><code>security.py</code>: SSRF 安全防护模块</li></ul></li></ul><h3 id="前端"><a href="#前端" class="headerlink" title="前端"></a>前端</h3><ul><li><strong>原生 JavaScript</strong>: 无框架依赖</li><li><strong>现代 CSS</strong>: Flexbox&#x2F;Grid 布局</li><li><strong>API 通信</strong>: Fetch API + FormData</li><li><strong>本地存储</strong>: localStorage API</li><li><strong>响应式设计</strong>: 移动端优先</li></ul><h3 id="API-端点"><a href="#API-端点" class="headerlink" title="API 端点"></a>API 端点</h3><ul><li><code>GET /</code>: 主页面</li><li><code>POST /api/parse_curl</code>: 解析 cURL 命令</li><li><code>POST /api/send</code>: 发送 HTTP 请求</li><li><code>GET /api/download/&lt;download_id&gt;</code>: 下载响应体</li></ul><h2 id="安装与运行"><a href="#安装与运行" class="headerlink" title="安装与运行"></a>安装与运行</h2><h3 id="环境要求"><a href="#环境要求" class="headerlink" title="环境要求"></a>环境要求</h3><ul><li>Python 3.8+</li><li>pip</li></ul><h3 id="安装步骤"><a href="#安装步骤" class="headerlink" title="安装步骤"></a>安装步骤</h3><ol><li>克隆项目</li></ol><pre><code class="hljs bash">git <span class="hljs-built_in">clone</span> https://github.com/Ryderwe/FlaskHTTPStudio.git<span class="hljs-built_in">cd</span> FlaskHTTPStudio</code></pre><ol start="2"><li>安装依赖</li></ol><pre><code class="hljs bash">pip install -r requirements.txt</code></pre><ol start="3"><li>运行应用</li></ol><pre><code class="hljs bash">python app.py</code></pre><ol start="4"><li>访问应用</li></ol><pre><code class="hljs plaintext">http://127.0.0.1:5000</code></pre><h3 id="生产部署建议"><a href="#生产部署建议" class="headerlink" title="生产部署建议"></a>生产部署建议</h3><ul><li>使用 gunicorn 或 uwsgi 作为 WSGI 服务器</li><li>配置 Nginx 反向代理</li><li>启用 HTTPS</li><li>设置适当的防火墙规则</li><li>考虑添加身份认证</li></ul><h2 id="使用场景"><a href="#使用场景" class="headerlink" title="使用场景"></a>使用场景</h2><h3 id="1-API-调试"><a href="#1-API-调试" class="headerlink" title="1. API 调试"></a>1. API 调试</h3><p>从浏览器开发者工具复制 cURL 命令，快速重放和修改请求。</p><h3 id="2-接口测试"><a href="#2-接口测试" class="headerlink" title="2. 接口测试"></a>2. 接口测试</h3><p>使用预设规则批量修改参数，测试不同场景。</p><h3 id="3-参数化测试"><a href="#3-参数化测试" class="headerlink" title="3. 参数化测试"></a>3. 参数化测试</h3><p>配置 JSON Path 预设，快速修改深层 JSON 字段。</p><h3 id="4-请求收藏"><a href="#4-请求收藏" class="headerlink" title="4. 请求收藏"></a>4. 请求收藏</h3><p>保存常用 API 请求，支持快速回放。</p><h3 id="5-团队协作"><a href="#5-团队协作" class="headerlink" title="5. 团队协作"></a>5. 团队协作</h3><p>导出预设规则 JSON，分享给团队成员。</p><h2 id="使用示例"><a href="#使用示例" class="headerlink" title="使用示例"></a>使用示例</h2><h3 id="示例-1-解析-cURL-并发送"><a href="#示例-1-解析-cURL-并发送" class="headerlink" title="示例 1: 解析 cURL 并发送"></a>示例 1: 解析 cURL 并发送</h3><ol><li>从浏览器开发者工具复制 cURL (bash)</li><li>粘贴到 “cURL (bash) 解析” 面板</li><li>点击 “解析 → 编辑器”</li><li>自动填充所有字段并应用预设规则</li><li>点击 “发送” 查看响应</li></ol><h3 id="示例-2-创建预设规则"><a href="#示例-2-创建预设规则" class="headerlink" title="示例 2: 创建预设规则"></a>示例 2: 创建预设规则</h3><ol><li>切换到 “Presets” 标签</li><li>配置规则：<ul><li>规则名: <code>update_timestamp</code></li><li>类型: <code>JSON Path 改值</code></li><li>目标: <code>Body(kv)</code></li><li>匹配 path: <code>data.timestamp</code></li><li>默认值: <code>2024-01-01T00:00:00Z</code></li><li>作用域域名: <code>api.example.com</code></li><li>启用: <code>true</code></li><li>自动应用: <code>true</code></li></ul></li><li>点击 “保存&#x2F;更新”</li><li>下次解析包含该域名的请求时自动应用</li></ol><h3 id="示例-3-文件上传"><a href="#示例-3-文件上传" class="headerlink" title="示例 3: 文件上传"></a>示例 3: 文件上传</h3><ol><li>选择 Body 类型为 <code>multipart</code></li><li>在 Body 文本框输入：<pre><code class="hljs plaintext">file=@uploaddescription=测试文件</code></pre></li><li>在自动生成的文件选择器中选择文件</li><li>点击发送</li></ol><h2 id="安全注意事项"><a href="#安全注意事项" class="headerlink" title="安全注意事项"></a>安全注意事项</h2><h3 id="默认安全策略"><a href="#默认安全策略" class="headerlink" title="默认安全策略"></a>默认安全策略</h3><ul><li><strong>禁止访问内网</strong>: 所有内网地址被阻断</li><li><strong>端口限制</strong>: 仅允许 80 和 443 端口</li><li><strong>重定向验证</strong>: 防止通过重定向绕过安全检查</li></ul><h3 id="自定义安全策略"><a href="#自定义安全策略" class="headerlink" title="自定义安全策略"></a>自定义安全策略</h3><p>如需访问特定内网服务或非标准端口，需修改 <code>core/security.py</code>:</p><pre><code class="hljs python"><span class="hljs-comment"># 允许其他端口</span>DEFAULT_ALLOWED_PORTS = &#123;<span class="hljs-number">80</span>, <span class="hljs-number">443</span>, <span class="hljs-number">8080</span>, <span class="hljs-number">3000</span>&#125;<span class="hljs-comment"># 或在调用时传入</span>validate_public_url(url, allowed_ports=&#123;<span class="hljs-number">80</span>, <span class="hljs-number">443</span>, <span class="hljs-number">8080</span>&#125;)</code></pre><h3 id="生产环境建议"><a href="#生产环境建议" class="headerlink" title="生产环境建议"></a>生产环境建议</h3><ul><li>部署在隔离网络环境</li><li>添加身份认证和授权</li><li>配置请求速率限制</li><li>启用访问日志审计</li><li>定期更新依赖包</li></ul><h2 id="项目结构"><a href="#项目结构" class="headerlink" title="项目结构"></a>项目结构</h2><pre><code class="hljs plaintext">FlaskHTTPStudio/├── app.py                 # Flask 应用主文件├── requirements.txt       # Python 依赖├── core/                  # 核心模块│   ├── __init__.py│   ├── curl_parser.py     # cURL 解析器│   ├── sender.py          # HTTP 请求发送器│   ├── response_store.py  # 响应临时存储│   └── security.py        # SSRF 安全防护├── templates/             # HTML 模板│   └── index.html         # 主页面└── static/                # 静态资源    ├── app.css            # 样式文件    └── app.js             # JavaScript 逻辑</code></pre><h2 id="开发计划"><a href="#开发计划" class="headerlink" title="开发计划"></a>开发计划</h2><h3 id="已实现功能"><a href="#已实现功能" class="headerlink" title="已实现功能"></a>已实现功能</h3><ul><li>✅ cURL 命令解析</li><li>✅ 多种 Body 类型支持</li><li>✅ 预设规则系统</li><li>✅ JSON Path 修改</li><li>✅ 历史记录和收藏</li><li>✅ 响应查看和下载</li><li>✅ SSRF 安全防护</li><li>✅ 移动端适配</li><li>✅ 导入&#x2F;导出功能</li></ul><h3 id="未来计划"><a href="#未来计划" class="headerlink" title="未来计划"></a>未来计划</h3><ul><li>⏳ WebSocket 支持</li><li>⏳ GraphQL 请求支持</li><li>⏳ 环境变量管理</li><li>⏳ 请求链（Chain Requests）</li><li>⏳ 批量请求测试</li><li>⏳ 性能测试（压测）</li><li>⏳ Mock 服务器</li><li>⏳ API 文档生成</li></ul><h2 id="常见问题"><a href="#常见问题" class="headerlink" title="常见问题"></a>常见问题</h2><h3 id="Q-为什么无法访问-localhost？"><a href="#Q-为什么无法访问-localhost？" class="headerlink" title="Q: 为什么无法访问 localhost？"></a>Q: 为什么无法访问 localhost？</h3><p>A: 出于安全考虑，默认禁止访问所有内网地址。如需访问本地服务，请修改 <code>core/security.py</code> 中的安全策略。</p><h3 id="Q-响应被截断了怎么办？"><a href="#Q-响应被截断了怎么办？" class="headerlink" title="Q: 响应被截断了怎么办？"></a>Q: 响应被截断了怎么办？</h3><p>A: 超过 2MB 的响应会被截断预览。可以使用下载按钮获取完整内容，或修改 <code>core/sender.py</code> 中的 <code>MAX_PREVIEW_BYTES</code> 常量。</p><h3 id="Q-历史记录保存在哪里？"><a href="#Q-历史记录保存在哪里？" class="headerlink" title="Q: 历史记录保存在哪里？"></a>Q: 历史记录保存在哪里？</h3><p>A: 所有数据保存在浏览器的 localStorage 中，服务端不存储任何数据。清除浏览器数据会导致历史记录丢失。</p><h3 id="Q-如何备份预设规则？"><a href="#Q-如何备份预设规则？" class="headerlink" title="Q: 如何备份预设规则？"></a>Q: 如何备份预设规则？</h3><p>A: 在 Presets 标签页点击 “导出 JSON”，将规则复制保存。需要恢复时点击 “导入 JSON” 粘贴即可。</p><h3 id="Q-支持哪些-cURL-选项？"><a href="#Q-支持哪些-cURL-选项？" class="headerlink" title="Q: 支持哪些 cURL 选项？"></a>Q: 支持哪些 cURL 选项？</h3><p>A: 支持大部分常用选项，详见 “核心特性 - cURL 解析” 部分。不支持的选项会被忽略。</p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/httpstudio/</id>
    <link href="https://blog.leguans.cn/posts/httpstudio/"/>
    <published>2026-01-03T16:00:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="FlaskHTTPStudio-介绍"><a href="#FlaskHTTPStudio-介绍" class="headerlink" title="FlaskHTTPStudio 介绍"></a>FlaskHTTPStudio 介绍</h2><p>一个功能强大]]>
    </summary>
    <title>FlaskHTTPStudio - 在线webHTTP 请求测试工具（开源）</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="开发日志" scheme="https://blog.leguans.cn/categories/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97/"/>
    <category term="SollinPlayer" scheme="https://blog.leguans.cn/tags/SollinPlayer/"/>
    <category term="ios" scheme="https://blog.leguans.cn/tags/ios/"/>
    <content>
      <![CDATA[<p>最近终于把手上这个音乐播放器项目收尾了，从最开始的想法到现在能用，前前后后折腾了好几个月。趁着还有印象，写篇文章记录一下。</p><h2 id="为什么要做这个？"><a href="#为什么要做这个？" class="headerlink" title="为什么要做这个？"></a>为什么要做这个？</h2><p>说实话，App Store 上的音乐 App 已经够多了。但用了一圈下来，总觉得差点意思：</p><ul><li>想听网易云的歌？得装网易云</li><li>想听 QQ 音乐？得再装一个</li><li>家里 NAS 上存了一堆无损？还得再找个支持 WebDAV 的</li><li>更别提有些歌这个平台有那个平台没有…</li></ul><p>所以就想着，能不能做一个”全都要”的播放器？本地、在线、NAS 全支持，而且界面得好看。</p><h2 id="先看效果"><a href="#先看效果" class="headerlink" title="先看效果"></a>先看效果</h2><h3 id="主界面"><a href="#主界面" class="headerlink" title="主界面"></a>主界面</h3><p>底部是常驻的迷你播放器，点一下就能展开完整播放界面。资料库这边可以按歌曲、专辑、艺术家来浏览，用起来和系统音乐 App 差不多的逻辑。</p><h3 id="播放界面"><a href="#播放界面" class="headerlink" title="播放界面"></a>播放界面</h3><p>播放界面花了不少心思。背景会自动取当前歌曲封面做模糊，看起来比纯黑舒服多了。左右滑动可以切换队列、封面、歌词三个页面。</p><h3 id="歌词同步"><a href="#歌词同步" class="headerlink" title="歌词同步"></a>歌词同步</h3><p><img src="/images/posts/ai-replace/sollinios-lyrics.webp" alt="IMG_3319.PNG">[1]</p><p>歌词这块支持 LRC 格式的逐行同步，当前播放的那句会高亮显示。双击某一句可以直接跳到那个时间点播放，还挺方便的。</p><p>有些歌词时间不太准，可以单独给每首歌设置偏移量，调一次下次就记住了。</p><h3 id="在线搜索"><a href="#在线搜索" class="headerlink" title="在线搜索"></a>在线搜索</h3><p><img src="/images/posts/ai-replace/sollinios-search.webp" alt="IMG_3336.PNG">[2]</p><p>搜索这块接了网易云、QQ音乐、酷我几个平台的接口。搜到的歌可以直接播放，也可以批量选中加到歌单里。</p><p>音质可以选 128k、320k 或者无损，在设置里改就行。</p><h3 id="WebDAV-播放"><a href="#WebDAV-播放" class="headerlink" title="WebDAV 播放"></a>WebDAV 播放</h3><p><img src="/images/posts/ai-replace/sollinios-webdav.webp" alt="IMG_3325.PNG">[3]</p><p>这个是我自己用得最多的功能。家里群晖上存了不少无损，配置好 WebDAV 地址和账号密码就能直接播放。</p><p>播放的时候是边播边缓存的，不用等整首歌下载完才能听。而且音频的封面、歌手这些元数据都能正常识别。</p><h3 id="主题切换"><a href="#主题切换" class="headerlink" title="主题切换"></a>主题切换</h3><p><img src="/images/posts/ai-replace/sollinios-theme.webp" alt="IMG_3322.PNG">[4]</p><p>内置了几套主题，除了经典的 iOS 风格，还有个新拟态风格和一个粉粉的可爱风格。粉粉主题还能把封面换成磁带的样式，挺有意思的。</p><p>播放界面的背景也可以自定义，除了封面模糊，还能选纯色、渐变，或者干脆自己传张图。</p><h3 id="均衡器"><a href="#均衡器" class="headerlink" title="均衡器"></a>均衡器</h3><p>加了个 10 段均衡器，内置了摇滚、流行、古典这些常用预设。喜欢折腾的也可以自己调。不过因为技术限制，均衡器只对本地文件有效，在线播放用不了。</p><h3 id="歌单管理"><a href="#歌单管理" class="headerlink" title="歌单管理"></a>歌单管理</h3><p>歌单分本地和在线两种，本地歌单只能加本地歌，在线歌单只能加在线歌，这样播放的时候不会乱。</p><p>支持多选批量添加、滑动删除、批量删除这些操作，管理起来挺顺手的。</p><h2 id="一些技术细节"><a href="#一些技术细节" class="headerlink" title="一些技术细节"></a>一些技术细节</h2><p>整个项目是纯 SwiftUI 写的，最低支持 iOS 16。</p><p>播放核心用的 AVFoundation，均衡器那块用了 AVAudioEngine 做实时处理。WebDAV 流式播放踩了不少坑，最后用 AVURLAsset 配合自定义的资源加载器才搞定。</p><p>数据存储没用 Core Data，直接 UserDefaults 加 JSON 文件，简单够用。</p><h2 id="还想做的"><a href="#还想做的" class="headerlink" title="还想做的"></a>还想做的</h2><p>目前能想到的还有这些：</p><ul><li><input disabled="" type="checkbox"> CarPlay 支持</li><li><input disabled="" type="checkbox"> Apple Watch App</li><li><input disabled="" type="checkbox"> 歌词翻译显示</li><li><input disabled="" type="checkbox"> 更多在线音乐源</li><li><input disabled="" type="checkbox"> iPad 适配</li></ul><h2 id="最后"><a href="#最后" class="headerlink" title="最后"></a>最后</h2><p>这个项目断断续续做了挺久，中间重构过好几次。现在总算是能拿出来见人了，虽然还有不少可以改进的地方，但基本功能都齐了。</p><hr><p><em>最后更新：2026年1月</em></p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/sollinios/</id>
    <link href="https://blog.leguans.cn/posts/sollinios/"/>
    <published>2026-01-02T16:00:00.000Z</published>
    <summary>
      <![CDATA[<p>最近终于把手上这个音乐播放器项目收尾了，从最开始的想法到现在能用，前前后后折腾了好几个月。趁着还有印象，写篇文章记录一下。</p>
<h2 id="为什么要做这个？"><a href="#为什么要做这个？" class="headerlink" title="为什么要做这个]]>
    </summary>
    <title>iOS 版 SollinPlayer 已发布！</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
  <entry>
    <author>
      <name>Leguan</name>
    </author>
    <category term="搞七捻三" scheme="https://blog.leguans.cn/categories/%E6%90%9E%E4%B8%83%E6%8D%BB%E4%B8%89/"/>
    <category term="SollinPlayer" scheme="https://blog.leguans.cn/tags/SollinPlayer/"/>
    <content>
      <![CDATA[<p><strong>虽然入不敷出，但本软件依旧永不收费！为爱发电！</strong></p><p><strong>也可以对作者进行打赏哦（会更有开发动力的！）｜ <a href="/sponsor/">点我去打赏！</a></strong></p><p>TG 群聊：<a href="https://t.me/SollinPlayer">SollinPlayer 交流群</a><br>QQ 群聊：<a href="https://qm.qq.com/cgi-bin/qm/qr?k=SDgaMyt8gDIl9nRie5KT5OrBKq3f2Sdl&jump_from=webapi&authKey=XCTx6fz+V9Lp94PLQTpwEKam0gqE7BP4xsxnuoi2fj22a7OgFwiGyI84J4VEM9b1">SollinPlayer qq群-853534298</a></p><h2 id="领取入口"><a href="#领取入口" class="headerlink" title="领取入口"></a>领取入口</h2><ul><li>领取步骤：<strong>加群（QQ或tg）</strong> -&gt; <strong>评论区留言(在网站的评论区，不是群里面发消息！)</strong> -&gt; <strong>输入评论区留的邮箱领取</strong></li><li><a href="/sollin/ios/">iOS 激活码领取</a>：面向ipa 自签 ios 版本用户。</li><li><a href="/sollin/android/">安卓下载兑换码领取</a>：适用于安卓包下载兑换码获取。</li></ul><h2 id="操作步骤"><a href="#操作步骤" class="headerlink" title="操作步骤"></a>操作步骤</h2><ol><li>先确认自己需要的客户端平台，再点击对应入口。</li><li>按页面提示填写必要信息（为防止盗刷，请先评论区留言后再领取兑换码、激活码）。</li><li>领取后立刻备份激活码或兑换码，避免刷新页面后丢失。</li><li>完成兑换后在当前页面留言反馈异常，方便追踪。</li></ol><h2 id="注意事项"><a href="#注意事项" class="headerlink" title="注意事项"></a>注意事项</h2><ul><li>激活码与兑换码具有时效性，超过页面注明的截止时间可能失效。</li><li>每位用户仅限领取一次，重复提交会被系统自动过滤。</li><li>勿在公开渠道分享自己的兑换码或账号截图，以免被他人盗用。</li><li>若遇到验证码或下载链接失效，可在留言区附上截图，我们会集中处理。</li></ul><p>保持页面收藏，后续活动或补发也会在这里第一时间更新。</p>]]>
    </content>
    <id>https://blog.leguans.cn/posts/sollincode/</id>
    <link href="https://blog.leguans.cn/posts/sollincode/"/>
    <published>2026-01-01T16:00:00.000Z</published>
    <summary>Sollinplayer 激活码兑换码领取，ios 和安卓。</summary>
    <title>SollinPlayer 激活码兑换领取（暂停领取）</title>
    <updated>2026-08-31T13:43:56.524Z</updated>
  </entry>
</feed>
