普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月12日掘金 iOS

淘宝 9 块 9 的 DeepSeek,撕开了魔幻世界的一角

作者 iOS研究院
2025年11月12日 08:54

前言

现在的世界是越来越魔幻,早就超出了常人的理解范畴。

我最近老刷淘宝,昨天不知哪来的兴致,没什么特定缘由,纯粹好奇当下的相关生态,便鬼使神差在搜索栏敲了 “DeepSeek”。翻了没两下,一家店的标题瞬间抓住了他的眼球 ——“无需部署、打开即用,全程不卡顿”,售价才 10 块钱,付款人数却已经破了 1000。

盯着页面琢磨了半天,越想越纳闷:不用买家本地部署,还能保证立刻响应,这到底是什么神仙操作?

要知道,DeepSeek 的热度虽已过去半年,但直到现在,偶尔还是能见到这玩意儿的身影,甚至可能让人猛地想起当初被它支配的日子。

于是点进去了,这数据还确实挺好的,DeepSeek的需求还是旺。图片

按捺不住满心的好奇,我干脆直接下了单、付了款。

收到货后

可等卖家发来所谓的 “产品”,当场就懵了 —— 那瞬间的冲击感,让差点以为自己不小心闯进了平行宇宙,满脑子都是 “这到底是啥?!”

图片

我第一反应直接懵了:这难道是遇到活菩萨了?居然自己部署了 DeepSeek,还开了公网供大家免费似的用?这算力得有多足啊?

有这资源,哥们儿直接去卖算力不比这 10 块钱一单赚得多?

可等定睛一看,瞬间愣住了 —— 这网址怎么这么眼熟?再仔细瞅了瞅,好家伙,居然是n.cn?!

**这不是...360的纳米搜索???图片

我点开另一个网址。

图片

我一时间无语凝噎,我的大脑宕机了整整10秒钟。我想到了这个事情比较抽象,但是,我没想到能抽象到这种地步。因为,反差感极强,我本来会以为,哥们就卖链接,肯定不少差评,但,事情不太一样了起来。这个评论区,几乎全是好评。

图片

甚至有300个88VIP的好评。我一条一条的看了下去,我感觉,这个世界在我的脑中,好像更魔幻,更立体了。有用户留言说,确实不卡,输出的代码很规范。图片

“可以啊,是免费的r1,跟描述一致,性价比很高,不用买会员了也。” 图片

**不用买会员了。我不知道你们是什么感受,我突然鼻子一酸。我看到的,忽然是一个个非常活生生、立体形象的人。

或许对于这些Ta们来说:白天在公司,他得忍着老板的 PUA 强撑着干活,连反驳的底气都没有 —— 这份薪资微薄的工作,是他在四五线小城唯一的糊口依靠。

晚上回到十几平米的出租屋,狭小的空间里塞满了不甘。他太想改变命运了,太怕一辈子困在原地,所以总琢磨着学门新本事,抓住点什么。

AI、大模型、席卷时代的技术革命,这些词像针一样扎着他的心。他知道这是翻身的机会,可每一次听说,都伴随着深深的恐慌 —— 怕自己追不上这股浪潮,怕被时代彻底抛弃。

可真要迈出脚步,才发现前路全是望不到头的坎:官网得靠 “魔法上网” 才能访问,他摸不着门道;会员订阅几十上百块一个月,抵得上他好几天的饭钱,根本舍不得花;那些部署教程里的术语,像天书一样晦涩,硬生生把他挡在门外,连入门的缝隙都不给留。

好不容易扒到 DeepSeek 和 ChatGPT 的官网,他咬碎了牙才掏出不便宜的会员费,以为终于摸到了门槛。可实际用起来,却满是失望 —— 不仅卡顿得让人抓狂,历史记录里还莫名冒出不属于自己的内容,钱花得冤枉又憋屈。

一次次尝试,一次次被现实打回原形。他就像个被遗弃在站台外的人,眼睁睁看着时代的列车呼啸而过,自己却连上车的资格都没有。那种求而不得的无力感,沉甸甸压在胸口,让他喘不过气。

直到那天在淘宝刷到那个 9.9 元的商品,“无需部署”“打开即用”“立刻响应” 这几个字,像黑夜里的一束光,瞬间照亮了他的渴望。

他反复看了好几遍商品页面,纠结了很久 —— 兜里的钱每一分都要算计,他怕这又是一场骗局,怕最后连这两顿拼好饭的钱都打了水漂。可对改变的渴望,终究压过了所有顾虑。

下单后,他攥着手机等回复,收到可直接点开的链接时,手指都带着点颤抖。怀着忐忑点进去,居然真的能用,还异常流畅。

那一刻,他紧绷的肩膀突然垮了下来,长长舒了一口气,眼眶甚至有点发热。他觉得自己终于花最少的钱,攥住了那张三寸见方的、通往新世界的船票。

满心欢喜地跑到评论区,他认认真真打下 “性价比很高,不用买会员了”,字里行间全是满足。他甚至偷偷窃喜,觉得自己占了天大的便宜,用 9.9 元就撬动了原本遥不可及的生产力工具。

他把这个链接小心翼翼收进浏览器书签,像珍藏一件稀世珍宝。这是他在残酷生活里,拼尽全力才抓住的小小捷径,是支撑他继续往前走的一点希望。

可他不知道,这份被他视若珍宝的 “机会”,本就明晃晃摊在阳光下,对所有人免费开放。他只是恰好站在了信息的阴影里,没见过那片触手可及的光明,只能靠着这点 “微光”,笨拙又执着地追赶着时代的脚步。

AI的发展,永不止步。 愿所有人都能跟上时代的洪流,用自己信息差打出一片新的天地!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

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

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

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

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

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

SwiftUI-WebView 全面指南

作者 YungFan
2025年11月12日 10:38

介绍

在 iOS 开发中,网页内容展示几乎是每个 App 的刚需场景。无论是展示帮助中心、隐私政策,还是嵌入在线课程、文档预览等,WebView 都扮演着重要角色。SwiftUI 7.0 通过 WebKit 模块,可以轻松实现网页加载、导航控制以及 JavaScript 交互功能。本文将从基础到高级,循序渐进介绍 SwiftUI 中的 WebView 用法。

网页加载

  • 在使用 WebView 前,需要先导入 WebKit 模块。
  • 借助 URL 或者 WebPage,可以实现网页加载与状态管理功能。

直接加载URL

import SwiftUI
import WebKit

struct ContentView: View {
    let url = URL(string: "https://www.apple.com.cn")!

    var body: some View {
        WebView(url: url)
    }
}

使用WebPage

WebPage 是一个功能更强的类型,它不仅能加载网页,还能实时监控网页的标题、加载进度、URL 状态等信息。

基本用法

import SwiftUI
import WebKit

struct ContentView: View {
    @State private var webPage = WebPage()
    let url = URL(string: "https://www.apple.com.cn")!

    var body: some View {
        WebView(webPage)
            .ignoresSafeArea()
            .onAppear {
                webPage.load(URLRequest(url: url))
            }
    }
}

内容控制

import SwiftUI
import WebKit

struct ContentView: View {
    @State private var webPage = WebPage()
    let url = URL(string: "https://www.apple.com.cn")!

    var body: some View {
        VStack {
            // 标题
            Text(webPage.title)
            // 进度
            ProgressView("", value: webPage.estimatedProgress, total: 1.0)
            // 内容
            WebView(webPage)
                .ignoresSafeArea()
                .onAppear {
                    webPage.load(URLRequest(url: url))
                }
        }
    }
}

高级控制

导航策略

如果需要在网页跳转时进行判断(例如拦截某些 URL、仅允许访问特定域名),可以自定义导航策略。

import SwiftUI
import WebKit

// 导航处理方式
struct NavigationDecider: WebPage.NavigationDeciding {
    func decidePolicy(for response: WebPage.NavigationResponse) async -> WKNavigationResponsePolicy {
        if response.response.url?.absoluteString.starts(with: "https://www.apple.com") == true {
            .allow
        } else {
            .cancel
        }
    }
}

struct ContentView: View {
    @State private var webPage: WebPage = {
        var config = WebPage.Configuration()
        config.applicationNameForUserAgent = "User Agent"
        return WebPage(configuration: config, navigationDecider: NavigationDecider())
    }()
    let url = URL(string: "https://www.baidu.com")!

    var body: some View {
        VStack {
            Text(webPage.title)
            
            ProgressView("", value: webPage.estimatedProgress, total: 1.0)

            WebView(webPage)
                .ignoresSafeArea()
                .onAppear {
                    webPage.load(URLRequest(url: url))
                }
        }
    }
}

JavaScript 交互

许多场景需要与网页内部的 JavaScript 进行交互,如调用函数、获取返回值等。SwiftUI 也提供了非常方便的异步调用方式。

代码

import SwiftUI
import WebKit

struct ContentView: View {
    @State private var webPage = WebPage()
    @State private var title = ""
    let url = URL(string: "https://www.apple.com.cn")!

    var body: some View {
        VStack {
            title.isEmpty ? Text(webPage.title) : Text(title)

            ProgressView("", value: webPage.estimatedProgress, total: 1.0)

            WebView(webPage)
                .ignoresSafeArea()
                .onAppear {
                    webPage.load(URLRequest(url: url))
                }
                .task {
                    try? await Task.sleep(for: .seconds(3), clock: .suspending)
                    let jsResult = try? await webPage.callJavaScript(
                        """
                        console.log("Hello SwiftUI")
                        return "JavaScript Returned Value"
                        """
                    )
                    title = jsResult as! String
                }
        }
    }
}

效果

效果.gif

来了解一下,为什么你的 Flutter WebView 在 iOS 26 上有点击问题?

2025年11月12日 08:48

前段时间 #175099 又提出了一个 iOS 26 的问题,大概就是 webview_flutter 的点击事件又出现了“点不动”或“点了不触发” 的情况,源头还是 WKWebView(WebKit)内部的手势识别器与 Flutter 在 Engine 里用于“阻止/延迟”手势的 recognizer 之间的冲突

针对和这个问题,去年 iOS 18.2 beta 里有出现类似情况,而那时候在 Engine 里,可以通过 #56804 这个 PR,临时移除并再添加 delayingRecognizer 的实现来暂时绕过问题,主要是通过刷新 WebKit 的内部状态从而临时修复,但这个绕过在 iOS 26 上造成了另一个严重回归(overlay 的手势阻止失效、触摸穿透底下的 WebView),因此在最近被针对 iOS 26 的条件下回退(revert)了该提交。

另外也是因为 Flutter 团队发现这是 Apple / WebKit 的 bug ,所以也已经同步上报请求和 Apple 协作。

问题最开始出现在 iOS 18.2 beta 版本上,当页面上先触发了某些 Flutter widget(或者 overlay,比如 context menu / Drawer)后,WKWebView 内的点击(链接、按钮)不再响应(可高亮,但不会激活),需要重新加载 WebView 才恢复。

而具体原因在于,Flutter 在 iOS 的 PlatformView(例如承载 WKWebView 的视图)上实现了一套“手势拦截/延迟”机制:在需要时会把一个 FlutterDelayingGestureRecognizerdelayingRecognizer )切到某些状态(possible, ended, failed 等)来告诉 UIKit 或者其他 recognizers 是否应该阻止/允许手势传递。

而 UIKit 的手势识别器有自己的状态机(possiblerecognized/failed / ended ),不同 recognizer 相互之间会有阻塞/依赖关系:

https://developer.apple.com/documentation/uikit/about-the-gesture-recognizer-state-machine

这里需要简单介绍一个背景知识:Flutter + iOS 平台视图的手势处理机制,在 iOS 上当你把一个原生控件(比如 WKWebView)嵌进 Flutter 时,实际上会经历以下层级:

[FlutterView]               ← 整个 Flutter 渲染层(Dart UI 层)
   ├─ Flutter widgets
   │     ↑
   │     │ 手势事件由 Flutter framework(Dart)处理
   │
   └─ PlatformView (e.g. WKWebView)
         ↑
         │ 手势事件由 UIKit / WebKit 内部 recognizer 处理

Flutter 和 UIKit 都各自有手势识别系统(GestureRecognizer),为了防止互相抢事件,Flutter engine 在 iOS 上加入了一个“delaying gesture recognizer”(延迟识别器):

它的作用是:当 Flutter 框架检测到某个 widget 想“阻止”事件时(比如 GestureDetector 或 overlay 遮罩),Flutter 会让这个 delayingRecognizer 阻止 UIKit 里的 recognizer(例如 WKWebView 的点击识别器)响应。

这个系统在 Flutter → UIKit 手势交界处非常敏感,而问题就出现在:WebKit(WKWebView)内部的某些 recognizer 会“缓存”或持有对 delayingRecognizer 的“旧状态”,导致当 Flutter 在运行时切换 delayingRecognizer 状态(例如 blockGesture)时,WebKit 的部分识别器获取到了过时状态,从而无法触发正确的“激活 click”逻辑,例如:

它们可能只看到 failed/possible 的不一致组合,导致只高亮不执行动作。

针对这个问题,在 iOS 18.2 时,Flutter 团队进行了多种尝试,比如 toggle enabled、插入 dummy recognizer、异步 dispatch、重建 recognizer 实例等,最后发现移除并重新添加同一个 delayingRecognizer 实例 会触发 UIKit 重新刷新相关 recognizers 的关联,从而让 WebKit 的内部识别器看到“最新”状态并恢复点击功能:

在 blockGesture 的处理流程里把 delayingRecognizer 移除后再添加回去,以强制 UIKit/WebKit 刷新识别器关系,这个功能应该是在 3.29 的版本里发布了:

不过在 iOS 26 上,这个“移除再添加”的操作带来了新的严重问题:Flutter 的手势阻塞系统在某些场景(比如 Drawer/overlay)里完全失效,触摸会穿透到下面的 WebView,这比“点不动”更糟,因为会造成错点与功能错乱。

所以,Flutter 在针对 iOS 26(@available(iOS 26.0, \*))上不再执行“移除再添加 delayingRecognizer ” 的绕过逻辑,但回退会让之前通过绕过解决的“WebView 点不动”问题在 iOS 26 上再次出现。

很明显这是一个 iOS 18.2 时 WKWebView 自身就存在的 bug,并且因为系统升级修改,WebKit 内部 recognizer 缓存行为在新版 iOS 上变化,所以如果要完成修复问题,还是需要和 Apple 一起修复处理。

所以问题主要出现的场景在于:“必须在 WebView 上出现过 overlay 或类似触摸阻止的 widget” 才会触发 Bug ,比如:

  • 打开了一个半透明的 ModalBarrier
  • 弹出了一个 Drawer
  • 显示了一个半透明的 PopupMenu
  • 使用了 showDialog()
  • 甚至某些动画(Hero )在内部也会临时创建 overlay 层

这些操作的里根本的诱饵就是:“要阻止触摸传递到底层 platform view” ,于是 engine 调用 delaying_recognizer.blockGesture(true);,WKWebView 的内部 recognizer 因此暂停触发,然后 overlay 消失后,engine 再执行 blockGesture(false) ,但是 UIKit 没有恢复 WKWebView recognizer 的响应,从而导致问题。

而在 iOS18.2 可以通过 remove/add 的方式来重置刷新状态,但是在 iOS 26 上 Recognizer 重新添加后,看起来系统会重新建立默认的依赖关系,也就是当 Flutter 把 delaying recognizer 移除再添加时,UIKit 不仅刷新了它的依赖, 还重置了某些全局 recognizer 的 delaysTouchesBegan / requiresFailureOf 配置,这些配置正是 Flutter engine 用来防止 overlay 点击穿透的相关逻辑。

而针对这个问题,目前社区层面的临时解决方法是通过 pointer_interceptor 来规避 overlay 与 WebView 的事件竞争,核心是在 iOS 26 上的 WebView ,只要在它上方有视图并点击就会导致它停止接受点击,而在此之前 WebView 一直可以正常工作,所以使用 PointerInterceptor 可以防止在与 WebView 上方的视图交互后中断 WebView,例如:

  // to know anytime if we are on top of navigation stack
  bool get _isTopOfNavigationStack => ModalRoute.of(context)?.isCurrent ?? false;

  // Wrapper for the webview
  Widget buildWebviewWithIOSWorkaround(BuildContext context) {
    return Stack(
      children: [
        buildWebView(context),
        if (Platform.isIOS)
          Positioned.fill(
            child: PointerInterceptor(
              intercepting: !_isTopOfNavigationStack, // the webview is not on top -> inhib click
              debug: false,
              child: const SizedBox.expand()
            ),
          )
      ],
    );
  }

另外,在和 Apple 进行问题推动修复的同时,Flutter 也在需求一些外部解决思路,例如通过全新的 HitTest 来规避问题:

根据 #176597 ,主要基于假设大多数用例平台视图只有一个重叠,如果触摸位置在 Flutter Widget 和平台视图之间的“重叠”范围内,Flutter 会阻止平台视图上的所有 UIGestureRecognizer,具体为:

  • 定义了一个新的拦截策略枚举值:FlutterPlatformViewGestureRecognizersBlockingPolicyHitTestByOverlay( “通过 overlay 层的 hitTest 来阻止手势”)
  • 在 platform view 的 touch / hitTest 逻辑里加入判断:如果某点落在 overlay 区域,就让 hitTest: 返回拦截自己(self),而不是默认走到底层 WebView,也就是说 在 Flutter 层“用 hitTest”来屏蔽底层点击
  • blockGesture 方法里,对于这种 overlay-hitTest 类型的策略,PR 把原来 blockGesture 的逻辑改为 “no-op”(什么都不做),因为在这个策略下,“拦截”是在 hitTest 层做了,不需要再在 delaying recognizer 层去干预
  • 在 controller 更新 overlay 层(bringLayersIntoView:)时,把 overlay 视图引用记录下来,并赋给内部的 intercepting view(interceptor.overlays = overlays;)这样拦截逻辑有 overlay 区域信息可用

总的来说,改动提供了一种 “hitTest 层面的 overlay 拦截” 策略,不依赖 delaying recognizer 的状态切换,以避免手势状态切换带来的复杂性

但是,如果多个重叠被合并到一个触摸阻挡区域时,blocking area 将是一个包含所有重叠的区域。

不过维护人员在进行到一半的时候发现,完整解决方案也许并不难推进(我感觉是他的一厢情愿居多),所以决定关闭临时的 MVP 方案:

完整的解决方案,是依赖于 FFI 从 Flutter 的手势竞技场同步查询来做出决定,不过这又是属于另外一个重大改动了

所以目前推进流程进入到了 #177859 ,PR 将 不再通过“延迟(delaying)手势识别器”来阻塞 platform view 的手势,改成在 iOS 端对触点做 同步 hit-test(利用 FFI 从 framework 查询是否应接受/阻止该手势),解决了 web_view / admob 等平台视图不可点按的问题,并新增一个可选的 blocking policy(FlutterPlatformViewGestureRecognizersBlockingPolicyHitTest

具体调整有:

  • 从“延迟识别器(delaying recognizer)”切换到“hit test”决策

    • 以往的方案是把识别器设置成 delaying 类型,然后用延迟决策来阻塞/接受手势,本次 PR 直接改成直接做 hit test 判断是否应阻止该手势(在触点处是否落在应该被 platform view 拦截的区域)
  • FFI 同步调用框架(framework)以避免死锁

    • 直接让 embedder 在主线程等待 framework 的异步回应会导致主线程互相等待(deadlock),利用 FFI 在 native 层同步调用框架中的函数(_platformViewShouldAcceptGesture/platformViewShouldAcceptGesture`)来获得是否接受手势的结果,从而避免线程死锁问题
  • 新增/修改 policy 与 API 辅助

    • 通过 FlutterPlatformViewGestureRecognizersBlockingPolicyHitTest,逐步采纳并降低全局回归风险(也就是说不把旧策略直接替换掉,而是增加新的策略供插件或内部使用)

PR 还涉及 engine 的 UI 层( engine/src/.../hooks.dartplatform_configuration.cc 等)以及 iOS 平台 view / embedder 相关代码, 这些改动把 hit-test 的入口函数和 FFI 绑定、以及 platform view 手势决策路径连接起来 。

所以,这会是一个涉及很多地方的底层调整,也算是一个高风险的修改,特别是 iOS 平台 view(platform view)手势处理路径(尤其 web_view、admob、任何嵌入 UIView 的插件),目前建议事是需要插件作者(特别是官方 1P 插件)切换使用新 policy。

当然,PR 还需要等等,目前除此之外,我们也可以做的规避问题还有:

  • 避免在 WebView 上方显示需要拦截手势的复杂 overlay(尽量减少交互型 overlay),如果能避免覆盖 WebView,问题就不会触发
  • 在 overlay 关闭后重载或重建 WebView(重建 controller / reload),不过这种造成的闪烁其实并不友好

所以目前的方向,应该是先完成 #176597 的 PR,之后再实现 FFI 从框架中进行查询的完整解决方案,也就是从目前来说:

  • 对于 iOS 18.2 上的因为重叠控件导致 WebView 点击问题,需要 3.29 以及以上版本解决
  • 对于 iOS 26 上的因为重叠控件导致 WebView 点击问题
    • 3.35.4 之前,会出现触摸穿透问题
    • 3.35.4 之后,由于 cp revert,会恢复成点击无效问题
    • 以上两个问题可以使用 pointer_interceptor 来尝试规避
  • 等待官方内置 HitTest 和 FFI 解决方案发布

只能说,之前发布的线程合并为 FFI 提供了支持的基础,也为这次调整的方向提供了一种全新的思路,只是这个修改需要更加谨慎。

参考链接

苹果上线App Store Web版本,以后浏览外区更方便了

作者 CocoaKier
2025年11月11日 21:46

近期,苹果低调上线了网页版 App Storeapps.apple.com/cn

只要打开浏览器,用户就可以浏览AppStore了,即便非苹果设备也能访问,但目前只能浏览、搜索,不支持在网页端下载 app

网页版的一个亮点是支持快速切换区域,我们只需修改网页地址中的区域代码即可快速浏览其他地区的 App Store 内容。这对于竞品分析,特别是出海产品的竞品分析,带来了非常大的便利,可以更方便快捷地查看某个国家地区的榜单,同类型应用有哪些,某个应用在不同地区的可用性、价格、评分、评论情况。

中国大陆 apps.apple.com/cn/
中国香港:apps.apple.com/hk/
中国台湾:apps.apple.com/tw/
中国澳门:apps.apple.com/mo/
新加坡 apps.apple.com/sg/
日本 apps.apple.com/jp/
韩国 apps.apple.com/ru/
美国 apps.apple.com/us/

更多地区代码见《ISO 31666-1》中的两位字母改小写。

Web版本几乎就是复刻了App端的样式。 图片

图片

图片

Web版还支持切换到特定设备端进行浏览 图片

Web版有啥使用场景?

1、竞品分析
这个应该是它最大的价值,上文提到过,Web版可以方便快捷地浏览不同国家地区的商店页面。在此之前,我们可能需要借助第三方平台(比如 点点数据)才能实现,第三方平台的缺点:步骤繁琐(需要登录)、网页加载慢、数据延迟、部分数据VIP解锁。

2、浏览特定设备端(Mac、iPad、Vision、Watch)商店
以前,想看下某个产品(或者自家产品)有没有上Mac端,你首先得有一台Mac M系列芯片的电脑;
以前,想看下商店5图在iPad端的呈现效果,首先你得有一台iPad;
以前,作为独立开发,你可能想了解下iWatch上都有哪些产品,首先你得有一台iWatch;
Vision,更别说了...

现在,你只需要打开浏览器,切换一下左边的设备专区,不花一分钱,上面的这些都搞定!

3、安卓和Windows也可以浏览AppStore了
假如你是产品经理或者运营,你做调研需要浏览器AppStore,但你只有安卓手机和Windows电脑,以后不用再找同事借苹果手机了。

4、更快速的分享应用(商店地址)
运营时不时找我要公司产品的AppStore商店地址,以前我要么去自己存的xx.txt中找,要么打开点点数据搜应用,再点到苹果的商店页面,把浏览器URL复制给运营。现在好了,我可以直接打开App Store Web版,搜应用,然后把浏览器URL发给他们。或者我干脆告诉他们,你们自己去搜吧!

5、更大的屏幕、更方便的操作
电脑端浏览AppStore,有更大的屏幕、更方便的操作(键盘打字),无论是竞品分析,还是纯自己想找找新应用或游戏,都有更好的浏览和操作体验。这就是手机端腾讯视频和Web端腾讯视频的区别。

6、爬虫门槛更低了
以前爬苹果的数据,要么使用苹果官方的API(功能有限),要么破解App Store App抓包。现在Web版出来了,爬数据更方便了。

结语

苹果上线App Store Web版,不知道是出于什么目的,几方欢喜几方愁吧。对于开发者和用户更多的是便利,还是给苹果点个赞吧!

苹果上线App Store Web版,还闹出了点幺蛾子,感兴趣的可以看看: 《苹果又闹乌龙,App Store源码泄露》

相关阅读:
多地区发布,苹果商店下载链接怎么填

Root-> A ->B -> C page, 当前C page, 如何返回B,又如何直接返回A page呢

作者 littleplayer
2025年11月11日 18:12

在 SwiftUI 中,从 C 页面返回到 B 或直接返回到 A 页面有多种方式。以下是具体的实现方法:

1. 使用 NavigationStack 和 NavigationPath

基本设置

enum Route: Hashable {
    case a
    case b
    case c
}

struct ContentView: View {
    @State private var navigationPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            AView()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .a:
                        AView()
                    case .b:
                        BView()
                    case .c:
                        CView()
                    }
                }
        }
        .environment(\.navigationPath, $navigationPath)
    }
}

2. 从 C 返回 B,或直接返回 A

使用环境变量传递导航控制

// 定义环境Key
struct NavigationPathKey: EnvironmentKey {
    static let defaultValue: Binding<NavigationPath>? = nil
}

extension EnvironmentValues {
    var navigationPath: Binding<NavigationPath>? {
        get { self[NavigationPathKey.self] }
        set { self[NavigationPathKey.self] = newValue }
    }
}

// A 页面
struct AView: View {
    @Environment(\.navigationPath) private var navigationPath
    
    var body: some View {
        VStack {
            Text("A 页面")
            
            Button("前往 B 页面") {
                navigationPath?.wrappedValue.append(Route.b)
            }
            .padding()
        }
        .navigationTitle("A 页面")
    }
}

// B 页面
struct BView: View {
    @Environment(\.navigationPath) private var navigationPath
    
    var body: some View {
        VStack {
            Text("B 页面")
            
            Button("前往 C 页面") {
                navigationPath?.wrappedValue.append(Route.c)
            }
            .padding()
        }
        .navigationTitle("B 页面")
    }
}

// C 页面 - 关键部分
struct CView: View {
    @Environment(\.navigationPath) private var navigationPath
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        VStack(spacing: 20) {
            Text("C 页面")
            
            // 方法1: 返回上一页 (B)
            Button("返回上一页 (B)") {
                dismiss() // 或者 navigationPath?.wrappedValue.removeLast()
            }
            
            // 方法2: 直接返回 A 页面
            Button("直接返回 A 页面") {
                // 移除最后2个页面 (C 和 B)
                navigationPath?.wrappedValue.removeLast(2)
            }
            
            // 方法3: 返回根页面 (A)
            Button("返回根页面 (A)") {
                // 移除所有页面,回到根
                navigationPath?.wrappedValue.removeLast(navigationPath?.wrappedValue.count ?? 0)
            }
            
            // 方法4: 特定路径跳转
            Button("跳转到 A 页面") {
                // 清空路径,然后添加 A
                navigationPath?.wrappedValue = NavigationPath()
                navigationPath?.wrappedValue.append(Route.a)
            }
        }
        .navigationTitle("C 页面")
    }
}

3. 使用导航管理器 (推荐)

创建导航管理器

class NavigationManager: ObservableObject {
    @Published var path = NavigationPath()
    
    // 返回上一页
    func goBack() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }
    
    // 返回到特定页面
    func goBackToA() {
        // 假设路径是 [B, C],移除最后2个
        if path.count >= 2 {
            path.removeLast(2)
        }
    }
    
    // 返回到根页面
    func goToRoot() {
        path.removeLast(path.count)
    }
    
    // 导航到特定页面
    func navigateTo(_ route: Route) {
        path.append(route)
    }
}

在视图中使用

struct AppView: View {
    @StateObject private var navManager = NavigationManager()
    
    var body: some View {
        NavigationStack(path: $navManager.path) {
            AView()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .a: AView()
                    case .b: BView()
                    case .c: CView()
                    }
                }
        }
        .environmentObject(navManager)
    }
}

// C 页面使用导航管理器
struct CView: View {
    @EnvironmentObject private var navManager: NavigationManager
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        VStack(spacing: 20) {
            Text("C 页面")
            
            Button("返回上一页 (B)") {
                navManager.goBack()
            }
            .buttonStyle(.bordered)
            
            Button("直接返回 A 页面") {
                navManager.goBackToA()
            }
            .buttonStyle(.borderedProminent)
            
            Button("返回根页面") {
                navManager.goToRoot()
            }
            .buttonStyle(.bordered)
            
            // 当前路径信息
            Text("当前导航栈深度: \(navManager.path.count)")
        }
        .navigationTitle("C 页面")
        .navigationBarBackButtonHidden(true) // 隐藏默认返回按钮
        .toolbar {
            ToolbarItem(placement: .navigationBarLeading) {
                Button("返回") {
                    navManager.goBack()
                }
            }
        }
    }
}

4. 使用 @Binding 传递控制 (简单场景)

struct CView: View {
    @Binding var path: NavigationPath
    
    var body: some View {
        VStack {
            Text("C 页面")
            
            Button("返回 B") {
                path.removeLast()
            }
            
            Button("返回 A") {
                // 移除 B 和 C
                if path.count >= 2 {
                    path.removeLast(2)
                }
            }
        }
    }
}

总结

从 C 返回 B 的方法:

  • dismiss() - 最简单的返回上一页
  • navigationPath.removeLast() - 编程式返回
  • navManager.goBack() - 通过管理器返回

从 C 直接返回 A 的方法:

  • navigationPath.removeLast(2) - 移除最后2个页面
  • navigationPath.removeLast(navigationPath.count) - 清空所有页面
  • navManager.goBackToA() - 通过管理器精确控制

推荐使用导航管理器的方式,因为它提供了更好的代码组织和可维护性。

SwiftUI 导航

作者 littleplayer
2025年11月11日 17:49

SwiftUI 提供了多种导航方式,让我为你详细介绍主要的导航模式和相关组件。

1. NavigationStack (iOS 16+)

基本用法

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("前往详情页", value: "详情内容")
                NavigationLink("设置", value: "设置页面")
            }
            .navigationDestination(for: String.self) { value in
                DetailView(content: value)
            }
        }
    }
}

struct DetailView: View {
    let content: String
    
    var body: some View {
        Text("详情: \(content)")
            .navigationTitle("详情页")
    }
}

多类型导航

enum Route: Hashable {
    case product(Int)
    case category(String)
    case settings
}

struct ContentView: View {
    var body: some View {
        NavigationStack {
            VStack {
                NavigationLink("产品123", value: Route.product(123))
                NavigationLink("电子产品", value: Route.category("electronics"))
                NavigationLink("设置", value: Route.settings)
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .product(let id):
                    ProductView(productId: id)
                case .category(let name):
                    CategoryView(category: name)
                case .settings:
                    SettingsView()
                }
            }
        }
    }
}

2. NavigationView (iOS 13-16)

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: DetailView()) {
                    Label("详情页面", systemImage: "star")
                }
                NavigationLink(destination: SettingsView()) {
                    Label("设置", systemImage: "gear")
                }
            }
            .navigationTitle("主页面")
        }
    }
}

3. 编程式导航

使用 NavigationPath

struct ContentView: View {
    @State private var navigationPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            VStack(spacing: 20) {
                Button("跳转到产品页") {
                    navigationPath.append(Route.product(456))
                }
                
                Button("跳转到分类页") {
                    navigationPath.append(Route.category("books"))
                }
                
                Button("多层级跳转") {
                    navigationPath.append(Route.category("electronics"))
                    navigationPath.append(Route.product(789))
                }
                
                Button("返回根页面") {
                    navigationPath.removeLast(navigationPath.count)
                }
                
                Button("上一步") {
                    guard !navigationPath.isEmpty else { return }
                    navigationPath.removeLast()
                }
            }
            .navigationDestination(for: Route.self) { route in
                // 路由处理...
            }
        }
    }
}

4. Sheet 和 FullScreenCover

模态展示

struct ContentView: View {
    @State private var showingSheet = false
    @State private var showingFullScreen = false
    
    var body: some View {
        VStack(spacing: 20) {
            Button("显示 Sheet") {
                showingSheet = true
            }
            
            Button("全屏显示") {
                showingFullScreen = true
            }
        }
        .sheet(isPresented: $showingSheet) {
            SheetView()
        }
        .fullScreenCover(isPresented: $showingFullScreen) {
            FullScreenView()
        }
    }
}

struct SheetView: View {
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        NavigationView {
            Text("这是 Sheet 视图")
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button("完成") {
                            dismiss()
                        }
                    }
                }
        }
    }
}

5. TabView 标签导航

struct MainTabView: View {
    var body: some View {
        TabView {
            HomeView()
                .tabItem {
                    Label("首页", systemImage: "house")
                }
            
            SearchView()
                .tabItem {
                    Label("搜索", systemImage: "magnifyingglass")
                }
            
            ProfileView()
                .tabItem {
                    Label("我的", systemImage: "person")
                }
        }
    }
}

6. 复杂导航示例

带导航栏的完整示例

struct MainView: View {
    @State private var navigationPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            ProductListView()
                .navigationTitle("产品列表")
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button("设置") {
                            navigationPath.append(AppRoute.settings)
                        }
                    }
                }
                .navigationDestination(for: AppRoute.self) { route in
                    route.destination
                }
        }
    }
}

enum AppRoute: Hashable {
    case productDetail(Product)
    case category(String)
    case settings
    case profile
    
    @ViewBuilder
    var destination: some View {
        switch self {
        case .productDetail(let product):
            ProductDetailView(product: product)
        case .category(let category):
            CategoryView(category: category)
        case .settings:
            SettingsView()
        case .profile:
            ProfileView()
        }
    }
}

自定义导航栏

struct CustomNavigationView: View {
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        NavigationView {
            Text("自定义导航栏")
                .navigationTitle("标题")
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    // 左侧按钮
                    ToolbarItem(placement: .navigationBarLeading) {
                        Button("取消") {
                            dismiss()
                        }
                    }
                    
                    // 右侧按钮
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button("保存") {
                            // 保存操作
                        }
                        .bold()
                    }
                }
        }
    }
}

7. 导航状态管理

class NavigationManager: ObservableObject {
    @Published var path = NavigationPath()
    
    func navigateToProduct(_ id: Int) {
        path.append(Route.product(id))
    }
    
    func navigateToCategory(_ name: String) {
        path.append(Route.category(name))
    }
    
    func popToRoot() {
        path.removeLast(path.count)
    }
}

struct AppView: View {
    @StateObject private var navManager = NavigationManager()
    
    var body: some View {
        NavigationStack(path: $navManager.path) {
            ContentView()
                .navigationDestination(for: Route.self) { route in
                    // 路由处理
                }
        }
        .environmentObject(navManager)
    }
}

主要特点总结

  1. NavigationStack: iOS 16+ 推荐使用,支持类型安全的路由
  2. 编程式导航: 通过状态管理控制导航流程
  3. 模态展示: Sheet 和 FullScreenCover 用于临时内容
  4. 标签导航: TabView 用于主要功能模块切换
  5. 灵活的路由系统: 支持复杂导航逻辑和深度链接

这些导航方式可以组合使用,创建出符合你应用需求的完整导航体验。

昨天以前掘金 iOS

Swift 并发:我到底该不该用 Actor?——一张决策图帮你拍板

作者 unravel2025
2025年11月11日 08:20

Actor 是什么?(一句话版)

Actor = 自带大门的房间:一次只能进一个人,进门要“等钥匙”(await)。

它存在的唯一理由:保护非 Sendable 的可变状态。

Actor vs Class:只差一个隔离域

维度 Class Actor
引用语义
继承
隔离域 ❌(谁都能同步访问) ✅(必须 await进门)
线程安全 手动锁/队列 编译器保证
同步调用 任意 外部禁止

把 Actor 想成“远程服务”: 数据在“服务器”里,你要发请求(await)才能读写。

决策三要素:缺一不可!

只有同时满足下面 3 条,才值得上 Actor:

  1. 有非 Sendable 状态

    (纯 Sendable 结构体/类 → 无需保护)

  2. 操作必须原子性

    (读-改-写必须打包,不能中途被插)

  3. 这些原子操作

    不能在现有 Actor 上完成(如 MainActor)

缺一条 → 用别的方式 原因
只有 ① 缺 ② 用 Sendable+ 值类型即可
有 ①② 但能在 MainActor 做 直接标 @MainActor,还能同步访问 UI
为了“避开主线程”而造 Actor 反模式!用 @concurrentTask.detached即可

反例集合:这些 Actor 都“师出无名”

❌ 网络客户端 Actor

actor APIClient {
    // 全是 Sendable:URLSession、tokenString
    func request() async -> Data { ... }
}
  • 状态已 Sendable → 无需保护
  • 副作用只是“不想跑主线程”→ 用 @concurrent 函数即可
  • 结果:人为加锁,解码都无法并发

❌ “看不懂并发报错”就套 Actor

@globalActor actor MyRandomActor {
    // 空状态,只为消 Sendable 警告
}

→ 永远别用 Actor 当创可贴!

先理解警告,再选工具(Sendable@MainActor@concurrent)。

正例:真正需要 Actor 的场景

✅ 本地非 Sendable 缓存

actor ImageCache {
    private var store: [URL: UIImage] = [:]   // UIImage 非 Sendable
    func image(for url: URL) async -> UIImage? {
        if let img = store[url] { return img }
        let data = try await URLSession.shared.data(from: url).0
        let img = UIImage(data: data)!
        store[url] = img
        return img
    }
}
  • 状态非 Sendable
  • 读-写-缓存必须原子
  • MainActor 不适合(网络+解码耗时)

✅ 协议强制 Sendable

protocol DataSource: Sendable {
    func fetch() async -> [Item]
}
  • 实现层含非 Sendable 状态 → 只能用 Actor 满足 Sendable

决策流程图

需要共享可变状态?
├─ 否 → 用 struct / class(Sendable)
├─ 是 → 状态 Sendable?
│   ├─ 是 → 用 Sendable 值类型或锁自由类
│   └─ 否 → 操作必须原子?
│       ├─ 否 → 拆成 Sendable 片段
│       └─ 是 → 能在 MainActor 完成?
│           ├─ 是 → @MainActor
│           └─ 否 → **上 Actor** ✅

口诀:

“Sendable 先,MainActor 其次,新 Actor 最后。”

同步访问红线:Actor = “远程服务”

外部调用必须异步:

actor Counter {
    func increment() { value += 1 }
}

// 外部
await counter.increment()   // ✅
counter.increment()         // ❌ 编译失败

→ 如果你无法容忍这种异步接口(例如实时音频回调),

根本不该用 Actor —— 考虑锁、原子类或 @concurrent 函数。

常见误解速答

误解 真相
“Actor 让并发更快” 它更安全而非更快;异步排队可能更慢
“把类改成 actor 就能消并发警告” 治标不治本;先理解 Sendable 要求
“网络层必须 actor” 若状态 Sendable,用 @concurrent函数/任务即可
“actor 里所有代码都异步” 内部可完全同步;只有外部调用需 await

一句话总结

“Actor 是保护‘非 Sendable 可变状态’的昂贵保险箱—— 确认你真的有宝贝,且别处放不下,再把它请回家。”

记住三要素:

  1. 非 Sendable 状态
  2. 必须原子操作
  3. 现有 Actor 帮不上

同时满足 → 用 Actor;缺一条 → 找更简单的工具。

让 Actor 留在真正需要串行大门的地方,别把远程服务的复杂度,带进本可并行的小花园。

深入理解 DispatchQueue.sync 的死锁陷阱:原理、案例与最佳实践

作者 unravel2025
2025年11月11日 08:06

为什么要谈“死锁”

在 Swift 并发编程中,DispatchQueue.sync 以“阻塞式同步”著称:简单、直观、线程安全,却也最容易让生产环境直接崩溃。

什么是死锁(Deadlock)

维度 说明
定义 两个(或多个)执行单元互相等待对方释放资源,导致永远阻塞。
在 GCD 中的表现 线程 A 通过 sync 提交任务到队列 Q,而队列 Q 正在等待线程 A 完成 → 循环等待 → 触发 EXC_BAD_INSTRUCTION 崩溃。
常见结果 主线程卡死、App 秒退;Crash 日志中出现 0x8badf00d(应用无响应)或 EXC_I386_INVOP(非法操作)等错误码。

餐厅比喻

  • waiter(服务员)同步下订单给 chef(厨师);
  • chef 需要 waiter 回去问顾客口味,又同步派任务给 waiter;
  • 两人互相等,餐厅停摆 → 死锁。

Swift 最小死锁示例

import Foundation

// 1. 同队列嵌套 sync → 立即崩溃
let queue = DispatchQueue(label: "com.demo.queue")
queue.sync {
    print("外层 sync")
    queue.sync {          // ❌ 在这里死锁
        print("永远进不来")
    }
}

运行后控制台只会打印 外层 sync,随后 App 崩溃。

原因:

  • 外层闭包已占用队列唯一线程;
  • 内层 sync 要求同一条线程再次进入 → 无法满足 → 死锁。

双队列交叉死锁(更接近真实业务)

let waiter = DispatchQueue(label: "waiter")
let chef   = DispatchQueue(label: "chef")

// 模拟下单流程
waiter.sync {
    print("① Waiter:同步下单给 Chef")
    
    chef.sync {
        print("② Chef:同步要求 Waiter 去问口味")
        
        waiter.sync {     // ❌ 交叉等待
            print("③ Waiter:永远无法执行")
        }
    }
}

崩溃点:③ 处 waiter 队列已被①占用,而①又在等② → 循环等待。

如何“一键”解决——把任意一个 sync 改成 async

修改方案 代码片段 是否死锁
① → async waiter.async { … }
② → async chef.async { … }
③ → async waiter.async { … }

结论: 只要打破“循环等待链”中的任意一个环,死锁即刻解除。

在真实项目中,优先把“反向调用”做成 async 即可。

工程中最容易踩的“隐性死锁”

  1. 对外暴露 sync 接口
class ImageCache {
    private let queue = DispatchQueue(label: "cache")
    private var storage: [String: UIImage] = [:]
    
    // ❌ 危险:把内部队列 sync 暴露给外部
    func read<T>(_ closure: () -> T) -> T {
        return queue.sync(execute: closure)   // 闭包里可能再调 read()
    }
}

问题:调用方可能在闭包里再次调用 read() → 递归同步 → 死锁。

解决:

  • 绝不对外暴露 sync;
  1. 主线程 sync 到主队列
DispatchQueue.main.sync {   // ❌ 100 % 死锁
    // 代码永远不会进来
}

场景:在后台线程计算完后,想“立刻”回主线程刷新 UI,却手滑写成 sync

正确姿势:

永远用 DispatchQueue.main.async { ... }

sync 的正确打开方式——“私有队列 + 原子访问”

/// 线程安全的日期格式化器缓存
final class DateFormatterCache {
    private var formatters: [String: DateFormatter] = [:]
    private let queue = DispatchQueue(label: "cache.\(UUID().uuidString)")
    
    func formatter(using format: String) -> DateFormatter {
        // 1. 只在此私有队列里同步,外部无法递归根除
        return queue.sync { [unowned self] in
            if let cached = formatters[format] {
                return cached
            }
            let df = DateFormatter()
            df.locale = Locale(identifier: "en_US_POSIX")
            df.dateFormat = format
            formatters[format] = df
            return df
        }
    }
}

为什么这里不会死锁?

  • queue 私有,外部无法直接往它塞 sync 任务;
  • 函数内部无递归调用;
  • 闭包执行时间极短,不会阻塞用户可见线程。

checklist ✅

使用 sync 前自问 回答
队列是否私有?
闭包里还会 sync 到同队列吗?
阻塞是否影响主线程/用户滑动?

封装一个“防死锁”的读写锁

/// 读写锁:写操作 barrier,读操作并发
final class RWLock<T> {
    private var value: T
    private let queue: DispatchQueue
    
    init(_ initial: T) {
        value = initial
        queue = DispatchQueue(label: "rw.\(UUID().uuidString)", attributes: .concurrent)
    }
    
    // 读:并发
    func read<U>(_ closure: (T) throws -> U) rethrows -> U {
        try queue.sync { try closure(value) }
    }
    
    // 写:barrier
    func write(_ closure: @escaping (inout T) -> Void) {
        queue.async(flags: .barrier) { closure(&self.value) }
    }
}

优点:

  • 读并行、写串行;
  • 外部无法拿到 queue 引用,彻底杜绝递归 sync;
  • 所有写操作是 async,不会阻塞调用方。

总结——一句话记住

除非你在做原子访问,且队列私有、无递归,否则一律用 async。

扩展阅读 & 下一步

  1. 官方文档:DispatchQueue.sync
  2. WWDC 2022 – Visualize and eliminate hangs with Instruments
  3. Swift Concurrency 时代:
    • actor 替代“私有队列 + sync”;
    • AsyncSequence 做“异步回调链”,天然避免死锁。

学习资料

  1. www.donnywals.com/understandi…

Skip Fuse现在对独立开发者免费! -- 肘子的 Swift 周报 #0110

作者 东坡肘子
2025年11月11日 07:51

issue110.webp

📮 想持续关注 Swift 技术前沿?

每周一期《肘子的 Swift 周报》,为你精选本周最值得关注的 Swift、SwiftUI 技术文章、开源项目和社区动态。

一起构建更好的 Swift 应用!🚀

Skip Fuse现在对独立开发者免费!

在 Swift 社区发布官方 Android 版 SDK 不久之后,Skip 宣布其 Skip Fuse 版本将对符合条件的独立开发者免费开放,用于构建 Android 应用。

与过去一年多独立开发者可免费使用的 Skip Lite 相比,Skip Fuse 带来了实质性的技术变革。Skip Lite 的原理是将 Swift 代码转译为 Kotlin,而 Skip Fuse 则直接利用 Swift 官方 Android SDK 进行交叉编译,将 Swift 源码编译为可在 Android 平台上原生运行的 ARM 二进制文件。这意味着开发者不再局限于“具有 Skip 感知”的依赖包,而可以使用任何能在 Android 上编译的 Swift 包。

根据 Swift Everywhere 的统计,目前已有超过 2000 个 Swift Package 支持 Android 平台,其中包括 Alamofire、SwiftSoup、swift-sqlcipher 等常用库。换句话说,Fuse 模式让开发者能够充分利用标准 SwiftPM 生态。这不仅显著拓宽了可用依赖的范围,也降低了项目迁移与维护的复杂度。

在架构层面上,Skip Fuse 采用了混合实现方案:业务逻辑部分由原生 Swift 直接编译执行,而 UI 层的 SwiftUI DSL 则在构建过程中由 Skip Fuse UI 模块映射为 Jetpack Compose 代码,从而在 Android 上呈现出完全原生的用户体验。这种做法既保留了 SwiftUI 的声明式语法,又遵循了 Android 平台的设计规范。

此次政策调整,或许会让许多独立开发者和中小团队在技术选型上更倾向于具备跨平台潜力的方案。即便继续主要面向 Apple 生态进行开发(使用苹果私有框架),也可能开始在架构中加入抽象层,为未来的多平台拓展预留空间。

当然,Skip 对“独立开发者”的定义也有明确限制:仅适用于个人或不超过两人的团队,年收入需低于 30,000 美元,并且免费许可仅允许发布一个闭源商业应用(开源项目数量不限)。即便如此,这一政策仍为 Swift 开发者以近乎零成本的方式进入 Android 市场打开了大门,为他们在这一庞大平台上探索新的可能与收入来源提供了契机。

尽管未来还会出现更多面向 Swift 的跨平台开发方案,但 Skip 已经为 Swift 在 Android 生态中的落地提供了一条清晰、可行的路径。社区也正期待着类似 Skip 这样成熟的跨平台方案能够扩展至 Linux、Windows 乃至嵌入式平台,为 Swift 的多平台发展奠定更坚实的基础。

本期内容 | 前一期内容 | 全部周报列表

近期推荐

SwiftData 的优雅取消编辑方案:从混乱到艺术 (The Art of SwiftData in 2025: From Scattered Pieces to a Masterpiece)

由于取消了父子上下文的概念,SwiftData 在实现“可撤销修改”方面变得更加棘手。Mathis Gaignet 在这篇长文中,通过构建一个专用于编辑状态的独立 ModelContext,阐述了在 SwiftData 中实现“可撤销、可复用、低样板”增改(Upsert)架构的思路,用以取代散乱的表单状态与脆弱的回滚方案。

作者并未直接给出“标准答案”,而是完整展示了从问题发现到方案演进的思考路径。文章深入解析了 @Model@Query 宏的底层机制,揭示了 PersistentIdentifier 的临时与永久状态陷阱,以及上下文同步延迟导致的隐蔽 Bug。这种探索式的写作方式,使读者不仅明白“怎么做”,更理解“为什么这样做”。


从 SPM 项目管理迁移到 Tuist 项目管理 (Back Market x Tuist - Part I: Why We Moved Our iOS Project To Tuist)

在 Swift 项目中,SPM 除了负责代码模块化,也承担了项目结构管理的职责。然而,随着项目规模的扩大,这种方式的局限逐渐显现:依赖解析频繁、缺乏构建设置和自定义阶段、无法运行脚本、xcodeproj 文件易冲突、模块间规则难以统一,且 SPM 插件能力有限。Alberto Salas 在本文中介绍了其团队如何将项目结构管理从 SPM Packages 迁移至 Tuist Targets,并系统梳理了问题成因、探索路径与决策过程。在 Part II 中,他进一步分享了如何在不阻塞日常开发的前提下完成迁移,并量化了构建性能的提升。

本文并非要抛弃 SPM,而是将项目结构与生成工作交由 Tuist 接管,依赖管理依然由 SPM 负责。文章还比较了 SPM、Bazel 与 Tuist 的不同取舍。作者指出,Bazel 功能更强大,但最终选择 Tuist,是因为它更符合团队能力与项目特性。这也提醒我们,工具选型的关键不在“最强”,而在“最合适”——尤其对于中小型团队,可自主掌控、易于维护的方案往往更具长期价值。


让应用更懂用户语言:Language Discovery 的个性化新机制 (Making Apps More Personal with Language Discovery)

传统的“选择主要语言”(Locale.preferredLanguages) 模式假设每位用户都有单一语言偏好,但现实中人们往往在不同情境下使用多种语言——例如在工作中使用英语、在社交中使用法语、在媒体消费中使用西班牙语。苹果在 iOS 26 中添加了 Language Discovery 功能,通过设备端的机器学习,在确保隐私的前提下,基于用户的输入模式、内容消费、沟通语言以及应用偏好等行为数据,自动推断用户的语言使用习惯。Letizia Granata 在文中介绍了这一智能识别用户语言偏好的系统机制。

通过 Language Discovery,应用可以更准确地响应用户的语言与文化背景,从而在本地化层面实现个性化。这项功能标志着苹果在多语言支持上的一次重要转变:从被动配置走向主动理解,从单一语言到多语共存,为开发者提供了打造更包容、更真实应用体验的新基础。


关于 Xcode 26.1 CPU 异常占用的提醒和临时解决方案

iOS 开发者 Artem Mirzabekian 指出,Xcode 26.1 在运行 iOS 26.1 模拟器时会出现异常的 CPU 占用问题。原因是 iOS 26.1 模拟器中与壁纸渲染相关的 MercuryPosterExtension 进程持续崩溃并重启,导致 CPU 异常占用。Xcode 26.2(beta 版)目前也受影响。

临时解决方案:使用 iOS 26.0 模拟器,或在 iOS 26.1 模拟器中更换壁纸并删除默认的纯黑壁纸。


Swift 6 并发模型:技术挑战与社区争议

Michael Tsai 汇总了两组关于 Swift 6 并发的重要讨论。第一篇 关注 Swift 6.2 推出的 “Approachable Concurrency” 改进;第二篇 则聚焦具体技术议题,如 MainActor.assumeIsolated@preconcurrency 的实际使用与限制;

社区观点呈现明显分化。支持者认为,默认主线程隔离有效防止了“意外跑到后台线程”的常见问题,降低了初学者与 UI 密集型项目的上手难度,一些团队已经成功完成大型应用的迁移。质疑者则指出,完整的 UIKit 应用仍难以全面采用 Swift 6 模式,与 Core Data 和第三方框架的集成问题频出,错误信息晦涩难懂,而语言的持续演进也让代码不断老化。更激进的声音甚至认为当前并发模型已成为“无法协同运作的拼凑体系”,需要一次“彻底重置”。

整体共识是:Swift 6 并发机制的收益高度依赖项目的并发复杂度——对单线程或 UI 驱动型应用帮助显著,但对于并发密集或系统耦合度高的项目,迁移仍充满挑战。


深入解析 visionOS 上的动画机制 (Deep Dive into Animation on visionOS)

空间计算不仅改变了用户体验,也对开发者提出了更高的要求——许多在平面界面中行之有效的技巧,在三维空间中已不再适用。Cristian Díaz 从“空间交互的可感知性与舒适性”出发,提出了一个动画决策框架:谁创作动画(设计师预制 vs. 运行时生成)、什么需要动画(SwiftUI 窗口 vs. RealityKit 实体)、复杂度如何(微交互 vs. 编排表演)。在此基础上,他系统梳理了 visionOS 的五条渲染路径与十种动画机制,为每种方案明确列出适用场景、避免情形与实现要点。

即便你并非 visionOS 开发者,也能从这篇文章中受益。Cristian 以“从感知需求推导技术选择”的方式诠释了动画设计思维,这种方法同样适用于其他平台和界面的动态设计。


使用 Instruments 找出 SwiftUI 中更新最频繁的视图 (Find the SwiftUI Views that Update the Most Using Instruments)

Xcode 26 为 Instruments 新增了 SwiftUI 专用的分析工具,可统计视图的更新次数与耗时,并通过 All Updates Summary 与 Cause & Effect Graph 定位哪些视图“更新过于频繁”以及具体触发链路。Mark Szymczyk 以实操示例展示了如何创建 SwiftUI Profiling 会话、按更新次数/耗时排序视图,并用因果图追踪更新来源。

排查症状之外,更应理解 SwiftUI 的刷新原理,才能从源头减少无效重绘。在我的文章理解 SwiftUI 的视图刷新机制:从 TimelineView 刷新问题谈起中,借由 TimelineView 个案系统阐述了视图声明、响应机制与递归更新的判定逻辑。只有搞清“为什么视图会更新”、“系统如何决定是否重算视图声明值”,优化才不会沦为补丁式修修补补。

工具

imessage-kit:在 AI Agent 中提供消息集成能力

imessage-kit 是一个由 Photon 开发的功能强大的 iMessage 开源 SDK,非常适合将 iMessage 集成到 AI 智能体或自动化工作流中。其主要功能有:

  • 现代化的 API:提供优雅的链式调用(Fluent API),可以轻松实现“收到消息 A,则回复 B”的逻辑。
  • 功能全面:不仅支持收发文本,还能处理图片、各类文件,并能实时监控新消息。
  • 类型安全与跨运行时:完全使用 TypeScript 编写,类型支持良好,并同时兼容 Node.js 和 Bun。
  • AI 集成友好:官方定位就是为 AI Agent 提供消息集成能力,是连接物理世界和 AI 的一个有趣尝试。

它通过直接读取 iMessage 数据库(chat.db)并结合 AppleScript 实现自动化,因此仅限 macOS 使用,且需要授予应用"完全磁盘访问权限"。

该库采用 SSPL-1.0 许可证,禁止用于创建竞争产品(如其他消息 SDK/API),但允许用于内部业务、个人项目和教育用途。值得注意的是,Photon 还提供高级版本,支持线程回复、消息撤回/编辑、实时输入指示器等功能,以及企业级托管服务。更多内容可以在其官网获取。


SwiftUI-DetectGestureUtil:为单个 SwiftUI 视图绑定多个自定义手势

在 SwiftUI 中,让同一个视图同时识别多个手势一直是个棘手的问题。由 Saw-000 开发的 SwiftUI-DetectGestureUtil 通过引入独立的“检测阶段”,让开发者能在多个自定义手势中只确认其中一个,从而绕开系统默认组合方式(simultaneous、sequenced、exclusive)带来的约束,实现更自然的多手势交互。

它将手势识别流程清晰地拆分为两个阶段:

  • 检测阶段(detectGesture):在手势发生的整个周期内持续更新状态,直到某一自定义规则匹配并返回手势类型
  • 处理阶段(handleGesture):在识别完成后持续追踪手势进展,通过 .yet.finished 明确控制生命周期

这一模型为构建复杂交互(如“双击 + 拖动”、“圆形绘制”、“自定义笔迹检测”)提供了新的思路。借助 DetectGestureState,开发者可在一次手势周期内获得所有触点、时间与几何信息,实现远超 SwiftUI 原生 Gesture API 的表达力与精度。

往期内容

THANK YOU

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

📮 想持续关注 Swift 技术前沿?

每周一期《肘子的 Swift 周报》,为你精选本周最值得关注的 Swift、SwiftUI 技术文章、开源项目和社区动态。

一起构建更好的 Swift 应用!🚀

从0使用Kuikly框架写一个小红书Demo-Day7

2025年11月10日 11:36

通过Kuikly的拓展能力在ios平台实现自定义的图片加载和缓存

我们以iOS平台为例,体验Kuikly框架强大的拓展能力  

首先看看Kuikly demo是怎么在iOS原生层面实现图片加载与缓存的:

打开目录:iosAPP -> KuiklyRenderExpand -> Handlers -> KuiklyRenderComponentExpandHandler.m文件

#import "KuiklyRenderComponentExpandHandler.h"
#import <SDWebImage/UIImageView+WebCache.h>
 
@implementation KuiklyRenderComponentExpandHandler
 
+ (void)load {
    // 注册自定义实现
    [KuiklyRenderBridge registerComponentExpandHandler:[self new]];
}
 
/*
 * 自定义实现设置颜值
 * @param value 设置的颜色值
 * @return 完成自定义处理的颜色对象
 */
- (UIColor *)hr_colorWithValue:(NSString *)value {
    return nil;
}
 
/*
 * 自定义实现设置图片
 * @param url 设置的图片url,如果url为nil,则是取消图片设置,需要view.image = nil
 * @return 是否处理该图片设置,返回值为YES,则交给该代理实现,否则sdk内部自己处理
 */
- (BOOL)hr_setImageWithUrl:(NSString *)url forImageView:(UIImageView *)imageView {
    [imageView sd_setImageWithURL:[NSURL URLWithString:url]
                        completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
        
    }];
    return YES;
}
 
/*
 * 自定义实现设置图片(带完成回调,优先调用该方法)
 * @param url 设置的图片url,如果url为nil,则是取消图片设置,需要view.image = nil
 * @param completeBlock 图片加载完成的回调,如有error,会触发loadFailure事件
 * @return 是否处理该图片设置,返回值为YES,则交给该代理实现,否则sdk内部自己处理
 */
- (BOOL)hr_setImageWithUrl:(NSString *)url forImageView:(UIImageView *)imageView complete:(ImageCompletionBlock)completeBlock {
    [imageView sd_setImageWithURL:[NSURL URLWithString:url]
                        completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
        if (completeBlock) {
            completeBlock(image, error, imageURL);
        }
    }];
    return YES;
}
 
@end
 

可以看到demo中是通过调用第三库SDWebImage来实现图片的下载与缓存的

 

我们可以尝试不使用SDWebImage,自行实现图片的下载和缓存,体验Kuikly在不同平台的定制化能力

 

7.1 扩展机制实现原理

Kuikly框架在设计平台扩展机制时,采用了一种极其巧妙的插件化架构思想。这种设计的核心理念是将框架的核心功能与具体的实现细节完全解耦,让开发者能够在灵活地定制不同平台的特定行为。这种设计哲学体现了现代软件架构中的几个重要原则:开闭原则(对扩展开放,对修改封闭)、依赖倒置原则(高层模块不依赖低层模块,都依赖于抽象)以及单一职责原则(每个组件只负责一个特定的功能)。

 

Kuikly框架的扩展机制采用了协议驱动的插件化架构,通过定义标准的扩展协议接口,允许开发者实现自定义的组件处理逻辑。

具体实现上,扩展处理器类在+load方法中自动向KuiklyRenderBridge桥接管理器注册自己,框架在运行时通过单例模式管理这些扩展处理器。这种设计实现了零配置的自动注册、完全的实现解耦,使得开发者可以在不修改框架核心代码的情况下,通过实现协议方法来定制图片加载、颜色处理等功能,同时保持了良好的向后兼容性和扩展性。

 

Kuikly框架定义了KuiklyRenderComponentExpandProtocol协议,允许开发者自定义组件行为:

@protocol KuiklyRenderComponentExpandProtocol <NSObject>
 
// 必须实现的基础图片加载方法
- (BOOL)hr_setImageWithUrl:(NSString *)url forImageView:(UIImageView *)imageView;
 
@optional
// 可选的带回调图片加载方法(优先调用)
- (BOOL)hr_setImageWithUrl:(NSString *)url 
              forImageView:(UIImageView *)imageView 
                  complete:(ImageCompletionBlock)completeBlock;
 
// 自定义颜色处理
- (UIColor *)hr_colorWithValue:(NSString *)value;
 
// 文本后处理扩展
- (NSString *)kr_customTextWithText:(NSString *)text 
                  textPostProcessor:(NSString *)textPostProcessor;
 
@end
 

扩展处理器通过 +load() 方法实现自动注册,

@implementation KuiklyRenderComponentExpandHandler
 
+ (void)load {
    // 类加载时自动注册,无需手动调用
    [KuiklyRenderBridge registerComponentExpandHandler:[self new]];
}
 
@end

KuiklyRenderBridge作为中央管理器,负责扩展处理器的注册和获取:

// 全局静态变量存储扩展处理器实例
static id<KuiklyRenderComponentExpandProtocol> gComponentExpandHandler;
 
@implementation KuiklyRenderBridge
 
+ (void)registerComponentExpandHandler:(id<KuiklyRenderComponentExpandProtocol>)componentExpandHandler {
    gComponentExpandHandler = componentExpandHandler;
}
 
+ (id<KuiklyRenderComponentExpandProtocol>)componentExpandHandler {
    if (!gComponentExpandHandler) {
        // 支持动态创建,通过类名反射
        gComponentExpandHandler = [[NSClassFromString(@"KuiklyRenderComponentExpandHandler") alloc] init];
    }
    return gComponentExpandHandler;
}
 
@end

当我们调用使用在Kuikly框架中使用Image组件的src方法的时候,实际上会调用:

- (BOOL)p_setImageWithUrl:(NSString *)url {
    BOOL handled = NO;
    
    // 1. 优先调用带回调的扩展方法
    if ([[KuiklyRenderBridge componentExpandHandler] 
         respondsToSelector:@selector(hr_setImageWithUrl:forImageView:complete:)]) {
        
        handled = [[KuiklyRenderBridge componentExpandHandler] 
                   hr_setImageWithUrl:url 
                         forImageView:self 
                             complete:^(UIImage *image, NSError *error, NSURL *imageURL) {
            // 处理加载结果,触发相应事件
            if (error) {
                self.pendingLoadFailure = true;
                self.errorCode = error.code;
            }
        }];
        
    // 2. 降级到基础扩展方法
    } else if ([[KuiklyRenderBridge componentExpandHandler] 
               respondsToSelector:@selector(hr_setImageWithUrl:forImageView:)]) {
        
        handled = [[KuiklyRenderBridge componentExpandHandler] 
                   hr_setImageWithUrl:url forImageView:self];
        
    // 3. 如果没有扩展处理器,抛出断言错误
    } else {
        NSAssert(0, @"should expand hr_setImageWithUrl:forImageView:");
    }
    
    return handled;
}

7.2 在iOS层自定义图片加载

 

KRImageLoader:统一加载接口层,用于对外提供api接口

KRImageCache:缓存管理层 内存缓存+磁盘缓存 LRU淘汰算法

KRImageDownloader:网络下载层 并发控制

 

拓展处理器实现,用于对接Kuikle框架调用:

@implementation KuiklyRenderComponentExpandHandler
 
- (BOOL)hr_setImageWithUrl:(NSString *)url 
              forImageView:(UIImageView *)imageView 
                  complete:(ImageCompletionBlock)completeBlock {
    
    if (!url) {
        // 处理URL为空的情况
        imageView.image = nil;
        [[KRImageLoader sharedLoader] cancelLoadingForImageView:imageView];
        if (completeBlock) {
            completeBlock(nil, nil, nil);
        }
        return YES;
    }
    
    // 使用自定义图片加载器
    [[KRImageLoader sharedLoader] loadImageForImageView:imageView
                                                withURL:[NSURL URLWithString:url]
                                            placeholder:nil
                                               progress:nil
                                              completed:^(UIImage *image, NSError *error, NSURL *imageURL) {
        // 将结果回调给Kuikly框架
        if (completeBlock) {
            completeBlock(image, error, imageURL);
        }
    }];
    
    return YES; // 表示已处理
}
 
@end

7.2.1 缓存系统设计(双重缓存策略)

1、  内存缓存

通过NSCache自动管理内存

 

2、  磁盘缓存

手动实现了LRU缓存策略,并使用异步的IO操作,避免阻塞接口

 

接口定义,具体实现可以查看仓库

/**
 * KRImageCache - 图片缓存管理器
 * 
 * 功能特性:
 * - 双重缓存策略:内存缓存(NSCache) + 磁盘缓存(文件系统)
 * - 自动内存管理:响应内存警告自动清理
 * - 后台清理:应用进入后台时清理过期缓存
 * - 线程安全:使用串行队列保证磁盘操作安全
 */
@interface KRImageCache : NSObject
 
/**
 * 获取缓存管理器的单例实例
 * @return 缓存管理器单例
 */
+ (instancetype)sharedCache;
 
/**
 * 同步获取缓存图片(仅从内存缓存获取)
 * @param key 缓存键值
 * @return 缓存的图片对象,如果不存在则返回nil
 */
- (UIImage * _Nullable)imageForKey:(NSString *)key;
 
/**
 * 异步获取缓存图片(先检查内存缓存,再检查磁盘缓存)
 * @param key 缓存键值
 * @param completion 完成回调,在主线程执行
 */
- (void)imageForKey:(NSString *)key completion:(void(^)(UIImage * _Nullable image))completion;
 
/**
 * 同步存储图片到缓存(内存+磁盘)
 * @param image 要缓存的图片
 * @param key 缓存键值
 */
- (void)storeImage:(UIImage *)image forKey:(NSString *)key;
 
/**
 * 异步存储图片到缓存(内存+磁盘)
 * @param image 要缓存的图片
 * @param key 缓存键值
 * @param completion 存储完成回调,在主线程执行
 */
- (void)storeImage:(UIImage *)image forKey:(NSString *)key completion:(void(^ _Nullable)(void))completion;
 
/**
 * 清理内存缓存
 */
- (void)clearMemoryCache;
 
/**
 * 清理磁盘缓存
 */
- (void)clearDiskCache;
 
/**
 * 清理所有缓存(内存+磁盘)
 */
- (void)clearAllCache;
 
 
/**
 * 异步获取磁盘缓存大小
 * @param completion 完成回调,在主线程执行,参数为磁盘缓存占用的字节数
 */
- (void)diskCacheSizeWithCompletion:(void(^)(NSUInteger size))completion;
 
/**
 * 当缓存超过限制时,按LRU策略删除最旧的文件
 */
- (void)cleanDiskCacheWithSizeLimit;
 
/**
 * 根据URL生成缓存键值
 * @param url 图片URL
 * @return 生成的缓存键值(SHA256哈希)
 */
+ (NSString *)cacheKeyForURL:(NSURL *)url;
 
@property (nonatomic, strong) NSCache *memoryCache;        // 内存缓存,自动管理内存使用
@property (nonatomic, strong) dispatch_queue_t ioQueue;    // 串行IO队列,保证磁盘操作线程安全
@property (nonatomic, copy) NSString *diskCachePath;       // 磁盘缓存目录路径
@property (nonatomic, assign) BOOL isCleaningDiskCache;    // 是否正在清理磁盘缓存,避免重复清理
@property (nonatomic, strong) NSDate *lastCleanupTime;     // 上次清理时间,避免频繁清理
 
@end

7.2.2 下载器设计

采用单例模式管理NSURLSession,通过任务合并机制避免重复下载相同URL的图片,支持最大6个并发下载、15秒超时控制、精确的ImageView关联取消机制,并使用barrier队列确保线程安全的任务管理,为图片加载框架提供了可靠的网络下载能力。

 

接口定义,具体实现可以查看仓库

/**
 * KRImageDownloader - 图片下载器
 *
 */
@interface KRImageDownloader : NSObject
 
/**
 * 获取下载器的单例实例
 * @return 下载器单例
 */
+ (instancetype)sharedDownloader;
 
/**
 * 下载图片
 * @param url 图片URL
 * @param progressBlock 下载进度回调(可选)
 * @param completedBlock 下载完成回调(可选)
 * @return 下载任务对象,可用于取消下载
 */
- (NSURLSessionDataTask * _Nullable)downloadImageWithURL:(NSURL *)url
                                                progress:(KRDownloadProgressBlock _Nullable)progressBlock
                                               completed:(KRDownloadCompletionBlock _Nullable)completedBlock;
 
/**
 * 为特定ImageView下载图片
 * @param url 图片URL
 * @param imageView 关联的ImageView,用于精确取消
 * @param progressBlock 下载进度回调(可选)
 * @param completedBlock 下载完成回调(可选)
 * @return 下载任务对象,可用于取消下载
 */
- (NSURLSessionDataTask * _Nullable)downloadImageWithURL:(NSURL *)url
                                               imageView:(UIImageView *)imageView
                                                progress:(KRDownloadProgressBlock _Nullable)progressBlock
                                               completed:(KRDownloadCompletionBlock _Nullable)completedBlock;
 
/**
 * 取消指定URL的下载任务
 * @param url 要取消的图片URL
 */
- (void)cancelDownloadForURL:(NSURL *)url;
 
/**
 * 为特定ImageView取消下载任务
 * 只移除该ImageView对应的回调,不影响其他ImageView
 * @param url 图片URL
 * @param imageView 要取消的ImageView
 */
- (void)cancelDownloadForURL:(NSURL *)url imageView:(UIImageView *)imageView;
 
/**
 * 取消所有下载任务
 */
- (void)cancelAllDownloads;
 
/**
 * 设置最大并发下载数量
 * @param maxConcurrentDownloads 最大并发数
 */
- (void)setMaxConcurrentDownloads:(NSInteger)maxConcurrentDownloads;
 
/**
 * 设置下载超时时间
 * @param timeout 超时时间(秒)
 */
- (void)setDownloadTimeout:(NSTimeInterval)timeout;
 
@end

7.3 小结

通过自定义图片的加载和缓存,可以体验到Kuikly框架极强的拓展能力,尤其在平台定制化。以iOS平台自定义图片加载与缓存为例,无论是集成第三方库如SDWebImage,还是使用自研方案,开发者都可以通过Kuikly的插件化架构和协议驱动机制,实现与核心逻辑完全解耦的功能扩展。各类自定义组件只需遵循标准协议,通过零配置自动注册机制即可无缝集成到系统。这种设计不仅保障了应用的灵活性和向后兼容性,还让每个平台都能根据自身需求精准定制,充分体现了Kuikly框架开放、高效和可持续的扩展能力。

14.5 绘制(一)绘制原理及Layer——问答

作者 默默_david
2025年11月10日 02:19

书上章节链接

一、基础概念

  1. 绘制三要素

    简述Flutter绘制流程中Canvas、Layer、Scene的作用及相互关系。

    • Canvas:封装了Flutter Skia/Impeller的绘制指令,绘制时调用Canvas对象进行绘制;
    • Layer:绘制结果的载体,分为绘制类(PictureLayer)和容器类(ContainerLayer)
    • Scene:由Layer树组成的场景对象

    关系: Canvas的多条绘制指令,通过PictureRecorder记录下后生成Picture->Picture存入PictureLayer->Layer组成树结构->Scene包装Layer树->window.render(scene)提交给GPU

  2. PictureRecorder的作用

    为什么创建Canvas时必须传入PictureRecorder对象?它如何记录绘制指令?

    • 记录Canvas的绘制指令流

    • 调用endRecording()后生成Picture对象

    • 未调用时Picture为null,导致无法上屏

    注意:PictureRecorder 记录的是向量绘制指令,不是位图,位图是在光栅化后才会生成

  3. Layer的分类

    绘制类Layer(如PictureLayer)与容器类Layer(如OffsetLayer)的核心区别是什么?

    • 绘制类Layer:保存具体绘制内容(PictureLayer保存Picture)
    • 容器类Layer:管理子Layer的变换组合(如OffsetLayer管理位移,ClipRectLayer管理裁剪)
  4. 上屏(Rasterize)

    解释window.render(scene)方法的执行过程及其在渲染流水线中的意义。

    • window.render(scene)将Scene发送给Flutter引擎
    • 引擎在GPU线程光栅化(将矢量指令转为像素)
    • 最终合成到屏幕,完成渲染闭环

二、绘制流程

  1. 流程排序

    将以下步骤按正确顺序排列,并说明缺失环节:

   A. 调用Canvas绘制API  
   B. 创建PictureRecorder和Canvas  
   C. 调用window.render(scene)  
   D. 将Picture保存到PictureLayer  
   E. 构建Scene并关联Layer  
  • 顺序:B->A->D->E->C
  • 缺失:在D之前需调用recorder.endRecording()生成Picture
  1. Picture生成时机

    何时调用recorder.endRecording()?延迟调用会导致什么问题?

    • 绘制完成后立即调用recorder.endRecording()
    • 延迟调用会导致:
      • 当前帧无有效Picture
      • 可能内存泄漏(未释放绘图资源)
  2. Layer树的构建

    为什么需要将PictureLayer添加到OffsetLayer?直接使用PictureLayer构建Scene是否可行?

    • 必须通过容器类Layer(如OffsetLayer)组织
    • 直接使用PictureLayer不可行:
      • Scene要求根节点是容器类Layer
      • 缺少变换/裁剪等组合能力
  3. 绘制区域限制

    PictureLayer(rect)中的rect参数有何作用?如果绘制的图形超出该区域,是否会被裁剪?

    • rect定义Layer的显示范围
    • 超出区域的绘制内容会被裁剪
    • 未设置时默认不裁剪(Rect.largest)

三、Picture与光栅化

  1. Picture的本质

    为什么说Picture是“绘制指令的集合”而非像素数据?它如何被转换为屏幕图像?

    • 存储的是矢量绘制指令序列
    • 光栅化过程: GPU线程解析指令 → 生成位图 → 纹理上传 → 屏幕合成
  2. 性能优化

频繁创建Picture对象可能引发什么性能问题?如何避免?(提示:复用或缓存)

  • 问题:频繁创建导致内存压力,触发GC卡顿
  • 优化:对静态内容复用Picture,或使用Layer.reuse复用
  1. 光栅化线程

Picture的光栅化在哪个线程执行?如何影响UI线程的性能?

  • 执行在GPU线程(独立于UI线程)
  • 耗时操作会阻塞光栅化 → 导致界面掉帧卡顿

四、Layer机制深入

  1. Layer复用场景

哪些情况适合复用Layer?一般如何复用Layer如何提升渲染效率。

  • 适用:静态背景/复杂但不变的UI元素
  • 对于需要复用的部分在外层用RepaintBoundary包裹(独立的layer);CustomPainter中保存layer并且让shouldRepaint返回false;
  1. 容器类Layer的应用

TransformLayer/ClipRectLayer如何通过Layer树实现复杂效果?写出伪代码示例。

    // 伪代码:旋转+裁剪组合
    final root = OffsetLayer();
    final transformLayer = TransformLayer(transform: rotationMatrix);
    final clipLayer = ClipRectLayer(rect: clipRect);
    clipLayer.add(pictureLayer);
    transformLayer.add(clipLayer);
    root.add(transformLayer);
  1. Layer与RenderObject

自定义RenderObject时,其paint()方法内部如何生成和处理Layer?

    void paint(PaintingContext context, Offset offset) {
      // 生成PictureLayer
      context.canvas.drawRect(...);
      // 添加容器Layer
      context.pushLayer(ClipRectLayer(...), painter, offset);
    } 
  1. Layer的脏检查

Flutter如何判断某个Layer需要重绘?与Widget的setState()有何关联?

  • 当RenderObject调用markNeedsPaint()时,会逐层向上标记_needsPaint为true,直到找到一个isRepaintBoundary为true的绘制边界;绘制从绘制边界开始从上往下绘制,_needsPaint为true的才需要重绘,_needsPaint为false的直接复用
  • 与setState()关系:setState()标记脏点后,会调用scheduleFrame()请求一个新的frame,回调到drawFrame后,会执行渲染管线;渲染管线:构建->布局->层合成->更新绘制->上屏;布局中,遍历_nodesNeedingLayout数组,对每一个renderObject重新布局(调用其layout方法),layout方法中会调用markNeedsPaint()

五、底层API实操

  1. 代码补全

补全缺失代码,实现绘制圆形并上屏:

    void main() {  
      PictureRecorder recorder = __________;  
      Canvas canvas = _________;  
      canvas.drawCircle(Offset(100,100), 50,  Paint()..color=Colors.red);  
      var pictureLayer = PictureLayer(_________);  
      pictureLayer.picture = recorder.__________;  
      var rootLayer = OffsetLayer();  
      rootLayer.__________;  
      Scene scene = __________.buildScene(SceneBuilder());  
      window.render(scene);  
    }  

答案:

    void main() {
      PictureRecorder recorder = PictureRecorder();
      Canvas canvas = Canvas(recorder);
      canvas.drawCircle(Offset(100,100), 50, Paint()..color=Colors.red);
      var pictureLayer = PictureLayer(Rect.largest);
      pictureLayer.picture = recorder.endRecording();
      var rootLayer = OffsetLayer();
      rootLayer.append(pictureLayer);
      Scene scene = rootLayer.buildScene(SceneBuilder());
      window.render(scene);
    }
  1. 错误分析

以下代码为何无法显示图像?

    Canvas(recorder);  
    drawChessboard(canvas, rect);  
    window.render(SceneBuilder().build());  
  • 缺少PictureLayer创建
  • 未调用endRecording()
  • SceneBuilder未添加任何Layer
  • 未创建根OffsetLayer
  1. 多Layer合成

如何在同一个Scene中叠加两个PictureLayer,并让第二个Layer偏移50像素? (提示:使用OffsetLayer嵌套)

    final root = OffsetLayer();
    root.append(layer1);  // 直接添加第一层
    
    final offsetLayer = OffsetLayer(offset: Offset(50, 50));
    offsetLayer.append(layer2); // 偏移的第二层
    root.append(offsetLayer);
  1. 性能陷阱

在每帧都创建新的PictureLayer并上屏,会导致什么问题?如何优化?

  • 问题:每帧创建新Layer → 内存抖动 → 频繁GC → 界面卡顿
  • 优化:对静态内容复用PictureLayer

六、综合应用

  1. 自定义绘制控件

结合CustomPaint设计:

  • 解释CustomPainter的paint()方法内部如何通过Canvas生成PictureLayer;
  • 为何shouldRepaint()返回值影响Layer更新? 答:
    class MyPainter extends CustomPainter {
      void paint(Canvas canvas, Size size) {
        // 内部生成Picture → 存入PictureLayer
        canvas.drawRect(...); 
      }
      
      bool shouldRepaint(old) => true; // 返回true时重建Layer
    }
  • canvas获取时会调用_startRecording(),在_startRecording()中会创建PictureLayer对象
  • 在绘制阶段
    • 对于每个需要绘制的 RenderObject,检查 shouldRepaint()
    • 如果返回 false 且 Layer 有效 → 直接复用现有 Layer
    • 如果返回 true 或 Layer 无效 → 创建新的 PictureRecorder 和 Canvas
  1. 动态绘制优化

实现一个实时绘制的动画(如进度条),比较直接上屏 vs. 复用PictureLayer的帧率差异。

  • 直接上屏:每帧创建新Layer → 帧率<30fps
  • 复用优化:复用后帧率可达60fps
      void update() {
        if (_needsUpdate) {
          _updateForegroundLayer(); // 只更新前景层
          _needsUpdate = false;
        }
        rootLayer.append(_cachedBackground);
        rootLayer.append(_foregroundLayer);
      }
  1. 离屏渲染

利用Picture和Layer实现离屏绘制,并将结果缓存为图片,避免重复计算。

  • 优势:避免重复计算,直接复用位图
    Future<ui.Image> _renderOffscreen() async {
      final recorder = PictureRecorder();
      final canvas = Canvas(recorder);
      _drawComplexPath(canvas); // 复杂绘制
      final picture = recorder.endRecording();
      
      // 光栅化为图片
      return await picture.toImage(500, 500);
    }

Swift 协议(Protocol)指南(四):协议扩展(Protocol Extension)——让“协议”自己也有默认实现

作者 unravel2025
2025年11月10日 09:00

为什么要有“协议扩展”

  1. 协议只能“声明”要求,不能“实现”要求

    在 Swift 2 之前,协议类似 Java 的 Interface:

    • 只能写方法签名,不能写大括号实现。
    • 所有遵守者必须自己抄一遍模板代码,导致大量重复。
  2. 协议扩展的到来(Swift 2+)

    官方给协议本身也增加了 extension 语法,允许:

    • 提供默认实现(default implementation)
    • 追加新的计算属性 / 方法 / 下标 / 关联类型约束

    于是“协议 + 扩展”合二为一,既能约束,又能给代码。

  3. 与“基类”相比的优势

    • 多继承效果:一个类型可同时遵守多条带默认实现的协议。
    • 值类型可用:struct/enum 也能享受默认实现,而它们没有继承。
    • Retroactive:即使第三方类型,只要让它遵守协议即可注入功能。
    • 可拆分:把“接口”与“实现”彻底分离,各模块按需依赖。

语法速览

protocol 某能力 {
    func foo()          // 声明:遵守者“可以”自己实现
}
extension 某能力 {
    func foo() { print("默认 foo") }  // 默认实现
}

调用优先级(谁赢?)

类型自身实现 > 协议扩展实现 > 父类实现

也就是说:

  • 如果类型自己写了 foo,就用自己的;
  • 如果没写,就用协议扩展的;
  • 如果多个协议都提供了默认实现,则类型必须提供多个协议中相同方法的实现

4 大能力拆解

  1. 提供默认实现(最常用)
protocol TextDescribable {
    var text: String { get }
}

extension TextDescribable {
    // 80% 的场景只需要这个实现
    var text: String { String(describing: self) }
}

// 结构体什么都不用做就获得了 text
struct Point: TextDescribable {
    let x, y: Double
}
let p = Point(x: 1, y: 2)
print(p.text)   // "Point(x: 1.0, y: 2.0)"
  1. 追加新功能(协议自己“加私货”)
protocol CollectionPlus {}
extension CollectionPlus where Self: Collection {
    /// 任何 Collection 一键转 JSON 数组字符串
    func jsonString() -> String? {
        guard
            let data = try? JSONSerialization.data(withJSONObject: Array(self), options: [])
        else { return nil }
        return String(data: data, encoding: .utf8)
    }
}

// 让数组自动获得能力
extension Array: CollectionPlus {}
print([1,2,3].jsonString())  // Optional("[1,2,3]")

要点:

  • where Self: Collection 把能力限制在“真正遍历得到元素”的类型上,避免 String 等也误闯。
  • 这种“协议 + where”组合又称“条件扩展”,是 POP 的精髓。
  1. 多协议冲突与消除歧义
protocol A {
    func play()
}
protocol B {
    func play()
}

extension A {
    func play() {
        print("A")
    }
}
extension B {
    func play() {
        print("B")
    }
}

struct C: A, B {
    // 有同名的方法,必须在这里实现
    func play() {
        print("C")
    }
}

let c = C()
c.play()
// 这里不管怎样as,都会调用C中的实现
(c as A).play()   // A
(c as B).play()   // B

结论:

  • 协议扩展不会“重载”,只会“冲突”。
  • 在设计 SDK 时,尽量给方法加前缀,或把能力拆成更细粒度协议。
  1. 扩展协议本身也能“关联类型”约束
protocol Summable {
    associatedtype Element
    func reduce() -> Element
}

extension Summable where Element: Numeric {
    // 只有 Element 遵守 Numeric 才给默认实现
    func reduce() -> Element { 0 }
}

利用这套机制,我们可以把“算法”写成协议,再根据不同关联类型分发不同默认实现。

实战 3 段式:“接口 → 默认实现 → 具体类型”

场景:做一个日志组件,既能本地 print,也能远程上报,还能一键关闭。

  1. 定义接口
protocol Logger {
    func log(_ msg: String, file: String, line: Int)
}
  1. 提供默认实现(99% 模块只需要打印)
extension Logger {
    func log(_ msg: String,
             file: String = #fileID,
             line: Int = #line) {
        #if DEBUG
        print("🪵\(file):\(line) | \(msg)")
        #endif
    }
}
  1. 任意类型一键获得日志能力
final class NetworkManager: Logger {}   // 空实现即可
NetworkManager().log("请求接口 /user/info")

如果想“远程上报”怎么办?

——只在自己类型里重新实现 log(),默认实现就被覆盖,其他模块不受影响。

class RemoteLog: Logger {
    func log(_ msg: String, file: String = #file, line: Int = #line) {
        print("假装这里上报到了远端")
    }
}
RemoteLog().log("any message")

与泛型结合:写出“算法级别”的复用

例子:给一个“能比较”的数组追加“最大 N 个”方法

protocol TopNable {}
extension TopNable where Self: Sequence, Self.Element: Comparable {
    func topN(_ n: Int) -> [Self.Element] {
        let sorted = self.sorted(by: >)
        return Array(sorted.prefix(n))
    }
}

extension Array: TopNable {}
print([3, 1, 7, 2, 9].topN(3))   // [9, 7, 3]
  • 没有创建基类,也没有改动 Array 源码。
  • 任何 Sequence + Comparable 的类型都能一键获得 topN 能力。
  • 想换算法?只改协议扩展一处即可。

协议扩展不能做的事

  1. 不能给协议新增“存储属性”

    只能写计算属性,原因与类型扩展一样:无法分配内存。

  2. 不能给协议写 deinit / 存储型观察器

    协议本身没有生命周期。

  3. 不能“强制”让遵守者失去默认实现

    一旦提供了默认实现,遵守者永远“隐式”拥有它;想强制其重写,只能把默认实现删掉。

  4. 不能防止“菱形冲突”

    多协议默认实现冲突时,必须重新实现冲突的方法

工作流 checklist:如何设计一套“POP 组件”

  1. 先写最小协议,只放“必须”约束。
  2. 再写协议扩展,给通用能力提供默认实现。
  3. where 做条件扩展,把算法拆成“高内聚”的小协议。
  4. 给可能冲突的方法加前缀,或拆成更细粒度协议。
  5. 值类型优先(struct/enum),避免继承树。
  6. 单元测试:针对“协议 + 默认实现”写测试,任何遵守者自动受益。
  7. 文档:在协议头上写注释,Xcode 会自动带到所有遵守者,无需重复写。

总结

  • 协议扩展 = 协议(约束) + 扩展(实现),是 Swift 对“接口编程”的终极回答。
  • 它让“多继承”在值类型世界成为可能,同时保持静态派度、无运行时开销。
  • 掌握“默认实现 + where 约束”两件套,就能把“算法”从类型身上剥离出来,真正做到“写一次,到处复用”。
  • 牢记冲突规则与优先级,设计 SDK 时留好“消除歧义”的逃生舱。

Swift 协议(Protocol)指南(三):Primary Associated Type、some/any 与泛型式协议实战

作者 unravel2025
2025年11月10日 08:30

为什么 Swift 5.7 再次“颠覆”协议

在 Swift 5.7 之前,带关联类型的协议只能当约束 <T: Sequence>,不能当类型 Sequence

这导致两个老大难:

  1. 声明变量/参数/返回值时,必须再包一层类型擦除(AnySequence<Int>)。
  2. 泛型函数无法“一眼看出”序列里到底是什么元素。

Swift 5.7 一次性给出三把新钥匙:

特性 关键词 解决痛点
主要关联类型 protocol Sequence<Element> 把最常用的关联类型“提级”到协议名上
不透明参数 some Sequence<Int> 直接当参数类型,编译期确定,零运行时开销
存在性类型 any Sequence<Int> 真·变量类型,运行时盒子,语法终于可读

Primary Associated Type:把“泛型参数”搬到协议头上

  1. 标准库示例
// Swift 5.7 标准库定义
protocol Sequence<Element> {
    associatedtype Element
    associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
    func makeIterator() -> Iterator
}

Element 被写到协议名后面,成为主要关联类型。

于是我们可以像泛型结构体一样,直接写:

func sum(_ numbers: some Sequence<Int>) -> Int {   // ① 参数类型
    numbers.reduce(0, +)
}

let total = sum([1, 2, 3] + [4, 5, 6])            // ② 数组拼接也适用
  1. 为自己协议添加“主关联类型”
protocol Feed<Element> {          // ① 把最常用的关联类型提出来
    associatedtype Element
    mutating func next() -> Element?
}

struct IteratorFeed: Feed {
    var a = 0, b = 1
    mutating func next() -> Int? {
        let next = a
        a = b
        b = next + a
        return next
    }
}

// ② 立刻享受 some/any 语法
func makeIntFeed() -> some Feed<Int> {
    IteratorFeed()
}

var feeds: [any Feed<Int>] = []   // ③ 数组里放不同实现,无需再包 AnyFeed

规则

  • 只能把一个或多个 associatedtype 声明为“主要”,不强制全部。
  • 主要关联类型顺序不影响使用,但建议按“常用度”排序。
  • 一旦声明,协议就获得“泛型形参”资格,可直接写 Feed<Int>

some vs. any:一句话区分

维度 some P any P
语义 编译期确定的某个具体类型 运行时存在的任何具体类型
内存布局 无间接寻址,内联存储 存在性容器(Box),通过指针引用
主要用法 返回值、泛型约束 变量、集合元素、函数参数
性能 零额外开销 轻微运行时开销(Box管理)
使用限制 不能用于var/集合类型声明 无显式限制

示例对比

protocol Feed<Element> {          // ① 把最常用的关联类型提出来
    associatedtype Element
    mutating func next() -> Element?
}

struct IteratorFeed<Iterator: IteratorProtocol>: Feed  {
    var iterator: Iterator
    init(_ iterator: Iterator) {
        self.iterator = iterator
    }
    mutating func next() -> Iterator.Element? {
        iterator.next()
    }
}

// 1. 返回值:编译期就知道真实类型
func uniqueElements<S: Sequence>(_ seq: S) -> some Sequence<S.Element>
where S.Element: Hashable & Comparable {
    Set(seq).sorted()
}

// 2. 数组:运行期才知道真实类型
var parsers: [any Feed<Int>] = [
    IteratorFeed([1, 2, 3].makeIterator()),
    IteratorFeed(stride(from: 0, to: 10, by: 2).makeIterator())
]

实战:一行代码写“泛型” SwiftUI View

import Charts

struct ChartView<Data: RandomAccessCollection>: View where Data.Element == Double {
    let data: Data
    var body: some View {
        // 老写法:调用方必须写冗长泛型参数
    }
}

// ✅ 新写法:直接不透明返回,调用方无感知
func makeChart(_ data: some RandomAccessCollection<Double>) -> some View {
    
    Chart {
        ForEach(data.enumerated(), id: \.offset) { idx,value in
            LineMark(x: .value("x", idx), y: .value("y", value))
        }
    }
}

迁移旧代码:把 AnySequence 换成 any Sequence

旧代码 新代码
AnySequence<Int> any Sequence<Int>
AnyPublisher<Int, Never> any Publisher<Int, Never>
AnyView any View(iOS 17+ 可用,仍需权衡性能)

步骤

  1. 在协议名后加 <Element>
  2. eraseToAnyPublisher() / AnySequence(...) 改成 any Sequence<Int>
  3. 若返回值无需运行时多态,直接用 some Sequence<Int> 获得零开销。

性能与二进制大小小贴士

  • some P<T> 完全静态派发,零额外内存。
  • any P<T> 引入存在性容器,16 字节 inline + 外挂堆(若值过大)。
  • 滥用 any 会让二进制出现大量“协议见证表”,release 模式下编译器会优化,但debug 增量编译可能变慢。
  • 对 SwiftUI body 这种超高频调用,优先 some View;只在需要运行时异构数组时才用 any View

总结

关键词 适用场景 记忆口诀
protocol P<Element> 给自己协议“提级” “协议也能带泛参”
some P<T> 返回值、泛型约束 “编译期就确定”
any P<T> 变量、数组、字典 “运行期多态盒子”

一句话总结

Swift 5.7 让协议第一次真正拥有了“泛型形参”能力:

  • 写库的人:给协议加 <Element>,调用方立刻享受 some/any 语法糖。
  • 写业务的人:用 some Sequence<Int> 替代冗长泛型约束,用 any Sequence<Int> 替代 AnySequence,代码短一半、可读性翻倍。

Swift 协议(Protocol)指南(二):关联类型、Self 约束与泛型递归,一次彻底搞懂

作者 unravel2025
2025年11月10日 08:07

为什么“关联类型”是协议的分水岭

在上面,我们接触的协议都属于“无关联类型协议”——编译期无需知道协议里的泛型占位符具体是什么。

一旦协议里出现了 associatedtype,它就不再是普通类型,而变成了协议泛型:只能当作约束使用,不能当作变量/参数/返回值类型直接出现。

关联类型基础语法

protocol Container {
    associatedtype Item                     // ① 声明一个占位符
    var count: Int { get }
    mutating func append(_ item: Item)      // ② 在方法里使用占位符
    subscript(index: Int) -> Item { get }   // ③ 在下标里使用占位符
}

实现者决定真实类型

struct IntStack: Container {
    private var items = [Int]()
    typealias Item = Int      // 可省略,编译器自动推断
    
    mutating func append(_ item: Int) { items.append(item) }
    subscript(index: Int) -> Int { items[index] }
    var count: Int { items.count }
}

struct StringQueue: Container {
    private var items = [String]()
    // 不写 typealias,编译器也能从 append 参数推断 Item == String
    mutating func append(_ item: String) { items.append(item) }
    subscript(index: Int) -> String { items[index] }
    var count: Int { items.count }
}

带约束的关联类型

//  继承Container,只接收Item是Numeric的类型
protocol SummableContainer: Container where Item: Numeric {
    func total() -> Item
}

extension SummableContainer {
    func total() -> Item {
        var sum: Item = 0
        for i in 0..<count { sum = sum + self[i] }
        return sum
    }
}

使用

extension IntStack: SummableContainer {}   // Int 符合 Numeric,自动获得 total()
var s = IntStack()
s.append(1)
s.append(2)
print(s.total()) // 3

Self 出现在协议里:返回自身类型的需求

protocol Copyable {
    func copy() -> Self        // 要求返回“真实类型”本身
}

class Dog: Copyable {
    var name = ""
    required init() {}         // 必须保证子类也能初始化
    func copy() -> Self {
        let new = Self()       // Self 在运行期代表真实动态类型
        new.name = name
        return new
    }
}

let husky = Dog()
husky.name = "Husky"
let another = husky.copy()     // 类型推断为 Dog

注意

  • Self 只能出现在“协议内”或“协议扩展”中,不能出现在普通结构体/类的方法签名里。
  • 如果协议用 associatedtypeSelf 就是该类型的别名。

泛型递归约束:where 子句的魔法

protocol Node {
    associatedtype Value
    var value: Value { get }
    var children: [Self] { get }        // 递归引用自身
}

extension Node where Value: Numeric {
    func sumTree() -> Value {
        value + children.reduce(0) { $0 + $1.sumTree() }
    }
}

struct IntNode: Node {
    let value: Int
    let children: [IntNode]
}

let tree = IntNode(value: 10, children: [
    IntNode(value: 3, children: []),
    IntNode(value: 5, children: [
        IntNode(value: 1, children: [])
    ])
])
print(tree.sumTree())  // 10+3+5+1 = 19

真实案例:Swift 标准库里的 Sequence

protocol Sequence {
    associatedtype Element
    associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
    
    __consuming func makeIterator() -> Iterator
}

protocol IteratorProtocol {
    associatedtype Element
    mutating func next() -> Element?
}

自定义序列

struct Fibonacci: Sequence {
    struct Iterator: IteratorProtocol {
        var a = 0, b = 1
        mutating func next() -> Int? {
            let next = a
            a = b
            b = next + a
            return next
        }
    }
    func makeIterator() -> Iterator { Iterator() }
}

for (i, n) in Fibonacci().enumerated().prefix(10) {
    print("F\(i) = \(n)")
}

类型擦除(Type Erasure):解决“关联类型协议不能当返回类型”

protocol Feed {
    associatedtype Item
    func next() -> Item?
}

// 1. 包装类擦除具体 Item 类型
struct AnyFeed<Item>: Feed {
    private var _next: () -> Item?
    init<F: Feed>(_ real: F) where F.Item == Item {
        _next = { real.next() }
    }
    func next() -> Item? { _next() }
}

// 2. 使用处不再出现关联类型
func makeIntFeed() -> AnyFeed<Int> {
    let fib = Fibonacci().makeIterator()
    return AnyFeed(IteratorFeed(fib))
}

标准库已有

  • AnySequence<Element>
  • AnyPublisher<Output, Failure> (Combine)
  • AnyView (SwiftUI)

实战技巧清单

技巧场景 具体实现技巧
想返回“某个遵守协议的对象” 用泛型 <T: Protocol>some Protocol
协议中定义 associatedtype 仅能作为类型约束,无法直接当作变量类型;需通过“类型擦除”后暴露
要求子类必须实现指定初始化器 协议中声明 init(),类侧配合写 required init()
让扩展方法仅对满足部分约束的类型可见 使用 where 子句,例如 extension Container where Item: Comparable
禁止类型隐式标记为 Sendable 声明 struct File: ~Sendable,或通过 @available(*, unavailable) extension File: Sendable 禁用

Swift 协议(Protocol)指南(一):从语法到实战

作者 unravel2025
2025年11月10日 07:54

基础语法:一份“合同”长什么样

// 1. 定义协议:只声明,不实现
protocol FullyNamed {
    // 只要可读,不要求可写
    var fullName: String { get }
}

// 2. 遵守协议:编译器会强制兑现
struct Person: FullyNamed {
    // 存储属性即可满足
    var fullName: String           
}

class Starship: FullyNamed {
    var prefix: String?
    var name: String
    init(name: String, prefix: String? = nil) {
        self.name = name
        self.prefix = prefix
    }
    // 计算属性同样可以满足协议
    var fullName: String {
        (prefix != nil ? prefix! + " " : "") + name
    }
}

知识点小结

  • 协议不分存储/计算属性,只关心“名字+类型+读写权限”。
  • 协议不能规定默认值、不能要求存储/计算属性二选一。

方法需求与变体方法(mutating)

protocol Togglable {
    // 允许修改值类型自身
    mutating func toggle()          
}

enum OnOffSwitch: Togglable {
    case off, on
    // enum 必须标记 mutating
    mutating func toggle() {        
        self = self == .off ? .on : .off
    }
}

var s = OnOffSwitch.off
s.toggle() // on

Tips

  • class 实现 mutating 方法时不需要写 mutating(引用类型默认可变)。
  • 协议里的 init 需求,在 class 实现时必须写 required,保证子类也能履约。

协议里的初始化器 & 可失败初始化器

protocol SomeProtocol {
    init(param: Int)          // 非可失败
    init?(failParam: Double)  // 可失败
}

class MyClass: SomeProtocol {
    required init(param: Int) {}          // 必须写 required
    required init?(failParam: Double) {}  // 可失败版本也要 required
}

协议作为类型:面向接口编程

let things: [TextRepresentable] = [game, d12, simonTheHamster]
for thing in things {
    print(thing.textualDescription)   // 统一调用,无需关心真实类型
}

三种“协议类型”场景

  1. 泛型约束 <T: SomeProtocol>
  2. 不透明类型 some SomeProtocol(编译期确定,调用方看不到具体类型)
  3. 装箱协议类型(Existential)any SomeProtocol(运行时决定,有微小性能开销)

委托(Delegation)模式实战

// 1. 定义协议(嵌套在类内部)
class DiceGame {
    weak var delegate: Delegate?   // 弱引用防循环引用
    protocol Delegate: AnyObject { // 类专属协议
        func gameDidStart(_ game: DiceGame)
        func game(_ game: DiceGame, didEndRound: Int, winner: Int?)
        func gameDidEnd(_ game: DiceGame)
    }
    func play(rounds: Int) {
        (0..<rounds).forEach { i in
            delegate?.gameDidStart(self)
            delegate?.game(self, didEndRound: i, winner: i)
            delegate?.gameDidEnd(self)
        }
    }
}

// 2. 任何类都能当“记录员”
class DiceGameTracker: DiceGame.Delegate {
    func gameDidStart(_ game: DiceGame) { print("开始新游戏") }
    func game(_ game: DiceGame, didEndRound r: Int, winner: Int?) {
        print("第\(r)轮 winner=\(winner ?? 0)")
    }
    func gameDidEnd(_ game: DiceGame) { print("游戏结束") }
}

// 3. 运行
let game = DiceGame()
let gameTracker = DiceGameTracker()
game.delegate = gameTracker
game.play(rounds: 3)

协议扩展:给协议“写实现”

protocol RandomNumberGenerator {}
extension RandomNumberGenerator {
    // 所有遵守者自动获得
    func randomBool() -> Bool {          
        random() > 0.5
    }
}

// 默认实现 + where 约束
extension Collection where Element: Equatable {
    func allEqual() -> Bool {
        guard let first = self.first else { return true }
        return self.allSatisfy { $0 == first }
    }
}

好处

  • 避免重复代码;
  • 允许“协议+约束”泛型算法;
  • 提供默认实现后,类型可再自定义覆盖。

协议继承与协议组合

protocol TextRepresentable {}
protocol Named {
    var name: String { get }
}
protocol Aged {
    var age: Int { get }
}
// 继承TextRepresentable
protocol PrettyTextRepresentable: TextRepresentable {
    var pretty: String { get }
}

func birthday(person: Named & Aged) {   // 同时遵守两个协议
    print("Happy \(person.age), \(person.name)!")
}

可选协议需求(仅兼容 Objective-C)

@objc protocol CounterDataSource {
    @objc optional func increment(forCount: Int) -> Int
    @objc optional var fixedInc: Int { get }
}
  • 必须 @objc & class 才能用;
  • 调用时用可选链 dataSource?.increment?(forCount: 5)

隐式合成与条件一致性

protocol TextRepresentable {
    var textualDescription: String { get }
}

// 1. 编译器自动帮你实现 Equatable / Hashable / Comparable
struct Point3D: Equatable {          // 不自己写 == 也行
    var x, y, z: Double
}

// 2. 条件一致性:给泛型类型“按需”加协议
extension Array: TextRepresentable where Element: TextRepresentable {
    var textualDescription: String {
        "[" + map(\.textualDescription).joined(separator: ", ") + "]"
    }
}

检查与转换协议类型

for object in objects {
    if let areaObj = object as? HasArea {
        print("面积=\(areaObj.area)")
    } else {
        print("没有面积属性")
    }
}

实战技巧 & 常见坑

  1. 性能:
    • Existential(装箱协议)有微小间接调用开销,热点代码可改泛型 <T: Protocol>some Protocol
  2. 关联类型:
    • associatedtype 的协议只能当泛型约束,不能直接当变量类型(后续文章展开)。
  3. 循环引用:
    • 委托一定用 weak + 类专属协议 : AnyObject
  4. 协议粒度:
    • 小功能拆小协议,再 & 组合,比“胖协议”更灵活。

Swift TaskGroup 结果顺序踩坑指南:为什么返回顺序和创建顺序不一致,以及最通用的修复办法

作者 unravel2025
2025年11月10日 07:39

现象:看起来“随机”的结果顺序

在 Swift 并发模型里,withTaskGroup 让我们可以一次性启动多个子任务并发执行。

很多初学者第一次写出的代码类似下面这样

import Foundation

/// 模拟网络请求:根据 id 返回字符串,耗时 0~4 秒随机
func fetchData(id: Int) async -> String {
    // 让任务随机“卡”一会儿
    try! await Task.sleep(for: .seconds(Int.random(in: 0..<5)))
    return "Result for \(id)"
}

let results = await withTaskGroup(of: String.self) { group in
    // 1. 把 0~5 共 6 个任务依次放进组里
    for i in 0...5 {
        group.addTask {
            await fetchData(id: i)
        }
    }
    
    // 2. 按任务完成顺序收集结果
    var temp = [String]()
    for await value in group {
        temp.append(value)
    }
    return temp
}

print(results) 
// 实际打印可能:["Result for 1", "Result for 5", "Result for 0", "Result for 2", "Result for 3", "Result for 4"]

运行后发现:

  • 数组里字符串的下标和 for i in 0...5 的循环顺序毫无关系。
  • 哪个子任务先结束,哪个就排在前面——完全符合并发语义,却不符合“人类直觉”。

根本原因

TaskGroup 的 addTask 只是把任务扔进并发调度器,调度器按可用线程/协程资源自由执行。

for await ... in group 则是按完成顺序逐个给出结果。

因此“创建顺序”与“完成顺序”天然解耦,这是设计使然,不是 bug。

最通用、可扩展的修复思路

把“能用来排序的元数据”和“真正的结果”一起带回来。最常见的就是“下标/序号”本身。

修改后的核心代码:

let ordered = await withTaskGroup(of: (Int, String).self) { group in
    // 1. 返回元组:(原始下标, 业务结果)
    for i in 0...5 {
        group.addTask {
            let value = await fetchData(id: i)
            return (i, value)   // 关键:把序号带回来
        }
    }
    
    // 2. 先收集到字典(或临时数组)
    var dict = [Int: String]()
    for await (index, value) in group {
        dict[index] = value
    }
    
    // 3. 按原始序号排序
    return dict
        .sorted(by: { $0.key < $1.key })
        .map(\.value)
}

print(ordered) 
// 保证是 ["Result for 0", "Result for 1", ... "Result for 5"]

知识点再梳理

  1. withTaskGroup(of:)of 参数决定子任务返回类型。
  2. addTask 闭包内部可以捕获外部常量,因此能拿到循环变量 i
  3. for await 迭代的是完成顺序;想恢复“创建顺序”必须自带排序键。
  4. 如果业务需要“部分结果优先返回”,可以改用 AsyncSequencemerge()TaskGroup + AsyncChannel

完整可运行 Demo

import Foundation

/// 模拟网络请求
func fetchData(id: Int) async -> String {
    let milliseconds = Int.random(in: 0..<5_000)
    try! await Task.sleep(for: .milliseconds(milliseconds))
    return "结果-\(id)"
}

/// 保证顺序的并行抓取函数
func fetchAll() async -> [String] {
    await withTaskGroup(of: (Int, String).self) { group in
        // 1. 添加任务
        for i in 0...5 {
            group.addTask {
                let value = await fetchData(id: i)
                return (i, value)
            }
        }
        
        // 2. 收集
        var dict = [Int: String](minimumCapacity: 6)
        for await (index, value) in group {
            dict[index] = value
        }
        
        // 3. 排序
        return dict
            .sorted(by: { $0.key < $1.key })
            .map(\.value)
    }
}

Task {
    let list = await fetchAll()
    print("最终顺序:", list)
}

总结与扩展场景

  1. 只要“对外表现需要有序”,就一定把序号带回来;这是并发到顺序的通用模式,不限于 Swift。
  2. 如果子任务量巨大,占内存太多,可以把“结果”换成磁盘缓存 URL 或数据库主键,排序后再分批读取。
  3. 当顺序敏感且需要增量刷新 UI 时,改用 AsyncSequence 并按序号插入 List/UITableView 数据源,用户体验更好。
  4. 若业务允许“先出来先展示”,就无需任何额外工作,直接用 for await 流式消费,反而性能最佳。

牢记:并发世界里,顺序不是默认,而是额外成本。想清楚“是否真的需要顺序”,再决定要不要买单。

学习资料

  1. www.swiftwithvincent.com/blog/dont-m…

猿族代码战记:Mutex 升级版——守护 Swift 并发的“香蕉仓库”

2025年11月8日 13:25

在这里插入图片描述

🦍 引子

旧金山废墟的猿族技术区,金属支架撑起的荧光屏泛着冷光,首席 Swift 架构师科巴的指节因攥紧终端而发白 —— 食物计数系统又出问题了。

在这里插入图片描述

刚录入的 27 根香蕉,刷新页面竟变成 29,再点一下又跳回 28,旁边年轻猿工程师紧张地挠着头:“科巴大人,不会是小猩猩偷偷改数据吧?” 科巴瞪了他一眼:“是‘并发幽灵’!自从用 Actor 保护状态,简单的计数全成了麻烦 —— 查个库存要写await,就像咱们去仓库拿根香蕉,得先找凯撒签字、找后勤登记,折腾半小时!”

在本堂猩猩课堂中,您将学到如下内容:

  • 🦍 引子
  • 🛡️ 第一章:Actor 的麻烦 —— 被异步绑架的简单需求
  • 🔧 第二章:Mutex 实战 —— 零 bug 香蕉计数器
  • 📱 第三章:适配 SwiftUI—— 让 @Observable “看见” 变化
  • ⚔️ 第四章:抉择 ——Mutex vs Actor
  • 🌟 结尾:代码丛林的生存法则

今天,他要拿出压箱底的 “轻量武器” Mutex,让代码既能挡住并发风险,又能像猿族奔袭般迅猛如潮。

🛡️ 第一章:Actor 的麻烦 —— 被异步绑架的简单需求

科巴拉过一把生锈的金属椅坐下,指尖在键盘上敲出 Actor 代码:“你们看,要改香蕉数量,必须写await counter.addBanana()—— 就一个破赋值操作,硬生生被拖进异步队列!

他顿了顿,指着屏幕上的@MainActor标签,“就算把计数器绑在主线程,其他哨站的猿想查库存,还是得等主线程‘有空’—— 这和把仓库钥匙只给主营地的猿,其他猿只能站在门口等,有啥区别?”

在这里插入图片描述

旁边的猿工程师小声问:“那以前咱们是怎么处理的?”

“以前靠 GCD 串行队列!” 科巴一拍桌子,“就像给仓库配个专属管理员,所有拿香蕉的请求都排队,谁也别插队。但队列太重了,现在 Swift 出了 Mutex—— 它是‘轻量级锁’,只护一小块状态,操作完自动解锁,还不用写一堆异步代码!”

在这里插入图片描述

🔧 第二章:Mutex 实战 —— 零 bug 香蕉计数器

科巴清了清嗓子,手指在键盘上飞快跳动,边写边讲解:“Mutex 的核心是withLock方法 —— 它会先‘抢锁’,确保当前只有一个线程能操作状态,操作完不管成功或失败,都会自动‘释放锁’,绝不会像手动加锁那样,忘了解锁导致整个系统卡死。”

在这里插入图片描述

很快,FoodCounter类的代码出现在屏幕上:

class FoodCounter {
    // 初始化 Mutex,把初始香蕉数设为0——相当于给空仓库装了把新锁
    private let mutex = Mutex(0)
    
    // 增加香蕉:开锁、给库存+1、自动锁门
    func addBanana() {
        mutex.withLock { count in
            count += 1 // 操作超简单,就像把香蕉放进仓库,一秒搞定
        }
    }
    
    // 减少香蕉:逻辑和加香蕉一样,只是把+1改成-1
    func removeBanana() {
        mutex.withLock { count in
            count -= 1
        }
    }
    
    // 读取库存:重点!读操作也要走 withLock,防止读的时候正好在写(比如刚加了半根香蕉)
    var bananaCount: Int {
        mutex.withLock { count in
            return count // 只读取,不修改,但也要保证独占访问
        }
    }
}

“千万别犯懒!” 科巴突然提高声音,“有猿觉得‘读操作不用锁’,结果读的时候正好赶上写,拿到的是‘脏数据’—— 上次有个猿没加锁读库存,以为还有 10 根香蕉,结果实际只剩 2 根,导致整个哨站的猿饿了半天!”

在这里插入图片描述

他演示了如何使用计数器,代码简洁得让猿工程师们发出惊叹:

let counter = FoodCounter()

counter.bananaCount = 10 // 直接赋值,不用等异步
print(counter.bananaCount) // 立刻输出10,没有半点延迟
counter.addBanana()

print(counter.bananaCount) // 输出11,实时更新

在这里插入图片描述

📱 第三章:适配 SwiftUI—— 让 @Observable “看见” 变化

正当猿族为新计数器欢呼时,负责 SwiftUI 仪表盘的猿跑过来:“科巴大人,计数器接入界面后,香蕉数变了,界面却一动不动!” 科巴凑过去看了眼平板 —— 屏幕上的数字始终停留在 10,哪怕点了 “加香蕉” 按钮也没反应。

在这里插入图片描述

“这是因为 @Observable ‘瞎’了!” 科巴很快找到问题,“Mutex 保护的是内部的库存数,库存变了,但 Mutex 本身没变化 ——@Observable 只能‘看见’对象属性的直接修改,看不到 Mutex 里面的小动作。”

他伸手在键盘上敲了几行代码,给bananaCountgetset加了 “传令兵”:

@Observable
final class FoodCounter: Sendable { // 加Sendable,允许计数器跨线程传递
    private let mutex = Mutex(0)
    
    var bananaCount: Int {
        get {
            // 告诉@Observable:“有人在读香蕉数量啦,记下来!”
            self.access(keyPath: \.bananaCount)
            return mutex.withLock { $0 }
        }
        set {
            // 告诉@Observable:“香蕉数量要变了,准备更新界面!”
            self.withMutation(keyPath: \.bananaCount) {
                mutex.withLock { count in
                    count = newValue
                }
            }
        }
    }
    
    // 省略addBanana和removeBanana...
}

“这俩方法是 @Observable 宏自动加的‘钩子’,” 科巴解释,“access告诉框架‘有人在读数据’,withMutation告诉框架‘数据要改了’—— 这样界面就能跟 Mutex 里的库存同步,点一下按钮,数字立刻更新,童叟无欺!”

在这里插入图片描述

⚔️ 第四章:抉择 ——Mutex vs Actor

科巴把猿族工程师召集到一起,在黑板上画了张对比表,用炭笔重重标出关键差异:

对比维度 Mutex(轻量锁) Actor(异步卫士)
代码风格 同步代码,不用写await,清爽直接 强制异步,处处要await,略显繁琐
适用场景 保护 1-2 个简单属性(如计数)、操作耗时极短 保护复杂对象(如网络管理器)、操作耗时较长(如下载图片)
线程行为 抢不到锁会 “阻塞”(等锁释放) 抢不到隔离权会 “挂起”(不阻塞线程)
学习成本 低,API 简单,上手快 高,要理解隔离域、Sendable 等概念

“选哪个不是看‘谁更强’,而是看‘谁更适合’!” 科巴敲了敲黑板,“如果你的需求像‘数香蕉’一样简单,不想写一堆异步代码,就用 Mutex—— 它是‘贴身短刀’,快准狠;如果你的需求是‘跟人类服务器同步数据’,要处理一堆异步逻辑,就用 Actor—— 它是‘坚固盾牌’,能扛住复杂并发。”

在这里插入图片描述

他顿了顿,补充道:“我通常会两种都试一下,哪个写起来顺手就用哪个。比如这次的计数器,用 Mutex 写出来的代码比 Actor 简洁一半,还不用处理异步等待,那肯定选 Mutex 啊!”

🌟 结尾:代码丛林的生存法则

科巴把最后一行代码提交到猿族的代码仓库,终端屏幕上的香蕉计数稳定跳动 —— 从 100 跳到 101,又跳到 102,那是远方哨站的猿刚入库的香蕉,正通过 Mutex 守护的代码,实时同步到主营地的仪表盘。

在这里插入图片描述

他走到窗边,看着外面:凯撒正带领年轻的猿族围着平板学习 Swift,阳光透过废墟的缝隙洒在他们身上,像给代码世界镀上了一层金光。

科巴拉过身边的年轻猿工程师,指着屏幕上的 Mutex 代码说:“咱们猿族在丛林里生存,不会拿长矛去抓兔子,也不会拿匕首去对付狮子 —— 代码世界也一样,没有‘最强的工具’,只有‘最适合当下的工具’。Mutex 是短刀,适合近距离快速解决问题;Actor 是盾牌,适合抵御大规模的并发攻击。懂取舍,会选工具,才是真 - 正的工程师。”

在这里插入图片描述

平板上的计数又跳了一下,这次是 103—— 猿族的食物储备越来越多,他们的 Swift 代码,也在 Mutex 和 Actor 的守护下,越来越稳固。

那么,各位微秃小猩猩,你们学“废”了吗?感谢观看,下次再会啦!8-)

Thread.sleep 与 Task.sleep 终极对决:Swift 并发世界的 “魔法休眠术” 揭秘

2025年11月8日 13:23

在这里插入图片描述

📜 引子:霍格沃茨的 “并发魔咒” 危机

在霍格沃茨城堡顶层的 “魔法程序与咒语实验室” 里,金色的阳光透过彩绘玻璃洒在悬浮的魔法屏幕上。哈利・波特正对着一段闪烁着蓝光的 Swift 代码抓耳挠腮,罗恩在一旁急得直戳魔杖 —— 他们负责的 “魁地奇赛事实时计分器” 又卡住了。

赫敏抱着厚厚的《Swift 并发魔法指南》凑过来,眉头紧锁:“肯定是上次加的‘休眠咒语’出了问题!我早就说过 Thread.sleep 像‘摄魂怪的拥抱’,会吸干线程的活力,你们偏不信!

在这里插入图片描述

这时,实验室的门 “吱呀” 一声开了,负责教授高阶魔法编程的菲尼亚斯・奈杰勒斯・布莱克(没错,就是那位爱吹牛的前校长幽灵)飘了进来,黑袍在空气中划出一道残影。

在本堂魔法课中,您将学到如下内容:

  • 📜 引子:霍格沃茨的 “并发魔咒” 危机
  • 🧙‍♂️ 开篇:“休眠魔咒” 的污名化 ——Task.sleep 真不是 “过街老鼠”
  • 🔍 迷雾初探:为何 Task.sleep 总出现在 “实用魔咒” 里?
  • 🧵 核心奥秘:任务与线程的 “从属关系”—— 不是 “替代”,而是 “调度”
  • 💤 危险实验:Thread.sleep 的 “沉睡诅咒”—— 吸干线程,卡住全局
  • ✨ 救赎之光:Task.sleep 的 “智能休眠”—— 只停任务,不放线程
  • 📜 终极戒律:Swift 并发的 “不可违背法则”—— 避坑指南
  • 🌟 结尾:魔法与代码的共通之道 —— 细节定成败

“一群小笨蛋,连‘休眠魔咒’的门道都没摸清,还想搞定魁地奇的实时数据?今天就给你们上一课 ——Thread.sleep 和 Task.sleep 的终极区别,搞懂了它,你们的计分器才能像火弩箭一样流畅!”


🧙‍♂️ 开篇:“休眠魔咒” 的污名化 ——Task.sleep 真不是 “过街老鼠”

在 Swift 魔法世界里,“让代码暂停执行” 这事儿,历来被视为 “禁忌操作”—— 毕竟谁也不想自己的魔法程序突然 “卡壳”,就像罗恩上次在魔药课上把坩埚炸了一样狼狈。

但菲尼亚斯的第一句话就颠覆了众人认知:“别一提‘休眠’就谈虎色变!你们总觉得 Task.sleep 和 Thread.sleep 是一丘之貉,其实前者根本没你们想的那么‘不靠谱’,今天咱们就扒掉它俩的‘魔法伪装’,看看谁才是真正的‘捣蛋鬼’。”

首先得明确一点:在 Swift 里让代码 “歇口气” 的法子不止一种,但 Thread.sleep 早就因为 “破坏力太强” 而被老法师们拉入了 “慎用清单”。而 Task.sleep 呢?虽然也常被用来实现 “防抖”(比如防止用户疯狂点击魁地奇计分按钮)或 “任务超时”(比如等待球员数据加载的时限),却总因为和 Thread.sleep 沾了 “sleep” 二字,被不少新手当成 “洪水猛兽”。

在这里插入图片描述

“这就像把‘荧光闪烁’和‘阿瓦达索命’归为一类 —— 纯属谬以千里!” 菲尼亚斯敲了敲魔法屏幕,上面立刻浮现出两行发光的文字,“关键区别,全藏在 Swift 并发世界里‘任务’和‘线程’的运作逻辑里,这可是你们之前逃课没学的重点!

🔍 迷雾初探:为何 Task.sleep 总出现在 “实用魔咒” 里?

哈利举手提问:“教授,我上次在论坛上看别人写‘魁地奇进球防抖’的代码,十篇有九篇用了 Task.sleep,这是为啥呀?”

菲尼亚斯飘到哈利身边,用魔杖一点屏幕,一段代码立刻跳了出来:

// 魁地奇进球防抖逻辑:防止用户1秒内重复点击“进球”按钮
func handleGoalTap() {
    // 先取消之前可能还在等待的任务(类似“解除旧咒语”)
    currentDebounceTask?.cancel()
    // 新建一个任务,让它“休眠”1秒后再执行真正的计分逻辑
    currentDebounceTask = Task {
        do {
            // Task.sleep 的参数是纳秒,这里1_000_000_000纳秒 = 1秒
            // 重点:这里休眠的是“任务”,不是“线程”!
            try await Task.sleep(nanoseconds: 1_000_000_000)
            // 休眠结束后,执行计分(比如格兰芬多得分+10)
            updateScore(for: .gryffindor, points: 10)
        } catch {
            // 如果任务被取消(比如用户1秒内又点了一次),就不执行计分
            print("防抖任务被取消,避免重复计分")
        }
    }
}

“看到没?” 菲尼亚斯的声音里带着得意,“这种场景下,Task.sleep 就像‘时间转换器’—— 让任务先‘暂停’一会儿,既不会耽误其他代码运行,还能精准控制逻辑触发时机。要是换成 Thread.sleep,你们的计分器早就像被施了‘石化咒’一样动不了了!”

在这里插入图片描述

🧵 核心奥秘:任务与线程的 “从属关系”—— 不是 “替代”,而是 “调度”

要搞懂两者的区别,首先得打破一个 “根深蒂固” 的误区 —— 很多新手以为 “Swift 并发里,任务取代了线程”,就像当年巫师用魔杖取代了木棍一样。

菲尼亚斯听到这话,差点笑出了幽灵特有的 “滋滋” 声:“简直是无稽之谈!任务和线程根本不是‘替代关系’,而是‘调度与被调度’的关系,就像魁地奇比赛里,球员(任务)需要骑着扫帚(线程)才能上场,你能说球员取代了扫帚吗?”

在这里插入图片描述

他用魔杖在空中划出两张魔法图,左边是 “无 Swift 并发时代”,右边是 “Swift 并发时代”:

时代 调度工具 执行载体 核心逻辑
无 Swift 并发 Dispatch Queues(飞路网信使队列) Thread(魔法信使) 用 “飞路网队列” 给 “魔法信使” 分配任务,信使跑完一个再跑下一个
Swift 并发 Task(魔法任务卷轴) Thread(魔法信使) 用 “任务卷轴” 给 “魔法信使” 分配任务,信使可以随时切换卷轴,不用等一个跑完

简单说,以前是‘一个信使只能扛一个包裹’,现在是‘一个信使能扛多个包裹,还能随时换着扛’!” 菲尼亚斯解释道,“不管有没有 Swift 并发,你们都不用直接‘创造信使’(管理线程)—— 以前靠‘飞路网队列’安排信使干活,现在靠‘任务’安排。这才是正确的‘心智模型’,要是理解错了,后面的内容就像听‘蛇佬腔’一样难懂!”

💤 危险实验:Thread.sleep 的 “沉睡诅咒”—— 吸干线程,卡住全局

为了让大家直观感受 Thread.sleep 的 “破坏力”,菲尼亚斯启动了一个 “魔法实验”:他召唤出 4 个 “魔法信使”(对应程序的 4 个线程),每个信使负责处理 3 个 “任务”(比如更新计分、播放欢呼声、记录数据等)。

“看好了,这 4 个信使就是你们程序的‘全部运力’,就像霍格沃茨只有 4 辆‘夜骐马车’负责运输一样。” 菲尼亚斯说着,给其中一个信使施了 “Thread.sleep 咒语”—— 只见那个信使立刻停下脚步,抱着包裹原地 “昏睡” 过去,不管其他任务怎么 “喊” 它,都纹丝不动。

在这里插入图片描述

“现在问题来了!” 菲尼亚斯的声音突然变得严肃起来,“原本 4 个信使能轻松搞定 12 个任务,现在少了 1 个,剩下 3 个得扛 12 个任务 —— 这就像让罗恩一个人搬 10 箱魔药材料,不累死才怪!”

更可怕的还在后面:当他给 4 个信使都施了 “Thread.sleep 咒语” 后,所有信使都昏睡过去,屏幕上的任务进度条瞬间变成了红色,魁地奇计分器的数字停在 “40:30” 不动了,连背景音乐都卡住了。

“这就是 Thread.sleep 的‘致命缺陷’!” 菲尼亚斯的魔杖指向昏睡的信使,“它会让整个‘信使’(线程)彻底休眠,期间既不能处理‘飞路网队列’的活,也不能跑其他‘任务’—— 就像被摄魂怪吸走了所有活力!

GCD 时代还好,因为它会‘临时召唤新信使’(新建线程),虽然效率低,但至少不会全卡住;可 Swift 并发不轻易‘加信使’,线程数量是固定的,要是所有信使都睡了,你们的程序就会像被施了‘统统石化’,直到信使醒来才能动 —— 这要是在魁地奇决赛上,观众不得把球场拆了才怪?!”

✨ 救赎之光:Task.sleep 的 “智能休眠”—— 只停任务,不放线程

就在哈利和罗恩倒吸一口凉气时,菲尼亚斯挥了挥魔杖,解除了 “Thread.sleep 诅咒”,然后启动了第二个实验 —— 给任务施 “Task.sleep 咒语”。

同样是 4 个信使,12 个任务。当菲尼亚斯对其中一个 “计分任务” 施咒后,神奇的事情发生了:那个任务暂时 “消失” 了,但执行它的信使没有昏睡,反而立刻拿起了下一个 “播放欢呼声” 的任务,继续干活!

在这里插入图片描述

“看到没?这就是 Task.sleep 的‘智慧’!” 菲尼亚斯的声音里满是赞叹,“它休眠的是‘任务’,不是‘线程’—— 就像让一个球员暂时下场休息,但他的扫帚不会闲着,会立刻交给另一个球员继续比赛!”

他进一步解释道:Task.sleep 本质是 “让当前任务暂时放弃线程的使用权”,线程会立刻被 “调度中心” 分配给其他等待的任务,既不会浪费 “信使资源”,也不会耽误整体进度。 就像赫敏在图书馆查资料时,会把笔记本借给哈利记笔记,而不是抱着笔记本发呆 —— 这才是 Swift 并发的 “高效精髓”!

菲尼亚斯又展示了一段对比代码,清晰标出了两者的区别:

// 🔴 危险!Thread.sleep 的错误示范:让线程昏睡1秒,期间啥也干不了
func badSleepExample() {
    Thread.sleep(forTimeInterval: 1.0) // 这里会让当前线程彻底休眠1秒
    print("1秒后才会打印这句话,但线程休眠期间,其他任务全卡住!")
}

// 🟢 安全!Task.sleep 的正确示范:只休眠任务,线程去干别的
func goodSleepExample() async throws {
    try await Task.sleep(nanoseconds: 1_000_000_000) // 1秒 = 1e9纳秒
    // 休眠期间,执行这个任务的线程已经去处理其他任务了
    print("1秒后打印这句话,但线程没闲着,效率拉满!")
}

📜 终极戒律:Swift 并发的 “不可违背法则”—— 避坑指南

实验结束后,菲尼亚斯飘到实验室中央,黑袍无风自动,活像个即将宣布 “三强争霸赛” 规则的裁判:“现在给你们立下 Swift 并发的‘第一戒律’——在 Swift 并发代码里,永远、永远、永远不要用 Thread.sleep,只用 Task.sleep!

在这里插入图片描述

他特意加重了 “永远” 两个字,眼神扫过罗恩(毕竟罗恩上次就犯过这错):“我很少说‘永远’,但这次必须说 ——Thread.sleep 就像‘未经许可使用时间转换器’,看似能解决问题,实则会引发连锁反应,把整个程序的并发逻辑搅得一团糟。而 Task.sleep 是‘被官方认可的休眠术’,既安全又高效。”

但菲尼亚斯话锋一转,表情又变得严肃:“不过,你们也别把 Task.sleep 当成‘万能解药’!要是有人写代码时说‘不加个 0.01 秒的休眠,这段逻辑就跑不通’—— 这绝对是‘治标不治本’!”

在这里插入图片描述

他举例:比如有人发现 “更新计分后立刻刷新 UI 会卡顿”,就加了 Task.sleep (0.01),看似解决了问题,实则掩盖了 “UI 更新和数据计算没在正确队列执行” 的根本问题 —— 就像罗恩为了掩盖魔药熬糊的事实,往里面加了 “香精”,闻着香,喝下去照样会拉肚子。

“真正的高手,会找到问题的根源,而不是用‘休眠’来藏拙。” 赫敏听到这话,立刻点了点头,偷偷把自己笔记里 “临时加 0.01 秒休眠” 的注释划掉了。

🌟 结尾:魔法与代码的共通之道 —— 细节定成败

当实验室的钟声响起时,哈利已经把 “魁地奇计分器” 的代码改好了 —— 他用 Task.sleep 替代了原来的 Thread.sleep,还修复了隐藏的 “队列串行化问题”。

运行代码的瞬间,屏幕上的计分器流畅地跳动着,格兰芬多的分数从 40 变成 50 时,背景立刻响起了欢呼声,没有一丝卡顿。

菲尼亚斯看着屏幕,满意地捋了捋不存在的胡子:“记住,小巫师们,魔法的真谛在于‘理解每一个咒语的本质’—— 你知道‘除你武器’是缴械,‘昏昏倒地’是催眠,才不会用错场合。编程亦如是:Thread.sleep 是‘困住信使的枷锁’,会让你的程序陷入停滞;而 Task.sleep 是‘给任务的智能休战符’,能让并发逻辑如凤凰涅槃般流畅自如。”

在这里插入图片描述

他最后一挥魔杖,魔法屏幕上浮现出一行金色的大字:“Swift 并发的战场里,选对‘休眠术’,你的代码才能像火弩箭一样,快得让对手望尘莫及;选错了,便是万丈深渊的卡顿,让用户对你的程序‘敬而远之’。”

哈利、罗恩和赫敏对视一眼,都露出了恍然大悟的笑容 —— 原来编程和魔法一样,细节里藏着成败的关键,而今天这堂 “休眠术” 课,无疑给他们的 “魔法编程手册” 添上了至关重要的一页。

那么,各位秃头魔法师,你们学“废”了吗?

感谢观看,我们下次再会吧!8-)

【大话码游之 Observation 传说】下集:破咒终局了,天眼定乾坤

2025年11月8日 13:20

在这里插入图片描述

⚡️ 引子:内存魔咒锁盘丝,旧妖狂笑待崩盘

上回说到,至尊宝用 “信号仓库” 暂时破解了旧观老妖的 “信号失踪” 计,正得意间,盘丝洞的地砖突然开始冒黑烟 —— 观气道人被 “内存魔咒” 缠上,变成了不死不休的僵尸进程,就算把月光宝盒砸成碎片,后台里的计数观测还在疯跑!

“哈哈哈!” 旧观老妖踩着黑烟狂笑,手里的破葫芦(withObservationTracking)都笑出了裂纹,“让你用新法宝!这‘强引用捆仙绳’一旦缠上,别说你这臭猴子,就算如来佛祖来了也解不开!等内存仙力耗尽,整个盘丝洞都得炸成原子!”

在这里插入图片描述

紫霞仙子急得用紫青宝剑砍代码,火花四溅却毫无用处:“亲爱的!这可怎么办?老祖的秘籍里没说这招啊!”

在本篇西游外传中,您将学到如下内容:

  • ⚡️ 引子:内存魔咒锁盘丝,旧妖狂笑待崩盘
  • 7️⃣ 破解内存魔咒:三招斩断强引用捆仙绳
  • 第一招:Task 初始化,弱引用先行
  • 第二招:循环内解包,见好就收
  • 第三招:闭包弱引用,釜底抽薪
  • 8️⃣ 天眼通的终极形态:多属性观测,一网打尽
  • 9️⃣ 终局对决:旧妖溃败,天眼定乾坤
  • 🏁 尾声:新篇待启,仙法无边

至尊宝攥着金箍棒,额头上青筋暴起:“妖魔鬼怪都给我听着!今天就算拆了这破代码,我也得把这魔咒破了!”

在这里插入图片描述


7️⃣ 破解内存魔咒:三招斩断强引用捆仙绳

就在此时,云端传来菩提老祖的洪钟之音:“痴儿!慌什么!这‘内存魔咒’看着吓人,实则有三招可破!且听我道来 ——”

在这里插入图片描述

第一招:Task 初始化,弱引用先行

老祖掷下第一道金光,照在 Task 的初始化代码上:

// 错误示范:Task强引用self,等于给观气道人戴了紧箍咒,永远摘不下来
Task { 
  // 这里的self是强引用,哪怕外面的观气道人被销毁,Task还抱着self不放
  let values = Observations { self.counter.count }
  for await value in values { /* 处理信号 */ }
}

// 正确示范:用[weak self]给Task松绑,像给捆仙绳抹了润滑油
Task { [weak self] in // 关键!弱引用self,Task不绑架观气道人
  guard let self else { return } // 先确认观气道人还在,不在就直接跑路
  let values = Observations { [weak self] in 
    self?.counter.count ?? 0 
  }
  for await value in values { /* 处理信号 */ }
}

在这里插入图片描述

“记住!” 老祖的声音震得洞顶掉灰,“Task 这东西,就像个贪财的小妖,你不给它套‘弱引用紧箍咒’,它就会把 self 死死攥在手里,就算主人(观气道人)死了,它还抱着尸体不放!”

第二招:循环内解包,见好就收

金光再闪,照向 for await 循环:

// 错误示范:循环外强解包self,等于给魔咒上了双保险
Task { [weak self] in
  guard let self = self else { return } // 在这里强解包,等于重新捆紧绳子
  for await value in values {
    // 就算观气道人后来被销毁,self还被循环攥着,内存泄漏没跑
    print(self.counter.count)
  }
}

// 正确示范:循环内按需解包,用完就扔
Task { [weak self] in
  let values = Observations { self?.counter.count ?? 0 }
  for await value in values {
    guard let self else { break } // 每次循环都检查:主人不在了?立马停手!
    print(self.counter.count)
    // 处理完就放手,绝不纠缠
  }
}

在这里插入图片描述

紫霞仙子恍然大悟:“哦!就像我给你送吃的,你吃完了就该把碗还给我,总抱着碗不放,我怎么再给别人送啊!”

第三招:闭包弱引用,釜底抽薪

最后一道金光劈向 Observations 的闭包:

// 错误示范:闭包强引用self,形成“观气道人→Task→闭包→观气道人”的死亡循环
let values = Observations { 
  self.counter.count // 这里的self是强引用,等于给魔咒加了锁
}

// 正确示范:闭包也用[weak self],从根源上断了循环
let values = Observations { [weak self] in // 闭包也弱引用,釜底抽薪
  self?.counter.count ?? 0 
}

在这里插入图片描述

“这三招齐出,” 老祖总结道,“就像给捆仙绳剪了三刀,强引用的循环链条一断,观气道人该投胎投胎,该销毁销毁,内存魔咒自然破解!”

8️⃣ 天眼通的终极形态:多属性观测,一网打尽

破解了内存魔咒,至尊宝突然一拍大腿:“对了老祖!要是我想同时盯着好几个属性变化,比如计数和宝盒的能量值,这天眼通能行吗?”

“问得好!” 老祖赞许道,“这正是天眼通比旧观气术厉害百倍的地方 —— 它能同时观测多个属性,只要你在闭包里碰过的,一个都跑不了!”

在这里插入图片描述

说着,紫霞给计数仙核加了个新属性,演示起多属性观测:

// 升级后的计数仙核,多了个能量值属性
@Observable 
class Counter {
  var count: Int
  var power: Int = 100 // 月光宝盒的能量值
}

// 天眼通同时观测count和power
let values = Observations { [weak self] in
  guard let self else { return (0, 0) }
  // 闭包里访问了两个属性,天眼通会同时盯着它们
  return (self.counter.count, self.counter.power) 
}

// 只要count或power变了,仙流就会发信号
for await (count, power) in values {
  print("次数:\(count), 能量:\(power)")
}

旧观老妖看得眼睛发直:“不可能!我那破葫芦(withObservationTracking)要同时盯两个属性,得写 twice 代码,还经常串线!这新法宝怎么能这么丝滑?”

“因为天眼通是‘属性感知雷达’,” 老祖解释道,“闭包里访问多少属性,它就自动布多少个监测点,不管你最后返回啥,只要碰过的属性变了,立马报警 —— 比哮天犬的鼻子还灵!”

在这里插入图片描述

9️⃣ 终局对决:旧妖溃败,天眼定乾坤

“不!我不甘心!” 旧观老妖见底牌被破,掏出最后一招 —— 疯狂修改计数和能量值,想让仙流过载崩溃。

可至尊宝早已用三招破解了内存魔咒,又靠着多属性观测稳稳接住所有信号。屏幕上的日志整整齐齐,没有一个遗漏,没有一丝卡顿。

在这里插入图片描述

“不可能… 我的时代… 怎么会结束…” 旧观老妖的黑气越来越淡,手里的破葫芦咔嚓一声裂成两半,“想当年,我 withObservationTracking 横行江湖的时候,你们这些小娃娃还没出生呢… 现在… 唉…”

在这里插入图片描述

随着一声叹息,旧观老妖化作一缕青烟消散,只留下一句回荡的遗言:“记住… 技术迭代如江水东流… 不跟上,就只能被拍在沙滩上…”

盘丝洞的黑烟渐渐散去,月光宝盒的计数恢复正常,内存仙力平稳流动。至尊宝搂着紫霞仙子,看着屏幕上顺畅运行的代码,嘿嘿一笑:“看来这 Xcode 26 的天眼通,还真不是盖的!”

在这里插入图片描述

🏁 尾声:新篇待启,仙法无边

紫霞仙子把玩着老祖留下的秘籍,突然发现最后一页有行小字:“天眼通初成,然仙法无穷。他日或有‘多线程仙流分流术’‘信号重放真经’问世,有缘者自得之。”

在这里插入图片描述

至尊宝凑过去一看,眼睛发亮:“多线程分流?那岂不是能让观测速度再快十倍?”

“傻猴子,” 紫霞笑着敲他的脑袋,“先把眼下的观气术练熟吧!说不定哪天,又有更厉害的妖魔鬼怪等着咱们呢!”

在这里插入图片描述

月光透过盘丝洞的窗棂,照在代码上,反射出金色的光芒。属于 Observations 的时代,才刚刚开始。而那些藏在技术深处的奥秘,还等着后来者一一揭开…

感谢各位宝子们的观看,下次我们再会吧!8-)

(全剧终)

❌
❌