<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

  <title><![CDATA[肇鑫的技术博客]]></title>
  <link href="https://zhaoxin.pro/technology/atom.xml" rel="self"/>
  <link href="https://zhaoxin.pro/technology/"/>
  <updated>2026-07-29T09:59:56+08:00</updated>
  <id>https://zhaoxin.pro/technology/</id>
  <author>
    <name><![CDATA[]]></name>
    
  </author>
  <generator uri="http://www.coderforart.com/">CoderForArt</generator>

  
  <entry>
    <title type="html"><![CDATA[警告：iOS 26.6之后SwiftUI注册的后台任务有问题]]></title>
    <link href="https://zhaoxin.pro/technology/17861716420392.html"/>
    <updated>2026-08-08T14:47:22+08:00</updated>
    <id>https://zhaoxin.pro/technology/17861716420392.html</id>
    <content type="html"><![CDATA[
<p>最近我在 SleepTap 上遇到了一个很隐蔽的问题。问题最早不是在 iPhone 上发现的，而是在 Apple Watch 上。</p>
<p>我在手表上点了上床按钮，手表突然让我手动输入起床时间。我一开始还觉得奇怪，仔细一看，原来手机同步给手表的起床日期已经用完了。SleepTap 会提前生成七天的起床时间，只要后台任务正常执行，这七天就会不断向后延长。现在日期彻底用完了，也就是说，至少有一个星期没有一次成功的后台刷新走到生成日期这一步。</p>
<p>这件事刚好发生在我把 iPhone 升级到 iOS 26.6 之后。升级之后，我也一直没有手动打开过 SleepTap。所以我的第一反应是，系统升级后没有启动过应用，后台任务是不是就不工作了？按 API 的设计，不应该这样。已经注册和提交的后台任务由 iOS 调度，又不是靠用户每天给应用上香才能继续运行。</p>
<p>可我手动打开一次 SleepTap，起床日期立刻同步到了手表，一直排到 8 月 10 日。这至少证明了三件事：日期生成逻辑没坏，手机和手表的同步没坏，前台刷新也没坏。坏掉的只剩后台执行这条路。</p>
<h2><a id="%E7%AD%89%E5%BE%85%E3%80%81%E9%87%8D%E5%90%AF%E3%80%81%E9%87%8D%E8%A3%85%EF%BC%8C%E9%83%BD%E6%B2%A1%E7%94%A8" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>等待、重启、重装，都没用</h2>
<p>我没有马上改代码，而是先等了一天。结果日期还是停在 8 月 10 日。接着我重启 iPhone，又等了一天，还是不执行。后来我修改了一部分代码，重新安装并运行，结果依旧没有后台记录。</p>
<p>这时候很容易开始怀疑玄学。比如 iOS 会根据应用过去的执行情况分配后台额度，SleepTap 会不会因为以前注册了两个 App Refresh 任务，被系统悄悄惩罚了？以前系统睁一只眼闭一只眼，现在 iOS 26.6 不提示错误，直接一个都不给执行？这听起来有些像奇谈怪论，但后台调度本来就是黑盒，不做实验，谁也不能说它一定不可能。</p>
<p>SleepTap 当时有两个后台刷新任务，一个更新起床日期，一个更新天气和晒太阳通知。我先把天气任务停掉，只留下日期任务，又把应用版本升了。结果还是不执行。也就是说，就算所谓的惩罚存在，至少也不是简单地删掉一个任务就会恢复。</p>
<p>我还担心过另一个问题：测试时 iPhone 是通过 Xcode 安装的，如果从 Mac 断开，应用会不会也跟着“断开”？其实不会。任务提交给的是 iPhone 上的 <code>BGTaskScheduler</code>，Mac 和数据线只负责安装、调试和读取日志。拔掉数据线会断开调试器，不会删除手机系统保存的请求。</p>
<h2><a id="%E5%8D%95%E7%8B%AC%E5%86%99%E4%B8%80%E4%B8%AA%E5%BA%94%E7%94%A8%EF%BC%8C%E7%BB%93%E6%9E%9C%E5%AE%83%E4%B9%9F%E4%B8%8D%E6%89%A7%E8%A1%8C" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>单独写一个应用，结果它也不执行</h2>
<p>既然 SleepTap 身上的历史包袱太多，我干脆新建了一个测试应用。它只做一件事：注册一个 <code>BGAppRefreshTaskRequest</code>，后台处理器被调用之后立刻发一条本地通知，并把 <code>handler.entered</code>、通知添加和完成状态写进本地记录。</p>
<p>这已经简单到不能再简单了。没有天气，没有手表，没有 SwiftData，也没有复杂的网络请求。如果它能执行，说明 SleepTap 可能真的被系统区别对待。如果它也不能执行，那就不是 SleepTap 自己的问题。</p>
<p>结果，这个新应用也一直没有执行。</p>
<p>我手动打开过一次，通知权限是 <code>true</code>，提交 API 也明确返回成功。请求早就超过了 <code>earliestBeginDate</code>，但记录里一次 <code>handler.entered</code> 都没有。不是通知被系统隐藏了，而是处理器根本没有被调用。</p>
<p>到这里，我原以为已经证明了 iOS 26.6 的后台调度整体坏了。可我又想了想，这个所谓的“独立测试应用”，其实并不独立。它和 SleepTap 使用的是同一种注册方式：</p>
<pre class="line-numbers"><code class="language-swift">.backgroundTask(.appRefresh(identifier)) {
  await handleAppRefresh()
}
</code></pre>
<p>如果坏掉的是 SwiftUI 的 <code>.backgroundTask</code>，那么两个应用当然会一起失败。这不叫对照实验，这叫把同一份可疑代码抄了两遍。</p>
<h2><a id="%E8%8B%B9%E6%9E%9C%E8%87%AA%E5%B7%B1%E7%9A%84%E7%A4%BA%E4%BE%8B%E4%B8%8D%E6%98%AF%E8%BF%99%E4%B9%88%E5%86%99%E7%9A%84" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>苹果自己的示例不是这么写的</h2>
<p>我接着看了苹果的官方示例 <a href="https://developer.apple.com/documentation/BackgroundTasks/refreshing-and-maintaining-your-app-using-background-tasks">Refreshing and Maintaining Your App Using Background Tasks</a>。这个示例是 WWDC 2019 的，使用的是古法 AppDelegate：在 <code>application(_:didFinishLaunchingWithOptions:)</code> 中调用 <code>BGTaskScheduler.shared.register</code>，任务到期时取消操作，完成时显式调用 <code>setTaskCompleted(success:)</code>。</p>
<p>我原本以为这只是 UIKit 和 SwiftUI 的写法差异。SwiftUI 的 <code>.backgroundTask</code> 本来就是官方 API，凭什么旧写法可以，新写法不行？可苹果开发者论坛里早已有一串相关讨论。在 <a href="https://developer.apple.com/forums/thread/775182">BGTaskScheduler crashes on iOS 18.4</a> 这个帖子里，苹果 DTS 工程师确认 SwiftUI 的后台任务集成存在注册时序问题，并建议用 <code>UIApplicationDelegateAdaptor</code> 回到 AppDelegate，在 <code>didFinishLaunchingWithOptions</code> 中尽早注册。帖子讨论的是 iOS 18.4 上的崩溃，而我遇到的是 iOS 26.6 上悄悄不执行，表现并不完全一样。但它们指向的是同一个危险点：SwiftUI 可能太晚才替应用注册处理器，而任务没有正确注册，就不可能被执行。</p>
<p>还有一个很容易误判的地方。<code>earliestBeginDate</code> 不是预约时间，只是“不早于这个时间”。<a href="https://developer.apple.com/documentation/backgroundtasks/bgtaskrequest/earliestbegindate">苹果的文档</a>写得很清楚，系统不保证在指定时间启动。所以等一小时没执行，不能立刻说有 bug。可连续多天不执行、七天日期全部耗尽，再加上测试应用完全没有 <code>handler.entered</code>，这就不能只用“系统会择机调度”来搪塞了。</p>
<h2><a id="%E4%BA%94%E5%88%86%E9%92%9F%E4%B9%8B%E5%90%8E%EF%BC%8C%E9%80%9A%E7%9F%A5%E6%9D%A5%E4%BA%86" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>五分钟之后，通知来了</h2>
<p>我把测试应用改成 AppDelegate 注册，保留原来的 identifier、通知和事件记录，只替换注册路径。为了不用再傻等一天，我把第一次请求的最早时间改成五分钟后。任务真正执行之后，再续订到一小时后。五分钟并不是要求系统五分钟准时执行，只是让它尽快进入可执行状态。</p>
<p>手机里的记录很清楚：14:00:53 提交请求，14:05:53 开始具备执行资格，14:09:09 系统调用 <code>handler</code>。同一秒，应用提交了下一次请求，加入本地通知，最后记录 <code>handler.completed</code>。整个链条走完，没有过期，也没有失败。</p>
<p>macOS 随后通过 iPhone Mirroring 显示了这条来自 iPhone 的通知：后台任务已执行，时间 14:09:09。当时 iPhone 并不依赖 Xcode 的调试连接。这也顺手排除了我之前关于“断开 Mac 会不会导致应用断开”的担心。</p>
<p>严格地说，这还不是完美的单变量实验，因为我同时改变了注册方式和第一次的等待时间。但原来的 SwiftUI 请求已经超过最早时间几个小时，甚至几天，也从未进入处理器；AppDelegate 版本第一次自然调度就完整执行。对于工程判断来说，证据已经足够了。继续等，不会让问题变得更正确。</p>
<h2><a id="%E6%88%91%E6%9C%80%E5%90%8E%E6%80%8E%E6%A0%B7%E4%BF%AE%E6%94%B9sleeptap" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>我最后怎样修改 SleepTap</h2>
<p>我把 SleepTap 也改成了 AppDelegate 早期注册，版本升到 1.6.3。现在应用只保留一个系统后台任务，最早时间仍然按照用户的起床时间加一小时计算。处理器进入之后先续订下一次任务，再更新睡眠通知和手表上的起床日期，最后尝试刷新天气和晒太阳通知。</p>
<p>天气原来有自己的 App Refresh 任务，现在不再单独注册。它仍保留原来的业务逻辑：只在 6:00 到 18:00 之间处理，检查六小时缓存和位置变化，确实需要时才请求天气。用户手动打开 SleepTap 时也会检查天气，缓存过期就刷新，缓存有效就直接重新计算晒太阳计划。</p>
<p>这样做不是因为两个后台任务在 API 上一定不允许，而是我已经没有理由继续让两个不确定因素同时存在。一个任务负责唤醒，里面顺序执行日期和天气逻辑，出了问题也更容易看日志。这叫减少变量，不叫架构升级。</p>
<h2><a id="%E4%BB%8E7%E6%9C%88-27%E6%97%A5%E5%88%B0-8%E6%9C%88-8%E6%97%A5" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>从 7 月 27 日到 8 月 8 日</h2>
<p>回头看，这个问题的时间线其实很完整。不是某一天突然灵光一现找到了答案，而是每天排除一点，最后才发现，最值得怀疑的恰恰是看起来最不值得怀疑的那一层。</p>
<ul>
<li>
<p><strong>7 月 27 日：升级 iOS 26.6。</strong> 苹果在这一天发布了 iOS 26.6，<a href="https://support.apple.com/en-us/128066">官方安全更新页面</a>也记录了这个日期。我当天升级了系统，之后一直没有手动打开 SleepTap。当时当然没觉得有什么问题，后台任务本来就应该由系统调度，我没有理由每天打开一次应用检查它还活着没有。</p>
</li>
<li>
<p><strong>7 月 28 日到 8 月 2 日：一切看起来都很正常。</strong> 这几天我没有收到晒太阳通知，但也没有立刻把它和后台任务联系起来。手表里的起床时间是提前生成七天的，所以即使后台任务从升级之后一次都没成功，前几天仍然不会露馅。这个七天缓存原本是保护用户的，结果也把故障藏了七天。</p>
</li>
<li>
<p><strong>8 月 3 日：七天用完，问题出现。</strong> 我在 Apple Watch 上点击上床按钮，手表突然要求我手动输入起床时间。检查后发现，提前同步的起床日期已经全部耗尽。我手动打开一次 SleepTap，日期马上补齐到 8 月 10 日。于是我们先确认了日期生成、前台刷新和 Watch 同步都正常，把问题缩小到后台执行。同时也提出了第一个怀疑：会不会是升级系统之后没有再手动启动过应用，导致后台注册失效？这和 API 应有的行为不符，但和实际现象很像，所以先不急着下结论。</p>
</li>
<li>
<p><strong>8 月 4 日：继续等，不重启。</strong> 手表上的日期仍然只到 8 月 10 日，没有刷新出 11 日。此时有两个选择，要么重启手机，要么再保留一天自然观察。我选择先等，因为一旦重启，系统调度状态就变了。假如第二天任务自己恢复，我们还能证明它只是延迟；马上重启反而会把这个证据毁掉。</p>
</li>
<li>
<p><strong>8 月 5 日：等待无效，开始重启实验。</strong> 又等了一天，后台任务还是没有执行。自然恢复这条路基本可以排除了，我于是重启 iPhone，再给系统一天时间。这里仍然没有立刻改代码，因为重启和改代码一起做，即使恢复了，也不知道是谁起的作用。古法排错虽然慢，但至少不会自己骗自己。</p>
</li>
<li>
<p><strong>8 月 6 日：重启也没用，开始减少变量。</strong> 重启后又观察了一天，日期仍然没有向后延长。我修改代码、重新安装并运行，还升级了应用版本，避免系统如果真的针对旧版本保留了某种调度判断，新代码仍然继承旧状态。同时暂停天气后台任务，只注册起床日期这一个任务。可它还是没有执行。检查天气后又发现，晒太阳通知也已经多天没出现，这说明不是单独一条日期逻辑坏了，两个后台处理器都没有留下成功执行的迹象。也正是在这一天，我决定另建一个只有后台任务和本地通知的测试应用，和 SleepTap 并行观察。</p>
</li>
<li>
<p><strong>8 月 7 日：最小测试应用也沉默。</strong> 新应用没有天气、手表和数据库，处理器只要被调用，就写一条记录并发送通知。我手动打开过，也手动提交过请求，等了几个小时仍然没有通知。我一度担心从 Mac 断开 iPhone 会不会让应用也跟着断开，后来排除了：断开的只是 Xcode 调试连接，手机上的应用和已经提交给系统的请求都还在。新应用也失败，本来很像是 iOS 26.6 把整个后台调度弄坏了。可问题是，新应用照抄了 SleepTap 的 SwiftUI <code>.backgroundTask</code> 注册方式。两个使用同一种可疑写法的应用一起失败，根本不能证明系统不调度，只能证明这条注册路径值得怀疑。</p>
</li>
<li>
<p><strong>8 月 8 日：对照苹果 Demo，问题解决。</strong> 我把苹果官方示例和我们的代码逐项比较，终于注意到官方 Demo 并没有使用 SwiftUI <code>.backgroundTask</code>，而是在 AppDelegate 的 <code>didFinishLaunchingWithOptions</code> 中直接调用 <code>BGTaskScheduler.register</code>。再结合苹果 DTS 对 SwiftUI 注册时序问题的说明，我把测试应用改成和官方 Demo 一样的注册方式。14:00:53 提交请求，14:05:53 开始具备执行资格，14:09:09 处理器真正进入，通知也通过 iPhone Mirroring 显示在 Mac 上。证据已经足够，我随后用同样方式修改 SleepTap，把天气合并进起床日期任务，并保留用户手动打开应用时的天气刷新。</p>
</li>
</ul>
<p>从升级到发现问题，刚好七天。从发现问题到真正解决，又用了五天。期间等待过、重启过、重装过、升级过版本、减少过任务，也怀疑过系统惩罚和 Xcode 连接。结果真正的答案不在这些地方，而在苹果自己的 Demo 里：SwiftUI 提供了更漂亮的注册方式，但至少在我的 iOS 26.6 设备上，真正可靠的仍然是 AppDelegate 里的古法注册。</p>
<h2><a id="%E6%80%BB%E7%BB%93" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>总结</h2>
<p>我现在能确认的是：在我的 iPhone SE 3、iOS 26.6 上，两个使用 SwiftUI <code>.backgroundTask(.appRefresh(...))</code> 注册的应用都没有自然进入处理器；换成 AppDelegate 中直接调用 <code>BGTaskScheduler.register</code> 后，测试应用第一次就成功执行。</p>
<p>我不能据此宣布所有 iOS 26.6 设备都会中招，也不能证明它和 iOS 18.4 的论坛问题是同一个 bug。但如果你的后台任务在升级之后突然消失，提交又不报错，别只盯着 <code>earliestBeginDate</code>，也别急着怪系统在“惩罚”应用。先记录 <code>handler.entered</code>，再写一个最小测试应用，最后把 SwiftUI 注册换成 AppDelegate 注册做对照。</p>
<p>结论：iOS 26.6 之后，SwiftUI 注册的 App Refresh 后台任务不值得信任。至少在苹果修复并解释这个问题之前，我会使用 AppDelegate 的古法注册。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[Finder快速预览使用扩展模式苹果没告诉你的事]]></title>
    <link href="https://zhaoxin.pro/technology/17852901952757.html"/>
    <updated>2026-07-29T09:56:35+08:00</updated>
    <id>https://zhaoxin.pro/technology/17852901952757.html</id>
    <content type="html"><![CDATA[
<p>最近我给 Writer 的 Finder 快速预览增加了 Mermaid 支持。Writer 应用内本来就使用 <code>WKWebView</code> 预览 Markdown，Mermaid 也是通过本地 JavaScript 渲染的。既然应用里能显示，那么把同一套渲染代码放进 Quick Look，不就行了吗？</p>
<p>我一开始也是这么想的。</p>
<p>Writer 原来的 Quick Look 使用 data-based preview。扩展读取 Markdown，生成完整的 HTML，然后通过 <code>QLPreviewReply</code> 把 HTML 交给 Finder。标题、列表、表格、代码块这些静态内容都没问题，但 Mermaid 不一样。Mermaid 代码块不是一张现成的图片，需要 JavaScript 解析代码，再生成 SVG。</p>
<p>问题就是，Finder 拿到 HTML，不等于它会像 Safari 或 <code>WKWebView</code> 那样执行里面的 JavaScript。</p>
<p>所以我决定把 Quick Look 改成 view-based extension。也就是不再把一份 HTML 数据交给 Finder，而是由扩展自己提供一个 <code>NSViewController</code>，里面放一个 <code>WKWebView</code>。</p>
<p>这下总该和 Writer 应用内的预览一样了吧？</p>
<p>我修改了 Quick Look 扩展以后，在 Finder 中预览 Mermaid 文档，看到的仍然是一个普通代码块。Mermaid 源码还在，流程图没有生成。</p>
<p>这很像是 JavaScript 没有执行。但继续检查之前，必须先确认一件更基础的事：Finder 调用的到底是不是我刚刚修改的扩展？</p>
<p>因为 <code>/Applications</code> 里还安装着旧版 Writer。旧版扩展本来就能把 Markdown 转成 HTML，只是会把 Mermaid 当作普通代码块。所以当时看到的结果并不能证明新代码失败了，只能证明 Finder 找到了某个 Writer Quick Look 扩展。</p>
<p>我删除了 <code>/Applications</code> 里的 Writer。删除之后，Finder 一度退回了系统的纯文本预览。这证明刚才把 Mermaid 显示成代码块的，的确是旧版 Writer。</p>
<p>之后我安装了包含新版 view-based Quick Look 扩展的测试版。Finder 不再显示纯文本，右上角也出现了“Open with Writer”。这说明新版扩展终于被调用了。</p>
<p>可这一次，预览窗口变成了一片空白。</p>
<p>所以这里其实有两个完全不同的问题。Mermaid 显示成代码块，是 Finder 调用了旧版 Writer。真正调用新版扩展之后，遇到的问题才是 <code>WKWebView</code> 白屏。</p>
<p>如果不先排除旧版扩展，我很可能会继续在 Mermaid 初始化代码里找问题。可实际上，前一个结果根本不是新代码生成的。</p>
<h2><a id="%E5%86%99%E4%B8%80%E4%B8%AA%E4%BB%80%E4%B9%88%E9%83%BD%E4%B8%8D%E5%81%9A%E7%9A%84quick-look" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>写一个什么都不做的 Quick Look</h2>
<p>我临时创建了一个最小的 macOS 应用，只注册一种自定义文件类型，再给它加入一个最简单的 Quick Look view extension。</p>
<p>这个扩展不解析 Markdown，不读取主题，也没有 Mermaid。它只显示一个原生的 <code>NSView</code>，上面写着：</p>
<blockquote>
<p>Quick Look view extension is working</p>
</blockquote>
<p>安装之后，Finder 正确显示出了这个界面。</p>
<p>这说明 view-based Quick Look 本身没有问题。Finder 能加载扩展，扩展也能提供自己的视图。</p>
<p>不过这还不够。Writer 真正需要的是 JavaScript，不是一个静态的 <code>NSTextField</code>。于是我又把探针改成 <code>WKWebView</code>，加载一段完全内嵌的 HTML，并运行一小段 JavaScript，让页面显示：</p>
<blockquote>
<p>JavaScript check: 6 × 7 = 42</p>
</blockquote>
<p>结果，探针也变成了白色。</p>
<p>到这里，问题已经很清楚了。不是 Markdown，不是 Mermaid，也不是 Writer 的主题代码。只要在 Quick Look 扩展里换成 <code>WKWebView</code>，页面就出不来。</p>
<h2><a id="%E6%9C%AC%E5%9C%B0javascript%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%E7%BD%91%E7%BB%9C%E6%9D%83%E9%99%90%EF%BC%9F" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>本地 JavaScript 为什么需要网络权限？</h2>
<p>我查看了系统日志，终于看到了真正的错误：</p>
<pre class="line-numbers"><code class="language-text">Application does not have permission to communicate with network resources.
Invalid connection identifier (web process failed to launch)
</code></pre>
<p>这就很有意思了。</p>
<p>我加载的是 <code>loadHTMLString</code>，HTML 在内存里，JavaScript也在应用包里，没有 CDN，没有远程图片，甚至连一个 HTTP 请求都没有。你告诉我缺少网络权限？</p>
<p>可日志里写得很清楚，不是页面加载失败，而是 WebKit 的 Web Content 进程根本没有启动成功。</p>
<p>我于是给 Quick Look 扩展加入了：</p>
<pre class="line-numbers"><code class="language-xml">&lt;key&gt;com.apple.security.network.client&lt;/key&gt;
&lt;true/&gt;
</code></pre>
<p>重新编译，重新安装。</p>
<p>探针页面显示出来了，JavaScript 也成功计算出了 <code>42</code>。</p>
<p>故障排除。</p>
<p>按照苹果文档，这个 entitlement 表示沙盒应用可以主动建立网络连接。从字面上看，一个完全离线的 <code>WKWebView</code> 不应该需要它。可在我当前使用的 macOS 版本和 Quick Look 扩展环境中，没有这个权限，WebKit 的内容进程就无法正常启动。</p>
<p>我不敢说所有 macOS 版本、所有 Quick Look 扩展都一定如此。但至少在实际测试中，这不是推测，也不是所谓“可能和沙盒有关”。系统日志和最小探针已经把因果关系摆在这里了。</p>
<p>苹果告诉你 <code>network.client</code> 是用来联网的，却没有告诉你，在 Quick Look 扩展里，即使只加载一段本地 HTML，它也可能决定 <code>WKWebView</code> 能不能活着启动。</p>
<p>这才是这次最坑的地方。</p>
<h2><a id="%E7%BB%99%E4%BA%86%E7%BD%91%E7%BB%9C%E6%9D%83%E9%99%90%EF%BC%8C%E6%96%87%E6%A1%A3%E4%B8%8D%E5%B0%B1%E5%8F%AF%E4%BB%A5%E5%81%B7%E5%81%B7%E8%81%94%E7%BD%91%E4%BA%86%E5%90%97%EF%BC%9F" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>给了网络权限，文档不就可以偷偷联网了吗？</h2>
<p>我本来不愿意给 Quick Look 增加网络权限，就是因为快速预览的内容来自用户文件。Markdown 里可以写远程图片，也可以写 HTML。如果直接让这些内容进入一个能够联网的 <code>WKWebView</code>，那么用户只是在 Finder 里按了一下空格，文档就可能向外部服务器发出请求。</p>
<p>这肯定不行。</p>
<p>但这里要分清两件事。扩展拥有网络 entitlement，是为了让 WebKit 的进程能够正常启动，并不意味着页面里的内容也必须获得网络访问能力。</p>
<p>Writer 的 Quick Look 页面加入了严格的 Content Security Policy：</p>
<pre class="line-numbers"><code class="language-text">default-src 'none';
img-src data: blob:;
script-src 'nonce-writer-mermaid';
connect-src 'none';
frame-src 'none';
object-src 'none';
</code></pre>
<p>也就是说，页面默认什么都不能加载。图片只允许 <code>data:</code> 和 <code>blob:</code>，脚本只允许 Writer 自己注入并带有指定 nonce 的 Mermaid 脚本，网络连接直接设为 <code>none</code>。</p>
<p>在 HTML 进入 <code>WKWebView</code> 之前，扩展还会清理 <code>script</code>、<code>iframe</code>、<code>object</code>、事件属性和其他主动内容。普通 Markdown 的本地附件会转换成 <code>data:</code> URL，远程图片则继续显示占位内容。</p>
<p>这叫给 WebKit 启动进程的权限，不叫给 Markdown 文件自由上网的权限。两者看起来差不多，实际完全不是一回事。</p>
<h2><a id="%E8%BF%98%E6%9C%89%E4%B8%80%E4%B8%AA%E5%AE%B9%E6%98%93%E8%A2%AB%E5%BF%BD%E7%95%A5%E7%9A%84%E9%97%AE%E9%A2%98" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>还有一个容易被忽略的问题</h2>
<p>改成 view-based extension 以后，Quick Look 的入口也变了。</p>
<p>原来的实现是生成一个 <code>QLPreviewReply</code>。现在则是由 <code>QLPreviewingController</code> 实现异步的 <code>preparePreviewOfFile(at:)</code>，自己创建并维护 <code>WKWebView</code>。</p>
<p>如果调用 <code>loadHTMLString</code> 以后立刻告诉 Finder“准备好了”，WebView 其实可能还没有完成导航。于是我让扩展等待 <code>WKNavigationDelegate.didFinish</code>，失败、导航失败或者 Web Content 进程终止时，也会结束等待并返回真正的错误。</p>
<p>这不是为了架构漂亮，而是 Quick Look 的生命周期和普通应用窗口不同。应用里的 <code>WKWebView</code> 一直活着，晚一点显示也没关系。Finder 是来向扩展要一份预览的，扩展什么时候回答“准备完成”，本身就是协议的一部分。</p>
<p>同时，Mermaid 的 Tiny 构建和许可证也必须真正进入 <code>.appex</code> 的资源目录。放在主应用里不算。Finder 运行的是扩展进程，它不会因为资源在 Writer.app 的另一个角落，就自动帮你找到。</p>
<h2><a id="%E6%9C%80%E5%90%8E%E6%80%8E%E4%B9%88%E7%A1%AE%E8%AE%A4%E4%B8%8D%E6%98%AF%E8%87%AA%E6%88%91%E6%84%9F%E8%A7%89%E8%89%AF%E5%A5%BD%EF%BC%9F" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>最后怎么确认不是自我感觉良好？</h2>
<p>这类问题只看 Xcode 构建成功没有意义。Quick Look 扩展编译成功，不等于 Finder 正在使用它。Finder 正在使用它，也不等于 WebKit 进程成功启动。WebKit 启动了，也不等于 JavaScript真的执行了。</p>
<p>我最后做了几层验证：</p>
<ol>
<li>删除已安装的 Writer，确认 Finder 退回系统纯文本预览。</li>
<li>安装最小探针，确认原生 Quick Look view extension 能正常显示。</li>
<li>给探针加入 <code>WKWebView</code> 和 JavaScript，复现白屏。</li>
<li>加入 <code>network.client</code> 后，确认 JavaScript 成功算出 <code>6 × 7 = 42</code>。</li>
<li>安装新的 Writer，在 Finder 中预览 Mermaid 流程图，确认显示的是生成后的 SVG，而不是 Mermaid 源码。</li>
<li>再预览我实际使用的 iCloud Markdown 文档，确认标题、段落、编号和列表都正常。</li>
<li>Writer 和 Quick Look 两个 scheme 都重新构建，16 个 Mermaid 测试全部通过。</li>
</ol>
<p>其中最有价值的不是后面的测试全部通过，而是那个什么都不做的探针。没有它，我很容易继续怀疑 Markdown 渲染器、主题样式、资源路径或者 Finder 缓存。它把几十个变量砍到只剩一个：Quick Look 扩展里的 <code>WKWebView</code> 为什么启动不了？</p>
<h2><a id="%E6%80%BB%E7%BB%93" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>总结</h2>
<p>这次遇到的坑可以归纳成三件事：</p>
<ol>
<li>data-based Quick Look 能显示 HTML，不代表它会按照应用内 <code>WKWebView</code> 的方式执行 JavaScript。</li>
<li>view-based Quick Look 可以使用 <code>WKWebView</code>，也可以运行 Mermaid，但在实际测试的系统环境中，即使内容完全离线，扩展仍然需要 <code>com.apple.security.network.client</code>，否则 Web Content 进程可能直接启动失败。</li>
<li>给扩展网络 entitlement 之后，仍然要使用 CSP 和 HTML 清理限制文档内容。权限是让 WebKit 活下来，不是让预览文件随便联网。</li>
</ol>
<p>所以，Finder 的快速预览使用扩展模式以后，运行起来的确可以和应用内预览很接近。但这个“很接近”不是把 <code>WKWebView</code> 塞进 <code>NSViewController</code> 就结束了。</p>
<p>真正的结论是：Quick Look 扩展可以执行 JavaScript，也可以渲染 Mermaid。之前那片空白不是平台不支持，而是 WebKit 的进程根本没有成功启动。苹果没告诉你的，恰恰就是这一点。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[苹果表上SleepTap的Smart Alarm的Session在Stop之后，对应的Smart Stack却没有立刻刷新问题的追踪]]></title>
    <link href="https://zhaoxin.pro/technology/17851023555827.html"/>
    <updated>2026-07-27T05:45:55+08:00</updated>
    <id>https://zhaoxin.pro/technology/17851023555827.html</id>
    <content type="html"><![CDATA[
<p>前天早上，SleepTap 的 Apple Watch Smart Alarm Session 把我叫醒了。系统界面给了两个选择，一个是 Open，一个是 Stop。以往我都是用Open，这次我特意没有选择Open，而是选择了 Stop。</p>
<p>Session 的确停了。但是我转动表冠打开 Smart Stack，发现 SleepTap 仍然显示 <code>Wake Up</code>。后来我手动进入 SleepTap，应用里已经没有 <code>Wake Up</code> 了。再回到 Smart Stack，它也变成了当晚 <code>23:00</code> 上床。</p>
<p>我当时认为，Stop 只是终止了 Session，其余步骤是在我进入应用后才执行的。这是我根据按下 Stop 后看到的现象作出的判断。但我并不确定，于是询问 AI。</p>
<h2><a id="stop%E5%88%B0%E5%BA%95%E5%81%9A%E4%BA%86%E4%BB%80%E4%B9%88%EF%BC%9F" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>Stop 到底做了什么？</h2>
<p>AI和我讲，Apple Watch 的系统 Stop 会使 <code>WKExtendedRuntimeSession</code> 失效，然后调用：</p>
<p><code>extendedRuntimeSession(_:didInvalidateWith:error:)</code></p>
<p>SleepTap 收到这个回调以后，会检查 Session 是否正常结束、之前是否已经通过 <code>notifyUser</code> 叫醒用户，以及这次失效是不是应用自己触发的。如果这些条件都符合，就会把它当成用户点击了系统 Stop，自动记录起床。</p>
<p>记录起床以后，SleepTap 还会写入 HealthKit，清空本轮睡眠时间线，通知手机取消对应的起床闹钟，然后重新生成 Smart Stack 使用的数据，最后调用 <code>reloadAllTimelines()</code> 请求 WidgetKit 刷新。</p>
<p>这套后台路径本来就存在，并不是一定要打开应用。</p>
<p>不过代码里也的确有一个前台兜底。如果系统已经叫醒了用户，但是后台处理没有完成，那么用户下次进入 SleepTap 时，应用会再次检查状态并补记起床。这样一来，前天的现象其实存在两种解释：一种是后台处理没有完成，打开应用以后走了兜底；另一种是后台早就处理完了，只是 Smart Stack 还在显示旧内容，打开应用以后又请求了一次刷新。</p>
<p>只看 Smart Stack，根本分不清。于是我和AI说，那我再试一晚，明天不手动进应用，看看Smart Stack到底能不能更新。AI说，应该这么做，并且建议我分不同时段，多次观察，每次截图。</p>
<h2><a id="%E7%AC%AC%E4%BA%8C%E5%A4%A9%E7%9A%84%E5%8F%A4%E6%B3%95%E9%AA%8C%E8%AF%81" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>第二天的古法验证</h2>
<p>今天的 Session 大约在凌晨 4:30 开始运行，我在 4:33 左右点击了 Stop。之后我一直没有打开 SleepTap。期间我多次观察，在几分钟后，半小时后都观察了，看到的都还是Wake Up。到了 5:33，我发现 Smart Stack 已经从 <code>Wake Up</code> 自动变成了 <code>Bed Time 23:00</code>。</p>
<p>这至少证明了一件事：不打开应用，后台链路也能完成。前台兜底并不是必需的。</p>
<p>但我很快又产生了另一个判断：难道 <code>extendedRuntimeSession(_:didInvalidateWith:error:)</code> 花了接近一个小时才执行？</p>
<p>我询问AI，AI建议我去看健康应用，根据健康应用的起床时间来辅助判断。</p>
<p>我打开健康 App，里面记录的起床时间是 4:33，和点击 Stop 的时间基本一致。SleepTap 在后台记录起床时，使用的是当时的当前时间。如果这段代码真的拖到 5:33 才执行，健康里的起床时间也应该接近 5:33，而不会是 4:33。</p>
<p>也就是说，Session 的失效回调和起床记录在 4:33 左右就已经完成了。延迟的不是业务处理，而是 Smart Stack 的显示。</p>
<p>这不叫回调执行了一小时，这叫 WidgetKit 一小时以后才把结果画出来。</p>
<p>当然，我只是在 5:33 才发现它发生了变化，并不能证明它正好延迟了一个小时。实际刷新时间位于我上一次查看和 5:33 之间。不过可以确认的是，在这段时间里，HealthKit 里的数据已经是正确的，而 Smart Stack 仍然可能显示旧的 <code>Wake Up</code>。</p>
<p><code>reloadAllTimelines()</code> 这个名字很容易让人误会。看起来像是“立即重新加载所有 Timeline”，其实它只是向 WidgetKit 提交刷新请求。什么时候重新生成 Timeline，什么时候重绘，什么时候改变 Smart Stack 里的优先级，最终还是系统说了算。调用成功，不等于用户马上能看到结果。</p>
<p>这和我以前调查 Widget 问题时遇到的情况一样：共享数据已经变了，Widget 还在显示旧数据。很多时候不是数据没写进去，也不是业务代码没执行，而是 WidgetKit 继续保留了上一份 Timeline。</p>
<h2><a id="%E6%80%BB%E7%BB%93" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>总结</h2>
<p>这两天的调查结果已经很明确：</p>
<p>点击 Stop 后，Smart Alarm Session 会正常结束。SleepTap 的后台回调也会立即记录起床、写入 HealthKit，并更新 Smart Stack 使用的数据。打开应用只是兜底，不是完成这些操作的必要条件。</p>
<p>Smart Stack 最多可能在相当长的一段时间里继续显示旧的 <code>Wake Up</code>，但它最终会自行刷新。这是 WidgetKit 的可见状态延迟，不是 Session 延迟，也不是睡眠记录延迟。</p>
<p>一开始我根据界面判断后台代码没有执行。第二天又根据刷新时间判断回调执行了一个小时。两次判断都很合理，也都被健康数据推翻了。</p>
<p>所以，调查 Widget 问题，不能只看 Widget。界面是界面，数据是数据。把两者混在一起，肯定会追错方向。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[处理copilot update更新缓慢问题获得的两个意外收获]]></title>
    <link href="https://zhaoxin.pro/technology/17784526707310.html"/>
    <updated>2026-05-11T06:37:50+08:00</updated>
    <id>https://zhaoxin.pro/technology/17784526707310.html</id>
    <content type="html"><![CDATA[
<p>今早发现copilot update更新的时候突然变慢了，速度只有10KB/s，于是跑去找ChatGPT问了问，为什么我终端已经设置了all_proxy，但是copilot update的时候却没有使用？</p>
<p>ChatGPT和我说，这是因为copilot这种有时候只会使用环境变量，我需要设置的是http_proxy和https_proxy。我设置好之后，更新了copilot，这下有500KB/s的速度了。</p>
<h2><a id="%E4%B8%A4%E4%B8%AA%E6%84%8F%E5%A4%96%E6%94%B6%E8%8E%B7" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>两个意外收获</h2>
<p>再次打开copilot，对话后我发现，默认的模型变成sonnet-4.6了。我很意外！因为我是中国用户的原因，很久以前就没法使用任何Claude的模型，只能使用OpenAI的。之前我虽然听说如果VPN开启全局模式也能做到，但是我嫌麻烦，就没有弄。</p>
<p>此外，我还发现，现在copilot响应速度快了很多。之前我就觉得copilot的gpt-5.4比OpenAI Codex app里的慢很多。我还以为是copilot的问题。因为我问ChatGPT为啥copilot比Codex的慢，它还给我解释的头头是道，什么copilot搞了负载均衡，额外会加载其他内容，还有Codex可能是独享的，copilot的是共享的。不过从这次改变可以知道，其实原因并不是ChatGPT说的那样，就是因为原本的代理设置不被copilot支持导致的。我们应该警惕大模型的回复，有的时候它说的也不一定对。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[中国开发者测试Google Play应用内购买的正确方法]]></title>
    <link href="https://zhaoxin.pro/technology/17769895238695.html"/>
    <updated>2026-04-24T08:12:03+08:00</updated>
    <id>https://zhaoxin.pro/technology/17769895238695.html</id>
    <content type="html"><![CDATA[
<p>作为一名苦逼的中国开发者，我们要想测试发布到Google Play的商店，会收到中美双方的排挤。本文是我通过调查资料，最终总结的一个我认为最适合的方式，希望后来者可以少走弯路。</p>
<h1><a id="%E5%BC%80%E5%8F%91%E8%80%85%E8%B4%A6%E5%8F%B7" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>开发者账号</h1>
<p>你需要一个开发者账户，一个测试账号。这是因为作为收钱的开发者账户，我们需要使用自己的真实信息，这样才能通过Google的开发者账户的验证。因此，开发者账户是中国账户，加上单Visa的信用卡即可，我使用的是招商银行的单Visa的全币卡。你还需要找银行要一下对账单，准备好身份证，这些都是Google验证时需要的。</p>
<h1><a id="%E6%B5%8B%E8%AF%95%E8%B4%A6%E6%88%B7" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>测试账户</h1>
<p>测试账户需要新建一个美国账户。感谢AI，ChatGPT告诉我，Google账户是根据你注册时的IP来判断你属于哪个国家的。所以，我们主要开启一个美国的VPN，然后注册就可以。特别的，注册时会要求手机号来验证。很多人担心能否用中国的手机号来验证，没问题的。因为这个只是验证你是真人，不作为判断国别的依据。因此，只要你注册时的IP是美国，以后也一直使用这个IP，就没问题。</p>
<p>此外，由于我们的测试账户只是为了测试。并且我们也没有美国的信用卡用来支付，所以我们的账户将不绑定任何信用卡。以免因为绑定了中国的信用卡而导致账户被风控。</p>
<h1><a id="%E6%B5%8B%E8%AF%95%E5%BA%94%E7%94%A8" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>测试应用</h1>
<ol>
<li>上传应用到Google Play</li>
<li>选择内部测试</li>
<li>然后在内部测试中，创建一个新的组，将测试账户的邮箱添加上去</li>
<li>复制定下的链接，用手机登录测试账户，打开这个链接</li>
<li>安装测试应用，就可以测试了。</li>
</ol>
<h1><a id="%E5%85%B6%E4%BB%96%E9%97%AE%E9%A2%98" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>其他问题</h1>
<p>如果测试时，在选择“快速卡，一直通过”之后遇到问题，那是因为你的VPN选择的是规则，导致Play商店和Google服务的IP不一致。解决办法就是关掉应用，然后将VPN的规则改为全局，然后重新测试。这样就可以了。</p>
<h1><a id="%E6%9C%80%E5%90%8E" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>最后</h1>
<p>我们采用这种方式，是没办法的的办法。因为我们中国的开发者账户直接用来测试，经常会遇到“设备或者账户不支持支付”这类的问题。那样的话，我们就没有办法测试应用内购买是否正确了。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[第三方应用使用DocumentGroup时，模仿Pages的工具栏]]></title>
    <link href="https://zhaoxin.pro/technology/17756134186479.html"/>
    <updated>2026-04-08T09:56:58+08:00</updated>
    <id>https://zhaoxin.pro/technology/17756134186479.html</id>
    <content type="html"><![CDATA[
<p>最近，我在开发iOS的Markdown应用的时候遇到一个问题，应用是使用DocumentGroup开发的，但是默认会显示标题，从而导致留给工具栏的空间太小，工具栏的图标经常被折叠起来。</p>
<p><img src="media/17756134186479/IMG_1181.jpeg" alt="IMG_1181" /></p>
<p>我一开始只是想把后退去掉，换成三个点的菜单，然后去掉标题。结果发现后退去不掉。于是我就想看看苹果自己是如何做的。于是我打开了苹果自己的Notes和Numbers。不过Notes其实是有内部库的，于是主要参考的Numbers。</p>
<p><img src="media/17756134186479/IMG_1184.jpeg" alt="IMG_1184" /></p>
<p>我把Numbers截图给AI，说想要弄一个这个风格的。结果AI搞不定。它虽然设置了标题为空，但是标题始终显示。于是我有把这个描述给Grok，Grok说，这是因为DocumentGroup自动包含了一层Navigation，你需要将它禁用。结果我禁用了，还是不行。又去问Grok，它又说，这是因为iOS的某些版本，有bug，不执行这个操作。但是网友总结了三种办法，1，2，3，然后挨个试。结果第一种就可以了。</p>
<p>这个是我应用最终的效果。</p>
<p><img src="media/17756134186479/IMG_1205.jpeg" alt="IMG_1205" /></p>
<p>最核心的代码只需要如下的部分。</p>
<p><img src="media/17756134186479/%E6%88%AA%E5%B1%8F2026-04-12%2010.50.33.png" alt="截屏2026-04-12 10.50.33" /></p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[Swift Package应用签名与公证实战总结]]></title>
    <link href="https://zhaoxin.pro/technology/17716310437233.html"/>
    <updated>2026-02-21T07:44:03+08:00</updated>
    <id>https://zhaoxin.pro/technology/17716310437233.html</id>
    <content type="html"><![CDATA[
<h2><a id="%E9%97%AE%E9%A2%98%E8%83%8C%E6%99%AF" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>问题背景</h2>
<p>fork 了一个第三方 macOS 应用，原 release 版本未签名，但提供了源码，为Swift Package格式，需要重新签名并公证后分发。</p>
<hr />
<h2><a id="%E9%81%87%E5%88%B0%E7%9A%84%E5%9D%91" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>遇到的坑</h2>
<h3><a id="1-swift-package-manager%E6%97%A0%E6%B3%95%E5%90%AF%E7%94%A8-hardened-runtime" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>1. Swift Package Manager 无法启用 Hardened Runtime</h3>
<p><strong>问题</strong>：使用 <code>swift build</code> 构建的可执行文件，无法通过签名添加 hardened runtime。公证时一直报错：</p>
<pre class="line-numbers"><code class="language-plain_text">The executable does not have the hardened runtime enabled.
</code></pre>
<p><strong>原因</strong>：Swift Package Manager 构建时不会启用 hardened runtime，签名时添加也不被认可。</p>
<p><strong>解决</strong>：使用 XcodeGen 生成 Xcode 项目，在项目配置中启用。</p>
<h3><a id="2-apple-development%E8%AF%81%E4%B9%A6%E6%97%A0%E6%B3%95%E5%85%AC%E8%AF%81" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>2. Apple Development 证书无法公证</h3>
<p><strong>问题</strong>：最初只有 Apple Development 证书（用于开发调试），公证时报错：</p>
<pre class="line-numbers"><code class="language-plain_text">The binary is not signed with a valid Developer ID certificate.
</code></pre>
<p><strong>原因</strong>：公证必须使用 <strong>Developer ID Application</strong> 证书，不能用 Apple Development。</p>
<p><strong>解决</strong>：在 Apple Developer 账号中添加 Developer ID Application 证书。</p>
<h3><a id="3%E6%97%B6%E9%97%B4%E6%88%B3%E9%97%AE%E9%A2%98" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>3. 时间戳问题</h3>
<p><strong>问题</strong>：签名缺少安全时间戳：</p>
<pre class="line-numbers"><code class="language-plain_text">The signature does not include a secure timestamp.
</code></pre>
<p><strong>解决</strong>：在签名配置中添加 <code>--timestamp</code> 参数。</p>
<h3><a id="4-get-task-allow-entitlement%E9%97%AE%E9%A2%98" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>4. get-task-allow entitlement 问题</h3>
<p><strong>问题</strong>：分发版本包含开发用 entitlement：</p>
<pre class="line-numbers"><code class="language-plain_text">The executable requests the com.apple.security.get-task-allow entitlement.
</code></pre>
<p><strong>解决</strong>：在 entitlements 配置中明确设置 <code>com.apple.security.get-task-allow: false</code>。</p>
<hr />
<h2><a id="%E7%BB%8F%E9%AA%8C%E6%95%99%E8%AE%AD" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>经验教训</h2>
<h3><a id="%E8%AF%81%E4%B9%A6%E7%B1%BB%E5%9E%8B" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>证书类型</h3>
<table>
<thead>
<tr>
<th>证书类型</th>
<th>用途</th>
</tr>
</thead>
<tbody>
<tr>
<td>Apple Development</td>
<td>开发调试，不能公证</td>
</tr>
<tr>
<td>Developer ID Application</td>
<td>分发和公证 ✅</td>
</tr>
</tbody>
</table>
<h3><a id="%E7%AD%BE%E5%90%8D%E9%85%8D%E7%BD%AE%E8%A6%81%E7%82%B9" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>签名配置要点</h3>
<ol>
<li><strong>Hardened Runtime</strong> - 必须启用</li>
<li><strong>时间戳</strong> - 必须添加</li>
<li><strong>entitlements</strong> - 分发版本禁止包含 get-task-allow</li>
<li><strong>使用 Manual 签名</strong> - 避免自动签名冲突</li>
</ol>
<hr />
<h2><a id="%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5%EF%BC%88%E7%AE%80%E5%8D%95%E6%9C%89%E6%95%88%EF%BC%89" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>最佳实践（简单有效）</h2>
<h3><a id="1%E4%BD%BF%E7%94%A8-xcode%E9%A1%B9%E7%9B%AE%E8%80%8C%E9%9D%9E-spm" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>1. 使用 Xcode 项目而非 SPM</h3>
<pre class="line-numbers"><code class="language-bash"># 安装 XcodeGen
brew install xcodegen

# 配置 project.yml（关键设置）
targets:
  YourApp:
    settings:
      base:
        ENABLE_HARDENED_RUNTIME: YES
        CODE_SIGN_STYLE: Manual
        CODE_SIGN_IDENTITY: &quot;Developer ID Application: Your Name (TEAM_ID)&quot;
        OTHER_CODE_SIGN_FLAGS: &quot;--timestamp&quot;
    entitlements:
      properties:
        com.apple.security.get-task-allow: false

# 生成项目
xcodegen generate

# 构建
xcodebuild -project YourApp.xcodeproj -scheme YourApp -configuration Release build
</code></pre>
<h3><a id="2%E9%AA%8C%E8%AF%81%E7%AD%BE%E5%90%8D" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>2. 验证签名</h3>
<pre class="line-numbers"><code class="language-bash">codesign -dvv YourApp.app/Contents/MacOS/YourApp
</code></pre>
<p>确认输出包含：</p>
<ul>
<li><code>flags=0x10000(runtime)</code> - hardened runtime 已启用</li>
<li><code>Timestamp=...</code> - 有时间戳</li>
</ul>
<h3><a id="3%E5%85%AC%E8%AF%81%E6%B5%81%E7%A8%8B" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>3. 公证流程</h3>
<pre class="line-numbers"><code class="language-bash"># 1. 创建 zip
zip -r YourApp.zip YourApp.app

# 2. 提交公证（需要 App 专用密码）
xcrun notarytool submit YourApp.zip \
  --apple-id &quot;your@email.com&quot; \
  --password &quot;app-specific-password&quot; \
  --team-id &quot;TEAM_ID&quot;

# 3. 等待完成
xcrun notarytool wait &lt;submission-id&gt; --apple-id &quot;your@email.com&quot; --password &quot;password&quot; --team-id &quot;TEAM_ID&quot;

# 4. 附加票据
xcrun stapler staple YourApp.app
</code></pre>
<hr />
<h2><a id="%E6%80%BB%E7%BB%93" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>总结</h2>
<p><strong>最简单有效的方式</strong>：</p>
<ol>
<li>用 <strong>XcodeGen</strong> 生成 Xcode 项目（不要用纯 SPM）</li>
<li>在 <code>project.yml</code> 中配置好签名和 hardened runtime</li>
<li>用 <strong>xcodebuild</strong> 构建</li>
<li>用 <strong>notarytool</strong> 公证</li>
</ol>
<p>不要试图用 <code>swift build</code> + 手动签名的方式来处理需要公证的分发版本，SPM 的构建产物不兼容。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[NavigationSplitView的一些奇技淫巧]]></title>
    <link href="https://zhaoxin.pro/technology/17614393407502.html"/>
    <updated>2025-10-26T08:42:20+08:00</updated>
    <id>https://zhaoxin.pro/technology/17614393407502.html</id>
    <content type="html"><![CDATA[
<h2><a id="sidebar%E6%8C%89%E9%92%AE%E9%9A%90%E8%97%8F%E8%B5%B7%E6%9D%A5%E7%9A%84%E6%AD%A3%E7%A1%AE%E5%81%9A%E6%B3%95" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>Sidebar按钮隐藏起来的正确做法</h2>
<p>使用</p>
<pre class="line-numbers"><code class="language-swift">.toolbar(removing: .sidebarToggle)
</code></pre>
<p>可以隐藏侧边栏切换按钮。但是注意，这个不是放在作用在<code>NavigationSplitView</code>上面的。而不是作用在sidebar的视图上面。即必须按照如下的方式使用：</p>
<pre class="line-numbers"><code class="language-swift">NavigationSplitView(columnVisibility: $columnVisibility) {
    sidebar
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}
</code></pre>
<h2><a id="%E9%9A%90%E8%97%8Fsidebar%E7%9A%84%E6%AD%A3%E7%A1%AE%E5%81%9A%E6%B3%95" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>隐藏Sidebar的正确做法</h2>
<p><code>NavigationSplitView</code>是三栏的，但是如果你要使用两栏，则有两种方式。</p>
<h3><a id="%E4%BD%BF%E7%94%A8sidebar" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>使用sidebar</h3>
<p>使用sidebar的好处是，sidebar可以隐藏，也可以显示。<br />
使用sidebar+detail的方式。</p>
<h3><a id="%E4%B8%8D%E4%BD%BF%E7%94%A8sidebar" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>不使用sidebar</h3>
<p>如果你希望永远显示为两栏，不希望隐藏任何一个栏隐藏，那么使用此方式。</p>
<pre class="line-numbers"><code class="language-swift">NavigationSplitView(columnVisibility: .constant(.doubleColumn)) {
    EmptyView()
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}
</code></pre>
<p>这个的重点在于，通过sidebar设置隐藏了切换sidebar按钮。同时，选择两栏模式，默认不显示sidebar。这样sidebar就无法被调用出来了。并且一直是两栏。</p>
<h2><a id="%E9%85%8D%E5%90%88settings%E4%BD%BF%E7%94%A8" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>配合Settings使用</h2>
<p>在使用Settings时候，包含sidebar的<code>NavigationSplitView</code>的sidebar按钮会导致出现问题。因此，必须使用上面的第二种方式，隐藏sidebar，并且使用一直存在的两栏。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[macOS在工具栏上切换页面，TabView和Picker怎么选？]]></title>
    <link href="https://zhaoxin.pro/technology/17598944718603.html"/>
    <updated>2025-10-08T11:34:31+08:00</updated>
    <id>https://zhaoxin.pro/technology/17598944718603.html</id>
    <content type="html"><![CDATA[
<p>目前的SwiftUI，如果你希望通过工具栏的按钮直接切换标签，可以使用TabView，也可以使用Picker，不过在细节上，二者有所不同。</p>
<h2><a id="tabview" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>TabView</h2>
<p>TabView是最简单的方式，只要你应用的根视图为TabView，那么你的系统架构会自动转成Navigation Tab Bar的方式，自动在工具栏显示Tab的标签。</p>
<p>不过这个方式有一种缺陷，就是应用的标题不会在工具栏上显示。如果你想强制限制，必须引入AppKit然后，覆盖Window的相应设置。</p>
<h2><a id="picker" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>Picker</h2>
<p>使用Picker的方式则更为友好。之需要在toolbar里添加Picker，然后使用segment样式，就可以获得同样的显示效果。然后使用Switch切换视图即可。</p>
<p>这么做不如直接使用TabView简单，但是可定制化强，并且不会影响标题的显示。</p>
<h2><a id="%E7%BB%93%E8%AE%BA" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>结论</h2>
<p>如果不需要设置标题栏，使用TabView更为简便。否则就使用Picker，这样效果更好。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[macOS系统菜单栏显示多图标的两种方式]]></title>
    <link href="https://zhaoxin.pro/technology/17591091125785.html"/>
    <updated>2025-09-29T09:25:12+08:00</updated>
    <id>https://zhaoxin.pro/technology/17591091125785.html</id>
    <content type="html"><![CDATA[
<p>一直以来都是使用的菜单栏单图标的方式。今天心血来潮，想在Focus原本的图标旁边新增一个刷新的图标，可以用来快速重置计时器。</p>
<p>把任务分配给AI，AI很快完成了第一版。我运行一看，怎么没看到新图标，再仔细一找，的确是两个图标了，但是两个图标是各自独立的，彼此之间还间隔了几个其它的图标。这和我看到的不一样，我其实希望的是像音乐播放器那样的，几个图标一体的方式。于是我和AI说明了要一体的。AI表示了解，然后重新生成了代码。AI特意解释说，一体之后，点击时就需要用点击的位置进行二次判断，还确定用户点击的是哪个图标。</p>
<p>最终结果我实验了一下，的确和音乐播放器的一样。看来他们也是这么做的。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[SwiftUI应用伴随系统登录自动启动后显示macOS应用的窗口的办法]]></title>
    <link href="https://zhaoxin.pro/technology/17591090521053.html"/>
    <updated>2025-09-29T09:24:12+08:00</updated>
    <id>https://zhaoxin.pro/technology/17591090521053.html</id>
    <content type="html"><![CDATA[
<p>我们知道SwiftUI应用本身没有应用窗口的概念，它是使用WindowGroup来自动管理窗口的。这在用户手动启动应用的时候没有问题。但是如果你设置了应用伴随系统登录后自动启动后，SwiftUI的应用会存在一些问题。</p>
<p>这是因为这种方式启动应用后，SwiftUI的应用不会主动创建窗口，视图会在用户手动点击应用之后才创建。而这可能不是我们所需要的。因为如果我们有使用onAppear来执行一些代码。我们实际上是希望代码可以在应用启动时就运行，而这个机制会导致执行会推迟到用户点击时。</p>
<p>因此，我们需要保证即便SwiftUI没有生成Window，也要自己主动来生成Window。</p>
<h2><a id="%E9%97%AE%E9%A2%98%E5%88%86%E6%9E%90" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>问题分析</h2>
<p>要解决这个问题，我们首先要了解这个启动的整个过程，然后才能知道如何来改进。具体调试的过程我就不讲了，最终我确认启动的过程是这样的：</p>
<ol>
<li>用户登录。</li>
<li>应用启动。</li>
<li>应用在后台启动，但是没有主窗口，因此无法自动切换到前台。</li>
</ol>
<p>我们可以使用<code>NSApplication.shared.windows.count</code>来判断。如果是用户手动打开的应用，SwiftUI会创建SwiftUI.AppKitWindow的窗口。而如果是跟随系统登录后后台启动，则不会有这个窗口。</p>
<p>我使用的判断函数是这个：</p>
<pre class="line-numbers"><code class="language-swift">func isLaunchedAtLogin() -&gt; Bool {
  NSApplication.shared.windows.count &lt; 2
}
</code></pre>
<p>之所以用2，而不是1判断。是因为我还使用了菜单栏图标，菜单栏图标使用的是NSStatusItem，因此还会包含一个叫NSStatusWindow的窗口。</p>
<h2><a id="%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>解决方案</h2>
<pre class="line-numbers"><code class="language-swift">func applicationDidFinishLaunching(_ notification: Notification) {
    createContentViewWindow()
}

/// 用户启动时SwiftUI的窗口会先创建，因此window不会为nil，但是有可能存在延迟，因为是通过.updateWindow还获取的。
/// 所以这里使用其他的方式进行判断，而不是使用window是否为nil
private func createContentViewWindow() {
  /// 若是用户点击，会有两个窗口，一个是SwiftUI创建的主窗口，一个status窗口。后者应该是对应菜单栏图标的。
  /// 如果是伴随系统启动，则SwiftUI创建的主窗口不存在，只有status的窗口。
  func isLaunchedAtLogin() -&gt; Bool {
    NSApplication.shared.windows.count &lt; 2
  }

  if isLaunchedAtLogin() {
    let contentView = ContentView()
      .environment(\.managedObjectContext, ModelProvider.shared.container.viewContext)
    let window = NSWindow(
      contentRect: NSRect(x: 0, y: 0, width: 400, height: 240),
      styleMask: [.titled, .closable, .miniaturizable, .resizable],
      backing: .buffered, defer: false)

    window.setFrameAutosaveName(&quot;Main Window&quot;)
    window.contentView = NSHostingView(rootView: contentView)
    window.center()
    window.makeKeyAndOrderFront(nil)
    self.window = window
  }
}
</code></pre>
<h2><a id="%E5%B0%8F%E7%BB%93" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>小结</h2>
<p>传统上，如果我们使用loginItem来实现应用伴随系统启动，那么为了区分是用户手动启动，还是伴随系统启动，需要使用传递参数的方式。但是传递参数，就需要使用额外的launcher辅助应用。</p>
<p>设置辅助应用的步骤是很复杂的。因此，我们现在大多都是直接使用下面的代码来直接使用自动伴随系统登录启动。</p>
<pre class="line-numbers"><code class="language-swift">try SMAppService.mainApp.register()
</code></pre>
<p>这个办法虽然大大简化了设置系统启动后启动应用的步骤。但是这么做之后，由于没有辅助应用，也就没法使用传递参数的办法了。</p>
<p>本文给出的使用<code>NSApplication.shared.windows.count</code>，用窗口数量来间接判断的方式，利用了SwiftUI在后台启动后，不会主动创建窗口的特性，解决了这个问题。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[解决Xcode Cloud无法enable Swift Package包中的宏的问题]]></title>
    <link href="https://zhaoxin.pro/technology/17590454903830.html"/>
    <updated>2025-09-28T15:44:50+08:00</updated>
    <id>https://zhaoxin.pro/technology/17590454903830.html</id>
    <content type="html"><![CDATA[
<p>最近我在做应用适配iOS/macOS 26的特性。今天在Xcode Cloud打包的时候遇到打包失败的错误。</p>
<blockquote>
<p>Macro “DefaultsMacrosDeclarations” from package “Defaults” must be enabled before it can be used.</p>
</blockquote>
<p>这个问题是我应用所使用的第三方的库 “Defaults”在其内部使用了宏。这个宏在Xcode本地编译时，需要用户手动点击确认才能继续。但是Xcode Cloud中，没有点击确认的位置。因此，就无法完成打包应用的过程。</p>
<h2><a id="%E8%A7%A3%E5%86%B3%E5%8A%9E%E6%B3%95" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>解决办法</h2>
<p>通过运行脚本的方式，在克隆完文件夹之后，运行脚本，规避掉对于宏的验证。</p>
<pre class="line-numbers"><code class="language-sh">#!/bin/sh 
defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES
</code></pre>
<p>必须在Xcode中的根部位置，创建一个新组，命名为ci_scripts，然后在这个组中创建ci_post_clone.sh，内容是上面的内容。</p>
<blockquote>
<p>必须在Xcode的根部位置创建组，并且命名也不能错。</p>
</blockquote>
<h3><a id="%E5%B0%8F%E6%8F%92%E6%9B%B2" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>小插曲</h3>
<p>我其实最开始是像GPT 4.1提出了这个问题。GPT 4.1的解答只对了一半。它提出了创建文件夹和脚本，文件夹是正确的，脚本名字是错误的。并且它也没有告诉需要在Xcode中创建组，而只是说在项目的根目录创建就可以。最后，它创建的脚本内容不完全正确。</p>
<p>之后我使用了Google搜索。Google搜索默认的AI总结的是正确的，但应该就是从stackoverflow里的答案总结的。我最后是看的SO里的回答，进行的总结。</p>
<p>另外，我建议你完整阅读下面的第一个引用。我使用了里面最为简便的方案。而非最安全的。也许你看了之后，会选择一条不同的手段。</p>
<h2><a id="%E5%BC%95%E7%94%A8%E6%96%87%E7%8C%AE" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>引用文献</h2>
<p><a href="https://stackoverflow.com/questions/77267883/how-do-i-trust-a-swift-macro-target-for-xcode-cloud-builds/77312559#77312559">How do I trust a swift macro target for Xcode Cloud builds?</a></p>
<p><a href="https://developer.apple.com/documentation/xcode/writing-custom-build-scripts">Writing custom build scripts</a></p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[什么？AccentColor又闹幺蛾子了？]]></title>
    <link href="https://zhaoxin.pro/technology/17590279465918.html"/>
    <updated>2025-09-28T10:52:26+08:00</updated>
    <id>https://zhaoxin.pro/technology/17590279465918.html</id>
    <content type="html"><![CDATA[
<p>一年以前，我就踩过一次AccentColor的坑。没想到，一年之后我又掉进来了。</p>
<blockquote>
<p><a href="17256661738520.html">SwiftUI下，TextField诡异失去Focus下样式的问题</a></p>
</blockquote>
<p>事情的起因是这样的。因为苹果的新系统发布了嘛。我不能免俗的也要改进我之前的应用，添加对于新系统特性的一些支持之类的。</p>
<p>但是我在修改代码后测试时发现，当使用ZStack模拟弹窗之后，弹窗后面的视图的颜色会出错。我一开始以为是ZStack的问题，于是将模拟弹窗改成了.fullScreenCover的方式。这个问题在当时看起来时解决了。但是今天我在使用中发现，这个问题又重新出现了。</p>
<p>于是我在Google上搜索了一下，没想到这还是一个SwiftUI长期存在的一个问题。</p>
<blockquote>
<p><a href="https://stackoverflow.com/questions/77550885/how-do-i-stop-the-accentcolor-from-turning-gray-when-a-sheet-is-being-presented/78128099#78128099">How do I stop the AccentColor from turning Gray when a sheet is being presented?</a></p>
</blockquote>
<p>原来在SwiftUI中实际使用时。原本应该一致的Color.accentColor和Color(&quot;AccentColor&quot;)在实际使用中是不一致的。说得更具体些，就是Color.accentColor会使用应用设置和用户的系统设置。而Color(&quot;AccentColor&quot;)则是将AccentColor作为颜色资源从Asset文件夹直接读取。因此，虽然它的名字也叫&quot;AccentColor&quot;，但是实际上它只是名字叫&quot;AccentColor&quot;的一个颜色，你改成别的名字，比如&quot;MyAppColor&quot;也是一样的。虽然这样会失去Color.accentColor一些独特的个性，但是能保证颜色的一致，即颜色不会莫名其妙的改变。</p>
<p>系统弹sheet的时候，Color.accentColor会改变的问题，应该就是sheet本身可能存在某种机制，将应用内设置的Color.accentColor从Asset文件夹设置的内容，改成了系统默认设置的内容。比如下图红色圈起来的部分，就是系统允许用户自定义用户AccentColor偏好的地方。</p>
<p><img src="media/17590279465918/accent_color.jpg" alt="accent_color" /></p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[大模型又暴露了……]]></title>
    <link href="https://zhaoxin.pro/technology/17572405061386.html"/>
    <updated>2025-09-07T18:21:46+08:00</updated>
    <id>https://zhaoxin.pro/technology/17572405061386.html</id>
    <content type="html"><![CDATA[
<p>这真的不是危言耸听，这是真的骇人听闻。盘点一下我遇到大模型的智障行为，本文不定期更新。</p>
<h2><a id="%E5%A4%A7%E6%A8%A1%E5%9E%8B%E8%87%B3%E4%BB%8A%E4%B8%8D%E4%BA%86%E8%A7%A3swiftui%E7%9A%84%E6%9B%B4%E6%96%B0%E6%9C%BA%E5%88%B6%EF%BC%8C%E5%BF%85%E9%A1%BB%E8%A6%81%E4%B8%A5%E6%A0%BC%E7%BA%A6%E6%9D%9F%EF%BC%8C%E5%B0%8F%E5%BF%83%E4%BD%BF%E7%94%A8" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>大模型至今不了解SwiftUI的更新机制，必须要严格约束，小心使用</h2>
<p>今天遇到了一个sheet弹窗之后，视图更新不同步的问题。由于我已经在提示词中，告诉了我倾向使用@Observable，而不是旧版的ObservableObject，所以一开始大模型创建了正确的@Observable class，然后在视图中使用@State创建了这个model的实例。</p>
<p>但是为什么还是出错了呢？那就要看使用时更具体的实现了。在开启sheet之前，大模型调用了一个准备函数，在这个函数中，对于model进行了一系列的初始化。具体的步骤是这样的，先新建一个model，然后修改它，然后用这个修改完成的model，替换为系统@State里的那个model。</p>
<p>我首先将问题描述给大模型，sheet首次打开的时候，显示的界面并不符合预期，应该有值的地方，实际上为空。但是如果关掉，再次打开，就又正确了。</p>
<p>大模型思考了一番，说这可能是sheet的机制造成，sheet有时会提前锁定一些值，这会造成打开视图时的不同步。它的建议是，在sheet打开的视图内部的onAppear中新增一个fallback的调用，重新检查并赋值。然后还说，如果这样还不行，可以考虑使用GCD延迟的方式，也就是使用DispatchQueme.main.async调用来实现改动。</p>
<p>试了，onAppear的确不行。但是我没有继续使用它建议的GCD延迟的方式。我问它是否还有其它的办法。</p>
<p>大模型想了想，说可以考虑将@Observable的class改成纯粹的struct的方式。然后就其次咔嚓地修改起来，我一看改动后的代码，妈呀！连mutating都搞出来了。SwiftUI的代码哪有这么写的啊？我果断点了Undo，取消了这次修改。</p>
<h3><a id="%E6%AD%A3%E7%A1%AE%E7%9A%84%E6%96%B9%E5%BC%8F" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>正确的方式</h3>
<p>我再次看了一下代码，突然我发现了问题的所在。前面提到，在准备函数中，大模型的改动方式是“先新建一个model，然后修改它，然后用这个修改完成model，替换为系统@State里的那个model。”这个方式在SwiftUI中显然是错误的。SwfitUI中@State的class类型，你不能替换它，因为一替换之前的跟踪就被中断了。</p>
<p>所以我和大模型说，我们不应该“新建model，修改，然后替换@State”，而是应该直接修改@State中的变量。</p>
<p>大模型想了想，觉得我说得有道理，就修改了。然后还跟我说，如果我确定这个好用，那么onAppear那里的代码，就可以删掉了。</p>
<p>于是我先注释掉onAppear那里的代码，然后运行应用，果然一切都正常了。</p>
<h3><a id="%E5%B0%8F%E7%BB%93" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>小结</h3>
<p>大模型并没有真正了解SwiftUI的更新机制，这部分的代码写的时候，它更像是一个拙略的模仿者。虽然这部分，我之前的领悟也不怎么深刻，但是我随着持续不断地学习和使用SwiftUI，现在在这方面，我有信心可以说，我可比大模型强多了。</p>
<h2><a id="%E5%A4%A7%E6%A8%A1%E5%9E%8B%E4%B8%8D%E6%93%85%E9%95%BF%E9%87%8D%E6%9E%84" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>大模型不擅长重构</h2>
<p>这个说法听起来是反直觉的。大模型相比于人类，应该更擅长重构吧。为什么说它不擅长呢？</p>
<p>事实如此。昨天我想重构一个900多行的SwiftUI的文件，里面包含一个主视图，主视图下的视图组件，以及一个弹出视图。如果是开发者来重构这个文件，之需要新建文件，然后复制，粘贴，再删掉旧的就可以了。可以很快就完成。</p>
<p>但是我用大模型来重构。却接连几次失败。过程一般是这样。一开始大模型计划的挺好。</p>
<ul>
<li>我将会将文件拆分为以下几个文件。</li>
<li>然后开始拆分，但是执行到5、6个的时候。</li>
<li>弹出出错提示，上下文已经耗尽了。</li>
</ul>
<p>我为了节省上下文，又单独增加了对于上下文节省的办法。每次重构一个文件，复制、粘贴、删除完了，验证有没有错误。有错误修复，没有再继续。并且可以抛弃掉使用过的上下文。</p>
<p>再次运行，稍微好了些，多实现几个文件，但是最后还是因为上下文不够而失败了。</p>
<h3><a id="%E5%B0%8F%E7%BB%93" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>小结</h3>
<p>大模型不擅长重构，不是因为它无法规划重构。而是因为大模型重构时需要考虑大量的上下文信息，最终会因为上下文耗尽而无法继续。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[苹果表无法解锁macOS问题的一个奇葩的解决办法]]></title>
    <link href="https://zhaoxin.pro/technology/17557696976538.html"/>
    <updated>2025-08-21T17:48:17+08:00</updated>
    <id>https://zhaoxin.pro/technology/17557696976538.html</id>
    <content type="html"><![CDATA[
<p>我实在无法找到更适当的词，只能用奇葩来形容这个解决办法。</p>
<p>我开始使用macOS 26 beta系列也有一段时间了，目前在使用的最新版本是beta 7。长久以来，我一直遇到一个有些奇怪的问题，就是每次系统睡眠之后唤醒，苹果表的解锁总是失败。但是进入系统之后，在需要苹果表解锁的其它情况下，比如打开密码应用或者钥匙串，双击苹果表侧键解锁的这个功能却总是能成功。</p>
<p>不过苹果表解锁也不总是失败。我有两台Mac mini，一台是M1，一台是M4，M1的解锁就总是成功，而M4这台就总是失败。</p>
<h2><a id="%E5%B0%9D%E8%AF%95%E8%A7%A3%E5%86%B3%E8%BF%99%E4%B8%AA%E9%97%AE%E9%A2%98" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>尝试解决这个问题</h2>
<p>今天我突发奇想，想要在AI的辅助下解决这个问题。我首先怀疑的时候VPN软件。因为更新到最新的macOS 26 beta之后，原来使用Clash X Pro不好用了，我不得不换成了Clash Verge rev。我问AI有没有可能是VPN软件造成的。AI说有可能。让我关了它再试。我试了。还是一样。</p>
<p>之后我又和AI一起开启了macOS控制台应用，想尝试通过读取日志来找到问题的解决方案。最后发现了一个loginwindow的一条故障日志，故障（红色）是比错误（黄色）等级更高的错误。它说在创建main文件夹的某个临时文件夹时出错，可能是沙盒的问题。我当时也信了，因为我的M4是256G的，为了节省磁盘空间，我将home文件夹设置到了外置的SSD上。我想这种比较罕见的设置，可能是苹果没有考虑过。我甚至还差一点儿就去跟苹果反馈这个问题。但是后来我放弃了。因为我觉得每次输入密码也还好，不算很麻烦，就没有反馈。同时我在想，以后我再买新电脑，一定要多花些钱，买个大一些的硬盘。</p>
<h2><a id="%E5%B1%B1%E9%87%8D%E6%B0%B4%E5%A4%8D%E7%96%91%E6%97%A0%E8%B7%AF" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>山重水复疑无路</h2>
<p>的确。我没能解决这个问题。我只是放下了它。然后，我开始着手解决我的另外一个开源应用的小问题。</p>
<p>App Helper，应用助手。是我开源在GitHub上的一个macOS的助手应用。它的其中一个功能，是一键切换HDR模式，即在开启和关闭之间切换。不过，我发现，我的显示器，虽然支持HDR，但是使用不同的连接方式它对于HDR的支持不同。比如用USB-C线直接连接，就不支持HDR，用HDMI或者DisplayPort线连接，就支持。</p>
<p>所以，我的目标是通过系统API检测，在不支持HDR的时候，隐藏这个一键切换的按钮。因为我当前是USB-C的连接，不支持HDR，所以我首先完成了这部分的代码。</p>
<p>因为我还需要测试支持HDR下的部分。于是我同时使用HDMI进行连接。macOS有一个问题，就是你用两根不同的线连接同一个显示器，但是在macOS看来你就是在使用双显示器。它的显示器设置中，同时显示出两台显示器，并且不能设置禁用其中的一个。这样就比较麻烦了。因为我虽然可以通过显示器上的信号源菜单来切换到不同的接口，但是存在一个主副窗口的问题。</p>
<p>最终没办法，我只能将Mac mini上的USB-C连接显示器的那根拔了下来。将HDMI的连接作为唯一的连接，这样可以方便我进行调试。</p>
<h2><a id="%E6%9F%B3%E6%9A%97%E8%8A%B1%E6%98%8E%E5%8F%88%E4%B8%80%E6%9D%91" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>柳暗花明又一村</h2>
<p>这次HDR的选项还是不能显示。经过调试我发现，必须显示器先打开HDR模式，苹果的API才能检测出显示器支持HDR，如果没有开启，那就检测不出来。</p>
<p>那也无妨，大不了我就跟原来一样，不检测了。我又测试一键切换的功能。结果这个功能也不好用。这就比较郁闷了。因为苹果本身并没有提供切换的HDR切换的API，我使用的是脚本调用控件的方式，这个方法生效的前提是控件的位置必须固定。我管它叫数格子，脚本的写法类似，找到xx组的xx格子，然后把它上面的开关打开/关闭。现在系统升级了，位置变了。苹果💊。</p>
<p>算了，心累。我打算彻底去掉这个功能。毕竟，苹果动动手指，我就得重新数格子，并且还需要考虑不同版本macOS的兼容性，实在得不偿失。</p>
<h3><a id="%E4%BC%91%E6%81%AF%EF%BC%8C%E4%BC%91%E6%81%AF%E4%B8%80%E4%BC%9A%E5%84%BF" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>休息，休息一会儿</h3>
<p>休息结束之后，重新唤醒已经睡眠的Mac，手表传来熟悉的解锁声。我居然成功解锁了这台M4。又试了几次，无论是睡眠之后立即解锁，还是睡眠了几小时之后再解锁，都是次次成功。</p>
<p>“这是为什么呢？”（蔡明）如是说。“排除了一切不可能，那么剩下的那个无论多么不合理，就是唯一的可能。”——福尔摩斯。</p>
<p>我做的最大改动只有一点，使用HDMI连接显示器，并且拿掉了USB-C的连线。所以，这个问题的原因就是使用USB-C连接显示器，会导致苹果表无法解锁macOS。这谁能想到啊，你说是不是奇葩的解决办法？</p>
<blockquote>
<p>特别说明，这个USB-C连接显示器，就是两边都是USB-C接口。而HDMI连接，则是用Mac mini独有的HDMI接口连接显示器的HDMI接口。</p>
<p>理论上，这个USB-C除了传递数据，还能同时获得显示器传来最大90瓦的电量。不过由于Mac mini的USB-C不支持反向供电，所以没啥用。如果比MacBook，是可以同时供电的。但是不知道是不是这一点影响了苹果表的解锁。我不是硬件工程师，不敢妄言。但是觉得这个值得提一下。</p>
</blockquote>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[macOS 26 Beta版至今发现的兼容性问题及解决方案]]></title>
    <link href="https://zhaoxin.pro/technology/17535688834291.html"/>
    <updated>2025-07-27T06:28:03+08:00</updated>
    <id>https://zhaoxin.pro/technology/17535688834291.html</id>
    <content type="html"><![CDATA[
<ul>
<li><a href="#beta-4">Beta 4</a>
<ul>
<li><a href="#%E5%91%BD%E4%BB%A4system-profiler%E7%9A%84%E5%8F%82%E6%95%B0%E5%8F%91%E7%94%9F%E6%94%B9%E5%8F%98">命令system_profiler的参数发生改变</a></li>
<li><a href="#%E5%BA%94%E7%94%A8%E4%BC%B4%E9%9A%8F%E7%B3%BB%E7%BB%9F%E5%90%AF%E5%8A%A8%E7%9A%84%E6%96%B9%E5%BC%8F%EF%BC%8C%E4%B8%8D%E5%90%8C%E7%9A%84%E6%96%B9%E6%B3%95%E5%9C%A8%E8%AE%BE%E7%BD%AE%E4%B8%AD%E6%98%BE%E7%A4%BA%E4%B8%8D%E5%90%8C">应用伴随系统启动的方式，不同的方法在设置中显示不同</a></li>
<li><a href="#%E7%B3%BB%E7%BB%9F%E5%B8%B8%E9%A9%BB%E8%8F%9C%E5%8D%95%E6%A0%8F%E5%9B%BE%E6%A0%87%E6%B6%88%E5%A4%B1%E9%97%AE%E9%A2%98%E7%9A%84%E8%A7%A3%E5%86%B3%EF%BC%88%E4%B8%8B%E6%96%B92025%E5%B9%B47%E6%9C%8829%E6%97%A5%E6%9C%89%E6%9B%B4%E6%96%B0%EF%BC%89">系统常驻菜单栏图标消失问题的解决（下方2025年7月29日有更新）</a></li>
</ul>
</li>
<li><a href="#beta-4-2025%E5%B9%B47%E6%9C%8829%E6%97%A5%E6%9B%B4%E6%96%B0">Beta 4 2025年7月29日更新</a>
<ul>
<li><a href="#%E7%B3%BB%E7%BB%9F%E5%B8%B8%E9%A9%BB%E8%8F%9C%E5%8D%95%E6%A0%8F%E5%9B%BE%E6%A0%87%E6%B6%88%E5%A4%B1%E9%97%AE%E9%A2%98%E7%9A%84%E8%A7%A3%E5%86%B3%EF%BC%88%E6%9B%B4%E6%96%B0%E7%89%88%EF%BC%89">系统常驻菜单栏图标消失问题的解决（更新版）</a></li>
</ul>
</li>
</ul>
<p>我是从beta 4开始当成主力机使用的，因此从beta 4开始记录。之后会不定期更新。</p>
<h2><a id="beta-4" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>Beta 4</h2>
<h3><a id="%E5%91%BD%E4%BB%A4system-profiler%E7%9A%84%E5%8F%82%E6%95%B0%E5%8F%91%E7%94%9F%E6%94%B9%E5%8F%98" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>命令system_profiler的参数发生改变</h3>
<p>之前要查看USB设备，需要使用</p>
<pre class="line-numbers"><code class="language-bash">system_profiler SPUSBDataType
</code></pre>
<p>但是新系统中，参数改变了，变成了<code>SPUSBHostDataType</code>。并且输出的结果中的属性也有了变化，需要进行对应的修改。</p>
<h3><a id="%E5%BA%94%E7%94%A8%E4%BC%B4%E9%9A%8F%E7%B3%BB%E7%BB%9F%E5%90%AF%E5%8A%A8%E7%9A%84%E6%96%B9%E5%BC%8F%EF%BC%8C%E4%B8%8D%E5%90%8C%E7%9A%84%E6%96%B9%E6%B3%95%E5%9C%A8%E8%AE%BE%E7%BD%AE%E4%B8%AD%E6%98%BE%E7%A4%BA%E4%B8%8D%E5%90%8C" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>应用伴随系统启动的方式，不同的方法在设置中显示不同</h3>
<p>如果要应用伴随系统启动，现在有两种方式：</p>
<ol>
<li>使用Login Item应用，注册一个Login Item辅助程序，系统启动它之后，由它来调用主应用。</li>
<li>直接在主应用中调用SMAppService.mainApp.register()。</li>
</ol>
<p>方法1是很早就有的方式。方法2是后来新增的方式。不过在新系统中，如果你使用的是方法1，那么在设置的启动项中，只有后台有主应用的名字，系统自动其中中不会有。如果是使用的方法2，那么启动项和后台中都会有主应用的名字。</p>
<p>两种方式都可以成功伴随系统启动。</p>
<h3><a id="%E7%B3%BB%E7%BB%9F%E5%B8%B8%E9%A9%BB%E8%8F%9C%E5%8D%95%E6%A0%8F%E5%9B%BE%E6%A0%87%E6%B6%88%E5%A4%B1%E9%97%AE%E9%A2%98%E7%9A%84%E8%A7%A3%E5%86%B3%EF%BC%88%E4%B8%8B%E6%96%B92025%E5%B9%B47%E6%9C%8829%E6%97%A5%E6%9C%89%E6%9B%B4%E6%96%B0%EF%BC%89" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>系统常驻菜单栏图标消失问题的解决（下方2025年7月29日有更新）</h3>
<p>新系统中，我发现有的应用的菜单栏常驻图标会消失。进一步调查我发现，如果你的菜单栏常驻图标中使用attributedTitle，并且重复设置了相同的值，常驻图标就会消失。</p>
<blockquote>
<p>临时解决方案是，每次更改之前比较值是否发生了改变，变了再重新设置。</p>
</blockquote>
<h2><a id="beta-4-2025%E5%B9%B47%E6%9C%8829%E6%97%A5%E6%9B%B4%E6%96%B0" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>Beta 4 2025年7月29日更新</h2>
<h3><a id="%E7%B3%BB%E7%BB%9F%E5%B8%B8%E9%A9%BB%E8%8F%9C%E5%8D%95%E6%A0%8F%E5%9B%BE%E6%A0%87%E6%B6%88%E5%A4%B1%E9%97%AE%E9%A2%98%E7%9A%84%E8%A7%A3%E5%86%B3%EF%BC%88%E6%9B%B4%E6%96%B0%E7%89%88%EF%BC%89" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>系统常驻菜单栏图标消失问题的解决（更新版）</h3>
<p>上面的临时解决方案虽然在系统运行时可以解决问题，但是一旦系统睡眠，唤醒之后还是可能出现同样的问题。经过进一步研究，我发现了更好的解决方案：</p>
<p>假设你的代码是类似这种</p>
<pre class="line-numbers"><code class="language-swift">class AppDelegate: NSObject, NSApplicationDelegate {
  private var statusItem: NSStatusItem?

  private func setupMenubarTray() {
    let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    self.statusItem = statusItem

    guard let button = statusItem.button else {
      fatalError()
    }
    
    // 其他代码
  }
}
</code></pre>
<p>那么将代码改成</p>
<pre class="line-numbers"><code class="language-swift">class AppDelegate: NSObject, NSApplicationDelegate {
  private var statusItem: NSStatusItem?

  private func setupMenubarTray() {
    if self.statusItem == nil {
      self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    }

    guard let button = self.statusItem?.button else {
      fatalError()
    }
    
     // 其他代码
  }
}
</code></pre>
<blockquote>
<p>我想问题应该是出在<code>NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)</code>这个函数。它可能没有正确的释放。导致同时存在多个实例了。</p>
</blockquote>
<h2><a id="beta-5-2025%E5%B9%B48%E6%9C%887%E6%97%A5%E6%9B%B4%E6%96%B0" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>Beta 5 2025年8月7日更新</h2>
<p>Beta 5修正了MacCatalyst下.fullScreenCover的显示问题。上一版，会透出后面视图的内容。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[不要使用NSDecimalNumber.intValue]]></title>
    <link href="https://zhaoxin.pro/technology/17502372934060.html"/>
    <updated>2025-06-18T17:01:33+08:00</updated>
    <id>https://zhaoxin.pro/technology/17502372934060.html</id>
    <content type="html"><![CDATA[
<p>今天遇到了一件怪事。函数返回值始终是0，但是中间计算过程又没啥问题。</p>
<p>最终判定，就是在计算百分比时将Decimal计算的百分比，通过（decimal as NSDecimalNumber).intValue进行输出时出错的。</p>
<p>深入分析具体的原因，是因为Int的精度不足以转换NSDecimalNumber，所以就自动设置为0了。这个对于普通人来说，可能很难理解。毕竟结果也才46，怎么说Int的精度不足呢？但是事实就是如此，你可以简单地认为就是46后面的小数点之后的位数过多了。</p>
<p>解决的办法有有两个，一种是直接使用.doubleValue，然后取整。</p>
<pre class="line-numbers"><code class="language-swift">let intValue = Int(nsDecimalNumber.doubleValue)
</code></pre>
<p>另一种则比较复杂，使用了专门的rounding函数。</p>
<pre class="line-numbers"><code class="language-swift">let rounded = nsDecimalNumber.rounding(accordingToBehavior: NSDecimalNumberHandler(
    roundingMode: .plain,
    scale: 0,
    raiseOnExactness: false,
    raiseOnOverflow: false,
    raiseOnUnderflow: false,
    raiseOnDivideByZero: true
))
let intValue = rounded.intValue
</code></pre>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[SwiftUI重复刷新的问题可能找到了]]></title>
    <link href="https://zhaoxin.pro/technology/17489028926615.html"/>
    <updated>2025-06-03T06:21:32+08:00</updated>
    <id>https://zhaoxin.pro/technology/17489028926615.html</id>
    <content type="html"><![CDATA[
<p>我：<br />
按照SwiftUI的刷新方式，如果有两个@State，然后在一个函数中分别为这两个@State对应的属性赋值，SwiftUI对应的View是会自动刷新1次，还是两次？</p>
<p>Claude Sonnet 4（C）：<br />
在SwiftUI中，如果你在一个函数中同时修改两个@State属性，View只会刷新1次，而不是两次。</p>
<p>这是因为SwiftUI有一个重要的优化机制：批量更新（Batch Updates）。当多个状态变化发生在同一个运行循环中时，SwiftUI会将这些变化合并成一次更新。</p>
<h2><a id="%E7%AC%AC%E4%B8%80%E8%BD%AE" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>第一轮</h2>
<p>让我用一个例子来说明：</p>
<pre class="line-numbers"><code class="language-swift">struct ContentView: View {
    @State private var count1 = 0
    @State private var count2 = 0
    
    var body: some View {
        VStack {
            Text(&quot;Count1: \(count1)&quot;)
            Text(&quot;Count2: \(count2)&quot;)
            
            Button(&quot;同时更新两个值&quot;) {
                // 在同一个函数中修改两个@State
                count1 += 1
                count2 += 1
                
                print(&quot;函数执行完毕&quot;) // 这会在View刷新之前打印
            }
        }
    }
}
</code></pre>
<p>在这个例子中，点击按钮时：</p>
<pre class="line-numbers"><code class="language-plain_text">1.	count1和count2都会被修改
2.	SwiftUI会将这两个状态变化合并
3.	View只会重新渲染1次，同时反映两个值的变化
</code></pre>
<p>这种机制的好处是：</p>
<pre class="line-numbers"><code class="language-plain_text">•	性能优化：避免不必要的多次渲染
•	视觉连贯性：用户看到的是一次性的状态更新，而不是闪烁的分步更新
•	逻辑一致性：相关的状态变化作为一个整体呈现
</code></pre>
<p>如果你想要强制分开更新，可以使用DispatchQueue：</p>
<pre class="line-numbers"><code class="language-swift">Button(&quot;分开更新&quot;) {
    count1 += 1
    
    DispatchQueue.main.async {
        count2 += 1  // 这会导致第二次刷新
    }
}
</code></pre>
<p>但在绝大多数情况下，SwiftUI的默认批量更新机制是最佳选择。</p>
<h2><a id="%E7%AC%AC%E4%BA%8C%E8%BD%AE" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>第二轮</h2>
<p>我：<br />
如果是@Observable这种，也会是批量刷新吗？</p>
<p>C：<br />
是的，**@Observable也会进行批量刷新！**这是SwiftUI观察系统的一个核心特性，不仅适用于@State，也适用于@Observable。</p>
<p>让我用例子来演示：</p>
<pre class="line-numbers"><code class="language-swift">@Observable
class UserData {
    var name = &quot;&quot;
    var age = 0
    var email = &quot;&quot;
}

struct ContentView: View {
    @State private var userData = UserData()
    
    var body: some View {
        VStack {
            Text(&quot;姓名: \(userData.name)&quot;)
            Text(&quot;年龄: \(userData.age)&quot;)
            Text(&quot;邮箱: \(userData.email)&quot;)
            
            Button(&quot;同时更新多个属性&quot;) {
                // 在同一个函数中修改多个@Observable属性
                userData.name = &quot;张三&quot;
                userData.age = 25
                userData.email = &quot;zhangsan@example.com&quot;
                
                print(&quot;所有属性更新完毕&quot;) // 这会在View刷新之前打印
            }
        }
    }
}
</code></pre>
<p>在这个例子中，虽然修改了3个不同的属性，但<strong>View只会刷新1次</strong>。</p>
<h3><a id="observable%E7%9A%84%E6%89%B9%E9%87%8F%E6%9B%B4%E6%96%B0%E6%9C%BA%E5%88%B6" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>@Observable的批量更新机制</h3>
<p>@Observable使用了Swift的新观察系统（Observation framework），它具有以下特点：</p>
<pre class="line-numbers"><code class="language-plain_text">1.	**事务性更新：**多个属性变化会被合并成一个更新事务
2.	**智能依赖追踪：**只有被View实际使用的属性变化才会触发刷新
3.	**性能优化：**避免了不必要的多次渲染
</code></pre>
<h3><a id="%E6%9B%B4%E9%AB%98%E7%BA%A7%E7%9A%84%E4%BE%8B%E5%AD%90" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>更高级的例子</h3>
<pre class="line-numbers"><code class="language-swift">@Observable
class ShoppingCart {
    var items: [String] = []
    var totalPrice: Double = 0.0
    var discountApplied: Bool = false
    
    func addItem(_ item: String, price: Double) {
        // 这些修改会被批量处理
        items.append(item)
        totalPrice += price
        
        if totalPrice &gt; 100 {
            discountApplied = true
            totalPrice *= 0.9 // 应用10%折扣
        }
    }
}

struct ShoppingView: View {
    @State private var cart = ShoppingCart()
    
    var body: some View {
        VStack {
            Text(&quot;商品数量: \(cart.items.count)&quot;)
            Text(&quot;总价: \(cart.totalPrice, specifier: &quot;%.2f&quot;)&quot;)
            Text(&quot;折扣: \(cart.discountApplied ? &quot;已应用&quot; : &quot;未应用&quot;)&quot;)
            
            Button(&quot;添加昂贵商品&quot;) {
                cart.addItem(&quot;iPhone&quot;, price: 120.0)
                // 即使函数内部修改了多个属性，View也只刷新1次
            }
        }
    }
}
</code></pre>
<h3><a id="%E5%85%B3%E9%94%AE%E8%A6%81%E7%82%B9" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>关键要点</h3>
<pre class="line-numbers"><code class="language-plain_text">•	**批量更新是自动的：**无需特殊配置
•	**跨属性合并：**同一对象的多个属性变化会合并
•	**运行循环边界：**更新会在当前运行循环结束时统一执行
•	**性能友好：**这种机制显著提升了UI响应性能
</code></pre>
<p>所以无论是@State、@Observable，还是其他SwiftUI的状态管理方式，都会享受到这种批量刷新的优化机制。</p>
<h2><a id="%E7%AC%AC%E4%B8%89%E8%BD%AE" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>第三轮</h2>
<p>是的，@ObservedObject确实在性能上相对较差，主要原因是它基于较老的ObservableObject协议和Combine框架。关于批量刷新，情况比较复杂：</p>
<h3><a id="observedobject%E7%9A%84%E6%89%B9%E9%87%8F%E5%88%B7%E6%96%B0%E8%A1%8C%E4%B8%BA" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>@ObservedObject的批量刷新行为</h3>
<p>@ObservedObject<strong>理论上也支持批量刷新</strong>，但实际效果取决于具体实现：</p>
<pre class="line-numbers"><code class="language-swift">class UserDataOld: ObservableObject {
    @Published var name = &quot;&quot;
    @Published var age = 0
    @Published var email = &quot;&quot;
}

struct ContentView: View {
    @ObservedObject var userData = UserDataOld()
    
    var body: some View {
        VStack {
            Text(&quot;姓名: \(userData.name)&quot;)
            Text(&quot;年龄: \(userData.age)&quot;)
            Text(&quot;邮箱: \(userData.email)&quot;)
            
            Button(&quot;同时更新多个属性&quot;) {
                userData.name = &quot;李四&quot;
                userData.age = 30
                userData.email = &quot;lisi@example.com&quot;
                // 这通常会触发1次刷新，但不如@Observable稳定
            }
        }
    }
</code></pre>
<h3><a id="observedobject%E7%9A%84%E6%80%A7%E8%83%BD%E9%97%AE%E9%A2%98" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>@ObservedObject的性能问题</h3>
<h4><a id="1%E8%BF%87%E5%BA%A6%E5%88%B7%E6%96%B0%E9%97%AE%E9%A2%98" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>1. 过度刷新问题</h4>
<pre class="line-numbers"><code class="language-swift">class ProblematicModel: ObservableObject {
    @Published var importantData = &quot;&quot;
    @Published var trivialData = &quot;&quot; // 即使只是这个改变
    
    func updateTrivialData() {
        trivialData = &quot;无关紧要的数据&quot;
        // 整个View都会刷新，即使View可能不关心这个属性
    }
}
</code></pre>
<h4><a id="2%E6%97%A0%E6%B3%95%E7%B2%BE%E7%A1%AE%E8%BF%BD%E8%B8%AA%E4%BE%9D%E8%B5%96" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>2. 无法精确追踪依赖</h4>
<pre class="line-numbers"><code class="language-swift">struct InefficientView: View {
    @ObservedObject var model = ProblematicModel()
    
    var body: some View {
        VStack {
            Text(&quot;重要数据: \(model.importantData)&quot;)
            // 即使这个View只使用importantData
            // 但model中任何@Published属性变化都会导致刷新
        }
    }
}
</code></pre>
<h3><a id="%E4%B8%8Eobservable%E7%9A%84%E5%AF%B9%E6%AF%94" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>与@Observable的对比</h3>
<pre class="line-numbers"><code class="language-swift">// 老式方式 - @ObservedObject
class OldUserData: ObservableObject {
    @Published var profile = &quot;&quot;
    @Published var settings = &quot;&quot;
    @Published var cache = &quot;&quot; // 改变这个会刷新整个View
}

// 新式方式 - @Observable  
@Observable
class NewUserData {
    var profile = &quot;&quot;
    var settings = &quot;&quot;
    var cache = &quot;&quot; // 只有被使用的属性改变才会刷新View
}

struct ComparisonView: View {
    @ObservedObject var oldData = OldUserData()
    @State private var newData = NewUserData()
    
    var body: some View {
        VStack {
            // 使用oldData时，任何@Published属性变化都会刷新
            Text(&quot;老数据: \(oldData.profile)&quot;)
            
            // 使用newData时，只有profile变化才会刷新这个Text
            Text(&quot;新数据: \(newData.profile)&quot;)
            
            Button(&quot;更新缓存&quot;) {
                oldData.cache = &quot;新缓存&quot; // 会刷新整个View
                newData.cache = &quot;新缓存&quot; // 不会刷新View，因为没有Text使用cache
            }
        }
    }
}
</code></pre>
<h3><a id="%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E5%BB%BA%E8%AE%AE" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>性能优化建议</h3>
<h4><a id="1%E8%BF%81%E7%A7%BB%E5%88%B0-observable" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>1. 迁移到@Observable</h4>
<pre class="line-numbers"><code class="language-swift">// 推荐的现代方式
@Observable
class ModernUserData {
    var name = &quot;&quot;
    var age = 0
    var email = &quot;&quot;
}
</code></pre>
<h4><a id="2%E5%A6%82%E6%9E%9C%E5%BF%85%E9%A1%BB%E4%BD%BF%E7%94%A8-observedobject%EF%BC%8C%E6%89%8B%E5%8A%A8%E6%8E%A7%E5%88%B6%E6%9B%B4%E6%96%B0" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>2. 如果必须使用@ObservedObject，手动控制更新</h4>
<pre class="line-numbers"><code class="language-swift">class OptimizedModel: ObservableObject {
    // 只对重要数据使用@Published
    @Published var criticalData = &quot;&quot;
    
    // 不重要的数据不使用@Published
    private var _cacheData = &quot;&quot;
    var cacheData: String {
        get { _cacheData }
        set { 
            _cacheData = newValue
            // 不会触发View刷新
        }
    }
}
</code></pre>
<h3><a id="%E6%80%BB%E7%BB%93" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>总结</h3>
<pre class="line-numbers"><code class="language-plain_text">•	**@ObservedObject：**支持批量刷新，但容易过度刷新，性能较差
•	**@Observable：**精确的依赖追踪，更好的批量刷新，性能优秀
•	**建议：**新项目优先使用@Observable，老项目逐步迁移
</code></pre>
<p>@Observable是SwiftUI未来的方向，它解决了@ObservedObject的大部分性能问题。</p>
<h2><a id="%E7%AC%AC%E5%9B%9B%E8%BD%AE" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>第四轮</h2>
<p>我：<br />
我注意到，你在使用@Observable的类时，使用了@State，@State private var newData = NewUserData()，而不是let或者var，这是为什么呢？</p>
<p>C：<br />
这是一个非常好的观察！你提到的这个细节很重要，让我详细解释一下：</p>
<h3><a id="%E4%B8%BA%E4%BB%80%E4%B9%88%E4%BD%BF%E7%94%A8state%E8%80%8C%E4%B8%8D%E6%98%AFlet%E6%88%96var" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>为什么使用@State而不是let或var</h3>
<h4><a id="1-observable%E9%9C%80%E8%A6%81%E8%A2%ABswiftui%E8%A7%82%E5%AF%9F%E7%B3%BB%E7%BB%9F%E8%BF%BD%E8%B8%AA" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>1. @Observable需要被SwiftUI观察系统追踪</h4>
<pre class="line-numbers"><code class="language-swift">@Observable
class UserData {
    var name = &quot;&quot;
    var age = 0
}

struct ContentView: View {
    // ❌ 错误方式 - 不会触发View更新
    let userData = UserData()
    
    // ❌ 错误方式 - 也不会触发View更新  
    var userData = UserData()
    
    // ✅ 正确方式 - 会触发View更新
    @State private var userData = UserData()
    
    var body: some View {
        VStack {
            Text(&quot;姓名: \(userData.name)&quot;)
            
            Button(&quot;更新姓名&quot;) {
                userData.name = &quot;张三&quot;
                // 只有使用@State时，这个变化才会触发View刷新
            }
        }
    }
}
</code></pre>
<h4><a id="2-swiftui%E7%9A%84%E8%A7%82%E5%AF%9F%E6%9C%BA%E5%88%B6%E9%9C%80%E8%A6%81%E5%8C%85%E8%A3%85%E5%99%A8" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>2. SwiftUI的观察机制需要包装器</h4>
<p>@Observable虽然让类具备了被观察的能力，但在SwiftUI中，<strong>View仍然需要通过属性包装器来建立观察关系：</strong></p>
<pre class="line-numbers"><code class="language-swift">@Observable
class Counter {
    var value = 0
    
    func increment() {
        value += 1
    }
}

struct CounterView: View {
    // @State告诉SwiftUI：&quot;请观察这个对象的变化&quot;
    @State private var counter = Counter()
    
    var body: some View {
        VStack {
            Text(&quot;计数: \(counter.value)&quot;)
            
            Button(&quot;增加&quot;) {
                counter.increment()
                // 因为用了@State，View会自动刷新
            }
        }
    }
}
</code></pre>
<h4><a id="3%E4%B8%8D%E5%90%8C%E5%9C%BA%E6%99%AF%E4%B8%8B%E7%9A%84%E7%94%A8%E6%B3%95%E5%AF%B9%E6%AF%94" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>3. 不同场景下的用法对比</h4>
<pre class="line-numbers"><code class="language-swift">@Observable
class AppState {
    var isLoggedIn = false
    var username = &quot;&quot;
}

struct LoginView: View {
    // 场景1: View拥有并管理对象的生命周期
    @State private var appState = AppState()
    
    var body: some View {
        VStack {
            if appState.isLoggedIn {
                Text(&quot;欢迎, \(appState.username)&quot;)
            } else {
                loginForm
            }
        }
    }
    
    var loginForm: some View {
        Button(&quot;登录&quot;) {
            appState.username = &quot;用户&quot;
            appState.isLoggedIn = true
        }
    }
}

struct SharedDataView: View {
    // 场景2: 对象从外部传入
    let sharedAppState: AppState
    
    var body: some View {
        VStack {
            Text(&quot;共享状态: \(sharedAppState.username)&quot;)
            // 注意：这里不会自动刷新，因为没有使用属性包装器
        }
    }
}
</code></pre>
<h4><a id="4%E6%AD%A3%E7%A1%AE%E7%9A%84%E5%85%B1%E4%BA%AB%E6%95%B0%E6%8D%AE%E6%96%B9%E5%BC%8F" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>4. 正确的共享数据方式</h4>
<p>如果需要在多个View之间共享@Observable对象：</p>
<pre class="line-numbers"><code class="language-swift">@Observable
class SharedAppState {
    var theme = &quot;light&quot;
    var language = &quot;zh&quot;
}

struct RootView: View {
    @State private var appState = SharedAppState()
    
    var body: some View {
        NavigationView {
            VStack {
                SettingsView(appState: appState)
                ProfileView(appState: appState)
            }
        }
    }
}

struct SettingsView: View {
    @Bindable var appState: SharedAppState  // 用于接收外部传入的@Observable对象
    
    var body: some View {
        VStack {
            Text(&quot;当前主题: \(appState.theme)&quot;)
            Button(&quot;切换主题&quot;) {
                appState.theme = appState.theme == &quot;light&quot; ? &quot;dark&quot; : &quot;light&quot;
            }
        }
    }
}
</code></pre>
<h3><a id="%E6%80%BB%E7%BB%93" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>总结</h3>
<pre class="line-numbers"><code class="language-plain_text">•	**@State：**告诉SwiftUI”我拥有这个对象，请观察它的变化”
•	**@Bindable：**告诉SwiftUI”这个对象是从外部传入的，请观察它的变化”
•	**let/var：**普通属性，SwiftUI不会观察其变化
</code></pre>
<p>使用@State是为了让SwiftUI知道需要观察这个@Observable对象，从而在对象属性发生变化时自动更新View。这是SwiftUI观察系统的要求，而不是@Observable本身的限制。</p>
<h2><a id="%E7%AC%AC%E4%BA%94%E8%BD%AE" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>第五轮</h2>
<p>我：<br />
你上面的说法有一部分并不准确。在苹果的官方文档中，<a href="https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro/%EF%BC%8C%E8%8B%B9%E6%9E%9C%E7%94%A8%E6%9D%A5%E6%9B%BF%E4%BB%A3@StateObject%E7%9A%84%E6%98%AF@State%EF%BC%8C%E7%94%A8%E6%9D%A5%E6%9B%BF%E4%BB%A3@ObservedObject%E7%9A%84%EF%BC%8C%E5%B0%B1%E6%98%AFvar%E3%80%82">https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro/，苹果用来替代@StateObject的是@State，用来替代@ObservedObject的，就是var。</a></p>
<p>C：<br />
你说得完全正确！我之前的解释确实有不准确的地方。感谢你提供的官方文档链接，让我重新准确地解释：</p>
<h3><a id="%E6%AD%A3%E7%A1%AE%E7%9A%84%E8%BF%81%E7%A7%BB%E5%AF%B9%E5%BA%94%E5%85%B3%E7%B3%BB" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>正确的迁移对应关系</h3>
<p>根据苹果官方文档，从ObservableObject迁移到@Observable的对应关系是：</p>
<pre class="line-numbers"><code class="language-swift">// 旧的ObservableObject方式
class OldDataModel: ObservableObject {
    @Published var name = &quot;&quot;
}

struct OldView: View {
    @StateObject private var model = OldDataModel()  // 创建并拥有
    @ObservedObject var shared: OldDataModel        // 接收外部传入
}

// 新的@Observable方式  
@Observable
class NewDataModel {
    var name = &quot;&quot;
}

struct NewView: View {
    @State private var model = NewDataModel()  // ✅ 替代@StateObject
    var shared: NewDataModel                   // ✅ 替代@ObservedObject (普通var!)
}
</code></pre>
<h3><a id="%E5%85%B3%E9%94%AE%E5%8C%BA%E5%88%AB%E8%A7%A3%E9%87%8A" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>关键区别解释</h3>
<h4><a id="1-state%E6%9B%BF%E4%BB%A3-stateobject" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>1. @State替代@StateObject</h4>
<pre class="line-numbers"><code class="language-swift">struct OwnerView: View {
    // 创建并拥有对象的生命周期
    @State private var userData = UserData()
    
    var body: some View {
        UserDetailView(user: userData)
    }
}
</code></pre>
<h4><a id="2%E6%99%AE%E9%80%9A-var%E6%9B%BF%E4%BB%A3-observedobject" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>2. 普通var替代@ObservedObject</h4>
<pre class="line-numbers"><code class="language-swift">struct UserDetailView: View {
    // 接收外部传入的@Observable对象 - 就是普通var!
    var user: UserData
    
    var body: some View {
        VStack {
            Text(user.name)
            Button(&quot;更新&quot;) {
                user.name = &quot;新名字&quot;  // 这会自动触发View更新!
            }
        }
    }
}
</code></pre>
<h3><a id="%E4%B8%BA%E4%BB%80%E4%B9%88%E6%99%AE%E9%80%9Avar%E5%B0%B1%E8%83%BD%E5%B7%A5%E4%BD%9C%EF%BC%9F" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>为什么普通var就能工作？</h3>
<p>这是因为@Observable宏为类生成了观察机制，SwiftUI会自动检测到对@Observable对象的访问和修改：</p>
<pre class="line-numbers"><code class="language-swift">@Observable
class UserData {
    var name = &quot;&quot;
    var age = 0
}

struct DetailView: View {
    var userData: UserData  // 普通属性，无需@ObservedObject
    
    var body: some View {
        VStack {
            Text(userData.name)     // SwiftUI自动追踪这个访问
            Text(&quot;\(userData.age)&quot;)  // SwiftUI自动追踪这个访问
            
            Button(&quot;更新&quot;) {
                userData.name = &quot;新名字&quot;  // 自动触发刷新
                userData.age += 1        // 自动触发刷新
            }
        }
    }
}
</code></pre>
<h3><a id="%E5%AE%8C%E6%95%B4%E7%9A%84%E8%BF%81%E7%A7%BB%E7%A4%BA%E4%BE%8B" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>完整的迁移示例</h3>
<pre class="line-numbers"><code class="language-swift">// === 旧方式 ===
class OldShoppingCart: ObservableObject {
    @Published var items: [String] = []
    @Published var total: Double = 0
}

struct OldShoppingView: View {
    @StateObject private var cart = OldShoppingCart()
    
    var body: some View {
        OldCartDetailView(cart: cart)
    }
}

struct OldCartDetailView: View {
    @ObservedObject var cart: OldShoppingCart  // 需要@ObservedObject
    
    var body: some View {
        VStack {
            Text(&quot;商品数: \(cart.items.count)&quot;)
            Text(&quot;总价: \(cart.total)&quot;)
        }
    }
}

// === 新方式 ===
@Observable
class ShoppingCart {
    var items: [String] = []
    var total: Double = 0
}

struct ShoppingView: View {
    @State private var cart = ShoppingCart()  // @State替代@StateObject
    
    var body: some View {
        CartDetailView(cart: cart)
    }
}

struct CartDetailView: View {
    var cart: ShoppingCart  // 普通var替代@ObservedObject!
    
    var body: some View {
        VStack {
            Text(&quot;商品数: \(cart.items.count)&quot;)
            Text(&quot;总价: \(cart.total)&quot;)
            
            Button(&quot;添加商品&quot;) {
                cart.items.append(&quot;新商品&quot;)  // 自动触发更新
                cart.total += 10.0          // 自动触发更新
            }
        }
    }
}
</code></pre>
<p>感谢你的纠正！@Observable的强大之处就在于，接收外部传入的对象时只需要普通的var属性，SwiftUI会自动处理观察关系。</p>
<h2><a id="%E7%AC%AC%E5%85%AD%E8%BD%AE" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>第六轮</h2>
<p>我：<br />
我有一些疑惑，@StateObject和@ObservedObject似乎没有什么区别啊。</p>
<p>C：<br />
你的疑惑很正常！从表面上看，@StateObject和@ObservedObject确实很相似，但它们有一个<strong>关键的区别：对象的生命周期管理</strong>。</p>
<h3><a id="%E6%A0%B8%E5%BF%83%E5%8C%BA%E5%88%AB%EF%BC%9A%E8%B0%81%E6%8B%A5%E6%9C%89%E5%AF%B9%E8%B1%A1%EF%BC%9F" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>核心区别：谁拥有对象？</h3>
<p>@StateObject - “我创建并拥有这个对象”</p>
<pre class="line-numbers"><code class="language-swift">struct ParentView: View {
    @State private var counter = 0
    
    var body: some View {
        VStack {
            Text(&quot;父视图计数: \(counter)&quot;)
            
            // 每次counter变化，ChildView都会重新创建
            ChildView(parentCounter: counter)
            
            Button(&quot;增加父计数&quot;) {
                counter += 1  // 这会导致ChildView重建！
            }
        }
    }
}

class DataModel: ObservableObject {
    @Published var value = 0
    
    init() {
        print(&quot;DataModel创建了！&quot;)
    }
    
    deinit {
        print(&quot;DataModel销毁了！&quot;)
    }
}

struct ChildView: View {
    let parentCounter: Int
    
    // ❌ 错误用法 - 每次父视图刷新都会重新创建DataModel
    @ObservedObject private var wrongModel = DataModel()
    
    // ✅ 正确用法 - 无论父视图如何刷新，DataModel只创建一次
    @StateObject private var correctModel = DataModel()
    
    var body: some View {
        VStack {
            Text(&quot;父传入的值: \(parentCounter)&quot;)
            Text(&quot;子视图数据: \(correctModel.value)&quot;)
            
            Button(&quot;增加子数据&quot;) {
                correctModel.value += 1
            }
        }
    }
}
</code></pre>
<h3><a id="%E5%AE%9E%E9%99%85%E9%97%AE%E9%A2%98%E6%BC%94%E7%A4%BA" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>实际问题演示</h3>
<p>运行上面的代码，你会发现：</p>
<h4><a id="%E4%BD%BF%E7%94%A8observedobject%E7%9A%84%E9%97%AE%E9%A2%98%EF%BC%9A" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>使用@ObservedObject的问题：</h4>
<pre class="line-numbers"><code class="language-swift">struct ProblematicChildView: View {
    let parentCounter: Int
    @ObservedObject private var model = DataModel()  // ❌ 问题代码
    
    var body: some View {
        VStack {
            Text(&quot;数据: \(model.value)&quot;)
            Button(&quot;增加&quot;) {
                model.value += 1
            }
        }
    }
}
</code></pre>
<p>**问题：**每当父视图刷新时，DataModel()会被重新执行，创建新对象，之前的数据丢失！</p>
<h4><a id="%E4%BD%BF%E7%94%A8stateobject%E7%9A%84%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88%EF%BC%9A" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>使用@StateObject的解决方案：</h4>
<pre class="line-numbers"><code class="language-swift">struct CorrectChildView: View {
    let parentCounter: Int
    @StateObject private var model = DataModel()  // ✅ 正确代码
    
    var body: some View {
        VStack {
            Text(&quot;数据: \(model.value)&quot;)
            Button(&quot;增加&quot;) {
                model.value += 1  // 数据会被保持，不会因父视图刷新而丢失
            }
        }
    }
}
</code></pre>
<h3><a id="%E5%AE%8C%E6%95%B4%E7%9A%84%E5%AF%B9%E6%AF%94%E7%A4%BA%E4%BE%8B" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>完整的对比示例</h3>
<pre class="line-numbers"><code class="language-swift">class Timer: ObservableObject {
    @Published var count = 0
    private var timer: Foundation.Timer?
    
    init() {
        print(&quot;⚠️ Timer对象创建&quot;)
        startTimer()
    }
    
    deinit {
        print(&quot;💥 Timer对象销毁&quot;)
        timer?.invalidate()
    }
    
    private func startTimer() {
        timer = Foundation.Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            self.count += 1
        }
    }
}

struct MainView: View {
    @State private var refreshTrigger = 0
    
    var body: some View {
        VStack(spacing: 20) {
            Text(&quot;主视图刷新触发器: \(refreshTrigger)&quot;)
            
            // 每次点击都会导致子视图重建
            BadTimerView(trigger: refreshTrigger)
            GoodTimerView(trigger: refreshTrigger)
            
            Button(&quot;触发父视图刷新&quot;) {
                refreshTrigger += 1
            }
        }
    }
}

struct BadTimerView: View {
    let trigger: Int
    @ObservedObject private var timer = Timer()  // ❌ 每次父视图刷新都重新创建
    
    var body: some View {
        VStack {
            Text(&quot;错误方式 - 计时: \(timer.count)&quot;)
            Text(&quot;触发值: \(trigger)&quot;)
        }
        .border(Color.red)
    }
}

struct GoodTimerView: View {
    let trigger: Int
    @StateObject private var timer = Timer()  // ✅ 只创建一次，状态保持
    
    var body: some View {
        VStack {
            Text(&quot;正确方式 - 计时: \(timer.count)&quot;)
            Text(&quot;触发值: \(trigger)&quot;)
        }
        .border(Color.green)
    }
}
</code></pre>
<h3><a id="%E4%BD%BF%E7%94%A8%E8%A7%84%E5%88%99%E6%80%BB%E7%BB%93" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>使用规则总结</h3>
<pre class="line-numbers"><code class="language-swift">struct MyView: View {
    // ✅ 我创建并拥有这个对象
    @StateObject private var myData = MyDataModel()
    
    // ✅ 这个对象是从外部传入的
    @ObservedObject var sharedData: SharedDataModel
    
    var body: some View {
        ChildView(data: myData)  // 传递给子视图
    }
}

struct ChildView: View {
    // ✅ 接收从父视图传来的对象
    @ObservedObject var data: MyDataModel
    
    var body: some View {
        Text(&quot;\(data.value)&quot;)
    }
}
</code></pre>
<h4><a id="%E7%AE%80%E5%8D%95%E8%AE%B0%E5%BF%86%E6%B3%95%EF%BC%9A" class="anchor" aria-hidden="true"><span class="octicon octicon-link"></span></a>简单记忆法：</h4>
<pre class="line-numbers"><code class="language-plain_text">•	**@StateObject：**我创建 = 我负责
•	**@ObservedObject：**别人创建 = 我只观察
</code></pre>
<p>这就是为什么在迁移到@Observable时，@StateObject对应@State（创建并拥有），@ObservedObject对应普通var（接收外部对象）。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[@State在初始化函数中的特殊性]]></title>
    <link href="https://zhaoxin.pro/technology/17416521091912.html"/>
    <updated>2025-03-11T08:15:09+08:00</updated>
    <id>https://zhaoxin.pro/technology/17416521091912.html</id>
    <content type="html"><![CDATA[
<p>在<a href="17416513703708.html">原来Swift的Sheet里还不能这么做</a>中，提到也可以使用初始化函数。但是我在修改使用为初始化函数之后，代码运行却不符合预期。于是我询问AI：</p>
<blockquote>
<p>使用初始化函数的方式之后，始终显示的是xtTransUnits.isEmpty == true的分支，这是为什么呢？</p>
</blockquote>
<p>#AI回答<br />
问题出在初始化器的实现方式上。当前的实现使用了立即执行的闭包来设置 xtTransUnits，但因为 @State 属性在初始化器中的特殊性，这种方式并不会正确地设置初始值。</p>
<p>修改建议如下：</p>
<pre class="line-numbers"><code class="language-swift">struct AITranslationView: View {
    // ... 其他属性 ...
    
    init(trees: [Tree&lt;IDTree&gt;]) {
        self.trees = trees
        let allUnits = Xliff_Tool.getXTTransUnits(from: trees)
        _xtTransUnits = State(initialValue: allUnits.filter { $0.target == nil || $0.target?.isEmpty == true })
    }
}
</code></pre>
<p>关键修改点：</p>
<ol>
<li>使用 _xtTransUnits = State(initialValue: ...) 来正确初始化 @State 属性</li>
<li>移除了立即执行闭包，改为直接赋值</li>
<li>保持过滤逻辑不变</li>
</ol>
<p>这样修改后，xtTransUnits 将被正确初始化，视图也会显示正确的分支。</p>

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[原来Swift的Sheet里还不能这么做]]></title>
    <link href="https://zhaoxin.pro/technology/17416513703708.html"/>
    <updated>2025-03-11T08:02:50+08:00</updated>
    <id>https://zhaoxin.pro/technology/17416513703708.html</id>
    <content type="html"><![CDATA[
<p>今天在调试一个应用崩溃的问题的时候，发现有个sheet弹出后，被反复执行了多次。于是询问AI怎么回事。</p>
<p>原始的代码是这样的</p>
<pre class="line-numbers"><code class="language-swift">.sheet(isPresented: $showAITranslationGuideView) {
  let xtTransUnits: [XTTransUnit] = {
    let xtTransUnits = Xliff_Tool.getXTTransUnits(from: trees)
    return xtTransUnits.filter { $0.target == nil || $0.target?.isEmpty == true }
  }()

  AITranslationView(xtTransUnits: xtTransUnits)
}
</code></pre>
<p>询问AI啥原因，AI是这么回答的。</p>
<blockquote>
<p>.sheet(isPresented: $showAITranslationGuideView)为什么会多次执行？</p>
</blockquote>
<p>在 .sheet(isPresented: $showAITranslationGuideView) 代码块中，每次 showAITranslationGuideView 状态变化时都会重新执行闭包内的代码。这里的问题是 xtTransUnits 的计算被放在了视图构建的闭包中，而不是在实际需要时才执行。</p>
<p>建议将这些逻辑移到 AITranslationView 的初始化或 onAppear 中。以下是修改建议：</p>
<pre class="line-numbers"><code class="language-swift">.sheet(isPresented: $showAITranslationGuideView) {
    AITranslationView(trees: trees)
}
</code></pre>
<p>然后在 AITranslationView 中处理数据：</p>
<pre class="line-numbers"><code class="language-swift">struct AITranslationView: View {
    let trees: [Tree&lt;IDTree&gt;]
    @State private var xtTransUnits: [XTTransUnit] = []
    
    var body: some View {
        // 你的视图内容
        .onAppear {
            xtTransUnits = getXTTransUnits(from: trees)
                .filter { $0.target == nil || $0.target?.isEmpty == true }
        }
    }
}
</code></pre>
<p>这样可以确保计算只在实际需要时执行一次。</p>
<blockquote>
<p>需要注意的是，AI的解释是对的，但是不是真正的原因。但是解法又同样是对的。只要移出这个初始化的代码之后，的确就不会重复计算了。</p>
<p>我认为真正原因是这样的。像.sheet(isPresented: $showAITranslationGuideView)的sheet，是在View初始化时就会被生成的。它是不变的。这就是为什么还有一个同名的但是使用Optional的sheet，那个才是动态的。但是我这里面的代码，在sheet使用前重新进行了计算。这导致原本应该只能是静态的sheet，变成了动态的，从而导致它从新被加载，而这种情形，其实是为定义的，因为这里不应该使用动态的。这个才是出错的真正原因。</p>
</blockquote>

]]></content>
  </entry>
  
</feed>
