肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

Finder快速预览使用扩展模式苹果没告诉你的事

Finder Quick Look

最近我给 Writer 的 Finder 快速预览增加了 Mermaid 支持。Writer 应用内本来就使用 WKWebView 预览 Markdown,Mermaid 也是通过本地 JavaScript 渲染的。既然应用里能显示,那么把同一套渲染代码放进 Quick Look,不就行了吗?

我一开始也是这么想的。

Writer 原来的 Quick Look 使用 data-based preview。扩展读取 Markdown,生成完整的 HTML,然后通过 QLPreviewReply 把 HTML 交给 Finder。标题、列表、表格、代码块这些静态内容都没问题,但 Mermaid 不一样。Mermaid 代码块不是一张现成的图片,需要 JavaScript 解析代码,再生成 SVG。

问题就是,Finder 拿到 HTML,不等于它会像 Safari 或 WKWebView 那样执行里面的 JavaScript。

所以我决定把 Quick Look 改成 view-based extension。也就是不再把一份 HTML 数据交给 Finder,而是由扩展自己提供一个 NSViewController,里面放一个 WKWebView

这下总该和 Writer 应用内的预览一样了吧?

我修改了 Quick Look 扩展以后,在 Finder 中预览 Mermaid 文档,看到的仍然是一个普通代码块。Mermaid 源码还在,流程图没有生成。

这很像是 JavaScript 没有执行。但继续检查之前,必须先确认一件更基础的事:Finder 调用的到底是不是我刚刚修改的扩展?

因为 /Applications 里还安装着旧版 Writer。旧版扩展本来就能把 Markdown 转成 HTML,只是会把 Mermaid 当作普通代码块。所以当时看到的结果并不能证明新代码失败了,只能证明 Finder 找到了某个 Writer Quick Look 扩展。

我删除了 /Applications 里的 Writer。删除之后,Finder 一度退回了系统的纯文本预览。这证明刚才把 Mermaid 显示成代码块的,的确是旧版 Writer。

之后我安装了包含新版 view-based Quick Look 扩展的测试版。Finder 不再显示纯文本,右上角也出现了“Open with Writer”。这说明新版扩展终于被调用了。

可这一次,预览窗口变成了一片空白。

所以这里其实有两个完全不同的问题。Mermaid 显示成代码块,是 Finder 调用了旧版 Writer。真正调用新版扩展之后,遇到的问题才是 WKWebView 白屏。

如果不先排除旧版扩展,我很可能会继续在 Mermaid 初始化代码里找问题。可实际上,前一个结果根本不是新代码生成的。

写一个什么都不做的 Quick Look

我临时创建了一个最小的 macOS 应用,只注册一种自定义文件类型,再给它加入一个最简单的 Quick Look view extension。

这个扩展不解析 Markdown,不读取主题,也没有 Mermaid。它只显示一个原生的 NSView,上面写着:

Quick Look view extension is working

安装之后,Finder 正确显示出了这个界面。

这说明 view-based Quick Look 本身没有问题。Finder 能加载扩展,扩展也能提供自己的视图。

不过这还不够。Writer 真正需要的是 JavaScript,不是一个静态的 NSTextField。于是我又把探针改成 WKWebView,加载一段完全内嵌的 HTML,并运行一小段 JavaScript,让页面显示:

JavaScript check: 6 × 7 = 42

结果,探针也变成了白色。

到这里,问题已经很清楚了。不是 Markdown,不是 Mermaid,也不是 Writer 的主题代码。只要在 Quick Look 扩展里换成 WKWebView,页面就出不来。

本地 JavaScript 为什么需要网络权限?

我查看了系统日志,终于看到了真正的错误:

Application does not have permission to communicate with network resources.
Invalid connection identifier (web process failed to launch)

这就很有意思了。

我加载的是 loadHTMLString,HTML 在内存里,JavaScript也在应用包里,没有 CDN,没有远程图片,甚至连一个 HTTP 请求都没有。你告诉我缺少网络权限?

可日志里写得很清楚,不是页面加载失败,而是 WebKit 的 Web Content 进程根本没有启动成功。

我于是给 Quick Look 扩展加入了:

<key>com.apple.security.network.client</key>
<true/>

重新编译,重新安装。

探针页面显示出来了,JavaScript 也成功计算出了 42

故障排除。

按照苹果文档,这个 entitlement 表示沙盒应用可以主动建立网络连接。从字面上看,一个完全离线的 WKWebView 不应该需要它。可在我当前使用的 macOS 版本和 Quick Look 扩展环境中,没有这个权限,WebKit 的内容进程就无法正常启动。

我不敢说所有 macOS 版本、所有 Quick Look 扩展都一定如此。但至少在实际测试中,这不是推测,也不是所谓“可能和沙盒有关”。系统日志和最小探针已经把因果关系摆在这里了。

苹果告诉你 network.client 是用来联网的,却没有告诉你,在 Quick Look 扩展里,即使只加载一段本地 HTML,它也可能决定 WKWebView 能不能活着启动。

这才是这次最坑的地方。

给了网络权限,文档不就可以偷偷联网了吗?

我本来不愿意给 Quick Look 增加网络权限,就是因为快速预览的内容来自用户文件。Markdown 里可以写远程图片,也可以写 HTML。如果直接让这些内容进入一个能够联网的 WKWebView,那么用户只是在 Finder 里按了一下空格,文档就可能向外部服务器发出请求。

这肯定不行。

但这里要分清两件事。扩展拥有网络 entitlement,是为了让 WebKit 的进程能够正常启动,并不意味着页面里的内容也必须获得网络访问能力。

Writer 的 Quick Look 页面加入了严格的 Content Security Policy:

default-src 'none';
img-src data: blob:;
script-src 'nonce-writer-mermaid';
connect-src 'none';
frame-src 'none';
object-src 'none';

也就是说,页面默认什么都不能加载。图片只允许 data:blob:,脚本只允许 Writer 自己注入并带有指定 nonce 的 Mermaid 脚本,网络连接直接设为 none

在 HTML 进入 WKWebView 之前,扩展还会清理 scriptiframeobject、事件属性和其他主动内容。普通 Markdown 的本地附件会转换成 data: URL,远程图片则继续显示占位内容。

这叫给 WebKit 启动进程的权限,不叫给 Markdown 文件自由上网的权限。两者看起来差不多,实际完全不是一回事。

还有一个容易被忽略的问题

改成 view-based extension 以后,Quick Look 的入口也变了。

原来的实现是生成一个 QLPreviewReply。现在则是由 QLPreviewingController 实现异步的 preparePreviewOfFile(at:),自己创建并维护 WKWebView

如果调用 loadHTMLString 以后立刻告诉 Finder“准备好了”,WebView 其实可能还没有完成导航。于是我让扩展等待 WKNavigationDelegate.didFinish,失败、导航失败或者 Web Content 进程终止时,也会结束等待并返回真正的错误。

这不是为了架构漂亮,而是 Quick Look 的生命周期和普通应用窗口不同。应用里的 WKWebView 一直活着,晚一点显示也没关系。Finder 是来向扩展要一份预览的,扩展什么时候回答“准备完成”,本身就是协议的一部分。

同时,Mermaid 的 Tiny 构建和许可证也必须真正进入 .appex 的资源目录。放在主应用里不算。Finder 运行的是扩展进程,它不会因为资源在 Writer.app 的另一个角落,就自动帮你找到。

最后怎么确认不是自我感觉良好?

这类问题只看 Xcode 构建成功没有意义。Quick Look 扩展编译成功,不等于 Finder 正在使用它。Finder 正在使用它,也不等于 WebKit 进程成功启动。WebKit 启动了,也不等于 JavaScript真的执行了。

我最后做了几层验证:

  1. 删除已安装的 Writer,确认 Finder 退回系统纯文本预览。
  2. 安装最小探针,确认原生 Quick Look view extension 能正常显示。
  3. 给探针加入 WKWebView 和 JavaScript,复现白屏。
  4. 加入 network.client 后,确认 JavaScript 成功算出 6 × 7 = 42
  5. 安装新的 Writer,在 Finder 中预览 Mermaid 流程图,确认显示的是生成后的 SVG,而不是 Mermaid 源码。
  6. 再预览我实际使用的 iCloud Markdown 文档,确认标题、段落、编号和列表都正常。
  7. Writer 和 Quick Look 两个 scheme 都重新构建,16 个 Mermaid 测试全部通过。

其中最有价值的不是后面的测试全部通过,而是那个什么都不做的探针。没有它,我很容易继续怀疑 Markdown 渲染器、主题样式、资源路径或者 Finder 缓存。它把几十个变量砍到只剩一个:Quick Look 扩展里的 WKWebView 为什么启动不了?

总结

这次遇到的坑可以归纳成三件事:

  1. data-based Quick Look 能显示 HTML,不代表它会按照应用内 WKWebView 的方式执行 JavaScript。
  2. view-based Quick Look 可以使用 WKWebView,也可以运行 Mermaid,但在实际测试的系统环境中,即使内容完全离线,扩展仍然需要 com.apple.security.network.client,否则 Web Content 进程可能直接启动失败。
  3. 给扩展网络 entitlement 之后,仍然要使用 CSP 和 HTML 清理限制文档内容。权限是让 WebKit 活下来,不是让预览文件随便联网。

所以,Finder 的快速预览使用扩展模式以后,运行起来的确可以和应用内预览很接近。但这个“很接近”不是把 WKWebView 塞进 NSViewController 就结束了。

真正的结论是:Quick Look 扩展可以执行 JavaScript,也可以渲染 Mermaid。之前那片空白不是平台不支持,而是 WebKit 的进程根本没有成功启动。苹果没告诉你的,恰恰就是这一点。

苹果表上SleepTap的Smart Alarm的Session在Stop之后,对应的Smart Stack却没有立刻刷新问题的追踪

watchOS

前天早上,SleepTap 的 Apple Watch Smart Alarm Session 把我叫醒了。系统界面给了两个选择,一个是 Open,一个是 Stop。以往我都是用Open,这次我特意没有选择Open,而是选择了 Stop。

Session 的确停了。但是我转动表冠打开 Smart Stack,发现 SleepTap 仍然显示 Wake Up。后来我手动进入 SleepTap,应用里已经没有 Wake Up 了。再回到 Smart Stack,它也变成了当晚 23:00 上床。

我当时认为,Stop 只是终止了 Session,其余步骤是在我进入应用后才执行的。这是我根据按下 Stop 后看到的现象作出的判断。但我并不确定,于是询问 AI。

Stop 到底做了什么?

AI和我讲,Apple Watch 的系统 Stop 会使 WKExtendedRuntimeSession 失效,然后调用:

extendedRuntimeSession(_:didInvalidateWith:error:)

SleepTap 收到这个回调以后,会检查 Session 是否正常结束、之前是否已经通过 notifyUser 叫醒用户,以及这次失效是不是应用自己触发的。如果这些条件都符合,就会把它当成用户点击了系统 Stop,自动记录起床。

记录起床以后,SleepTap 还会写入 HealthKit,清空本轮睡眠时间线,通知手机取消对应的起床闹钟,然后重新生成 Smart Stack 使用的数据,最后调用 reloadAllTimelines() 请求 WidgetKit 刷新。

这套后台路径本来就存在,并不是一定要打开应用。

不过代码里也的确有一个前台兜底。如果系统已经叫醒了用户,但是后台处理没有完成,那么用户下次进入 SleepTap 时,应用会再次检查状态并补记起床。这样一来,前天的现象其实存在两种解释:一种是后台处理没有完成,打开应用以后走了兜底;另一种是后台早就处理完了,只是 Smart Stack 还在显示旧内容,打开应用以后又请求了一次刷新。

只看 Smart Stack,根本分不清。于是我和AI说,那我再试一晚,明天不手动进应用,看看Smart Stack到底能不能更新。AI说,应该这么做,并且建议我分不同时段,多次观察,每次截图。

第二天的古法验证

今天的 Session 大约在凌晨 4:30 开始运行,我在 4:33 左右点击了 Stop。之后我一直没有打开 SleepTap。期间我多次观察,在几分钟后,半小时后都观察了,看到的都还是Wake Up。到了 5:33,我发现 Smart Stack 已经从 Wake Up 自动变成了 Bed Time 23:00

这至少证明了一件事:不打开应用,后台链路也能完成。前台兜底并不是必需的。

但我很快又产生了另一个判断:难道 extendedRuntimeSession(_:didInvalidateWith:error:) 花了接近一个小时才执行?

我询问AI,AI建议我去看健康应用,根据健康应用的起床时间来辅助判断。

我打开健康 App,里面记录的起床时间是 4:33,和点击 Stop 的时间基本一致。SleepTap 在后台记录起床时,使用的是当时的当前时间。如果这段代码真的拖到 5:33 才执行,健康里的起床时间也应该接近 5:33,而不会是 4:33。

也就是说,Session 的失效回调和起床记录在 4:33 左右就已经完成了。延迟的不是业务处理,而是 Smart Stack 的显示。

这不叫回调执行了一小时,这叫 WidgetKit 一小时以后才把结果画出来。

当然,我只是在 5:33 才发现它发生了变化,并不能证明它正好延迟了一个小时。实际刷新时间位于我上一次查看和 5:33 之间。不过可以确认的是,在这段时间里,HealthKit 里的数据已经是正确的,而 Smart Stack 仍然可能显示旧的 Wake Up

reloadAllTimelines() 这个名字很容易让人误会。看起来像是“立即重新加载所有 Timeline”,其实它只是向 WidgetKit 提交刷新请求。什么时候重新生成 Timeline,什么时候重绘,什么时候改变 Smart Stack 里的优先级,最终还是系统说了算。调用成功,不等于用户马上能看到结果。

这和我以前调查 Widget 问题时遇到的情况一样:共享数据已经变了,Widget 还在显示旧数据。很多时候不是数据没写进去,也不是业务代码没执行,而是 WidgetKit 继续保留了上一份 Timeline。

总结

这两天的调查结果已经很明确:

点击 Stop 后,Smart Alarm Session 会正常结束。SleepTap 的后台回调也会立即记录起床、写入 HealthKit,并更新 Smart Stack 使用的数据。打开应用只是兜底,不是完成这些操作的必要条件。

Smart Stack 最多可能在相当长的一段时间里继续显示旧的 Wake Up,但它最终会自行刷新。这是 WidgetKit 的可见状态延迟,不是 Session 延迟,也不是睡眠记录延迟。

一开始我根据界面判断后台代码没有执行。第二天又根据刷新时间判断回调执行了一个小时。两次判断都很合理,也都被健康数据推翻了。

所以,调查 Widget 问题,不能只看 Widget。界面是界面,数据是数据。把两者混在一起,肯定会追错方向。

处理copilot update更新缓慢问题获得的两个意外收获

大模型

今早发现copilot update更新的时候突然变慢了,速度只有10KB/s,于是跑去找ChatGPT问了问,为什么我终端已经设置了all_proxy,但是copilot update的时候却没有使用?

ChatGPT和我说,这是因为copilot这种有时候只会使用环境变量,我需要设置的是http_proxy和https_proxy。我设置好之后,更新了copilot,这下有500KB/s的速度了。

两个意外收获

再次打开copilot,对话后我发现,默认的模型变成sonnet-4.6了。我很意外!因为我是中国用户的原因,很久以前就没法使用任何Claude的模型,只能使用OpenAI的。之前我虽然听说如果VPN开启全局模式也能做到,但是我嫌麻烦,就没有弄。

此外,我还发现,现在copilot响应速度快了很多。之前我就觉得copilot的gpt-5.4比OpenAI Codex app里的慢很多。我还以为是copilot的问题。因为我问ChatGPT为啥copilot比Codex的慢,它还给我解释的头头是道,什么copilot搞了负载均衡,额外会加载其他内容,还有Codex可能是独享的,copilot的是共享的。不过从这次改变可以知道,其实原因并不是ChatGPT说的那样,就是因为原本的代理设置不被copilot支持导致的。我们应该警惕大模型的回复,有的时候它说的也不一定对。

中国开发者测试Google Play应用内购买的正确方法

Android

作为一名苦逼的中国开发者,我们要想测试发布到Google Play的商店,会收到中美双方的排挤。本文是我通过调查资料,最终总结的一个我认为最适合的方式,希望后来者可以少走弯路。

开发者账号

你需要一个开发者账户,一个测试账号。这是因为作为收钱的开发者账户,我们需要使用自己的真实信息,这样才能通过Google的开发者账户的验证。因此,开发者账户是中国账户,加上单Visa的信用卡即可,我使用的是招商银行的单Visa的全币卡。你还需要找银行要一下对账单,准备好身份证,这些都是Google验证时需要的。

测试账户

测试账户需要新建一个美国账户。感谢AI,ChatGPT告诉我,Google账户是根据你注册时的IP来判断你属于哪个国家的。所以,我们主要开启一个美国的VPN,然后注册就可以。特别的,注册时会要求手机号来验证。很多人担心能否用中国的手机号来验证,没问题的。因为这个只是验证你是真人,不作为判断国别的依据。因此,只要你注册时的IP是美国,以后也一直使用这个IP,就没问题。

此外,由于我们的测试账户只是为了测试。并且我们也没有美国的信用卡用来支付,所以我们的账户将不绑定任何信用卡。以免因为绑定了中国的信用卡而导致账户被风控。

测试应用

  1. 上传应用到Google Play
  2. 选择内部测试
  3. 然后在内部测试中,创建一个新的组,将测试账户的邮箱添加上去
  4. 复制定下的链接,用手机登录测试账户,打开这个链接
  5. 安装测试应用,就可以测试了。

其他问题

如果测试时,在选择“快速卡,一直通过”之后遇到问题,那是因为你的VPN选择的是规则,导致Play商店和Google服务的IP不一致。解决办法就是关掉应用,然后将VPN的规则改为全局,然后重新测试。这样就可以了。

最后

我们采用这种方式,是没办法的的办法。因为我们中国的开发者账户直接用来测试,经常会遇到“设备或者账户不支持支付”这类的问题。那样的话,我们就没有办法测试应用内购买是否正确了。

第三方应用使用DocumentGroup时,模仿Pages的工具栏

SwiftUI

最近,我在开发iOS的Markdown应用的时候遇到一个问题,应用是使用DocumentGroup开发的,但是默认会显示标题,从而导致留给工具栏的空间太小,工具栏的图标经常被折叠起来。

IMG_1181

我一开始只是想把后退去掉,换成三个点的菜单,然后去掉标题。结果发现后退去不掉。于是我就想看看苹果自己是如何做的。于是我打开了苹果自己的Notes和Numbers。不过Notes其实是有内部库的,于是主要参考的Numbers。

IMG_1184

我把Numbers截图给AI,说想要弄一个这个风格的。结果AI搞不定。它虽然设置了标题为空,但是标题始终显示。于是我有把这个描述给Grok,Grok说,这是因为DocumentGroup自动包含了一层Navigation,你需要将它禁用。结果我禁用了,还是不行。又去问Grok,它又说,这是因为iOS的某些版本,有bug,不执行这个操作。但是网友总结了三种办法,1,2,3,然后挨个试。结果第一种就可以了。

这个是我应用最终的效果。

IMG_1205

最核心的代码只需要如下的部分。

截屏2026-04-12 10.50.33

Swift Package应用签名与公证实战总结

Swift Packages

问题背景

fork 了一个第三方 macOS 应用,原 release 版本未签名,但提供了源码,为Swift Package格式,需要重新签名并公证后分发。


遇到的坑

1. Swift Package Manager 无法启用 Hardened Runtime

问题:使用 swift build 构建的可执行文件,无法通过签名添加 hardened runtime。公证时一直报错:

The executable does not have the hardened runtime enabled.

原因:Swift Package Manager 构建时不会启用 hardened runtime,签名时添加也不被认可。

解决:使用 XcodeGen 生成 Xcode 项目,在项目配置中启用。

2. Apple Development 证书无法公证

问题:最初只有 Apple Development 证书(用于开发调试),公证时报错:

The binary is not signed with a valid Developer ID certificate.

原因:公证必须使用 Developer ID Application 证书,不能用 Apple Development。

解决:在 Apple Developer 账号中添加 Developer ID Application 证书。

3. 时间戳问题

问题:签名缺少安全时间戳:

The signature does not include a secure timestamp.

解决:在签名配置中添加 --timestamp 参数。

4. get-task-allow entitlement 问题

问题:分发版本包含开发用 entitlement:

The executable requests the com.apple.security.get-task-allow entitlement.

解决:在 entitlements 配置中明确设置 com.apple.security.get-task-allow: false


经验教训

证书类型

证书类型 用途
Apple Development 开发调试,不能公证
Developer ID Application 分发和公证 ✅

签名配置要点

  1. Hardened Runtime - 必须启用
  2. 时间戳 - 必须添加
  3. entitlements - 分发版本禁止包含 get-task-allow
  4. 使用 Manual 签名 - 避免自动签名冲突

最佳实践(简单有效)

1. 使用 Xcode 项目而非 SPM

# 安装 XcodeGen
brew install xcodegen

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

# 生成项目
xcodegen generate

# 构建
xcodebuild -project YourApp.xcodeproj -scheme YourApp -configuration Release build

2. 验证签名

codesign -dvv YourApp.app/Contents/MacOS/YourApp

确认输出包含:

  • flags=0x10000(runtime) - hardened runtime 已启用
  • Timestamp=... - 有时间戳

3. 公证流程

# 1. 创建 zip
zip -r YourApp.zip YourApp.app

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

# 3. 等待完成
xcrun notarytool wait <submission-id> --apple-id "your@email.com" --password "password" --team-id "TEAM_ID"

# 4. 附加票据
xcrun stapler staple YourApp.app

总结

最简单有效的方式

  1. XcodeGen 生成 Xcode 项目(不要用纯 SPM)
  2. project.yml 中配置好签名和 hardened runtime
  3. xcodebuild 构建
  4. notarytool 公证

不要试图用 swift build + 手动签名的方式来处理需要公证的分发版本,SPM 的构建产物不兼容。

NavigationSplitView的一些奇技淫巧

SwiftUI

Sidebar按钮隐藏起来的正确做法

使用

.toolbar(removing: .sidebarToggle)

可以隐藏侧边栏切换按钮。但是注意,这个不是放在作用在NavigationSplitView上面的。而不是作用在sidebar的视图上面。即必须按照如下的方式使用:

NavigationSplitView(columnVisibility: $columnVisibility) {
    sidebar
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}

隐藏Sidebar的正确做法

NavigationSplitView是三栏的,但是如果你要使用两栏,则有两种方式。

使用sidebar

使用sidebar的好处是,sidebar可以隐藏,也可以显示。
使用sidebar+detail的方式。

不使用sidebar

如果你希望永远显示为两栏,不希望隐藏任何一个栏隐藏,那么使用此方式。

NavigationSplitView(columnVisibility: .constant(.doubleColumn)) {
    EmptyView()
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}

这个的重点在于,通过sidebar设置隐藏了切换sidebar按钮。同时,选择两栏模式,默认不显示sidebar。这样sidebar就无法被调用出来了。并且一直是两栏。

配合Settings使用

在使用Settings时候,包含sidebar的NavigationSplitView的sidebar按钮会导致出现问题。因此,必须使用上面的第二种方式,隐藏sidebar,并且使用一直存在的两栏。

macOS在工具栏上切换页面,TabView和Picker怎么选?

macOS

目前的SwiftUI,如果你希望通过工具栏的按钮直接切换标签,可以使用TabView,也可以使用Picker,不过在细节上,二者有所不同。

TabView

TabView是最简单的方式,只要你应用的根视图为TabView,那么你的系统架构会自动转成Navigation Tab Bar的方式,自动在工具栏显示Tab的标签。

不过这个方式有一种缺陷,就是应用的标题不会在工具栏上显示。如果你想强制限制,必须引入AppKit然后,覆盖Window的相应设置。

Picker

使用Picker的方式则更为友好。之需要在toolbar里添加Picker,然后使用segment样式,就可以获得同样的显示效果。然后使用Switch切换视图即可。

这么做不如直接使用TabView简单,但是可定制化强,并且不会影响标题的显示。

结论

如果不需要设置标题栏,使用TabView更为简便。否则就使用Picker,这样效果更好。