普通视图

发现新文章,点击刷新页面。
今天 — 2025年7月3日首页

Appstore开始新一轮备案号审查,看看你的产品被下架了么?

作者 iOS研究院
2025年7月3日 11:15

背景

最近Appstore出现大面积产品下架和重新上架的情况,正常来讲下架的产品通常都是违规导致的3.2f,也有其他几种非常规现象。比如,开发者账号到期未续费或者申诉成功解救

但是最近这批下架重新上架的面积及其普遍,加上粉丝留言。基本可以锁定是苹果主动下架了中国大陆区

在Appstore后将看到如下情况:

下架截图.png

App是啥?

自从新赛季开启App备案之后,苹果陆续同步了关于中国大陆区的校验工作,甚至在Appstore后台新增了专属于中国大陆区的ICP备案号填写。

其实App备案本身和域名备案是一样的,主要是确保开发者主体真实有效,约束大多数合规开发者的君子协议

Appstore后台的备案号也经常有Bug,所以才会导致这种局面。

应该做什么

对于中国大陆区的开发者来说,无论是个人还是公司主体都应该积极备案。如果说产品名称不确定的情况下,可以在提审时候不销售中国大陆区,同时着手备案相关事宜。这样既能保证产品的一个正常迭代,又能不受政策影响

不该做什么

既然可以备案名称而且不需要提供额外的软著或者其他证明材料,这时候就会有人抖机灵,耍小聪明。

首先就是备案竞品名称,抢注热品牌词,其实这种做法毫无意义。对备案的开发者来说,白瞎了一个备案服务号100元,以及一周左右的备案时间

其次备案顺利到手,也无法完成Appstore上架的需求。同时真正的品牌词持有者依旧可以正常备案,丝毫不受影响

特别说明:不要妄图乱填备案号的方式欺骗审核人员,已经有同行因为此问题触发了3.2f的彩蛋。所以,还是要老老实实做人,本本分分开发。

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# Pingpong和连连的平替,让AppStore收款无需新增持有人。

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

知识星球

更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」

昨天 — 2025年7月2日首页

小心处理器冻感冒了:一款使用 A18 Pro 芯片的 MacBook 即将到来

作者 马扶摇
2025年7月2日 17:31

当 iPad Pro 正忙着把 M 处理器塞进不到 5mm 厚的机身,努力向着「生产力」一步步进发的时候,隔壁的 MacBook 却计划着把 A 系列处理器放在笔记本里面。

库克这次不是要把 Mac 芯片偷到 iPad 上,而是要把 iPhone 芯片偷到 Mac 上

今天早些时候,MacRumors 发文声称:去年夏天,MacRumors 分析师 Aaron 就在 macOS 15 Sequoia 有关 Apple Intelligence 的后端代码中,发现了一台型号标识符(model identifier)为「Mac17,1」的新设备:

其中 Mac16,3 为 M4 iMac,Mac16,5 为 M4 Max 的 16 寸 MacBook Pro,等等|X @aaronp613

根据 MacRumors 的说法,它们后续通过其他途径分析和确证,最终认定这款标识符「Mac17,1」的新设备使用的处理器为 iPhone 16 Pro 上同规格的 A18 Pro 芯片,而非 M 系列 Apple Silicon 处理器。

与之相呼应的是,分析师郭铭錤也在 6 月末发表了一篇预测,指出苹果供应链巨头深圳长盈精密将会为一款「低价版 13 寸 MacBook 」提供外壳,这款低价版 MacBook 预计会使用 A18 Pro 处理器,以及类似 iMac 的多彩搭配。

此外,郭铭錤还在预测中对这款 A 处理器 MacBook 的投产时间做出了预估。根据预估的数据可以猜测:

– 新的 MacBook 预计于 2025 年 Q4 末或 2026 年 Q1 投入量产,比较可能的发布时间为 2026 年的春季发布会(一般是每年的三、四月)。

– 苹果计划在 2026 年让 MacBook 总出货量回到疫情期间时的约 2500 万台/年,因此可能会为这款新的 MacBook 做出一些有吸引力的定价,预期让它在 2026 年达到 500~700 万台的出货量。

相对较低的定价,既不属于 Air 也不属于 Pro 系列,以及别出心裁的处理器,当这三者结合在一起时,我们立刻就会发现它的历史相似性:这不就是曾经的 12 寸无印 MacBook 吗?

图|CNET

作为 MacBook 历史上无可争议的体型巅峰,发布于 2015 年的 12 寸 MacBook 为当年的笔记本市场带来了一场地震。凭借着最厚处仅 1.31 厘米的体型,它甚至直接革了同年 MacBook Air 的命。

然而它也是最毛病缠身的一代 MacBook,除了臭名昭著的初代蝶式键盘之外,无论是初期的 1.1GHz 酷睿 M-5Y31 还是后期的 1.4GHz i7-7Y75 处理器,始终都饱受「低频功耗下不去,高频性能上不来」的折磨,在 12 寸的小身板里一边发热一边卡顿,为那几年的英特尔受害者名单里再添一员。

因此,自从 2020 年苹果宣布与英特尔割席、推出自研的 Apple Silicon 以来,当大家见识到 M 系列处理器那堪称「遥遥领先」的能效比之后,要求苹果重新把 12 寸 MacBook 端出来的呼声就重新高涨起来。

新款 12 寸 MacBook 概念图,标志性的边到边键盘得以保留|X @BasicAppleGuy

而从目前已知的关于这款 A18 Pro 处理器的 MacBook 信息来看,似乎每一项指标都很适合用来复活这款曾经的苹果笔记本电脑便携性之王——非常可惜的是,这个可能性不大。

至于个中原因,倒也不难猜测:

自从 iPad 产品线用上 M 系列处理器、官方推出带触控版的 Smart Keyboard 配件、以及面积上涨到 13 寸以来,「超便携的 MacBook」和「超大屏的 iPad Pro」就一直处在互相打架的位置上。如果不是 iPadOS 与 macOS 泾渭分明的区隔,两者互相影响销量几乎是必然的结果。

而此时我们将所有在售的 MacBook(绿色)与 iPad(蓝色)产品线所能涵盖的屏幕面积拉一个表格,就能看到这种重叠的严重性:

我们能够看到的是,在 12 寸这个位置上,自从 2015 款 MacBook 以来就一直空缺着,反而是旁边的 11 寸和 13 寸成了苹果目前产品涵盖最密集的区域。而如果按照广大网友们的期盼,重开 12 寸无印 MacBook 的产品线,就会有两个大概率的后果:

– MacBook 与 iPad 在屏幕面积上的重叠进一步加大,这种混乱且重合的产品线对于普通消费者来说是非常困惑的。让消费者陷入「买个比 iPad 小的 MacBook」还是「买个比 MacBook 都大的 iPad」而无法下单,从商业销售的角度来看是非常严重的问题。

– 「供应链管理大师」库克需要为 12 寸机型单独开设带刘海的 Liquid Retina 显示屏生产线,以及全新的机身模具生产线,两项加起来成本动辄就是几千万美元。从商业角度上看,对于这样一款消费者接受度未知、与已有产品线互相干扰的新品来说是很不划算的。

因此,无论是从成本还是销售角度考量,这款使用 A18 Pro 处理器的新 MacBook 都不太可能会是 12 寸的机型,而是会和 13 寸的 MacBook Air 共同分摊零部件成本。这在苹果目前产品战略趋于保守、将主要精力都投入 AR 类产品的背景下,应该是最合理的一种可能。

图|WIRED

当然,哪怕 13 寸也并不妨碍这款新 MacBook 有潜力成为一个非常有趣的产品。

从今年手机领域的趋势可以洞见,未来两三年里,「轻薄」可能会再次成为电子产品的潮流,将将 5mm 的 iPad Pro 我们已经见识到了,而「薄爆表」的 iPhone 17 Air 也已经不远,这款新的 MacBook 有可能会成为苹果进一步压缩 MacBook 厚度的试验田——

十年前的 12 寸 MacBook 就已经将主板尺寸集成到了一个难以想象的程度

毕竟考虑到 A18 Pro 的发热控制在 iPhone 16 Pro 上(相比前代)已经相当出色,如果放进面积辽阔的 13 寸 MacBook 里,虽然机身尺寸无法回到当年的辉煌,但是能够在被动散热的同时将电池厚度压缩到什么程度,简直不敢想象。

换个角度想,下一代 MacBook 的主要竞争力或许并不是小,而是薄。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


掌握生死时速:苹果应用加急审核全攻略!

作者 iOS新知
2025年7月2日 14:50

这里每天分享一个 iOS 的新知识,快来关注我吧

前言

image.png

在应用开发的过程中,总会遇到一些突发情况,比如应用版本在上线后出现重大 bug 或崩溃,严重影响了大部分用户的使用体验。这种情况下,及时响应和修复问题至关重要,不仅可以减少用户的不良体验,还能防止应用评分的下降。

然而,即便你已经找到了问题的根源,并准备好了解决方案,想要快速发布更新版本,也并非完全由你决定。

因为首先苹果是不允许热更新的,每个版本发布都必须经过苹果的审核,其次审核是否通过完全取决于苹果,因此加快审核流程就显得尤为重要。

但很多 iOS 开发者可能不知道,其实可以通过“加急审核”来告知 App Review 团队有紧急更新需要审核,从而加快审核速度。

本文将详细介绍如何通过 Apple 开发者门户请求加急审核。

如何请求加急审核

要请求加急审核,你需要前往 developer.apple.com 并登录到你的开发者账号。登录的账号必须具备管理需要请求加急审核的应用的权限。

image.png

  1. 滚动到主页底部,点击“联系我们”链接。

image.png 2. 从列表中选择“应用审核”类别。

Image

  1. 选择“请求加急应用审核”选项。

Image

  1. 点击“联系应用审核团队”。

Image

  1. 填写表单,告知审核团队你希望加快处理的应用信息。

Image

需要在表单中提供的信息包括:

  • 你希望向应用审核团队提出的请求类型:在我们这种情况下,选择“请求加急审核”选项。

  • 请求加急审核的人员姓名。

  • 请求加急审核的人员邮箱。

  • 拥有该应用的组织名称。

  • 应用名称。

  • 需要加急审核的版本平台。

完成以上步骤后,点击最后的“Send”按钮,将请求提交给应用审核团队。

正常情况下,几个小时后会收到苹果审核的结果。

注意事项

虽然加急审核是开发者工具箱中的一个强大工具,是在紧急情况下将应用快速交到用户手中的最佳方式,但它应仅在特殊情况下使用。正如 Apple 在审核表单中提到的:

如果你面临紧急情况,比如修复关键 bug 或发布应用以配合某个事件,可以通过填写此表单请求加急审核。

同时需要注意,太多次的加急审核请求可能会导致 Apple 对你的反感,然后可能会导致对你之后的加急请求不予理会,因此要谨慎使用。

这里每天分享一个 iOS 的新知识,快来关注我吧

本文同步自微信公众号 “iOS新知”,每天准时分享一个新知识,这里只是同步,想要及时学到就来关注我吧!

昨天以前首页

Swift 的多平台策略,需要我们大家一起来建设 | 肘子的 Swift 周报 #091

作者 东坡肘子
2025年7月1日 08:00

issue91.webp

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

Swift 的多平台策略,需要我们大家一起来建设

继 2025 年 2 月 Swift 社区论坛发布关于启动 Android Community Workgroup 的消息数月后,Swift.org 于上周正式宣布成立官方 Android 工作组。这标志着由官方主导的 Swift 安卓平台支持正式启动,未来 Swift 开发者有望获得更完善的安卓适配工具链与开发体验。

不过,在欣喜之余,我们也应正视一个现实:对于绝大多数 Swift 开发者来说,长期以来的开发工作深度依赖苹果生态,日常所用 API 多与系统框架强耦合。尽管 Swift 社区和苹果已着手推进 Foundation 的纯 Swift 化改造,并陆续提供更多跨平台基础库,但这距离满足实际跨平台开发的需求仍有相当差距。

不久前,Swift Package Index 在原有对苹果平台和 Linux 的兼容性标识基础上,新增了对 Android 与 Wasm 平台的支持,侧面反映出社区对多平台适配的重视。我也借此机会让自己的两个库完成了对 Linux 的兼容。不过在适配过程中也深刻体会到,目前还缺乏一个便捷、统一的跨平台开发环境。虽然这两个库的适配较为简单,仅通过 GitHub Actions 就完成了编译测试和修复,但若将来需要支持更多平台,社区能否构建一个便利、安全的适配机制将变得至关重要。

近年来,Swift 在多平台战略上的推进明显提速,但若想真正成为跨平台开发者的主流选择,仅靠官方与苹果的努力还远远不够。我们每一位 Swift 开发者的参与同样不可或缺。Swift 越强大,Swift 开发者越受益。Swift 的多平台生态,需要我们共同建设!

前一期内容全部周报列表

原创

NotificationCenter.Message:Swift 6.2 并发安全通知的全新体验

NotificationCenter 作为 iOS 开发中的经典组件,为开发者提供了灵活的广播——订阅机制。然而,随着 Swift 并发模型的不断演进,传统基于字符串标识和 userInfo 字典的通知方式暴露出了诸多问题。为了彻底解决这些痛点,Swift 6.2 在 Foundation 中引入了全新的并发安全通知协议:NotificationCenter.MainActorMessageNotificationCenter.AsyncMessage。它们充分利用 Swift 的类型系统和并发隔离特性,让消息的发布与订阅在编译期就能得到验证,从根本上杜绝了“线程冲突”和“数据类型错误”等常见问题。

近期推荐

Xcode Coding Intelligence 逆向解析简报 (Reverse-Engineering Xcode's Coding Intelligence Prompt)

在 Xcode 26 中,苹果正式推出了备受期待的 AI 编码助手 —— Coding Intelligence。相较于市面上已有的 AI 编程工具,苹果在系统提示词(system prompt)的设计上是否有自己的哲学?Peter Friese 借助 Proxyman 对其进行了深入逆向分析。通过这些解析出的提示词内容,我们不仅可以了解 Coding Intelligence 的工作机制,也能窥见苹果对现代开发实践的倾向性,比如:强烈推荐使用 Swift Concurrency(async/await、actor)而非 Combine,测试建议使用 Swift Testing 框架与宏。这些设计细节,是苹果开发范式的重要指标。


SwiftUI 设计系统中的语义颜色设计 (SwiftUI Design System Considerations: Semantic Colors)

在构建 SwiftUI 设计系统 API 时,如何优雅地处理 语义颜色(Semantic Colors) 始终是一个令人头疼的问题。Magnus Jensen 在本文中系统梳理了常见方案的优缺点,并提出了一种基于宏(macro)的解决路径,力求实现 可读性强、类型安全、上下文感知 的色彩系统。如果你正打算为自己的 SwiftUI 项目设计一套结构清晰、可维护的风格体系,这篇文章值得一读。


iOS 内存效率指南系列 (Memory Efficiency in iOS)

随着项目复杂度的提升,开发者终将面对内存相关的问题:内存泄漏、系统警告,甚至因资源占用过高被系统强制终止。在这种情况下,如何诊断问题、控制内存占用,是对开发者经验与体系理解的深度考验。Anton Gubarenko 在两篇文章(内存优化篇)中,系统梳理了 iOS 应用内存使用的评估方式、诊断工具以及优化手段,构建出一套完整、实用的内存管理知识体系。


What is @concurrent in Swift 6.2?

从 Swift 最近的几个版本更新和 Xcode 26 的表现可以看出,Swift 团队正有意识地优化并发编程的开发体验。通过启用新的默认行为,开发者无需在一开始就理解所有细节,便能写出更安全的并发代码。@concurrent 的引入,正是这一策略下的产物之一。在 Donny Wals 的这篇文章中,他详细介绍了 @concurrent 的背景与用途。简单来说,@concurrent 是 Swift 6.2 引入的显式并发标记,主要用于在启用 NonIsolatedNonSendingByDefault 特性时,明确指定函数运行在全局执行器上,从而在需要时将工作负载转移到后台线程,避免阻塞调用者所在的 actor(如主线程)。

或许有人会质疑 Swift 是否又在“用新关键字补旧洞”,但从语言设计趋势来看,随着并发模型逐步完善,许多旧关键字的使用将逐渐被默认机制吸收、简化甚至隐藏。


Swift 与 Java 互操作 (Swift 6.2 Java interoperability in Practice)

Swift 与 Java 的互操作并非新鲜事物,但过往的解决方案往往过程复杂且容易出错。Swift 6.2 引入的 swift-java 包具有划时代意义——这是首次提供官方支持、与工具链深度集成、开发体验接近一等公民的互操作方案,标志着 Swift 和 Java 之间真正意义上的“无缝互通”正式到来。Artur Gruchała 通过一个完整的示例项目,详细演示了如何从 Swift 端调用 Java 方法、构建双语言协作的 CLI 应用,并深入分析了实际开发中容易踩坑的关键细节——特别是 classpath 配置等看似简单却至关重要的环节。


Kodeco 教程:迁移到 Swift 6 (Migrating to Swift 6 Tutorial)

Swift 6 引入了更严格的并发规则与更加结构化的编程范式。在迁移过程中,理解隔离域、Sendable 类型、默认行为,以及 @concurrent 的使用变得尤为重要。Audrey Tam 通过一个完整的 SwiftUI 示例项目(附项目源码),系统演示了从 Swift 5 迁移至 Swift 6.2 的全过程,涵盖 Xcode 设置、并发语义调整与数据隔离等核心环节,是一篇很具实用价值的迁移教程。


Modern Concurrency - Swift 6.2 Suite of Examples

如何在 async/await 中实现类似 Combine 的 throttle 操作?如何持续追踪 @Observable 属性的变化?如何构建支持多消费者的异步流?Lucas van Dongen 在这个开源项目中给出了系统性的实践示例。他汇集了 Swift 6.2 并发模型下的多种模式,演示了如何在实际项目中逐步替代 Combine,迁移到更现代、类型安全的并发范式。


是否升级应用的最低支持版本?(Considerations for New iOS Versions)

WWDC 25 中 Liquid Glass 的登场令人惊艳,但要同时支持两种视觉风格,对开发资源是一大考验。这也让很多开发者开始思考是否应放弃对旧系统的支持。David Smith 建议从两个角度判断:现有用户影响新用户流失。以他的 Widgetsmith 应用为例,当前仍有约 9% 的新增用户来自旧系统,一旦抬高最低支持版本将直接失去这部分潜在用户。他认为,只有当旧系统用户占比降至个位数时,再做版本升级才更合理——简化技术负担,不应以牺牲业务增长为代价

活动

AdventureX 25 游客指南

AdventureX 25 将于 2025 年 7 月 23 日至 27 日在杭州市湖畔创研中心与未来科技城学术交流中心举行。本指南包含活动行程介绍、参与方式、群聊福利、出行与住宿建议及注意事项等内容。不论你是来逛展、互动,还是寻找志同道合的伙伴,这份指南都将帮助你轻松规划行程~

往期内容

THANK YOU

如果你觉得这份周报或者我的文章对你有所帮助,欢迎 点赞 并将其 转发 给更多的朋友。

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

深度解析!Apple App Site Association 文件背后的秘密和配置攻略

作者 iOS新知
2025年6月30日 14:22

这里每天分享一个 iOS 的新知识,快来关注我吧

前言

image.png

在 iOS 生态系统中,Apple App Site Association(AASA)文件扮演着至关重要的角色。它通过在你的 iOS 应用和网络域之间建立安全且经过验证的链接,实现了诸如通用链接(Universal Links)、共享网络凭证、Handoff 和 App Clips 等功能。

不知道你有没有注意过,当你在手机浏览器上访问知乎、小红书或 YouTube 时,有些链接会让你继续留在浏览器中,而另一些则会直接跳转到对应的应用中?这背后的驱动力正是 AASA 文件。

AASA 文件的创建

Apple App Site Association 文件是一个配置文件,它定义了 URL 的处理方式,并指定它们是打开在浏览器中还是直接链接到应用程序内的内容。

以下是一个基本示例:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "ABCDE12345.com.example.app",
        "paths": [
          "/",
          "/about-us/",
          "/products/*",
          "/services/detail/?id=*",
          "/news/article/*",
          "/promo/*",
          "NOT /private/",
          "NOT /settings/*",
          "*.html"
        ]
      }
    ]
  },
  "webcredentials": {
    "apps": ["ABCDE12345.com.example.app"]
  },
  "appclips": {
    "apps": ["ABCDE12345.com.example.app"]
  }
}

访问下边这些路径的请求都会将用户路由到应用程序中:

"/",
"/about-us/",
"/products/*",
"/services/detail/?id=*",
"/news/article/*",
...

而对于那些以 NOT 前缀的路径,系统将不会重新路由用户,允许体验继续在浏览器中进行:

"NOT /private/",
"NOT /settings/*",
...

部署 AASA 文件

当你准备好部署 AASA 文件时,需要将其托管在网站的根目录或 .well-known 目录下:

  • /apple-app-site-association

  • /.well-known/apple-app-site-association

注意:AASA 文件不应有文件扩展名。为了让内容分发网络(CDN)成功缓存此文件,它必须托管在可供所有 IP 地址和范围访问的域上,通过 HTTPS 提供文件,不重定向,并且不被访问策略阻止。

你还应该确保服务器以 Content-Type: application/json 的形式提供文件,并且文件大小不超过 128KB。

当你首次托管 AASA 文件时,苹果的 CDN 将在 24 小时内获取它,这意味着它们将在文件发布的第一天内从你的服务器请求并缓存一份副本。

根据苹果文档的说法,这个 24 小时的窗口仅与首次将文件放入苹果的 CDN 有关。随后的更新会在不同的时间间隔发生。

其实我们可以通过下边的链接看下其他公司的 AASA 文件是如何写的:

测试与验证

随着应用程序的迭代,你将需要修订 AASA 文件,以便为新的用户流程整合通用链接(Universal Link)。

AASA 文件中的错误很常见,因此验证此文件的行为和语法成为流程中不可或缺的一部分。一旦被苹果的 CDN 确认,此文件中的任何错误都可能影响所有用户的通用链接行为——无论是新用户还是老用户。

幸运的是,在更改到达苹果之前,有几种方法可以验证 AASA 文件的行为。

绕过 CDN

关联域支持一种备用模式,允许开发者通过绕过 CDN 直接从服务器获取文件,以验证修改后的 AASA 文件的行为。

要在 Xcode 项目中激活此模式:

  • 前往 Signing & Capabilities -> Associated Domains

  • 在域条目的末尾添加 ?mode=developer(例如:applinks:yourdomain.com?mode=developer

现在,当你构建并运行应用程序时,它将直接从你的服务器检索更新的 AASA 文件。

在开发期间只能使用备用模式,你必须在将应用程序提交到 App Store 之前从关联域中删除后面这段字符串。

验证 AASA 文件配置

你可以使用以下工具来验证 AASA 文件的语法和配置:

  • Universal Link & Apple App Site Association Testing Tool[1]

这是一个免费的工具,用于验证和测试 Apple App Site Association(AASA)文件。确保你的通用链接配置正确,通过简单的链接创建、实时测试和团队协作功能简化 AASA 文件的故障排除。

官方查询方式

你可以通过访问以下网址检查苹果的 CDN 是否已获取文件的最新版本:

  • https://app-site-association.cdn-apple.com/a/v1/{YOUR_DOMAIN_HERE}

如果 CDN 确实具有文件的最新版本,那么任何新应用安装也将获得此最新版本。但是对于老用户,他们的设备每周只会检查一次更新的副本。

重新安装应用程序将从 CDN 获取最新版本。

实际上,AASA 文件的任何更改的推出周期是 CDN 刷新其缓存所需的时间与现有用户每周检查的时间相结合。

缓存更新时间

为了更好地理解缓存版本更新前剩余的时间,我们可以查看来自 CDN 的响应头中的 Cache-Control 字段。

Cache-Control 头由网络服务器使用,以决定浏览器和中间缓存(如 CDN)应该如何以及在多长时间内缓存文件的提供版本。

在 Facebook 和 Yelp 上,我们可以通过以下链接查看:

  • https://app-site-association.cdn-apple.com/a/v1/facebook.com

  • https://app-site-association.cdn-apple.com/a/v1/yelp.com

然后我们可以通过从 max-age 中减去 age 来确定缓存何时更新:

image.png

image.png

  • max-age:6 小时,缓存刷新时间为 17,760 秒 / 约 5 小时。

  • max-age:1 小时,缓存刷新时间为 3,112 秒 / 约 50 分钟。

在实际情况中,最短的 max-age 是 3,600 秒,即 1 小时,最长的是 21,600 秒,即 6 小时。

值得注意的是,苹果的 CDN 会覆盖原始网站指定的 Cache-Control 设置。例如,直接从 Yelp 访问 AASA 文件的 max-age 为 1200,但从苹果的 CDN 检索时,其 max-age 为 3600。

通过更长时间的缓存文件,苹果能够最大限度地减少对原始服务器的请求频率,从而减少网络和原始服务器的流量和负载。

总结

AASA 文件是 iOS 生态系统中一个非常重要的组件,它通过在 iOS 应用和网络域之间建立安全且经过验证的链接,实现了诸如通用链接(Universal Links)、共享网络凭证、Handoff 和 App Clips 等功能。

通过了解 AASA 文件的创建、部署、测试与验证,开发者可以更好地优化其应用的链接配置,提升用户体验,并确保应用的稳定性和可靠性。

参考资料

[1]

Universal Link & Apple App Site Association Testing Tool: getuniversal.link/?ref=digita…

这里每天分享一个 iOS 的新知识,快来关注我吧

本文同步自微信公众号 “iOS新知”,每天准时分享一个新知识,这里只是同步,想要及时学到就来关注我吧!

新版 Xcode 中 CoreData 模型编辑器显示拓扑图功能取消的替代方案

2025年6月29日 20:03

在这里插入图片描述

概述

何曾几时,小伙伴们在 Xcode 的 CoreData 模型编辑器里可以肆无忌惮的浏览数据库表结构的拓扑图,造福了我们这些秃头码农们,可惜这一功能现在已不复存在!

在这里插入图片描述

那么,还有没有什么替代方案呢?本文由此应运而生了。

在本篇博文中,您将学到如下内容:

    1. Xcode 中 CoreData 模型编辑器的现状
    1. 替代方案与工具
    • 方案一:使用第三方工具生成拓扑图
    • 方案二:通过代码或调试工具查看
    • 方案三:手动检查模型文件
    1. 未来可能的改进

众所周知,Core Data 模型编辑器在 Xcode 早期版本中确实提供了可视化的关系拓扑图(如实体间的关联关系视图)显示功能,但在 Xcode 14 及之后的版本中,这一功能已被取消。

以下是当前可用的替代方案和注意事项:


1. Xcode 中 CoreData 模型编辑器的现状

在这里插入图片描述

  • 功能移除:从 Xcode 14 开始,CoreData 模型编辑器中的可视化关系图(即拓扑图)功能被移除。虽然数据模型文件(.xcdatamodeld)中仍包含 elements 部分的 XML 数据,但这些信息已不再用于显示实体间的布局关系了。
  • 模型编辑方式:开发者只能通过文本或列表形式编辑实体、属性和关系,无法直接通过图形界面查看实体间的拓扑结构。

2. 替代方案与工具

方案一:使用第三方工具生成拓扑图

  • CoreDataPro:这是一款专门用于查看和管理 Core Data 数据库的工具,支持可视化数据模型的结构和关系。用户可以通过它加载 .momd.xcdatamodeld 文件,生成实体关系图。
  • 生成 ER 图:将 Core Data 的模型文件导出为其他格式(如 XML 或 SQL),再使用数据库设计工具(如 DBDiagramMySQL Workbench)生成实体关系图。

方案二:通过代码或调试工具查看

  • 逆向 SQLite 文件:Core Data 默认使用 SQLite 作为存储格式。开发者可以通过 SQLite 浏览器(如 SQLite ManagerDB Browser for SQLite)直接查看数据库表结构,包括实体对应的表和关系字段。
  • NSManagedObject 子类生成:通过 Xcode 自动生成的 NSManagedObject 子类代码,可以间接查看实体间的关联关系。例如,若实体 BookAuthor 存在一对多关系,生成的代码中会包含 @NSManaged 修饰的关联属性。

方案三:手动检查模型文件

  • 查看 XML 内容:Core Data 的模型文件(.xcdatamodeld)本质是 XML 格式。开发者可以直接查看其内容,解析实体间的关联关系(通过 <relationship> 标签)。
  • 示例代码片段
    <entity name="Book" representedClassName="Book">
        <relationship name="author" destinationEntity="Author" inverseName="books"/>
    </entity>
    

3. 未来可能的改进

  • SwiftData 的替代方案:不知道苹果在 WWDC 2023 推出的 SwiftData 框架(基于 Core Data 优化)是否会在未来提供更现代化的数据模型管理工具,更难预料其是否支持显示可视化拓扑图。因为 SwiftData 的本意是纯描述型数据库,所以这一事件的概率估计不是很高。☺
  • 社区工具开发:开发者社区可能继续推出更强大的第三方工具,弥补 Xcode 功能缺失的不足。

总结

如果依赖可视化拓扑图进行开发,推荐以下步骤:

  1. 使用 CoreDataPro 或 SQLite 浏览器:直接查看数据库结构和关系。
  2. 结合代码生成与 XML 分析:通过生成的 NSManagedObject 子类和模型文件 XML 内容,手动验证关系逻辑。
  3. 关注苹果更新:留意 Xcode 后续版本是否重新引入相关功能,或转向 SwiftData 等新框架。

若需进一步调试数据库内容,可参考如何通过 SQLite 工具查看 Core Data 存储文件。

感谢观赏,再会啦!8-)

“一人得道,雨燕升天”:Swift 协议扩展助力 CoreData 托管类型(下)

2025年6月28日 14:49

在这里插入图片描述

概述

相信各位似秃非秃小码农们都同意,Swift 是一门现代化、安全且表现力足够丰富的语言。不过,它毕竟还是一种偏静态的语言,灵活性无法和 Python、ruby 之类的动态语言相提并论。

在这里插入图片描述

不过话虽如此,通过巧妙的一步步重构源代码,我们也可以用 Swift 完成之前貌似不可能完成的任务,所需的只是那么一丢丢耐心和执着而已。

在本篇博文中,您将学到如下内容:

  1. 一种很“硬”的解决方案
  2. 不想回到最初的样子
  3. 让编译器乖乖听话

希望在亲眼目睹本系列文章中 Swift 代码那循序渐进的重构和升华之后,小伙伴们倘若再遇到与此类似的语言设计问题,必能胸有成竹、胜券在握!

无需等待,Let‘s go!!!;)


5. 一种很“硬”的解决方案

对于前文中的问题,一种简单粗暴的解决方法是:强行让两种类型“蛮来生作”。

extension AchievementEvaluator {
    static func queryAll(context: NSManagedObjectContext) throws -> [Evaluator] {
        let request = Evaluator.fetchRequest() as! NSFetchRequest<Evaluator> // ⚠️ 强制转换
        return try context.fetch(request)
    }
}

如您所见,我们通过 Swift 强制类型转换语法,将 Evaluator.fetchRequest 实际的类型与 Evaluator 类型强行匹配。

虽然,这可以让编译器暂时闭嘴,但是也同时置我们自己于“刀山火海”之上!

上述代码的风险是:我们需要自行确保类型转换的安全性,若 Evaluator.fetchRequest() 实际返回的请求类型与 Evaluator 不匹配,将立即导致运行时发生崩溃。

6. 不想回到最初的样子

除了强行转换以外,我们还可以采用迂回战术:创建约束协议从而绕过编译器的“桎梏”。

首先,新建一个约束协议 Fetchable:

// 定义核心约束协议
protocol Fetchable: NSManagedObject {
    static func fetchRequest() -> NSFetchRequest<Self>
}

接着,对原来的 AchievementEvaluator 协议定义稍作调整,让其关联类型遵守我们上面创建的约束协议:

// 原协议调整
protocol AchievementEvaluator {
    associatedtype Evaluator: Fetchable & AchievementEvaluator // 新增 Fetchable 约束
    
    static func queryAll(context: NSManagedObjectContext) throws -> [Evaluator]
}

随后,在 AchievementEvaluator 协议扩展中利用约束关系重新打造我们的 queryAll() 方法:

extension AchievementEvaluator where Evaluator: Fetchable {
    static func queryAll(context: NSManagedObjectContext) throws -> [Evaluator] {
        let request = Evaluator.fetchRequest() // ✅ 类型已明确为 NSFetchRequest<Evaluator>
        return try context.fetch(request)
    }
}

最后,让 Achv_NoBreakVictory 成就实体类遵守 Fetchable 约束协议即可:

extension Achv_NoBreakVictory: Fetchable, AchievementEvaluator {
    typealias Evaluator = Achv_NoBreakVictory
}

虽然这种思路本身没什么问题,但可惜的是编译器还是会义无反顾的再次大声说“我恨你!”:

在这里插入图片描述

Protocol 'Fetchable' requirement 'fetchRequest()' cannot be satisfied by a non-final class ('Achv_NoBreakVictory') because it uses 'Self' in a non-parameter, non-result type position

通过上面的错误信息不难发现:大家貌似又回到了之前的“故步自封”—— 我们仍然需要让 Achv_NoBreakVictory 类加上 final 成为“孤家寡人”才能得偿所愿,这是我们不希望看到的。

所以,我们又该如何随遇而安呢?

7. 让编译器乖乖听话

其实,解决之道并没有想象的那么复杂,我们只需重新设计 Fetchable 协议即可。

我们的核心思想是:

机制 作用
entityName 属性 动态获取实体名称,避免依赖自动生成的 fetchRequest()
手动构建 NSFetchRequest 通过 NSFetchRequest<Self>(entityName:) 确保类型匹配
子类覆盖 entityName 允许继承体系中的子类指定自己的实体名称

首先,通过 实体名称动态构建请求,绕过自动生成的 fetchRequest() 方法的限制:

protocol Fetchable: NSManagedObject {
    static var entityName: String { get } // 要求实体提供名称
}

extension Fetchable {
    static func fetchRequest() -> NSFetchRequest<Self> {
        // 手动构建请求,确保类型安全
        return NSFetchRequest<Self>(entityName: entityName)
    }
}

接下来,我们只要让 Achv_NoBreakVictory 类乖巧的提供 entityName 名称即可:

extension Achv_NoBreakVictory: Fetchable, AchievementEvaluator {
    static var entityName: String {
        "Achv_NoBreakVictory"
    }
    
    typealias Evaluator = Achv_NoBreakVictory
}

现在,编译源代码将如您所愿,一切都毫无问题,整个世界清净了!

通过 动态实体名称 + 手动构建请求,既能保持类的可继承性,又能满足 Core Data 类型安全要求。其关键点在于:

  1. 通过 entityName 属性解耦实体名称与类型推断。
  2. 子类必须显式覆盖 entityName 以正确映射数据库实体。

然而,我们还可以更进一步。

观察上面 Achv_NoBreakVictory 类中对应 entityName 属性的代码可以发现:每个成就实体类的 entityName 就是它们自己类的名称。既然如此,为什么不把 entityName 也直接放到协议扩展中去呢?

extension AchievementEvaluator where Evaluator: Fetchable {
    
    static var entityName: String {
        "\(Self.self)"
    }
    
    static func queryAll(context: NSManagedObjectContext) throws -> [Evaluator] {
        let request = Evaluator.fetchRequest() // ✅ 类型已明确为 NSFetchRequest<Evaluator>
        return try context.fetch(request)
    }
}

如上代码所示,我们将原本需要每个 AchievementEvaluator 实体类实现的 entityName 属性放到了 AchievementEvaluator 协议扩展中,大大减少了重复代码,这样的 DRY 和 KISS 谁能不爱呢?棒棒哒!

或者我们干脆彻底摆脱 entityName 属性的限制,直接将其嵌入到 Fetchable 协议扩展的 fetchRequest() 方法中,让实现百尺竿头、更入佳境:

extension Fetchable {
    static func fetchRequest() -> NSFetchRequest<Self> {
        // 手动构建请求,确保类型安全
        return NSFetchRequest<Self>(entityName: "\(Self.self)")
    }
}

至此,我们通过不断迭代重构,彻底摆脱了最初文章开头 CoreData 成就托管类实现的恼人纠缠,小伙伴们还不赶快给自己一个大大的赞吧!❤️

总结

在本篇博文中,我们借助于精心设计的 Fetchable 约束协议成功的摆脱了 Swift 协议扩展中的“磨搅讹绷”,小伙伴们值得拥有!

感谢观赏,再会啦!8-)

“一人得道,雨燕升天”:Swift 协议扩展助力 CoreData 托管类型(上)

2025年6月28日 14:43

在这里插入图片描述

概述

相信各位似秃非秃小码农们都同意,Swift 是一门现代化、安全且表现力足够丰富的语言。不过,它毕竟还是一种偏静态的语言,灵活性无法和 Python、ruby 之类的动态语言相提并论。

在这里插入图片描述

不过话虽如此,通过巧妙的一步步重构源代码,我们也可以用 Swift 完成之前貌似不可能完成的任务,所需的只是那么一丢丢耐心和执着而已。

在本篇博文中,您将学到如下内容:

  1. 背景故事
  2. 想法不错,无奈编译器不允许!
  3. “不情愿”的 final
  4. DRY 制胜法宝:协议扩展(Protocol Extension)

希望在亲眼目睹本系列文章中 Swift 代码那循序渐进的重构和升华之后,小伙伴们倘若再遇到与此类似的语言设计问题,必能胸有成竹、胜券在握!

无需等待,Let‘s go!!!;)


1. 背景故事

我们的项目基于 SwiftUI + CoreData 构建,在数据库中我们需要为用户创建各种各样的成就(Achievements),因为每种成就本身有很大的不同(字段、获取手段等),所以考虑在 CoreData 数据库中使用抽象基类 + 实体类的组成方法:

  • Achievement 类是成就的抽象基类,其中包含所有成就都共有的字段和方法;
  • Achv_NoBreakVictory 类和其它实体类都“派生”于 Achievement 基类,对应于每一种具体的成就,它们包含自己独有的字段和方法;

Achievement 和 Achv_NoBreakVictory 类的定义如下所示:

@objc(Achievement)
public class Achievement: NSManagedObject {

}

@objc(Achv_NoBreakVictory)
public class Achv_NoBreakVictory: Achievement {

}

对于 Achv_NoBreakVictory 这一成就实体托管类来说,我们往往需要查询它的所有实例,所以有必要写一个方法来达成此目的:

static func queryAll(context: NSManagedObjectContext) throws -> [Achv_NoBreakVictory] {
        let req: NSFetchRequest<Achv_NoBreakVictory> = fetchRequest()
        return try context.fetch(req)
    }

但是问题来了:如果我们有一大堆这样的实体类,难道要不厌其烦的在每个类中实现上面的方法吗?

答案当然是大大的 NO!

2. 想法不错,无奈编译器不允许!

因为 Achievement 会派生出很多不同的成就实体子类,这些子类同样需要上面的 queryAll 方法来查询它们各自的所有实例,为了规范它们共同的“言行”,我们决定创建一个协议让它们来遵守:

protocol AchievementEvaluator {
    static func queryAll(context: NSManagedObjectContext) throws -> [Self]
}

接下来,我们需要让 Achv_NoBreakVictory 实体类遵守 AchievementEvaluator 协议:

extension Achv_NoBreakVictory: AchievementEvaluator {
    static func queryAll(context: NSManagedObjectContext) throws -> [Achv_NoBreakVictory] {
        let req: NSFetchRequest<Achv_NoBreakVictory> = fetchRequest()
        return try context.fetch(req)
    }
}

不幸的是,这样做的话编译器会立即大声抱怨:

在这里插入图片描述

Protocol 'AchievementEvaluator' requirement 'queryAll(context:)' cannot be satisfied by a non-final class ('Achv_NoBreakVictory') because it uses 'Self' in a non-parameter, non-result type position

这个编译错误是由于 Swift 协议中 Self 类型与类继承体系之间的冲突引起的。要解决这个问题,需要理解以下核心机制:

  1. 协议中 Self 的严格性
    Swift 协议中的 Self 代表「实现该协议的具体类型」。当协议方法返回 [Self] 时,要求实现该方法的类型必须在编译时明确自身类型
  2. final 类的继承风险
    如果 Achv_NoBreakVictory 是非 final 类,它可以被继承(如 class SubAchv: Achv_NoBreakVictory)。此时子类 SubAchv 必须实现 spawnAll() -> [Self],但继承自父类的 spawnAll() 实际返回的是 [Achv_NoBreakVictory] 而非 [SubAchv],所以这会导致类型不匹配,违背协议要求。

那我们该如何解决呢?

3. “不情愿”的 final

经过查看上面的错误提示,我们可以幡然醒悟,一种简单的解决方案应运而生,即将 Achv_NoBreakVictory 类变为 final 类,可以让编译器“敢怒不敢言”:

public final class Achv_NoBreakVictory: Achievement {}

不过,或许我们的 Achv_NoBreakVictory 类是“委托” CoreData 模型编辑器自动生成的,这样的话每次更新 Achv_NoBreakVictory 类的内容都需要费劲手动再添加 final 关键字,不烦吗?

除了强制让 Achv_NoBreakVictory 类“后继无人”以外,另一种颇为 Nice 的解决方法是为 AchievementEvaluator 协议添加关联类型:

protocol AchievementEvaluator {
    associatedtype Evaluator
    static func queryAll(context: NSManagedObjectContext) throws -> [Evaluator]
}

通过上面一番操作之后,我们 Achv_NoBreakVictory 类扩展中 queryAll() 方法的代码已经可以顺利通过编译了,厉害了我的秃们!

4. DRY 制胜法宝:协议扩展(Protocol Extension)

通过仔细观察上面 Achv_NoBreakVictory 类扩展中的 queryAll() 方法,聪明的小伙伴们不难发现:每个 Achievement 实体类 queryAll() 方法的代码实际上都大同小异,我们实在没必要“痴鼠拖姜”的一一重复实现它们。

侵淫苹果撸码多年的秃头小码农们都知道,Swift 协议有一种机制专注于解决此事,它就是协议扩展(Protocol Extension)

简单来说,我们可以将 queryAll() 方法直接放在 AchievementEvaluator 协议扩展里,而不是在遵守它的每个类里:

extension AchievementEvaluator {
    static func queryAll(context: NSManagedObjectContext) throws -> [Evaluator] {
        let req: NSFetchRequest<Evaluator> = Evaluator.fetchRequest()
        return try context.fetch(req)
    }
}

extension Achv_NoBreakVictory: AchievementEvaluator {
    typealias Evaluator = Achv_NoBreakVictory
    
    /*
    static func queryAll(context: NSManagedObjectContext) throws -> [Achv_NoBreakVictory] {
        let req: NSFetchRequest<Achv_NoBreakVictory> = fetchRequest()
        return try context.fetch(req)
    }*/
}

在上面的代码中,我们将原本位于实体类 Achv_NoBreakVictory 中的 queryAll 方法调皮地瞬移到了 AchievementEvaluator 协议扩展里面。

不过这样一来,编译器的抱怨也会再次“卷土重来”:

在这里插入图片描述

Cannot assign value of type 'NSFetchRequest<any NSFetchRequestResult>' to type 'NSFetchRequest<Self.Evaluator>'

造成这种错误的根本原因是:在 Swift 中处理 Core Data 的 NSFetchRequest 泛型类型时,没有确保类型系统的严格匹配。

  • NSFetchRequest<Evaluator> 的泛型要求:Core Data 的 fetchRequest() 默认返回 NSFetchRequest<NSFetchRequestResult>,而协议中定义的 Evaluator 关联类型要求返回具体的 Evaluator 类型,导致类型不匹配。
  • 协议扩展的泛型约束不足:编译器无法确认 Evaluator.fetchRequest() 返回的请求类型是否与 Evaluator 类型一致。

那么,此时我们又该何去何从呢?

在下一篇博文中,我们将继续 AchievementEvaluator 协议扩展的进化之旅,敬请期待吧!

总结

在本篇博文中,我们讨论了在用 Swift 协议扩展优化和重构 CoreData 托管类型功能遇到的问题,并初步提供了一些“不尽如人意”的解决方法。

感谢观赏,我们下一篇再会!8-)

Swift 6 新特性(一):count(where:) 方法带来的从复杂到简洁变化

作者 iOS新知
2025年6月27日 12:47

这里每天分享一个 iOS 的新知识,快来关注我吧

前言

Swift 语言一直在不断演进,推出的新特性不仅提升了性能,还提高了代码的可读性。

其中一个值得关注的新功能就是在 SE-0220 中引入的 count(where:) 方法。这个方法让我们可以更高效、更具表现力地统计序列中满足特定条件的元素,避免了之前需要结合 filter()count 的复杂操作。

count(where:) 方法将过滤(filter)和计数(count)两个步骤合并为一个,省去了创建和丢弃中间数组的麻烦,从而提升了性能,同时代码也变得更加简洁。

示例1:统计高于冰点的温度

假设我们有一个以摄氏度为单位的温度数组,我们想统计其中有多少温度高于冰点(0°C):

在之前,我们可能需要这样写:

let temperatures = [-510, -22025, -1]
let aboveFreezingCount = temperatures.filter { $0 > 0 }.count

现在,我们可以使用 count(where:) 方法来简化代码:

let temperatures = [-510, -22025, -1]
let aboveFreezingCount = temperatures.count { $0 > 0 }

// 输出 `3`
print(aboveFreezingCount)

在这个例子中,aboveFreezingCount 的值为 3,因为有三个温度(10, 20, 25)符合条件。

示例2:统计具有特定前缀的元素

让我们再看一个示例,假如我们有一组产品名称,想统计以 "Apple" 开头的产品数量:

let products = [
    "Apple", 
    "Banana", 
    "Apple Pie",
    "Cherry", 
    "Apple Juice", 
    "Blueberry"
]
let appleCount = products.count { $0.hasPrefix("Apple") }

// 输出 `3`
print(appleCount)

在这个例子中,appleCount 的值为 3,因为 "Apple"、"Apple Pie" 和 "Apple Juice" 都以 "Apple" 开头。

示例3:根据长度统计元素

一个常见的应用场景是根据元素的长度进行统计。例如,我们可能想知道数组中有多少名字的长度少于六个字符:

let names = ["Natalia""Liam""Emma""Olivia""Noah""Ava"]
let shortNameCount = names.count { $0.count < 6 }

// 输出 `4`
print(shortNameCount)

在这个例子中,shortNameCount 的值为 4,因为 "Liam"、"Emma"、"Noah" 和 "Ava" 的长度都少于六个字符。

示例4:统计特定元素

如果需要统计序列中某个特定元素出现的次数,可以在闭包中使用等于运算符 (==)。例如:

let animals = ["cat""dog""cat""bird""cat""dog"]
let catCount = animals.count { $0 == "cat" }

// 输出 `3`
print(catCount)

在这个例子中,catCount 的值为 3,因为 "cat" 在数组中出现了三次。

适用范围和平台支持

count(where:) 方法适用于所有遵循 Sequence 协议的类型,这意味着我们不仅可以在数组中使用,还可以在集合、字典和其他序列类型中使用。

但需要注意的是,序列必须是有限的,以确保方法能够在合理的时间内完成。

count(where:) 方法在 Swift 6 中引入,因此需要 Xcode 16 才能使用这个特性。它支持多种平台和操作系统版本,包括 iOS 8.0+、macOS 10.10+、visionOS 1.0+ 等。

总结

count(where:) 方法是一个非常实用的功能,它不仅简化了代码,还提高了性能。如果你是一名经验丰富的 Swift 开发者,想要学习高级技巧,可以关注我的公众号,我会持续分享 Swift 相关的技巧和知识。

你对这个新特性有什么看法呢?欢迎在评论区与我们分享你的想法。

这里每天分享一个 iOS 的新知识,快来关注我吧

本文同步自微信公众号 “iOS新知”,每天准时分享一个新知识,这里只是同步,想要及时学到就来关注我吧!

为什么那些看起很丑的产品却能上架AppStore?

作者 iOS研究院
2025年6月27日 11:11

背景

关于AppStore允许怎样的产品过审,其实一直具有玄学性。这里主要是受审核员的心情和工作态度影响最大

最常见的莫过于上一版本过审,简单的更新关键词或者维护一个小Bug,遭受被拒审核的痛苦折磨,乃至3.2f致命打击。常见的场景如下:

  • 审核员用最新系统测试导致闪退被拒
  • 测试账户登录异常
  • 社区发帖功能要求登录
  • 社区帖子缺啥举报、拉黑功能
  • 页面空白

另外,统一回复一下最近提问最多的问题,那就是为什么那些看起来很丑的产品却能上架?直接上图。

示例产品一

示例产品二

示例产品三

为什么?

⚠️事先声明本文绝无攻击以上产品的言论和恶意,仅用来参考并客观讲述AppStore审核策略

从审美的角度来看,上面列举的产品其实并不够精品。但是对于AppStore来说是绝对合规的。

那么可能有同行会诡辩地说了,这是AppStore故意丑化大陆地区的形象。

其实不然,无论是代码层面还是设计层面,你不得不承认这样的设计其实也是一种标新立异对于破局4.3(a)肯定是有作用的

首先这种色块版的设计,并不是适用于所有产品。从哲学存在即合理的角度来说,这部分工具类的产品本身并不需要花里胡哨的设计,增加用户的教育成本。仅需这种朴华务实的卖点足以解决用户的痛点

其次,这种设计理念本身也是敢为天下先,与其美的千篇一律,不如“丑”的千奇百怪

风险点

这种产品的风险点在于后续的迭代与转型,只能说这样的设计可以破局0~1的尴尬处境,但是在后续的迭代依旧会存在4.3(a)的风险。陷入进退两难导致不改不够精美,改了不好过审的尴尬处境

所以,这种取巧的技巧更适合于工具类产品,如果是小而美的产品并完全适用。毕竟在弯道超车翻车的不在少数。

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# Pingpong和连连的平替,让AppStore收款无需新增持有人。

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

知识星球

更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」

Xcode 14.3 和 iOS 16.4 为 SwiftUI 带来了哪些新功能?

2025年6月27日 10:07

在这里插入图片描述

0. 概览

今年年初,Apple 推出了最新的 Xcode 14.3 以及对应的 iOS 16.4 。

与此同时,它们对目前最新的 SwiftUI 4.0 也添加了一些新功能:

  • sheet 弹窗后部视图(Interact with a view Behind a sheet)可交互;
  • sheet 弹窗背景透明化;
  • 调整 sheet 弹窗顶角弧度;
  • 控制弹窗内滚动手势优先级;
  • 定制紧密(compact-size )尺寸下 sheet 弹窗大小;
  • Xcode 预览(Preview)模式下对调试输出的支持;

让我们依次来了解一下它们吧。

Let‘s go!!!;)


1. sheet 后部视图可交互

在 iOS 16.4 之前,SwiftUI 中 sheet 弹窗后,如果点击其后部的视图会导致弹窗立即被关闭,从而无法与弹窗后部的视图进行交互。

从 iOS 16.4 开始,我们可以为 sheet 弹窗应用 presentationBackgroundInteraction() 方法,以达到不关闭弹窗而与后部视图交互之目的:

@available(iOS 16.4, *)
struct ContentView: View {
    @State private var isPresented = false
    @State private var number = 0
    
    var body: some View {
        ZStack(alignment: .top) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            Button("Sheet") {
                isPresented = true
            }
            .buttonStyle(.borderedProminent)
            .padding()
            
            VStack {
                Button("产生随机数: \(number)"){
                    number = Int.random(in: 0..<10000000)
                }
                .foregroundColor(.white)
                .font(.title.weight(.black))
            }.padding(.top, 200)
        }
        .sheet(isPresented: $isPresented) {
            Text("大熊猫侯佩 @ csdn")
                .font(.headline)
                .presentationDetents([.height(120), .medium, .large])
                // 开启后部视图交互
                .presentationBackgroundInteraction(.enabled)
        }
    }
}

在这里插入图片描述

2. sheet 背景透明化

从 iOS 16.4 开始,我们可以为 sheet 弹窗选择透明样式,更好的美化弹出窗口的显示效果。

如下代码所示,我们在 sheet 弹窗上应用了 presentationBackground(_: ) 修改器以实现透明磨砂效果:

@available(iOS 16.4, *)
struct ContentView: View {
    @State private var isSheet = false
    @State private var isSheetTransparency = false
    
    var body: some View {
        ZStack(alignment: .top) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            
            HStack {
                Button("弹出") {
                    isSheet = true
                }
                .sheet(isPresented: $isSheet) {
                    Text("大熊猫侯佩 @ csdn")
                        .font(.headline)
                        .presentationDetents([.height(120), .medium, .large])
                        // 或使用 .background 调用 presentationBackground() 方法效果相同
                        //.presentationBackground(.background)
                }
                
                Spacer()
                
                Button("透明弹出") {
                    isSheetTransparency = true
                }
                .sheet(isPresented: $isSheetTransparency) {
                    Text("大熊猫侯佩 @ csdn")
                        .font(.headline)
                        .presentationDetents([.height(120), .medium, .large])
                        .presentationBackground(.ultraThinMaterial)
                }
            }
            .font(.headline)
            .buttonStyle(.borderedProminent)
            .padding(.top, 200)
            .padding(.horizontal, 50)
        }
    }
}

在这里插入图片描述

3. sheet 顶部弧度调整

感觉 sheet 弹窗顶角生硬无弧度的小伙伴们有福了,从 iOS 16.4 开始,SwiftUI 开始支持调整 sheet 弹出窗口顶角的弧度了。

我们可以使用 .presentationCornerRadius() 修改器来实现这一功能:

@available(iOS 16.4, *)
struct ContentView: View {
    @State private var isSheet = false
    @State private var isSheetRadius = false
    
    var body: some View {
        ZStack(alignment: .top) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            
            HStack {
                Button("弹出") {
                    isSheet = true
                }
                .sheet(isPresented: $isSheet) {
                    Text("大熊猫侯佩 @ csdn")
                        .font(.headline)
                        .presentationDetents(.height(120), .medium, .large])
                }
                
                Spacer()
                
                Button("顶角圆润弧度弹出") {
                    isSheetRadius = true
                }
                .sheet(isPresented: $isSheetRadius) {
                    Text("大熊猫侯佩 @ csdn")
                        .font(.headline)
                        .presentationDetents([.height(120), .medium, .large])
                        .presentationCornerRadius(30.0)
                }
            }
            .font(.headline)
            .buttonStyle(.borderedProminent)
            .padding(.top, 200)
            .padding(.horizontal, 50)
        }
    }
}

在这里插入图片描述

4. sheet 滚动手势优先级调整

在 iOS 16.4 之前,如果我们 sheet 尺寸可变弹窗中包含滚动视图(比如 List,ScrollView 等),当用户在弹窗中滚动将会首先引起弹窗尺寸的改变,而不是其滚动内容的改变。

在 iOS 16.4 之后,我们可以调整 sheet 弹窗滚动手势优先级,以确保首先滚动其内容而不是改变弹窗尺寸。

这是通过 .presentationContentInteraction(.scrolls) 方法来实现的:

@available(iOS 16.4, *)
struct ContentView: View {
    @State private var isSheet = false
    @State private var isSheetScrollable = false
    
    var body: some View {
        
        ZStack(alignment: .top) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            
            
            HStack {
                Button("弹出") {
                    isSheet = true
                }
                .sheet(isPresented: $isSheet) {
                    VStack(spacing: 16) {
                        Text("大熊猫侯佩 @ csdn")
                            .font(.headline)
                        List(0..<50, id: \.self){ i in
                            Text("Item \(i)")
                                .font(.subheadline)
                        }
                        .listStyle(.plain)
                    }
                    .padding()
                    .presentationDetents([.height(120), .medium, .large])
                }
                
                Spacer()
                
                Button("滚动高优先级弹出") {
                    isSheetScrollable = true
                }
                .sheet(isPresented: $isSheetScrollable) {
                    VStack(spacing: 16) {
                        Text("大熊猫侯佩 @ csdn")
                            .font(.headline)
                        List(0..<50, id: \.self){ i in
                            Text("Item \(i)")
                                .font(.subheadline)
                        }
                        .listStyle(.plain)
                    }
                    .padding()
                    .presentationDetents([.height(120), .medium, .large])
                    .presentationContentInteraction(.scrolls)
                }
            }
            .font(.headline)
            .buttonStyle(.borderedProminent)
            .padding(.top, 200)
            .padding(.horizontal, 50)
        }
    }
}

在这里插入图片描述

5. 定制 sheet 在紧密尺寸下的大小

在 iOS 16.4 之前,如果在 iPhone 横屏时 sheet 弹窗,则弹出窗口将会铺满整个屏幕。

从 iOS 16.4 开始,我们可以为弹窗应用新的 .presentationCompactAdaptation(_: ) 修改器来改变横屏时弹窗的大小:

struct SheetView: View {
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        VStack(spacing: 16) {
            Text("大熊猫侯佩 @ csdn")
            Button("关闭"){
                dismiss()
            }
        }
    }
}

@available(iOS 16.4, *)
struct ContentView: View {
    @State private var isSheet = false
    @State private var isSheetCompactSizeCustom = false
    
    var body: some View {
                
        ZStack(alignment: .top) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            
            HStack {
                Button("弹出") {
                    isSheet = true
                }
                .sheet(isPresented: $isSheet) {
                    SheetView()
                        .padding()
                        .frame(width: 200)
                        .presentationDetents([.height(200), .medium, .large])
                }
                
                Spacer()
                
                Button("自定义尺寸弹出") {
                    isSheetCompactSizeCustom = true
                }
                .sheet(isPresented: $isSheetCompactSizeCustom) {
                    VStack(spacing: 16) {
                        Text("大熊猫侯佩 @ csdn")
                    }
                    .padding()
                    .frame(width: 350)
                    .presentationDetents([.height(200), .medium, .large])
                    .presentationCompactAdaptation(.sheet)
                }
            }
            .font(.headline)
            .buttonStyle(.borderedProminent)
            .padding(.top, 200)
            .padding(.horizontal, 50)
        }
    }
}

在这里插入图片描述

6. Xcode 预览模式对调试输出的支持

Xcode 14.3 之前,我们在预览(Preview)模式中测试 SwiftUI 界面功能时无法观察调试语句( print 等方法)的输出结果,必须在模拟器或真机中运行才可以在 Xcode 底部调试小窗口中看到 print() 等方法的输出。

从 Xcode 14.3 开始,以预览模式运行 App 时也可以在调试窗口中看到调试语句的输出了,真是太方便了:

@available(iOS 16.4, *)
struct ContentView: View {
    
    var body: some View {
                
        ZStack(alignment: .center) {
            Rectangle()
                .fill(Gradient(colors: [.red,.green]).opacity(0.66))
                .ignoresSafeArea()
            
            Button("显示 debug 输出") {
                print("显示随机数: \(Int.random(in: 0..<10000000))")
            }
        }
    }
}

在这里插入图片描述

7. 总结

在本篇博文中,我们介绍了在 Xcode 14.3 和 iOS 16.4 中 SwiftUI 为开发者带来的新方法和新功能,解决了诸多燃眉之急的问题,小伙伴们不想赶快尝试一下吗?🚀

感谢观赏,再会!8-)

有用的知识又增加了:为何无法编译某些  WWDC 官方视频中的代码?

2025年6月27日 10:12

在这里插入图片描述

概览

作为 Apple 开发者而言,每期 WWDC 官方视频无疑是我们日常必看的内容。

不过,小伙伴们是否发现视频中有些示例代码在我们自己测试时却无法编译这一尴尬的情况呢?

在本篇博文中,我们将通过一则非常简单的示例来向大家展示为什么会出现这种情况,以及如何解决它!

闲言不再,Let‘s go!!!;)


无法编译! 搞什么飞机?

Apple 在 WWDC21 关于 SwiftUI 3.0 的介绍视频中,曾经谈到了 Button 视图新的显示方式。

其中, 该演示视频强调过,码农们可以用新加入的 .buttonStyle(.bordered)、 .controlSize(.large) 以及 .controlProminence(.increased) 修改器方法来进一步增强按钮的外观显示:

Button {
        ...
  } label: {
    Text("Continue")
      .frame(maxWidth: .infinity)
  }
  .buttonStyle(.bordered)
  .controlSize(.large)
  .controlProminence(.increased)
  .padding(.horizontal)

按钮的显示应该为如下效果:

在这里插入图片描述

不过,现在我们在 Xcode 14.3 中编译如上代码,妥妥的会报错:

在这里插入图片描述

因为 SwiftUI 根本找不到 controlProminence() 这个方法!这是怎么回事呢?

Apple 的“小心机”

Apple 在每次 WWDC 视频中都会展示一些新的功能,其中包括一些新的方法,属性和类等。

不过,上述这些内容并不都是“板上钉钉”的事,有些可能会在正式代码中做出修改甚至删减。

Apple 这种又想“炫酷”又时常“翻脸不认人”的行为,对我们这些秃头码农来说是非常蛋疼的。

在这里插入图片描述

博文开头的“惨案”就由此而引出。

在 WWDC 视频发布时苹果“一拍脑袋”想出了个 controlProminence() 方法来渲染背景突出按钮的显示效果,可在 SwiftUI 3.0 正式发布时却觉得不妥对其做了人道毁灭!

都快两年了,你好歹也更新一下原来错误的视频啊!不可能!这对 Apple 来说绝不可能!!!

在这里插入图片描述

解决之道

所幸的是,诸如此类问题我们都可以自行搜索到解决之法,比如在一些技术大牛(比如我 ;-) )的博客、stackoverflow、Apple 官方论坛、某哥里等等。

拿上面的问题来说吧,Apple 连吱都不吱一声就删除了 controlProminence() 方法,而将 SwiftUI 按钮背景突出显示的功能放在了 buttonStyle 的 borderedProminent 样式中,你说气人不气人 :-|!

所以,原来的代码现在应该修改为如下样式:

Button {} label: {
    Text("Continue")
      .frame(maxWidth: .infinity)
  }
  .buttonStyle(.borderedProminent)
  .controlSize(.large)
  .padding(.horizontal)

现在疑惑终于解开了,我们又可以边掉头发边观赏可能有些许“变质”的 WWDC 官方视频了!棒棒哒!💯

总结

在本篇博文中,我们讨论了为何有些  官方 WWDC 视频中的示例代码无法编译通过的问题,并给出解决思路。

感谢观赏,再会 8-)

iOS 17(SwiftUI 5.0)带来的图表(Charts)新类型:“大饼”与“甜甜圈”

2025年6月27日 10:00

在这里插入图片描述

概览

WWDC 2023 为我们带来了 iOS 17,也为我们带来了 SwiftUI 5.0。

在 SwiftUI 新版中,Apple 增加了很多重磅功能,也对原有功能做了大幅度升级。

对于 Charts 框架, 新增了饼图(Pie)类型并且加入了图表元素的原生选择功能。

在这里插入图片描述

在本篇博文中,就让我们一起来看看 SwiftUI 5.0 中这些激动人心的新功能吧!

  1. "大饼"与"甜甜圈"
  2. 图表元素的选中
  3. 填上 WWDC 23 视频中的“坑”

Let's go!!!:)


1. "大饼"与"甜甜圈"

SwiftUI 5.0 在 4.0 众多图表类型基础之上,增加了全新的 饼图(Pie) 类型,我们可以通过它来更形象的展示图表数据。


注意:本文中的代码需要 Xcode 15 beta 版才能编译和运行。


下面是 SwiftUI 4.0 Charts 条状图的展示:

在这里插入图片描述

代码如下:

@Model
final class Item {
    var name: String
    var power: Int
    var timestamp: Date
    
    init(name: String, power: Int) {
        self.name = name
        self.power = power
        timestamp = Date.now
    }
}

Chart(items) { item in
    BarMark(x: .value("power", item.power), stacking: .normalized)
        .foregroundStyle(by: .value("name", item.name))
}
.chartLegend(.hidden)

想改为使用新饼图类型非常简单,只需将上面的 BarMark 换为 SectorMark 即可:

SectorMark(angle: .value("power", item.power))

在这里插入图片描述

我们可以调整每块“大饼”的空隙大小(angularInset)和圆角的弧度(cornerRadius):

SectorMark(angle: .value("power", item.power),angularInset: 3.0)
    .cornerRadius(10)

在这里插入图片描述

值得注意的是:Charts 中饼图数据改变的动画效果做的也非常生动,SwiftUI 会自动根据状态的变化来合成自然的动画,无需多写半行代码。

不过,“大饼”虽好,“甜甜圈”更佳!

小孩子才做选择,光有“大饼”怎么行,我们连“甜甜圈”也统统都要了🍩!

实现“甜甜圈”(饼图空心)效果也很容易,我们只需调整 SectorMark 构造器中 innerRadius 属性的值即可:

SectorMark(angle: .value("power", item.power),
           innerRadius: .ratio(innerRadius),
           angularInset: 3.0
)

在这里插入图片描述

好诱人的“甜甜圈”哦,有没有想吃的欲望呢?;)

2. 图表元素的选中

除了加入新图表类型以外,SwiftUI 5.0 中 Charts 终于可以支持原生选择啦!

现在,我们无需再手动计算是图表中哪个元素被选中了,一切回归简洁:

struct LocationDetailsChart: View {
  @Binding var rawSelectedDate: Date?

  var body: some View {
    Chart {
      ForEach(data) { series in
        ForEach(series.sales, id: \.day) { element in
          LineMark(
            x: .value("Day", element.day, unit: .day),
            y: .value("Sales", element.sales)
          )
        }
        .foregroundStyle(by: .value("City", series.city))
        .symbol(by: .value("City", series.city))
        .interpolationMethod(.catmullRom)
      }
    }
    .chartXSelection(value: $rawSelectedDate)
  }
}

如上代码所示,我们使用 chartXSelection(value:) 修改器方法将当前选中的数据放入指定的绑定($rawSelectedDate)中。

在这里插入图片描述

除了选择单个图表元素,我们还可以选择一段范围内的元素集合:

Chart(data) { series in
  ForEach(series.sales, id: \.day) { element in
    LineMark(
      x: .value("Day", element.day, unit: .day),
      y: .value("Sales", element.sales)
    )
  }
  ...
}
.chartXSelection(value: $rawSelectedDate)
.chartXSelection(range: $rawSelectedRange)

在这里插入图片描述

那么问题来了,能不能选中 SwiftUI 5.0 图表新饼图类型的“大饼”元素呢?答案是肯定的!

下面是官方视频中对应的代码:

Chart(data, id: \.name) { element in
  SectorMark(
    angle: .value("Sales", element.sales),
    innerRadius: .ratio(0.618),
    angularInset: 1.5
  )
  .cornerRadius(5)
  .foregroundStyle(by: .value("Name", element.name))
  .opacity(element.name == selectedName ? 1.0 : 0.3)
}
.chartAngleSelection(value: $selectedAngle)

类似的, 通过 chartAngleSelection(value:) 修改器方法实现了饼图元素的选中:

在这里插入图片描述

不过,单从这段代码我们还是无法了解饼图元素选中的实现细节,比如:selectedAngle 是什么?它是如何转换成 selectedName 的呢?

为什么  在此要“犹抱琵琶半遮面”隐藏相关的细节呢?这不禁让我预感到它会是一个“坑”!

“坑”中的实现很可能在 iOS 17 正式版中会有所不同,所以  才会这样“遮遮掩掩”。


想要了解更多相关的内容,请移步如下链接观赏:


3. 填上 WWDC 23 视频中的“坑”

WWDC 23 中对应内容的官方视频在下面,想要了解来龙去脉的小伙伴们可以“肆意”观赏:

尽管官方视频中的代码对如何完成饼图元素选中功能“闪烁其词”,但我们可以自己发挥“主观能动性”来大胆推测一下它的实现细节:即自己搞定“甜甜圈”的选中功能。

首先我们要搞清楚的是, chartAngleSelection 方法参数中的绑定值到底是个啥:

public func chartAngleSelection<P>(_ binding: Binding<P?>) -> some View where P : Plottable

我们可以通过监视 angleValue 的值,来看看它是如何跟随我们点击而变化的:

struct ContentView: View {
    // 省略其它状态定义...
    @Query private var items: [Item]
    @State private var angleValue: Int?
    
    var body: some View {
        NavigationView {
            List {
                Chart(items) { item in
                    SectorMark(angle: .value("power", item.power),
                               innerRadius: .ratio(innerRadius),
                               angularInset: 3.0
                    )
                    .cornerRadius(10)
                    .foregroundStyle(by: .value("name", item.name))
                }
                .chartLegend(.hidden)
                .chartAngleSelection($angleValue)
                .onChange(of: angleValue){ old,new in
                // 探查 angleValue 的真正面目...
                    print("new angle value: \(new)")
                }.padding(.vertical, 50)
                
                ForEach(items) { ... }
            }
            .navigationTitle("饼图演示")
        }
    }
}

在这里插入图片描述

如上图所示:chartAngleSelection($angleValue) 方法中的绑定是一个数量值(定义成浮点数类型也可以),我们还发现 angleValue 在 0° 位置附近点击时值越小,而在 360° 位置点击时值越大。

经过验证可得:angleValue 最大值就是 items 中所有元素 power 值的和!据此,我们可以轻松写一个从 angleValue 值找到对应选中 item 的方法:

private func findSltItem() -> Item? {
    guard let slt = angleValue else { return nil }
    
    var sum = 0
    // 若 angleValue 小于第一个 item.power ,则表示选择的是图表中首张“大饼”!
    var sltItem = items.first
    for item in items {
        sum += item.power
        // 试探正确选中的饼图元素
        if sum >= slt {
            sltItem = item
            break
        }
    }
    return sltItem
}

我们现在可以根据饼图中当前选中的 angleValue 值,轻松找到对应的 Item 了:

struct ContentView: View {
    // 省略其它状态定义...
    @Query private var items: [Item]
    @State private var angleValue: Int?
    @State private var sltItem: Item?
    
    var body: some View {
        NavigationView {
            List {
                Chart(items) { item in
                    SectorMark(angle: .value("power", item.power),
                               innerRadius: .ratio(innerRadius),
                               angularInset: 3.0
                    )
                    .cornerRadius(10)
                    .foregroundStyle(by: .value("name", item.name))
                    .opacity(sltItem?.id == item.id ? 1.0 : 0.3)
                }
                .onChange(of: angleValue){ old,new in
                    withAnimation {
                        if let item = findSltItem() {
                            if item == sltItem {
                                // 点击已被选中的元素时取消选择
                                sltItem = nil
                            }else{
                                sltItem = item
                            }
                        }
                    }
                }.padding(.vertical, 50)
                
                ForEach(items) {...}
            }
            .navigationTitle("饼图演示")
        }
    }
}

效果如下:

在这里插入图片描述

看来为  WWDC 官方代码填坑的感觉也很不错哦😘💯

总结

在本篇博文中,我们介绍了 WWDC 23 最新 SwiftUI 5.0(iOS 17)中关于图表的新体验,学习了如何创建饼图(Pie)和实现 Charts 元素的选中功能,小伙伴们还不赶快操练起来!

感谢观赏,再会!8-)

Xcode 高效秘诀:这 11 个快捷键你必须知道!

作者 iOS新知
2025年6月26日 20:02

这里每天分享一个 iOS 的新知识,快来关注我吧

前言

作为一个 iOS 开发者,在使用 Xcode 时,掌握键盘快捷键是提高生产力和效率的关键。在这篇文章中,我将为大家介绍一些我最喜欢的 Xcode 快捷键。

其实之前也写过一些相关的文章,感兴趣的也可以去看看:

快捷键速查表

在开始之前,先来一个快捷键速查表:

  • ⌘ - Command

  • ⇧ - Shift

  • ⌥ - Option/Alt

  • ⌃ - Control

现在,让我们一起深入了解吧。

1. 产品菜单快捷键

首先,让我们从 Xcode 的产品菜单中一些基础快捷键开始:

  • 运行:⌘ R

  • 测试:⌘ U

  • 清理构建文件夹:⇧ ⌘ K

  • 清理 DerivedData 文件夹:⌘ ⇧ K

  • 停止:⌘ .

如果你已经是一个 iOS 开发者了,相信这些快捷键你已经很熟悉了,下面我们再介绍一些更高级的快捷键。

2. 快速导航

在处理不同文件,或者跟踪调用栈来解决问题时,快速导航是节省时间的法宝:

  • 前进:⌃ ⌘ →

  • 后退:⌃ ⌘ ←

3. 快速打开与跳转定义

另一个重要的快捷键是快速打开文件,使用 ⇧ ⌘ O,这个快捷键不仅可以搜索文件,还可以搜索类名和方法。

image.png

使用此快捷键后,跳转到定义,然后如果你想知道当前方法的所在的文件,可以使用 ⌃ ⌘ J 快捷键。

4. 查找功能

无论是在当前打开的文件中,还是在整个项目/工作区内,查找都是必不可少的操作:

  • 当前文件查找:⌘ F

  • 当前文件查找并替换:⌥ ⌘ F

  • 全局查找:⇧ ⌘ F

5. 视图管理

能够快速显示和隐藏不同的 Xcode 区域特别有用,尤其是在较小屏幕上工作时:

  • 显示/隐藏项目导航器:⌘ 0

  • 显示/隐藏调试控制台:⌘ ⇧ Y

  • 显示/隐藏检查器:⌘ ⌥ 0

6. 快速打开两个文件并排显示

我之前提到过的快速打开命令 ⌘ ⇧ O,在结合使用 ⌥ 时更加强大。

  • 使用 ⌘ ⇧ O 打开对话框

  • 输入你要查找的文件名

  • 按住 ⌥ 键然后用鼠标单击目标项目,或者使用 enter 键选择文件。新文件将会在一个单独的编辑器中打开,这样你可以继续在当前文件上工作,同时访问新打开的文件。

这个 ⌥ 技巧在项目导航器中选择文件时也同样适用。

7. 快速跳转到文件中的特定方法

对于大型文件中有众多方法的情况,这个快捷键非常有用,因为滚动很快会变得繁琐。

  • 使用 ⌃ 6 打开文档结构

  • 开始输入方法名

  • 使用 enter 键跳转到该方法

image.png

在输入时还可以模糊匹配,Xcode 会为你完成搜索。

8. 重复上一次测试

如果你经常写单元测试,快捷键 ⌃ ⌘ ⌥ G 在特别有用,可以重复运行我们上次进行的测试。

9. 重新缩进代码

我们可以通过按 ⌃ I 来重新缩进选定的代码,这在代码位置混乱时(例如在重构之后)特别有用。

我一般会配合全选快捷键 ⌘ A 一起使用,先全选再重新缩进,这样就可以将整个文件的代码进行重新缩进了。

10. 启用/禁用断点

在调试代码时,快捷键 ⌘ Y 可以帮助我们快速启用和禁用断点。

11. 不编译运行

这个快捷键非常有用,但可能很多人不知道。

在开发过程中,我们通常使用 ⌘ R 来运行代码,但这个命令其实是先编译再运行,但有时候我们并不需要编译,比如我刚执行完 ⌘ B,或者刚刚运行完没改代码的情况下想再运行一次。

这时候就可以使用 ⌃ ⌘ R 快捷键,直接运行,不用重新编译,非常节省时间。

总结

通过掌握这些快捷键,你可以大大提高在 Xcode 中的工作效率,节省宝贵的时间,让开发过程更加顺畅。希望这些快捷键能为你的开发旅程带来更多便利,你还有哪些喜欢的快捷键,欢迎在评论区留言分享。

这里每天分享一个 iOS 的新知识,快来关注我吧

本文同步自微信公众号 “iOS新知”,每天准时分享一个新知识,这里只是同步,想要及时学到就来关注我吧!

Swift 新并发模型中 isolated 和 nonisolated 关键字的含义看这篇就懂了!

2025年6月26日 11:30

在这里插入图片描述

概览

在 Swift 新 async/await 并发模型中,我们可以利用 Actor 来避免并发同步时的数据竞争,并从语义上简化代码。

Actor 伴随着两个独特关键字:isolatednonisolated,弄懂它们的含义、合理合规的使用它们是完美实现同步的必要条件。

那么小伙伴们真的搞清楚它们了吗?

在本篇博文中,您将学到如下内容:

  1. isolated 关键字
  2. nonisolated 关键字
  3. 没有被 async 修饰的方法也可以被异步等待!

闲言少叙,让我们即刻启航!

Let‘s go!!!;)


isolated 关键字

Actor 从本质上来说就是一个同步器,它必须严格限制单个实例执行上下文以满足同步的语义。

这意味着在 Actor 中,所有可变属性、计算属性以及方法等默认都是被隔离执行的。

actor Foo {
    let name: String
    let age: Int
    var luck = 0
    
    init(name: String, age: Int, luck: Int = 0) {
        self.name = name
        self.age = age
        self.luck = luck
    }
    
    func incLuck() {
        luck += 1
    }
    
    var fullDesc: String {
        "\(name)[\(age)] luck is *\(luck)*"
    }
}

如上代码所示,Foo 中的 luck 可变属性、incLuck 方法以及 fullDesc 计算属性默认都被打上了 isolated 烙印。大家可以想象它们前面都隐式被 isolated 关键字修饰着,但这不能写出来,如果写出来就会报错:

在这里插入图片描述

在实际访问或调用这些属性或方法时,必须使用 await 关键字:

Task {
    let foo = Foo(name: "hopy", age: 11)
    await foo.incLuck()
    print(await foo.luck)
    print(await foo.fullDesc)
}

正是 await 关键字为 Foo 实例内容的同步创造了隔离条件,以摧枯拉朽之势将数据竞争覆巢毁卵。

nonisolated 关键字

但是在有些情况下 isolated 未免有些“防御过度”了。

比如,如果我们希望 Foo 支持 CustomStringConvertible 协议,那么势必需要实现 description 属性:

extension Foo: CustomStringConvertible {
    var description: String {
        "\(name)[\(age)]"
    }
}

如果大家像上面这样写,那将会妥妥的报错:

在这里插入图片描述

因为 description 作为计算属性放在 Actor 中,其本身默认处在“隔离”状态,而 CustomStringConvertible 对应的 description 实现必须是“非隔离”状态!

大家可以这样理解:我们不能异步调用 foo.description!

extension Foo: CustomStringConvertible {
    /*
    var description: String {
        "\(name)[\(age)]"
    }*/
    
    var fakeDescription: String {
        "\(name)[\(age)]"
    }
}

Task {
    let foo = Foo(name: "hopy", age: 11)
    // foo.description 不能异步执行!!!
    print(await foo.fakeDescription)
}

大家或许注意到,在 Foo#description 中,我们只使用了 Foo 中的只读属性。因为 Actor 中只读属性都是 nonisolated 隐式修饰,所以这时我们可以显式用 nonisolated 关键字修饰 description 属性,向 Swift 表明无需考虑 Foo#description 计算属性内部的同步问题,因为里面没有任何可变的内容:

extension Foo: CustomStringConvertible {
    nonisolated var description: String {
        "\(name)[\(age)]"
    }
}

Task {
    let foo = Foo(name: "hopy", age: 11)
    print(foo)
}

但是,如果 nonisolated 修饰的计算属性中含有可变(isolated)内容,还是会让编译器“怨声载道”:

在这里插入图片描述

没有被 async 修饰的方法也可以被异步等待!

最后,我们再介绍 isolated 关键字一个非常有用的使用场景。

考虑下面的 incLuck() 全局函数,它负责递增传入 Foo 实例的 luck 值,由于 Actor 同步保护“魔法”的存在,它必须是一个异步函数:

func incLuck(_ foo: Foo) async {
    await foo.incLuck()
}

不过,如果我们能够保证 incLuck() 方法传入 Foo 实参的“隔离性”,则可以直接访问其内部的“隔离”(可变)属性!

如何保证呢?

很简单,使用 isolated 关键字:

func incLuck2(_ foo: isolated Foo) {
    foo.luck += 1
}

看到了吗? luck 是 Foo 内部的“隔离”属性,但我们竟然可以在外部对其进行修改,是不是很神奇呢?

这里,虽然 incLuck2() 未用 async 修饰,但它仍是一个异步方法,我称之为全局“隐式异步”方法:

Task {
    let foo = Foo(name: "hopy", age: 11)
    await incLuck(foo)
    await incLuck2(foo)
}

虽然 foo 是一个 Actor 实例,它包含一些外部无法直接查看的“隔离”内容,但我们仍然可以使用一些调试手段探查其内部,比如 dump 方法:

Task {
    let foo = Foo(name: "hopy", age: 11)
    await incLuck(foo)
    await incLuck2(foo)
    dump(foo)
}

输出如下:

over
hopy[11]
▿ hopy[11] #0
  - $defaultActor: (Opaque Value)
  - name: "hopy"
  - age: 11
  - luck: 2

通过 dump() 方法输出可以看到,foo 的 luck 值被正确增加了 2 次,棒棒哒!!!💯

总结

在本篇博文中,我们通过几个通俗易懂的例子让小伙伴们轻松了解到 Swift 新 async/await 并发模型中 isolated 与 nonisolated 关键字的精髓,并对它们做了进一步的深入拓展。

感谢观赏,再会!8-)

如何让异步序列(AsyncSequence)优雅的感知被取消(Cancel)

2025年6月26日 11:23

在这里插入图片描述

概览

自  从 Swift 5.5 推出新的 async/await 并发模型以来,异步队列(AsyncSequence)就成为其中不可或缺的重要一员。

不同于普通的序列,异步序列有着特殊的“惰性”和并发性,若序列中的元素还未准备好,系统在耐心等待的同时,还将宝贵的线程资源供其它任务去使用,极大的提高了系统整体性能。

在本篇博文中,您将学到以下知识:

  1. 什么是异步序列?
  2. 创建自定义异步序列
  3. 另一种创建异步序列的方式:AsyncStream
  4. 取消异步序列的处理

什么是异步序列?

异步序列(AsyncSequence)严格的说是一个协议,它为遵守者提供异步的、序列的、迭代的序列元素访问。

在这里插入图片描述

表面看起来它是序列,实际上它内部元素是异步产生的,这意味着当子元素暂不可用时使用者将会等待直到它们可用为止: 在这里插入图片描述

诸多系统框架都对异步序列做了相应扩展,比如 Foundation 的 URL、 Combine 的发布器等等:

// Foundation
let url = URL(string: "https://kinds.blog.csdn.net/article/details/132787577")!
Task {
    do {
        for try await line in url.lines {
            print(line)
        }
    }catch{
        print("ERR: \(error.localizedDescription)")
    }
}

// Combine
let p = PassthroughSubject<Int,Never>()
for await val in p.values {
    print(val)
}

如上代码所示,URL#lines 和 Publisher#values 属性都是异步序列。

除了系统已为我们考虑的以外,我们自己同样可以非常方便的创建自定义异步序列。

创建自定义异步序列

一般来说,要创建自定义异步序列我们只需遵守 AsyncSequence 和 AsyncIteratorProtocol 协议即可:

在这里插入图片描述

下面我们就来创建一个“超级英雄们(Super Heros)”的异步序列吧:

struct SuperHeros: AsyncSequence, AsyncIteratorProtocol {
    
    private let heros = ["超人", "钢铁侠", "孙悟空", "元始天尊", "菩提老祖"]
    
    typealias Element = String
    var index = 0
    
    mutating func next() async throws -> Element? {
        defer { index += 1}
        
        try? await Task.sleep(for: .seconds(1.0))
        
        if index >= heros.count {
            return nil
        }else{
            return heros[index]
        }
    }
    
    func makeAsyncIterator() -> SuperHeros {
        self
    }
}

Task {
    let heros = SuperHeros()
    for try await hero in heros {
        print("出场英雄:\(hero)")
    }
}

以上异步序列会每隔 1 秒“产出”一名超级英雄:

在这里插入图片描述

如上代码所示,如果下一个超级英雄还未就绪,系统会在等待同时去执行其它合适的任务,不会有任何资源上的浪费。

另一种创建异步序列的方式:AsyncStream

其实,除了直接遵守 AsyncSequence 协议以外,我们还有另外一种选择:AsyncStream!

不像 AsyncSequence 和 AsyncIteratorProtocol 协议 ,AsyncStream 是彻头彻尾的结构(实体): 在这里插入图片描述

它提供两种构造器,分别供正常和异步序列产出(Spawning)情境使用:

public init(_ elementType: Element.Type = Element.self, bufferingPolicy limit: AsyncStream<Element>.Continuation.BufferingPolicy = .unbounded, _ build: (AsyncStream<Element>.Continuation) -> Void)

    
public init(unfolding produce: @escaping () async -> Element?, onCancel: (@Sendable () -> Void)? = nil)

下面为此举两个  官方提供的代码示例:

let stream_0 = AsyncStream<Int>(Int.self,
                    bufferingPolicy: .bufferingNewest(5)) { continuation in
     Task.detached {
         for _ in 0..<100 {
             await Task.sleep(1 * 1_000_000_000)
             continuation.yield(Int.random(in: 1...10))
         }
         continuation.finish()
    }
}

let stream_1 = AsyncStream<Int> {
    await Task.sleep(1 * 1_000_000_000)
    return Int.random(in: 1...10)
}

更多关于异步序列的知识,请小伙伴们移步如下链接观赏:


取消异步序列的处理

我们知道  新的 async/await 并发模型主打一个“结构化”,之所以称为“结构化”一个重要原因就是并发中所有任务都共同组成一个层级继承体系,当父任务出错或被取消时,所有子任务都会收到取消通知,异步序列同样也不例外。

就拿下面倒计时异步序列来说吧,它能感应父任务取消事件的原因是由于其中调用了 Task.sleep() 方法( sleep() 方法内部会对取消做出响应):

let countdown = AsyncStream<String> { continuation in
    Task {
        for i in (0...3).reversed() {
            try await Task.sleep(until: .now + .seconds(1.0), clock: .suspending)
            
            guard i > 0 else {
                continuation.yield(with: .success("🎉 " + "see you!!!"))
                return
            }
            
            continuation.yield("\(i) ...")
        }
    }
}

Task {
    for await count in countdown {
        print("current is \(count)")
    }
}

正常情况下,我们应该在异步序列计算昂贵元素之前显式检查 Cancel 状态:

let stream_1 = AsyncStream<Int> {
    // 假设 spawn() 是一个“昂贵”方法
    func spawn() -> Int {
        Int.random(in: 1...10)
    }
    
    // 或者使用 Task.checkCancellation() 处理异常
    if Task.isCancelled {
        return nil
    }
    
    return spawn()
}

在某些情况下,我们希望用自己的模型(Model)去关联退出状态,这时我们可以利用 withTaskCancellationHandler() 方法为异步序列保驾护航:

public func next() async -> Order? {
    return await withTaskCancellationHandler {
        let result = await kitchen.generateOrder()
        // 使用自定义模型中的状态来判断是否取消
        guard state.isRunning else {
            return nil
        }
        return result
    } onCancel: {
    // 在父任务取消时设置取消状态!
        state.cancel()
    }
}

注意,当父任务被取消时上面 onCancel() 闭包中的代码会立即执行,很可能和 withTaskCancellationHandler() 方法主体代码同步进行。

现在,在一些 Task 内置取消状态不适合或不及时的场合下,我们可以在异步序列中使用 withTaskCancellationHandler() 的 onCancel() 子句来更有效率的完成退出操作,棒棒哒!💯。

总结

在本篇博文中,我们首先简单介绍了什么是异步序列,接着学习了几种创建自定义异步序列的方法,最后我们讨论了如何优雅的取消异步序列的迭代。

感谢观赏,再会!8-)

Swift 5.9 与 SwiftUI 5.0 中新 Observation 框架应用之深入浅出

2025年6月25日 16:03

在这里插入图片描述

0. 概览

Swift 5.9 一声炮响为我们带来全新的宏(Macro)机制,也同时带来了干霄凌云的 Observation 框架。

在这里插入图片描述

Observation 框架可以增强通用场景下的使用,也可以搭配 SwiftUI 5.0 而获得双剑合璧的更强威力。

在本篇博文,您将学到如下内容:

  1. @Observable 宏
  2. 通用情境下如何观察 Observable 对象?
  3. Observable 对象与 SwiftUI 珠联璧合
  4. 被“抛弃的” @EnvironmentObject
  5. 在视图中将不可变 Observable 对象转换为可变对象的妙招

那么,就让我们赶快进入 Observation 奇妙的世界吧!

Let‘s go!!!;)


1. @Observable 宏

简单来说,Observation 框架为我们提供了集鲁棒性(robust)、安全性、高性能等三大特性为一身的 Swift 全新观察者设计模式。

它的核心功能在于:监视对象状态,并在改变时做出反应!

在这里插入图片描述

在 Swift 5.9 中,我们可以非常轻松的通过 @Observable 宏将普通类“转化为”可观察(Observable)类。自然,它们的实例都是可观察的:

@Observable
final class Hero {
    var name: String
    var power: Int
    
    init(name: String, power: Int) {
        self.name = name
        self.power = power
    }
}

@Observable
final class Model {
    var title: String
    var createAt: Date?
    var heros: [Hero]
    
    init(title: String, heros: [Hero]) {
        self.title = title
        self.createAt = Date.now
        self.heros = heros
    }
}

如上代码所示,我们定义了两个可观察类 Model 和 Hero,就是这么简单!

2. 通用情境下如何观察 Observable 对象?

在一个对象成为可观察之后,我们可以通过 withObservationTracking() 方法随时监听它状态的改变:

在这里插入图片描述

我们可以将对象需要监听的属性放在 withObservationTracking() 的 apply 闭包中,当且仅当( Hero 中其它属性的改变不予理会)这些属性发生改变时其 onChange 闭包将会被调用:

let hero = Hero(name: "大熊猫侯佩", power: 5)

func watching() {
    withObservationTracking({
        NSLog("力量参考值:\(hero.power)")
    }, onChange: {
        NSLog("改变之前的力量!:\(hero.power)")
        watching()
    })
}

watching()

hero.name = "地球熊猫"
hero.power = 11
hero.power = 121

以上代码输出如下:

在这里插入图片描述

使用 withObservationTracking() 方法有 3 点需要注意:

  1. 它默认只会被调用 1 次,所以上面为了能够重复监听,我们在 onChange 闭包里对 watching() 方法再次进行了调用;
  2. withObservationTracking() 方法的 apply 闭包不管如何都会被调用 1 次,即使其监听的属性从未改变过;
  3. 在监听闭包中只能得到属性改变前的旧值;

目前,上面测试代码在 Xcode 15 的 Playground 中编译会报错,提示如下:

error: test15.playground:8:13: error: external macro implementation type 'ObservationMacros.ObservableMacro' could not be found for macro 'Observable()' final class Hero { ^

Observation.Observable:2:180: note: 'Observable()' declared here @attached(member, names: named(_$observationRegistrar), named(access), named(withMutation)) @attached(memberAttribute) @attached(extension, conformances: Observable) public macro Observable() = #externalMacro(module: "ObservationMacros", type: "ObservableMacro")

小伙伴们可以把它们放在 Xcode 的 Command Line Tool 项目中进行测试:

在这里插入图片描述


3. Observable 对象与 SwiftUI 珠联璧合

要想发挥 Observable 对象的最大威力,我们需要 SwiftUI 来一拍即合。

在 SwiftUI 中,我们无需再显式调用 withObservationTracking() 方法来监听改变,如虎添翼的 SwiftUI 已为我们自动完成了所有这一切!

struct ContentView: View {
    let model = Model(title: "地球超级英雄", heros: [])

    var body: some View {        
        NavigationStack {
            Form {
                LabeledContent(content: {
                    Text(model.title)
                }, label: {
                    Text("藏匿点名称")
                })
                
                LabeledContent(content: {
                    Text(model.createAt?.formatted(date: .omitted, time: .standard) ?? "无")
                }, label: {
                    Text("更新时间")
                })
                
                Button("刷新") {
                    // SwiftUI 会自动监听可观察对象的改变,并刷新界面
                    model.title = "爱丽丝仙境兔子洞"
                    model.createAt = Date.now
                }
            }.navigationTitle(model.title)
        }
    }
}

注意,上面代码中 model 属性只是一个普通的 let 常量,即便如此 model 的改变仍会反映到界面上:

在这里插入图片描述

4. 被“抛弃的” @EnvironmentObject

有了 Swift 5.9 中新 Observation 框架加入游戏,在 SwiftUI 5.0 中 EnvironmentObject 再无用武之地,我们仅用 Environment 即可搞定一切!

早在 SwiftUI 1.0 版本时,其就已经提供了 Environment 对应的构造器:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen @propertyWrapper public struct Environment<Value> : DynamicProperty {...}

有了新 Observation 框架的入驻,结合其 Observable 可观察对象,Environment 可以再次大放异彩:

struct HeroListView: View {
    @Environment(Model.self) var model
    
    var body: some View {
        List(model.heros) { hero in
            HStack {
                Text(hero.name)
                    .font(.headline)
                Spacer()
                Text("\(hero.power)")
                    .font(.subheadline)
                    .foregroundStyle(.gray)
            }
        }
    }
}

struct ContentView: View {
    @State var model = Model(title: "地球超级英雄", heros: [
        .init(name: "大熊猫侯佩", power: 5),
        .init(name: "孙悟空", power: 1000),
        .init(name: "哪吒", power: 511)
    ])

    var body: some View {        
        NavigationStack {
            Form {
                NavigationLink(destination: HeroListView().environment(model)) {
                    Text("查看所有英雄")
                }
            }.navigationTitle(model.title)
        }
    }
}

现在,即使跨越多重层级关系我们也可以只通过 @Environment 而不用 @EnvironmentObject 来完成状态的间接传递了,是不是很赞呢?👍🏻

5. 在视图中将不可变 Observable 对象转换为可变对象的妙招

介绍了以上这许多,就还剩一个主题没有涉及:Observable 对象的可变性!

为了能够在子视图中更改对应的可观察对象,我们可以用 @Bindable 修饰传入的 Observable 对象:

struct HeroView: View {
    @Bindable var hero: Hero
    
    var body: some View {
        Form {
            TextField("名称", text: $hero.name)
            
            TextField("力量", text: .init(get: {
                String(hero.power) 
            }, set: {
                hero.power = Int($0) ?? 0
            }))
        }
    }
}

不过,对于之前 @Environment 那个例子来说,如何达到子视图能够修改传入的 @Environment 可观察对象呢?

别急,我们可以利用称为“临时可变(Temporary Variable)”的技术将原先不可变的可观察对象改为可变:

extension Hero: Identifiable {
    var id: String {
        name
    }
}

struct HeroListView: View {
    @Environment(Model.self) var model
    
    var body: some View {
        // 在 body 内将 model 改为可变
        @Bindable var model = model
        
        VStack {
            List(model.heros) { hero in
                HStack {
                    Text(hero.name)
                        .font(.headline)
                    Spacer()
                    Text("\(hero.power)")
                        .font(.subheadline)
                        .foregroundStyle(.gray)
                }
            }.safeAreaInset(edge: .bottom) {
            // 绑定可变 model 中的状态以修改英雄名称
                TextField("", text: $model.heros[0].name)
                    .padding()
            } 
        }
    }
}

运行效果如下:

在这里插入图片描述

“临时可变”这一技术可以用于视图中任何化“不变”为“可变”的场景中,当然对于直接视图间对象的传递,我们可以使用 @Bindable 这一更为“正统”的方法。

6. 总结

在本篇博文中,我们讨论了在 Swift 5.0 和 SwiftUI 5.0 中大放异彩 Observation 框架的使用,并就诸多技术细节问题给与了详细的介绍,愿君喜欢。

感谢观赏,再会!8-)

SwiftUI 4.0:两种方式实现子视图导航功能

2025年6月25日 15:58

在这里插入图片描述

0. 概览

从 SwiftUI 4.0 开始,觉悟了的苹果毅然抛弃了已“药石无效”的 NavigationView,改为使用全新的 NavigationStack 视图。

诚然,NavigationStack 从先进性来说比 NavigationView 有不小的提升,若要如数家珍得单开洋洋洒洒的一篇来介绍。


关于 SwiftUI 中旧 NavigationView 视图种种人神共愤的“诟病”和弊端,请移步我的其它专题博文观赏:


不过,这些都不是本篇的主旨。在本篇中我们将尝试一起另辟蹊径来完成两种截然不同的导航机制。

在本篇博文,您将学到如下内容:

  1. NavigationStack
  2. NavigationSplitView 导航之“假象”
  3. 洞若观火:在 iPad 上的比较

无需等待,Let’s go!!!;)


1. NavigationStack

从 SwiftUI 4.0 开始, 引入的新 NavigationStack 导航器终于不再以分散杂乱的数据作为导航触发媒介,而是将有序的数据集合作为导航跳转的核心来对待!(所以,一步跳回根视图成了雕虫小技)

其中,NavigationStack 导航器重要的组成部分 NavigationLink 除了为了兼容性暂时保留的传统跳转方式以外,主打以状态本身的值作为导航的基石。这样,对于不同类型状态触发的跳转,我们可以干净而从容的分别处理:

下面举一例。

首先,定义简单的数据结构,Alliance 中包含若干 Hero:

@Observable
final class Hero {
    var name: String
    var power: Int
    
    init(name: String, power: Int) {
        self.name = name
        self.power = power
    }
}

extension Hero: Identifiable {
    var id: String {
        name
    }
}

extension Hero: Hashable {
    static func == (lhs: Hero, rhs: Hero) -> Bool {
        lhs.name == rhs.name && lhs.power == rhs.power
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
        hasher.combine(power)
    }
}


@Observable
final class Alliance: Hashable {
    static func == (lhs: Alliance, rhs: Alliance) -> Bool {
        lhs.title == rhs.title
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(title)
    }
    
    var title: String
    var createAt: Date?
    var heros: [Hero]
    
    init(title: String, heros: [Hero]) {
        self.title = title
        self.createAt = Date.now
        self.heros = heros
    }
}

接下来是与之对应的子视图,注意驱动 NavigationLink 导航的是 Hero 本身,而不是什么“莫名奇妙”的条件变量:

struct HeroDetailView: View {
    let hero: Hero
    
    var body: some View {
        VStack {
            Text("力量: \(hero.power)")
                .font(.largeTitle)
            
        }.navigationTitle(hero.name)
    }
}

struct HeroListView: View {
    @Environment(Alliance.self) var model
    
    var body: some View {        
        VStack {
            List(model.heros) { hero in
                NavigationLink(value: hero) {
                    HStack {
                        Text(hero.name)
                            .font(.headline)
                        Spacer()
                        Text("\(hero.power)")
                            .font(.subheadline)
                            .foregroundStyle(.gray)
                    }
                }
            }
        }
    }
}

接着是主视图:

struct ContentView: View {
    @State var model = Alliance(title: "地球超级英雄", heros: [
        .init(name: "大熊猫侯佩", power: 5),
        .init(name: "孙悟空", power: 1000),
        .init(name: "哪吒", power: 511)
    ])

    var body: some View {
        NavigationStack {
            Form {
                NavigationLink("查看所有英雄", value: model)
            }
            .navigationDestination(for: Alliance.self) { model in
                HeroListView()
                    .environment(model)
            }
            .navigationDestination(for: Hero.self) { hero in
                HeroDetailView(hero: hero)
            }
            .navigationTitle(model.title)
        }
    }
}

从上面源代码中,我们可以看到几处有趣的地方:

  1. 子视图 HeroListView 和主视图 ContentView 都包含了 NavigationLink,但它们驱动状态的类型不一样(分别是 Hero 和 Model),这样不同的驱动源被清晰的区分开了;
  2. 放置 NavigationLink 和实际发生导航跳转的目标位置是分开的(通过 navigationDestination() 修改器),后者被放在了一起便于集中管理;

正是这些新的导航特性确保了导航逻辑代码清楚且集中,为日后自己或其它秃头码农来维护打下夯实基础:

在这里插入图片描述

以上就是第一种导航方法,即利用 NavigationStack + navigationDestination() 修改器方法来合作完成跳转功能。

2. NavigationSplitView 导航之“假象”

可能有的小伙伴们没太在意,SwiftUI 4.0 除了 NavigationStack 以外还新加入了另一个鲜为人知的导航器 NavigationSplitView!使用它,我们可以抛弃 navigationDestination() 去实现完全相同的导航功能。

我们对之前代码略作修改,看看能促成什么新奇的“玩法”:

struct HeroListView: View {
    @Environment(Alliance.self) var model
    @Binding var selection: Hero?
    
    var body: some View {        
        VStack {
            List(model.heros, selection: $selection) { hero in
                NavigationLink(value: hero) {
                    HStack {
                        Text(hero.name)
                            .font(.headline)
                        Spacer()
                        Text("\(hero.power)")
                            .font(.subheadline)
                            .foregroundStyle(.gray)
                    }
                }
            }
        }
    }
}

struct ContentView: View {
    @State var model = Alliance(title: "地球超级英雄", heros: [
        .init(name: "大熊猫侯佩", power: 5),
        .init(name: "孙悟空", power: 1000),
        .init(name: "哪吒", power: 511)
    ])
    
    @State private var selection: Hero?

    var body: some View {
        NavigationSplitView(sidebar: {
            HeroListView(selection: $selection)
                .environment(model)
                .navigationTitle("新导航方式")
        }, detail: {
            if let selection {
                HeroDetailView(hero: selection)
            } else {
                ContentUnavailableView("No Hero", systemImage: "person.badge.key.fill", description: Text("还未选中任何英雄!"))

            }
        })
    }
}

可以看到,修改后的代码与之前有几处不同:

  1. 使用 NavigationSplitView 而不是 NavigationStack;
  2. 没有使用任何 navigationDestination() 修改器方法;
  3. 向 List 构造器传入了 selection 参数,以判断用户选择了哪个 Hero;
  4. 根据 selection 的值驱动 NavigationSplitView 构造器 detail 闭包完成跳转功能;

简单来说:当用户选中任意 Hero 后,通过设置 NavigationSplitView 构造器 detail 闭包中的视图,我们完成了导航机制。

代码执行结果和之前几乎完全相同,这么神奇!?

可惜,你们看到的全是“假象”!!!

3. 洞若观火:在 iPad 上的比较

其实,设置 NavigationSplitView 构造器 detail 闭包的内容原本并不会造成导航跳转,它的原意是在大屏设备上更方便的利用大尺寸屏幕来浏览内容。

编译上面 NavigationSplitView 的实现,在 iPad 上运行看看效果:

在这里插入图片描述

看到了吗?第二种导航机制在大屏设备上原本并不是真正用来导航跳转的,只是在 iPhone 等小屏设备上它的行为退化成了导航!

而第一种导航实现是彻头彻尾、如假包换的“真”导航:

在这里插入图片描述

到底哪种方法更好一些?小伙伴们自有“仁者见仁智者见智”的看法,欢迎大家随后的讨论。

至此,我们完成了文章开头的目标,棒棒哒!!!💯

4. 总结

在本篇博文中,我们在 SwiftUI 4.0 里通过两种不同方式实现了相同的子视图导航功能,任君选择。

感谢观赏,再会!8-)

❌
❌