阅读视图

发现新文章,点击刷新页面。

探秘 WWDC 25 全新 #Playground 宏:提升 Swift 开发效率的超级神器

在这里插入图片描述

概述

在代码的武林中,开发者们如同闯荡江湖的侠客,不断追寻着提升功力的秘籍。

而在最新的 WWDC 25 大会上,苹果于 Xcode 26 中推出的 #Playground 宏,无疑是一本惊世骇俗的武功宝典,让开发者们有了新的 “修炼” 方向。

在这里插入图片描述

不过,在深入了解这位神秘的 #Playground 宏之前,咱们先回顾一下 Playground 项目这位 “老朋友”。

在本篇武林秘籍中,各位少侠将学到如下内容:

  1. Playground 项目的峥嵘岁月
  2. #Playground 宏初露锋芒
  3. 深入 #Playground 宏之变量查看
  4. #Playground 宏与复杂逻辑验证
  5. 灵活运用 #Playground 宏的场景 6.非凡大师:精通各种样式的显示
  6. 结语:开启代码新征程

闲言少叙,让我们马上操练起来吧! Let's go!!!;)


1. Playground 项目的峥嵘岁月

Playground 项目自问世以来,就如同江湖中的 “新手村”,是众多开发者初入 Swift 编程世界的启蒙之地。

在这里插入图片描述

在这里,开发者们无需构建复杂的项目框架,就能像初入江湖的少年,自由地尝试各种代码招式,体验 Swift 语言的精妙之处。

比如,我们想测试一个简单的字符串拼接操作:

let greeting = "Hello"

let name = "World"

let combined = greeting + ", " + name

print(combined)

在 Playground 项目中,我们只需寥寥数行代码,就能实时看到结果,快速验证自己的想法,就像少年在魔界村练习拳脚,快速掌握基础招式。

Xcode Playground 为我们提供了一个轻松的实验环境,极大地降低了学习和探索 Swift 的门槛。

在这里插入图片描述

但随着微秃少侠们在江湖中不断闯荡,功力日益提升,对代码调试和验证的需求也愈发高深莫测,传统的 Playground 项目渐渐显得有些力不从心。而此时,#Playground 宏横空出世,为我们带来了全新的机遇与挑战。

2. #Playground 宏初露锋芒

在 WWDC 25 中,苹果百尺竿头更进一步,推出了全新而又小巧伊人的 #Playground 宏,它就像是江湖中失传已久的绝世武功,一经现世,便惊艳众人。

在这里插入图片描述

#Playground 允许开发者在代码中直接嵌入一个类似 Playground 的环境,让代码的调试和验证变得更加随心所欲。

比如说,我们在一个普通的 Swift 文件中,想要测试一个简单的数学计算逻辑:

#Playground {
    let a = 5
    let b = 3
    let result = a * b + (a - b)
    print(result)
}

通过这个简单的 #Playground 宏,我们无需再切换到专门的 Playground 项目中,就能在当前文件中即时运行这段代码,查看结果。


友情提示:为了酣畅淋漓的使用 #Playground 宏,各位少侠需要首先导入 Playgrounds 框架:

import Playgrounds

在 Xcode 中,我们可以看到精巧的分步骤显示的结果界面:

在这里插入图片描述

这就好比侠客在战斗中,无需退回到安全的后方,就能当场施展新的武功招式,验证其威力,大大提高了开发效率。

3. 深入 #Playground 宏之变量查看

Playground 宏的强大之处远不止于此,它还能让我们像拥有 “透视眼” 一样,查看代码运行过程中的中间变量值。在传统的开发过程中,查看中间变量常常让开发者们煞费苦心,而 #Playground 宏让这一切变得易如反掌。

例如,我们有一段计算斐波那契数列的代码:

#Playground("计算”兔子“数列") {
    func fibonacci(_ n: Int) -> Int {
        if n <= 1 {
            return n
        }
        var a = 0
        var b = 1
        for _ in 2...n {
            let temp = a
            a = b
            b = temp + b
        }
        return b
    }
    let number = 10
    let result = fibonacci(number)
    print("第\(number)个斐波那契数是\(result)")
}

在这个 #Playground 结果的浏览中,我们可以看到代码各个关键位置变量的变化,比如变量abtemp在循环过程中的值,这就像在战斗中清晰洞察自己每一招每一式的威力,才能更好地优化代码招式逻辑。

在这里插入图片描述

4. #Playground 宏与复杂逻辑验证

对于一些复杂的业务逻辑,#Playground 宏更是大显身手。

假设我们正在开发一个电商应用中的购物车模块,需要验证添加商品、计算总价、处理折扣等一系列复杂逻辑。以前,我们可能需要在整个项目中构建各种测试场景,过程繁琐且容易出错。

现在,有了 #Playground 宏,我们可以在单独的文件中,或者在相关代码文件的合适位置,创建一个 #Playground 块来专门测试这些逻辑:

#Playground {
    class Product {
        let price: Double
        init(price: Double) {
            self.price = price
        }
    }
    class Cart {
        var products = [Product]()
        func addProduct(_ product: Product) {
            products.append(product)
        }
        func totalPrice() -> Double {
            return products.reduce(0) { $0 + $1.price }
        }
    }
    let product1 = Product(price: 10.0)
    let product2 = Product(price: 20.0)
    let cart = Cart()
    cart.addProduct(product1)
    cart.addProduct(product2)
    let total = cart.totalPrice()
    print("购物车总价:\(total)")
}

通过这样的方式,我们可以快速验证购物车模块的核心逻辑是否正确,及时发现并解决潜在问题,如同侠客在修炼高深武功时,通过一次次模拟战斗,不断完善自己的招式,很棒哦!

5. 灵活运用 #Playground 宏的场景

#Playground 宏的应用场景极为广泛。在团队协作开发中,当新成员加入项目,面对复杂代码库一头雾水时,可以利用 #Playground 宏在关键代码处创建示例,快速理解代码逻辑和功能。

比如,在一个复杂的网络请求模块中,九品之上的”大宗师“可以通过 #Playground 宏展示如何正确调用接口、处理返回数据的代码:

#Playground {
    func sendNetworkRequest(completion: @escaping (Data?, Error?) -> Void) {
        // 模拟网络请求,这里用延迟模拟网络耗时
        DispatchQueue.main.asyncAfter(deadline:.now() + 2) {
            let mockData = "Mock response data".data(using:.utf8)
            completion(mockData, nil)
        }
    }
    sendNetworkRequest { data, error in
        if let data = data, let response = String(data: data, encoding:.utf8) {
            print("网络请求成功,响应数据:\(response)")
        } else if let error = error {
            print("网络请求失败:\(error)")
        }
    }
}

这样,新成员就能迅速上手,融入项目开发。

又比如在探索新的框架或库时,#Playground 宏能帮助我们快速了解其使用的方法和特性。

以一个新的图表绘制框架为例,我们可以通过 #Playground 宏编写简单的示例代码,测试各种图表类型的绘制效果:

// 假设这里导入了一个新的图表框架
import SomeChartFramework

#Playground {
    let data = [10, 20, 30, 40, 50]
    let chart = BarChart(data: data)
    chart.show()
}

通过这种方式,我们能在短时间内对新框架有一个直观的认识,决定是否要将其应用到实际项目中去。

6.非凡大师:精通各种样式的显示

除了验证和快速评估代码逻辑以外,#Playground 宏还能够以所见即所得的方式向各位微秃少侠们展示多种对象的显示结果。

图片自然是小菜一碟:

#Playground("一张女孩图片") {
    let image = UIImage(named: "girl")
}

运行结果如下所示:

在这里插入图片描述

对于武林地图上各种美食藏宝图的坐标,我们也可以利用 #Playground 一蹴而就,好吃不忽悠

import MapKit
#Playground("北京天安门") {
    let coordinate = CLLocationCoordinate2D(latitude: 39.9087, longitude: 116.3975) // 天安门坐标
}

运行立即便知分晓:

在这里插入图片描述

碰到一些古怪刁钻的兵器形状(Path),我们也可以借助 #Playground 宏来按图索骥,龙门飞甲便知真假:

#Playground("兵器谱器型第一名") {
    // 定义多边形顶点坐标
    let points = [
        CGPoint(x: 100, y: 100),
        CGPoint(x: 300, y: 100),
        CGPoint(x: 250, y: 250),
        CGPoint(x: 150, y: 250)
    ]
    
    let path = UIBezierPath(ovalIn: .init(origin: .zero, size: .init(width: 200, height: 200)))
    
    guard let first = points.first else { return }
    path.move(to: first)
    for point in points.dropFirst() {
        path.addLine(to: point)
    }
    path.close()
}

从 Xcode 的预览中,我们可以一窥究竟:

在这里插入图片描述

最后,使用 #Playground 宏我们可以恣意查看和预览任何格式的文件,甚至 3D 建模也是不在话下:

import RealityKit
#Playground("3D 建模文件预览") {
    // 3D模型预览,支持旋转、缩放交互
    
    let url = Bundle.main.url(forResource: "cup", withExtension: "usdz")!
    let model = try? await Entity(contentsOf: url)
    
    model?.children.first
}

运行立知分晓:

在这里插入图片描述

7. 结语:开启代码新征程

Playground 全新宏的出现,为开发者们在代码江湖中的闯荡提供了一件超级神器。

在这里插入图片描述

它打破了传统开发中代码测试和验证的诸多限制,让我们能够更加高效、便捷地探索代码的无限可能。就如同一位侠客获得了一本绝世秘籍,功力大增,在江湖中能够更加游刃有余地应对各种挑战。

在这里插入图片描述

它与 Xcode 之前存在的 Preview 恰似两把倚天剑,各自都专注于代码或 UI 的显示,正所谓“双剑合璧,天下无敌”,棒棒哒!

在未来的开发旅程中,合理运用 #Playground 宏,必将助力我们在代码世界里披荆斩棘,创造出更加优秀、高效的应用。让我们怀揣着对代码的热爱与探索精神,借助 #Playground 宏的强大力量,开启一段全新的代码传奇之旅,在代码江湖中书写属于自己的辉煌篇章。

在这里插入图片描述

最后,感谢各位秃头少侠们的观看,我们下次再会吧!8-)

iPhone 数据擦除软件评测(最新且全面)

当您准备出售、捐赠或回收 iPhone 时,仅仅恢复出厂设置并不足以保证您的个人数据彻底消失。专业的 iPhone 数据擦除软件采用先进的技术,确保您的敏感信息永久无法恢复。

本文回顾了十种流行的 iPhone 数据擦除工具,详细介绍了它们的功能、优点和缺点,以帮助您选择最适合您需求的选项。

工具1:iReaShare iPhone数据橡皮擦

如何清除 iPhone 上的所有数据并覆盖已删除的数据?使用iReaShare iPhone Data Eraser,即可轻松实现。这款擦除软件可以一次性擦除所有 iOS 数据,如果选择“高级”选项,还会进行两次覆盖。使用后,您的数据将无法恢复。请务必先备份有用的数据,否则您将永远丢失它们。

这款 iPhone 橡皮擦工具的主要特点:

  • 一次性清除 iOS 设备中的所有数据。

  • 永久删除现有和已删除的数据。

  • 支持擦除联系人、照片、视频、密码、音乐、文档、消息、书签、浏览历史记录等。

  • 为您提供 3 种擦除模式:低、中、高。

  • 支持 iOS 5.0 及更高版本,包括 iOS 26。

优点:

  • 旨在彻底销毁数据。

  • 有利于在出售或捐赠之前保护隐私。

  • 无法恢复任何个人数据。

  • 支持大多数 iOS 设备,包括 iPhone 和 iPad。

缺点:

  • 不允许您选择特定文件。

  • 目前没有Mac版本可供下载。

定价:

  • 1 台电脑每月 15.95 美元。

  • 1 台电脑每年 25.95 美元。

  • 1 台电脑终身使用费用为 35.95 美元。

请访问 iReaShare 网站以获取更多价格选项。

下载 iReaShare iPhone 数据橡皮擦。

下载 Win 版

以下是如何使用此 iOS 数据橡皮擦:

  1. 在电脑上下载并安装 iReaShare iPhone 数据擦除器,然后打开它。接下来,用 USB 数据线将 iPhone 连接到电脑,并在出现提示时点击 iPhone 上的“信任”。连接后,点击“擦除”继续。

首页.png

  1. 点击“”选择擦除级别,然后在指定区域输入“ delete ”。接下来,点击“擦除”>“确定”即可开始擦除你的 iPhone 数据。

确认安全级别.png

  1. 在此过程中,它将重启您的 iPhone 并覆盖您的数据。请在此过程完全完成之前,请勿断开您的 iPhone。

工具2:Syncios iOS Eraser Pro

Syncios iOS 数据擦除器是 Syncios 套件的一部分,该套件以其全面的 iOS 设备管理功能而闻名。该数据擦除器专注于永久删除 iOS 设备中的数据。

主要特点:

  • 提供不同级别的数据擦除(例如,快速清理、擦除所有数据、擦除已删除的文件)。

  • 可以有选择地删除私人数据,如消息、通话记录、联系人、照片和应用数据。

  • 通过删除垃圾文件和临时数据来帮助优化设备性能。

*擦除消息、联系人、通话记录、照片、视频、浏览历史记录等。

优点:

  • 在 Syncios 生态系统中提供灵活的恢复模式(尽管橡皮擦专注于永久删除)。

  • 适用于选择性擦除和整个设备擦除。

缺点:

  • 其他 Syncios 工具的预览功能有时可能不稳定。

  • 对于某些用户来说,订阅定价可能是一个缺点。

定价:

  • 1 台电脑每年 19.95 美元。

  • 1 台电脑终身使用费为 29.95 美元。

  • 1 台电脑的商业许可证费用为每年 199 美元。

工具3:iMyFone Umate Pro

iMyFone Umate Pro 是一款广受好评的数据擦除器,可确保 iOS 设备上的数据 100% 不可恢复。对于在处理旧设备时注重隐私和安全的用户,它通常值得推荐。

主要特点:

  • 永久擦除所有数据和设置,使设备“像新的一样”。

  • 可以查找并永久删除以前“删除”但仍可恢复的文件。

  • 有选择地擦除私人信息,例如消息、通话记录、Safari 历史记录、照片等。

  • 清理垃圾文件、临时文件、压缩照片以及管理大文件和应用程序以释放空间。

优点:

  • 使数据无法恢复的成功率高。

  • 提供多种擦除模式,满足不同需求。

  • 有效释放存储空间。

缺点:

  • 这不是免费软件,有些人可能会认为其成本很高。

  • 扫描和恢复速度有时会比较慢。

定价:

  • 1 台电脑每年 29.99 美元。

  • 1 台电脑终身使用费用为 49.99 美元。

请访问其网站以获取更多价格选项。

工具4:Dr.Fone - 数据橡皮擦(iOS)

Dr.Fone 是一款广受欢迎的移动设备工具包,其 iOS 版 Data Eraser 是一款功能强大的模块,旨在安全永久地删除数据。对于想要可靠保护隐私的用户来说,它是一款安全可靠的解决方案。

主要特点:

  • 彻底清除 iOS 设备上的所有数据,包括设置,确保它就像一个全新的设备。

  • 允许选择性删除私人数据,例如消息、联系人、照片、视频和银行信息。

  • 安全地从设备中擦除已“删除”的文件。

  • 为不同安全级别提供各种数据擦除标准。

  • 允许用户在擦除之前预览数据。

优点:

  • 综合工具包的一部分,提供除数据擦除之外的附加功能(例如数据恢复、系统修复)。

  • 删除数据后提供隐私安全认证报告。

  • 用户友好的界面,具有清晰的说明。

缺点:

  • 如果您只需要数据擦除功能,那么完整的工具包可能会很昂贵。

  • 某些高级功能(如“屏幕解锁”)可能无法在较新的设备上运行。

  • 工具包中的其他功能不能免费使用。

定价:

  • 1 台电脑每月 12.95 美元。

  • 1 台电脑每年 14.95 美元。

  • 1 台电脑终身使用费为 19.95 美元。

提示: 在清除 iOS 设备之前,请先选择性地将重要文件从 iPhone 传输到 PC 。这样,您就不会丢失任何有用的数据。

工具 5:iPhone 专用 Stellar Eraser

Stellar Eraser for iPhone(通常是 Stellar Data Recovery for iPhone 的一部分)是一款可靠的 iOS 设备敏感数据永久删除工具。它以强大的数据擦除功能和用户友好的设计而闻名。

主要特点:

  • 永久删除 iPhone、iPad 和 iPod Touch 中的所有数据和设置。

  • 允许用户选择特定类型的数据(例如联系人、消息、照片)进行永久删除。

  • 确保已删除的数据无法被任何数据恢复软件恢复。

  • 清除通话记录、Safari 书签、笔记、提醒等。

  • 通常采用行业标准算法来确保安全的数据销毁。

优点:

  • 非常有效地使数据无法恢复。

  • 提供不同的数据擦除模式。

缺点:

  • 主要侧重于数据擦除,因此可能不包括其他设备管理功能。

  • 完整版的成本可能是一个因素。

定价:

  • 1 台电脑每年 29.99 美元。

  • 该工具包每年售价 49.99 美元。

请访问其网站以获取更多价格选项。

工具6:Aiseesoft FoneEraser

Aiseesoft FoneEraser 是一款专业的 iOS 数据擦除工具,旨在永久删除 iPhone、iPad 和 iPod touch 上的所有内容和设置,杜绝擦除后数据被恢复的可能性。

主要特点:

  • 提供低、中、高级别的擦除以满足不同的安全需求。

  • 清除设备上的所有内容,确保重新开始。

  • 可以有选择地删除消息、联系人、通话记录、照片和视频等私人数据。

  • 删除垃圾文件、临时文件和缓存以释放存储空间。

  • 可以同时从多个 iOS 设备擦除数据。

优点:

  • 提供可调节的数据擦除安全级别。

  • 有效清理垃圾文件以提高性能。

  • 支持同时擦除多个设备,节省时间。

缺点:

  • 扫描过程可能非常耗时,尤其是对于大量数据而言。

定价:

  • 1 台电脑每月 9.95 美元。

  • 1 台电脑终身使用费用为 29.95 美元。

  • 3 台电脑的多用户许可证价格为 59.00 美元。

工具 7:FoneTool iPhone 橡皮擦

FoneTool(原名 AOMEI MBackupper)以其全面的 iPhone 数据管理功能而闻名,其中包括强大的数据擦除功能。它允许用户在出售或赠送设备之前永久擦除数据,以保护隐私。

主要特点:

  • 从您的 iPhone 中删除所有数据和设置,使其无法恢复。

  • 允许有选择地删除敏感的个人信息。

  • 确保先前标记为“已删除”的文件被永久覆盖。

  • 擦除照片、视频、联系人、消息、通话记录、浏览历史记录等。

优点:

  • 提供完整和选择性数据擦除。

  • 有选择地删除私人数据。

缺点:

  • 缺少 macOS 版本。

  • 用户不能仅购买橡皮擦功能。

定价:

  • 5 台电脑每年 39.95 美元。

  • 5 台 PC 终身售价 59.95 美元。

另请阅读: 如果您想准备用旧 iPhone 进行以旧换新,请不要错过这些重要步骤。

工具8:Apowersoft iPhone数据清理器

Apowersoft iPhone 数据清理器旨在永久删除 iPhone、iPad 和 iPod Touch 上的数据。它可以帮助用户清除隐私信息,并通过删除垃圾文件和不需要的数据来优化设备性能。

主要特点:

  • 清除您 iOS 设备中的所有数据,且没有任何恢复的可能性。

  • 允许有选择地删除敏感数据,如消息、联系人、照片和通话记录。

  • 扫描并删除垃圾文件、临时文件和缓存数据以释放存储空间。

  • 可以压缩大照片以节省空间,而不会造成明显的质量损失。

  • 提供不同安全级别的数据擦除。

优点:

+有效清除隐私信息,优化存储。

  • 提供压缩照片的选项,有利于存储管理。

缺点:

  • “数据清理器”模块的具体用户评论可能比更广泛的电话管理工具少。

  • 一般来说,由于 iOS 的限制,一些清理应用程序可能无法清除更深层的系统缓存。

定价:

  • 其网站上没有定价说明。

工具9:Tenorshare iCareFone Cleaner

Tenorshare iCareFone Cleaner 是 iCareFone 套件的一部分,该套件提供各种 iOS 管理工具。该清洁器组件专注于优化设备性能并安全擦除数据以保护隐私。

主要特点:

  • 扫描并删除垃圾文件、临时文件、应用程序缓存和其他无用数据以释放空间。

  • 压缩照片以节省存储空间,同时保持可接受的质量。

  • 帮助识别和删除大文件(视频、应用程序安装)以释放空间。

  • 提供管理和卸载应用程序的方法。

  • 可以有选择地永久删除私人数据。

优点:

  • 提供全面的清洁和隐私保护方法。

  • 有效释放存储空间并提高性能。

缺点:

  • 虽然它是一种更大、功能更强大的工具的一部分,但与用于高度敏感数据的专用橡皮擦相比,其特定的“清洁器”功能可能无法提供最彻底的数据擦除。

  • 连接稳定性有时会影响性能。

定价:

  • 免费使用 3 天,之后每周 4.99 美元。

工具 10:PanFone iOS 橡皮擦

PanFone iOS Eraser 是一款专门用于安全永久删除 iOS 设备数据的工具。它强调了安全数据擦除的重要性,而不仅仅是简单的恢复出厂设置,以防止隐私泄露。

主要特点:

  • 永久清除您的 iOS 设备中的所有数据和设置。

  • 覆盖以前删除但仍可恢复的文件。

  • 有选择地删除敏感的个人信息,如消息、联系人、照片和财务数据。

  • 为数据擦除提供不同安全级别,包括使用美国国防部 58220.22-M 标准的高级别。

优点:

  • 提供多种擦除级别,包括军用级标准,以实现最高安全性。

  • 致力于使数据 100% 无法恢复。

缺点:

  • 与一些顶级竞争对手相比,评价较少。

  • 与大多数强大的工具一样,它不是免费的。

定价:

  • 1 台电脑每月 23.95 美元。

  • 1 台电脑每年 29.95 美元。

  • 1 台电脑终身使用费为 49.95 美元。

关于 iPhone 数据橡皮擦的常见问题解答

Q1:iPhone上有橡皮擦工具吗?

不,iPhone 上没有橡皮擦工具,但“设置”里有一项功能。您可以前往“通用”>“传输或重置 iPhone”,然后选择“抹掉所有内容和设置”来擦除数据。

问题 2:iPhone 最好的数据擦除器是什么?

哪款 iPhone 数据擦除器最适合你,取决于你的要求和偏好。每种工具都有其优缺点。你可以查看它们的主要功能,以确定你的需要。

Q3:如何彻底清除iPhone数据?

要彻底清除 iPhone 数据,您需要使用能够删除现有数据并覆盖已删除数据的工具。例如,iReaShare iPhone 数据擦除器的高级功能可以两次覆盖所有数据,使您的所有数据都无法恢复。

结论

选择合适的 iPhone 数据擦除工具取决于您的具体需求——无论是为了转售而彻底擦除设备数据、选择性删除私人数据,还是仅仅清理空间。例如, iReaShare iPhone Data Eraser可以擦除 iOS 设备中的所有数据,并两次覆盖已删除的数据。它还与几乎所有 iOS 设备广泛兼容。

在使用任何数据擦除软件之前,请务必记住备份您希望保留的任何数据,因为该过程是不可逆的。

轻松将文件从 iPhone 传输到 Mac

想把文件从 iPhone 传输到 Mac?这几乎是所有 iPhone 和 Mac 用户的常见任务。事实上,你可以轻松地将 iPhone 文件传输到 Mac。学习本指南中的 6 种有效方法,你将掌握所有步骤,轻松传输文件。

第 1 部分:如何通过 iReaShare iPhone Manager 在 iPhone 和 Mac 之间传输文件

iReaShare iPhone Manager是一款功能全面的工具,可以将文件从 iPhone 传输到 Mac,反之亦然,让您轻松在 Mac 上管理 iPhone 数据。有了它,您可以传输照片、视频、音乐、联系人、信息、日历等。您可以有选择地移动文件,并将其保存为可访问的文件格式,例如 HTML、XML、CSV、VCF 等。

这款 iPhone 管理器软件的主要特点:

  • 直接从 iPhone 或 iPad 传输文件到 Mac。

  • 将文件从计算机导入到 iOS 设备。

  • 使您能够导出音乐、照片、视频、联系人、短信、笔记等。

  • 允许您在传输之前选择所需的文件。

*将iOS设备中的各种数据备份到电脑。

  • 将数据从备份恢复到 iPhone/iPad。

  • 支持iOS 5.0及更高版本,包括最新版本。

要使用此软件将文件从 iPhone 传输到 Mac:

  1. 在 Mac 上安装 iReaShare iPhone Manager 并启动它。然后使用 USB 数据线将 iPhone 连接到 Mac。

  2. 当手机提示“信任这台电脑?  ”时,选择“信任”。软件会快速识别您的设备,并在连接界面上看到您的设备信息。

手机连接.png

  1. 选择所需的文件类型,并勾选具体文件。然后点击“导出”即可将其保存到您的 Mac。

电子书.png

第 2 部分:如何通过 Finder 将数据从 iPhone 传输到 MacBook

对于运行 macOS Catalina 或更高版本的 Mac,Finder 是管理和在 iPhone 和 Mac 之间传输文件的主要工具。它取代了之前由 iTunes 处理的功能。

要使用 Finder 将数据从 iPhone 传输到 MacBook:

  1. 使用 USB 数据线将您的 iPhone 连接到 MacBook,然后在 Mac 上打开一个新的 Finder 窗口。

  2. 在 Finder 侧边栏的“位置”下,点击你的 iPhone。然后,你可能需要在 iPhone 上点击“信任”。

  3. 在“访达”窗口中,点击顶部的“文件”选项卡。这会显示 iPhone 上可以共享文件的应用列表。

  4. 点击应用旁边的三角形即可查看其共享文件。选择要传输的文件,然后将其拖到 Mac 上的文件夹中。顺便说一句,如果您想将文件从 Mac 导入 iPhone,可以直接将文件从 Mac 拖到 Finder 窗口中所需应用的文件夹中。

提示: 将联系人从 iPhone 同步到 Mac非常简单。点击此处获取 5 个解决方案。

第 3 部分:如何通过 AirDrop 将文件从 iPhone 传输到 Mac

AirDrop 是一种便捷的无线方式,可在近距离的 Apple 设备之间快速共享文件。它结合使用 Wi-Fi 和蓝牙,方便发送小文件。

使用 AirDrop 将文件从 iPhone 传输到 Mac:

  1. 从右上角向下滑动(旧款 iPhone 则从底部向上滑动)以打开“控制中心”。

  2. 按住网络设置卡(Wi-Fi、蓝牙、蜂窝网络)。根据您的偏好以及您的 Mac 是否在您的通讯录中,选择“仅限联系人”或“所有人”。

  3. 要在 Mac 上启用 AirDrop,请打开Finder ,点击“前往”>“ AirDrop ”,然后点击“允许他人发现我”,并选择“仅限联系人”或“所有人”。确保两台设备上的 Wi-Fi 和蓝牙均已启用。

  4. 在 iPhone 上打开包含要共享文件的应用程序,然后选择文件。然后点击“共享”>“ AirDrop ”,然后点击你的 Mac 的名称

  5. Mac 上会出现一条通知,询问您是否要接受传入的文件。点击“接受”。传输的文件通常会出现在 Mac 的“下载”文件夹中。

第 4 部分:如何通过 iCloud Drive 将文件从 iPhone 上传到 Mac

iCloud Drive 提供基于云的解决方案,可在您的所有 Apple 设备(包括 iPhone 和 Mac)上同步和访问您的文件。您可以将文件从 iPhone 上传到 iCloud Drive,然后在 Mac 上下载。

方法如下:

  1. 在 iPhone 上,前往 “设置”  >“  [您的姓名]  ”>“ iCloud ”,向下滚动,然后点击“ iCloud 云盘”。确保“同步此 iPhone ”(或“iCloud 云盘”)已打开。您还可以允许单个应用将数据存储在 iCloud 中。

  2. 要将文件从 iPhone 上传到 iCloud Drive,请打开 iPhone 上的“文件”应用程序,导航到要上传的文件的位置,选择文件并点击“移动”按钮(文件夹图标),然后选择“ iCloud Drive ”作为目的地。

  3. 在 Mac 上,确保您登录的 Apple ID 与 iPhone 相同。接下来,打开“访达”,然后点击侧边栏中的“ iCloud 云盘”。您上传的文件将显示在这里,您可以将它们拖到 Mac 上的任何本地文件夹中。

第 5 部分:如何通过 Google Drive 将文件从 iPhone 移动到 Mac

对于使用 Google 生态系统或需要跨平台传输文件的用户来说,Google Drive 是一个绝佳的云存储选择。它支持 iOS、macOS、Android、Windows、Chrome 和 Linux,因此您可以在大多数设备上使用它。

使用 Google Drive 将文件从 iPhone 移动到 Mac:

  1. 从 iPhone 上的 App Store 下载并安装 Google Drive 应用。打开应用并使用你的 Google 帐户登录。

  2. 点击“  +  ”图标(加号),选择“上传”,从 iPhone 中选择文件。然后将它们上传到 Google 云端硬盘。

  3. 在 Mac 上打开网络浏览器,访问 drive.google.com 并使用您的 Google 帐户登录。或者,如果您已安装 Google Drive for Desktop,则可以直接通过 Finder 访问已同步的文件。

  4. 最后,找到上传的文件并将其下载到您的 Mac。

第 6 部分:如何通过电子邮件将文件从 iPhone 发送到 Mac

对于较小的文件或文档,通过电子邮件发送是一种快速简便的解决方案。只要您有电子邮件帐户,就可以在不同设备上自行收发电子邮件。不过,一般来说,您一次最多只能发送 50 MB 的文件。

通过电子邮件将文件从 iPhone 发送到 Mac:

  1. 在 iPhone 上打开邮件应用。点击即可撰写新邮件。触摸要插入文件的区域,在键盘上选择“  <  ”,然后点击“附加”图标(形状像回形针)。

  2. 从照片、文档或其他位置选择要附加的文件。请注意,电子邮件提供商通常对附件的大小有限制。输入您自己的电子邮件地址作为收件人,然后发送电子邮件。

  3. 在 Mac 上打开电子邮件客户端或网页邮箱。然后打开刚刚发送给自己的电子邮件。现在,将附件下载到 Mac 上。

第 7 部分:有关 Mac 和 iPhone 之间文件传输的常见问题

Q1:还有其他应用程序可用于 iPhone 和 Mac 之间的文件传输吗?

是的,除了上面的应用程序和工具之外,您还可以使用Send Anywhere,SHAREit等。但是,这些应用程序是免费使用的,但在使用时会有一些广告。

问题 2:我还可以使用 iTunes 将文件从我的 iPhone 传输到 Mac 吗?

仅适用于 macOS Mojave 及更早版本。较新的 macOS 版本改用 Finder。

Q3:为什么 AirDrop 无法在我的 Mac 和 iPhone 之间工作?

如果您的 Mac 和 iPhone 之间无法使用 AirDrop,请检查:

  1. 蓝牙和 Wi-Fi 已启用。
  2. 设备彼此靠近。
  3. AirDrop 设置为从所有人或联系人接收文件。
  4. 两个设备均已解锁且未处于睡眠模式。

结论

有多种方法可以将文件从 iPhone 传输到 Mac,非常简单。如果您打算离线发送重要文件,可以使用iReaShare iPhone Manager 。您可以使用 USB 安全地导出文件。总之,每种方法都有其优缺点,因此请选择最适合您需求的方法。

9.推送的扩展能力 — 打造安全的通知体验

推送的扩展能力 — 打造安全的通知体验

1. 什么是 Notification Service Extension?

Notification Service Extension(通知服务扩展,简称 NSE)是 iOS 10 及以后系统引入的一种特殊的 App 扩展,它允许开发者在推送通知到达设备时对通知的内容进行修改和处理,从而实现丰富的推送通知效果。通过 NSE,App 可以在通知展示给用户之前,动态地修改通知的标题、内容,或者添加附件(图片、音频、视频等),增强用户体验。

简单来说,NSE 介于 APNs 推送服务器和系统通知显示之间,拦截并“加工”通知内容,使推送通知更加生动和个性化。

2. Notification Service Extension 的工作流程

  1. 推送通知到达设备 APNs 发送包含 mutable-content: 1 标记的推送通知给用户设备。
  2. 系统触发 Notification Service Extension 系统检测到通知 payload 中包含 mutable-content,自动唤醒 NSE 扩展执行。
  3. NSE 执行处理逻辑 NSE 的入口方法 didReceive(_:withContentHandler:) 被调用,开发者在这里可以修改通知内容或下载附件等。
  4. 调用 Content Handler 完成通知修改 开发者处理完后调用 contentHandler,系统将修改后的通知内容交给通知中心显示给用户。
  5. 超时处理 NSE 有约 30 秒的执行时间限制,超时系统会显示原始通知。

3. 使用场景举例

  • 添加富媒体附件:图片、GIF、音频、视频等
  • 自定义通知内容:根据业务需求,动态修改通知标题、正文
  • 内容解密:对推送加密内容进行解密后再展示
  • 下载远程资源:从网络拉取相关内容,提升通知表现力
  • 统计或日志上报:通知展示前埋点统计

1. 修改通知标题和正文

 override func didReceive(_ request: UNNotificationRequest,
                          withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
    let bestContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
    bestContent?.title = "【重要提示】" + (bestContent?.title ?? "")
    bestContent?.body += "\n请及时查看。"
    contentHandler(bestContent ?? request.content)
 }

2. 动态添加本地附件资源

 if let url = Bundle.main.url(forResource: "localImage", withExtension: "jpg") {
    do {
        let attachment = try UNNotificationAttachment(identifier: "image", url: url, options: nil)
        bestContent?.attachments = [attachment]
    } catch {
        print("附件添加失败: (error)")
    }
 }
 contentHandler(bestContent ?? request.content)

4. 如何配置 Notification Service Extension

  1. 新建 Target 在 Xcode 中选择 File > New > Target,选择 Notification Service Extension 模板,填写名称如 MyAppNotificationService

  2. 修改 Info.plist 默认系统会生成对应的 Info.plist,确认 NSExtension 字典配置正确。一般无需手动修改。

  3. 配置推送 Payload 需要在 APNs 推送的 payload 中添加字段:

     {
      "aps": {
        "alert": {
          "title": "标题",
          "body": "内容"
        },
        "mutable-content": 1
      },
      "customKey": "自定义数据"
     }
    

    mutable-content: 1 是触发 NSE 的关键。

  4. 实现处理逻辑 在 NSE 入口类 NotificationService 中重写:

     override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
     
        guard let bestAttemptContent = bestAttemptContent else {
            contentHandler(request.content)
            return
        }
     
        // 示例:修改标题
        bestAttemptContent.title = "(bestAttemptContent.title) [修改]"
     
        // 示例:下载附件(异步操作需要调用contentHandler)
        if let urlString = bestAttemptContent.userInfo["image-url"] as? String,
            let url = URL(string: urlString) {
            downloadAttachment(from: url) { attachment in
                if let attachment = attachment {
                    bestAttemptContent.attachments = [attachment]
                }
                contentHandler(bestAttemptContent)
            }
        } else {
            contentHandler(bestAttemptContent)
        }
     }
    
  5. 处理超时 实现:

     override func serviceExtensionTimeWillExpire() {
        if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
     }
    

    保障超时后仍能显示内容。

5. 常见问题与注意事项

  • NSE 执行时间限制 通常只有约 30 秒,超时系统自动调用 serviceExtensionTimeWillExpire(),务必保证处理逻辑高效。
  • 推送 Payload 大小限制 推送负载有限制,NSE 下载远程资源可以突破内容限制,但附件大小不能太大,通常推荐几十 MB 以内。
  • 用户权限 只有当用户允许推送通知且 App 已启用通知权限时,NSE 才会被调用。
  • 调试技巧 可以通过 Xcode 的 Scheme 设置启动 NSE 调试,或使用真机配合 Xcode 控制台调试。
  • 模拟器限制 模拟器对推送通知支持有限,尤其是扩展调试,最好在真机上调试。

6. 总结

Notification Service Extension 让开发者有能力在通知内容展示前对通知进行安全、灵活的动态处理,提升通知内容的准确性和用户体验。 它与 Notification Content Extension 搭配使用,可以共同打造出既个性化又安全的推送体系。

8.远程推送(Remote Push Notification)

远程推送(Remote Push Notification)

远程推送是现代移动应用与用户建立高效连接的重要机制。它允许服务器主动将消息发送至用户设备,实现提醒、更新、召回等功能,而无需用户主动打开 App。

本章节旨在为 Notification Service Extension(NSE) 的介绍做铺垫,对远程推送的核心机制进行简要说明。如需更深入的配置和实现细节,建议参考 Apple 官方文档或第三方推送服务的完整教程。

🧭 后续可以补充完整推送配置与实战细节,或欢迎有经验的开发者补充。

一、远程推送的基本工作流程

  1. 服务器准备推送内容 根据业务逻辑,服务器构造推送 payload,通常包含标题(title)、正文(body)、声音(sound)、角标(badge)等字段,亦可附带自定义数据(如业务 ID、图片地址等)。
  2. 发送推送请求到 APNs 推送服务端通过 HTTP/2 接口,将构造好的 payload 发给 Apple 推送服务器(APNs)。
  3. APNs 分发通知到设备 Apple 推送服务验证请求后,将通知路由到目标设备。
  4. 设备收到并展示通知 系统收到通知,根据 payload 展示本地通知 UI,或交由 App 拓展进一步处理(如 NSE)。

二、远程推送的调试

使用命令行发送模拟推送

只能在模拟器中使用

1.创建文件并打开
 touch test.apns
 open -e test.apns
2.填充推送内容
 {
  "aps": {
    "alert": {
      "title": "测试标题",
      "body": "这是通过 Simulate Remote Notification 测试的内容"
    },
    "mutable-content": 1,
    "sound": "default"
  },
  "image-url": "https://via.placeholder.com/300x150.png"
 }

mutable-content: 1必须的字段,告诉系统触发 NSE

附加字段如 image-url 可自定义,供你在 NSE 中解析使用

3. 执行发布命令
 xcrun simctl push booted "bundleId" "apnsFilePath"

示例

 xcrun simctl push booted org.cocoapods.demo.McccNotify-Example /Users/Mccc/Desktop/apns.apns

使用 Apple 推送管理控制台(真机测试)

适合在真机上测试远程推送。需配置 Push Capability、证书等。

该后台提供基于 Apple ID 的测试推送工具,适用于开发期间调试(无需配置完整的后端服务)。

四、远程推送的内容限制与挑战

远程推送虽然是移动应用中非常重要的功能,但在实际应用中存在以下几个主要限制和挑战:

1. Payload 大小受限

  • APNs 对单条推送通知的 payload 大小有限制,通常不能超过 4KB。
  • 这意味着通知中的文本内容、附件信息和自定义数据都必须精简,无法直接包含大容量的富媒体内容。

2. 内容静态缺乏灵活性

  • 推送的内容大多由服务器预先生成,固定不变。
  • 这种静态内容无法根据用户的当前状态、设备环境或上下文进行动态调整,限制了个性化推送的实现。

3. 富媒体支持有限

  • 虽然推送可以携带附件字段,但图片、音频、视频等富媒体无法直接内嵌在推送中。
  • 需要额外下载处理,增加了实现的复杂度,同时也影响了推送的展示效果和用户体验。

4. 安全与隐私风险

  • 推送通知中可能包含敏感信息或业务数据。
  • 网络传输和存储过程中存在被窃取或篡改的风险。
  • 如何保障用户隐私和数据安全,是推送系统设计必须重点考虑的问题。

这些限制使得传统远程推送在丰富通知内容和提供个性化体验方面存在一定的瓶颈。为了克服这些不足,Apple 提供了新的机制和扩展方案,帮助开发者提升推送的表现力和灵活性。

五、Notification Service Extension

为了解决传统远程推送的内容固定、缺乏个性化、富媒体处理复杂等问题,Apple 提供了 Notification Service Extension(NSE)机制。

它允许 App 在系统准备展示推送通知前,对通知内容进行二次处理,包括:

  • 动态修改标题、正文、声音
  • 插入图片、音频、视频等富媒体
  • 执行解密操作
  • 替换关键内容

我们将在下一章节深入介绍 Notification Service Extension 的功能、结构与使用场景。

7.推送的扩展能力 — 打造个性化的通知体验

推送的扩展能力 — 打造个性化的通知体验

现代 App 的通知早已不仅仅是简单的文字提醒。设计良好的推送通知可以呈现丰富的视觉卡片,也能作为安全可控的消息通道,让用户在不打开 App 的情况下,获取关键信息并完成操作。

为满足这些高级需求,iOS 提供了 Notification Content Extension(通知内容扩展) ,它允许你在通知展示时,使用自定义界面替代系统默认样式,打造更丰富的视觉和交互体验。

一、什么是 Notification Content Extension?

Notification Content Extension 是 iOS 在通知展示阶段调用的一个独立模块,允许开发者:

  • 提供自定义的通知界面
  • 展示富媒体内容(图文、音视频预览)
  • 支持交互操作(按钮、文本输入等)

典型应用场景包括:

  • 新闻类 App 展示文章摘要和封面图
  • 电商类 App 展示商品卡片
  • 视频类 App 预览内容片段

二、创建通知的扩展

创建扩展.png 需要在项目中新增一个扩展模块(Target)

  • 击左下角的 + 按钮(见上图红框处),进入 “Add Target” 界面。
  • 选择 Notification Content Extension,填写名称并完成创建。

三、将扩展绑定到通知

为什么需要将通知扩展绑定到通知?

通知内容扩展是独立模块,系统不会自动知道它处理哪些通知。 只有绑定了对应的通知分类(categoryIdentifier),系统才能调用对应扩展展示和处理通知。

如果不绑定,扩展不会生效,通知只能按系统默认样式展示。

绑定步骤

为了让通知扩展正确处理通知,需要确保以下三部分的 categoryIdentifier 保持一致:

1. 通知内容中设置 categoryIdentifier
 let content = UNMutableNotificationContent()
 content.title = "每日一句"
 content.body = "你若盛开,清风自来"
 content.categoryIdentifier = "dailyQuotes"   // 分类标识
2. 创建并注册通知分类

在 App 启动时,创建一个与通知中的分类标识相同的通知分类,并注册给系统:

 let category = UNNotificationCategory(
    identifier: "dailyQuotes",  
    actions: [doneAction, inputAction],
    intentIdentifiers: [],
    options: []
 )
 
 // 注册分类,必须调用,否则按钮不会显示
 UNUserNotificationCenter.current().setNotificationCategories([category])

这样,系统收到带有 "categoryIdentifier": "my_custom_category" 的通知时,会自动调用绑定了该 category 的通知扩展来展示和交互。

3. 通知扩展 Info.plist 中声明支持的 category

通知扩展的 Info.plist 文件里,需要在 NSExtension → NSExtensionAttributes → UNNotificationExtensionCategory 字段填写相同的分类标识(如 "dailyQuotes"),告诉系统该扩展负责处理此类通知。

设置通知扩展的 target 最低部署版本 (Deployment Target) 不能高于你测试设备的系统版本,否则系统根本不会加载这个扩展,即便你的主 App 是可以运行的。

通知扩展的绑定.png

四、通知扩展 Info.plist 配置详解

 <key>NSExtension</key>
 <dict>
    <!-- 声明扩展类型为通知内容扩展 -->
    <key>NSExtensionPointIdentifier</key>
    <string>com.apple.usernotifications.content-extension</string>
 
    <!-- 通知扩展属性配置 -->
    <key>NSExtensionAttributes</key>
    <dict>
        <!-- 支持的 categoryIdentifier(与通知内容中设置的 categoryIdentifier 一致)-->
        <key>UNNotificationExtensionCategory</key>
        <array>
            <string>dailyQuotes</string>
            <string>taskReminder</string>
            <string>mediaAlert</string>
        </array>
 
        <!-- 展开时是否隐藏系统默认标题、正文、图片,仅显示扩展视图 -->
        <key>UNNotificationExtensionDefaultContentHidden</key>
        <true/>
 
        <!-- 控制初始展开高度:0.0 ~ 1.0,默认 1.0 是一半屏幕 -->
        <key>UNNotificationExtensionInitialContentSizeRatio</key>
        <real>0.7</real>
 
        <!-- 是否允许用户与扩展界面交互(按钮、文本输入等)-->
        <key>UNNotificationExtensionUserInteractionEnabled</key>
        <true/>
 
        <!-- 是否使用扩展自定义的标题,替代系统默认通知标题 -->
        <key>UNNotificationExtensionOverridesDefaultTitle</key>
        <true/>
 
        <!-- 如果展示音视频,是否显示播放按钮(default / none / overlay)-->
        <key>UNNotificationExtensionMediaPlayPauseButtonType</key>
        <string>overlay</string>
 
        <!-- 声明支持的媒体类型(如 public.audio、public.movie) -->
        <key>UNNotificationExtensionMediaFileTypes</key>
        <array>
            <string>public.audio</string>
            <string>public.movie</string>
        </array>
    </dict>
 </dict>
Key 名称 作用 类型 默认值 用途说明
UNNotificationExtensionCategory 支持的通知分类,匹配通知中的 categoryIdentifier [String]数组 必填。系统根据此字段判断哪些通知调用该扩展。
UNNotificationExtensionDefaultContentHidden 是否隐藏系统默认标题、正文及附件,完全自定义展示内容 Bool false 设置为 true 时,隐藏系统内容,仅显示自定义扩展界面。
UNNotificationExtensionInitialContentSizeRatio 通知扩展展开时的最大初始高度(相对于屏幕高度) Float 1.0 设置扩展最大展开高度比例,范围 0.0~1.0,内容不足时不会撑满。
UNNotificationExtensionUserInteractionEnabled 是否允许用户与扩展界面交互(按钮、输入框等) Bool true 控制扩展视图的交互能力。
UNNotificationExtensionOverridesDefaultTitle 是否由扩展自定义标题显示,替代系统默认标题 Bool false 设置为 true 后,默认标题不显示,需扩展自行处理标题显示。
UNNotificationExtensionMediaPlayPauseButtonType 媒体通知中播放/暂停按钮的样式 String default 仅针对音视频通知。可选值:defaultoverlaynone
UNNotificationExtensionMediaFileTypes 支持处理的媒体类型(UTType) [String]数组 声明扩展支持的媒体文件类型,系统据此判断是否使用扩展展示该通知。

五、推送内容扩展的交互

通知内容扩展中如果有按钮,默认事件无效。需要在扩展的 Info.plist 添加:

 <key>UNNotificationExtensionUserInteractionEnabled</key>
 <true/>
设置项 通知行为 是否可点击进入 App 是否响应扩展内按钮
未设置(默认 false 仅展示 UI ✅ 点击扩展区域可跳转 App ❌ 按钮无响应
设置为 true 支持交互(按钮、输入等) ❌ 点击扩展区域不能跳转 App ✅ 按钮可正常响应

设置为 true 后,系统认为扩展负责交互,取消点击扩展跳转 App 的行为。

跳转 App 正确做法

通过添加带 .foreground 选项的 UNNotificationAction,点击按钮可唤起 App。

调试提示:扩展内 print() 不显示

扩展运行在独立进程,默认 Xcode 只显示主 App 日志。要查看扩展日志:

  • 切换 Xcode 运行 Scheme 到通知扩展 target;
  • 运行扩展,触发交互即可在控制台看到打印或执行断点。

这样能正确调试扩展内的事件和日志。

六、不能自动展示内容扩展的原因

只在用户 主动操作通知 时触发,开发者无权干预。

原因类型 说明
安全考虑 扩展可展示敏感数据,自动展开可能泄露内容。
交互一致性 保持通知轻量,需用户主动操作保证 UI 行为统一。
系统策略限制 Content Extension 仅在用户主动操作通知时触发,开发者无法强制。

七、如何诱导用户长按展开通知?

iOS 通知扩展默认不会自动展开,需要用户通过长按(锁屏/通知中心)下拉(横幅) 的方式才能触发。因此,想要发挥通知扩展的价值,前提是“让用户愿意去长按”。

以下是几种实用策略,用于提升用户进行长按操作的意愿:

1. 在标题或内容中加入操作提示语

明确告诉用户“长按可查看更多”是最简单直接的引导方式,尤其在消息、日程、任务提醒等场景非常有效:

 content.title = "任务变更(长按查看详情)"
 content.body = "你有一个待办事项已延期,长按可查看最新信息。"

📌 建议位置:

  • title 末尾加括号提示(不影响主标题)
  • body 中补充可操作性说明

2. 制造信息“断点感” —— 激发点击欲望

设计内容时可以故意留“悬念”,让用户产生“点开看看”的冲动:

 content.title = "你的日程有更新..."
 content.body = "查看今日会议是否已取消?"

这种“半句未完”的表达常用于新闻推送、提醒通知、互动问候等场景,增强好奇心。

3. 利用 Emoji 和视觉符号吸引注意

在标题中巧用 Emoji、图标字符,使通知在列表中更突出:

 content.title = "📅 今日安排已更新(长按查看)"
 content.body = "上午会议时间有调整,建议查看详情。"

适当使用视觉符号能有效提升通知可见度和可点性。

4. 匹配用户时机与意图(内容精准 + 时机合适)

推送应具备强相关性和时效性,增强用户点击意愿:

  • 临近事件、限时提醒
  • 用户订阅、收藏内容相关通知
  • 明确的查看收益(优惠、奖励、反馈)

5. 利用图像预览提升吸引力

添加图片附件吸引用户长按查看大图:

 if let attachment = UNNotificationAttachment.create(image: yourImage, options: nil) {
    content.attachments = [attachment]
 }

6.通知交互设计全解:前台显示、按钮交互与用户行为响应

通知交互设计全解:前台显示、按钮交互与用户行为响应

一、前台通知不显示?其实是设计如此!

在使用本地通知时,开发者常常会遇到这样的困惑:

同样的通知请求,为什么在后台能弹出,在前台却毫无动静?

这并不是 Bug,而是 iOS 的系统默认行为:它根据 App 的运行状态对通知展示策略进行了差异化处理。

iOS 不同状态下的通知展示行为

App 状态 通知默认表现说明
后台 / 锁屏 / 被杀死 系统自动弹出横幅、播放声音、更新角标,用户直观可感。
前台运行中 默认不弹窗、不响铃、不亮角标,但通知内容依然会送达 App,由开发者决定如何展示。

这样的设计可以避免打扰用户当前操作,同时赋予开发者更灵活的展示策略。

如何让通知在前台也显示?

你需要借助系统提供的 UNUserNotificationCenterDelegate 协议。该协议提供了多个方法,用于管理通知的展示与响应,核心如下

 public protocol UNUserNotificationCenterDelegate : NSObjectProtocol {
 
    optional func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
 
    optional func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void)
 
    optional func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?)
 }

该协议主要负责以下几方面:

  • 前台通知展示控制 通过实现 willPresent 方法,开发者可以决定当 App 处于前台时,是否弹出通知横幅、播放声音、更新角标等,弥补系统默认不显示通知的行为。
  • 用户与通知的交互响应 实现 didReceive 方法,处理用户点击通知或通知中按钮的动作,完成页面跳转、数据处理等操作,无论 App 是前台、后台还是刚启动状态。
  • 通知设置跳转回调 通过 openSettingsFor 方法,支持用户从通知界面快速跳转到应用通知设置页,提供定制化的设置体验。
让前台通知可见

在合适的时机设置代理:

 UNUserNotificationCenter.current().delegate = self

然后实现代理方法,告知系统前台如何展示通知:

 extension YourViewController: UNUserNotificationCenterDelegate {
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification,
                                withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        // 指定前台展示通知的方式
        completionHandler([.banner, .sound])
    }
 }

UNNotificationPresentationOptions 可选值:

  • .banner:显示横幅
  • .sound:播放声音
  • .badge:更新角标
  • .list:加入通知中心列表

如果传入空数组([]),则前台不会有任何可视通知,等同于系统默认行为。

二、让通知动起来:给通知添加交互

通知不仅能传递信息,更可以承载操作行为。iOS 支持在通知中添加:

  • 按钮操作(UNNotificationAction
  • 文字输入(UNTextInputNotificationAction

你可以实现如“完成任务”、“快速回复”、“延迟提醒”等操作,无需打开 App 就能完成业务闭环,极大提升效率。

三个核心对象的协同关系

要实现交互式通知,需要理解三个关键组件之间的结构关系:

组件 类型 作用
按钮 UNNotificationAction / UNTextInputNotificationAction 定义按钮样式、标识、权限
分类 UNNotificationCategory 组合多个按钮,并赋予唯一 identifier
通知内容 UNMutableNotificationContent 通过 categoryIdentifier 绑定到某个按钮分类上

只有当分类已注册,且通知的 categoryIdentifier 与之匹配时,系统才会显示按钮。

配置交互按钮的完整流程

1. 创建按钮对象

你可以配置多个按钮,不同按钮可以有不同的权限和表现方式:

 let doneAction = UNNotificationAction(
    identifier: "MARK_DONE",
    title: "完成",
    options: [.authenticationRequired] 
 )
 
 let inputAction = UNTextInputNotificationAction(
    identifier: "INPUT",
    title: "输入框",
    options: [.authenticationRequired, .foreground]
 )

UNNotificationActionOptions 详解

UNNotificationActionOptions 是一个位掩码类型(OptionSet),用于配置通知按钮的行为特点。选项包括:

选项名 作用说明
.authenticationRequired 用户点击该按钮时,系统会先要求用户解锁设备(Touch ID/Face ID 或密码),增强安全性。适合敏感操作。
.destructive 按钮被标记为“破坏性操作”,系统会使用红色突出显示按钮文本,提醒用户操作不可逆,通常用于删除、拒绝等场景。
.foreground 点击按钮后会自动启动或唤醒 App 进入前台,适合需要立即打开 App 处理的操作(默认按钮点击不会唤醒 App)。
2. 创建并注册通知分类

将按钮组打包为一个分类,并在通知中心注册,注册是按钮生效的前提

 let category = UNNotificationCategory(
    identifier: "TODO_CATEGORY",
    actions: [doneAction, inputAction],
    intentIdentifiers: [],
    options: []
 )
 
 // 注册分类,必须调用,否则按钮不会显示
 UNUserNotificationCenter.current().setNotificationCategories([category])

注册通知的交互按钮后,系统需要一点时间将这些设置生效,这是一种异步生效机制。如果你立刻添加通知:

 UNUserNotificationCenter.current().setNotificationCategories([category])
 UNUserNotificationCenter.current().add(request) // ⚠️ 可能无效

这种写法在 App 首次运行时(即第一次调用这个方法) 很可能导致:

通知被成功调度了,但按钮 不会显示,因为系统还没注册好对应的 category。

intentIdentifiers

intentIdentifiers 是一个 [String],表示你想让这个通知类别关联到哪些 INIntent 类型(SiriKit 的 Intent 类),供系统智能识别或在 Siri 建议中使用。

如果你没有使用 SiriKit 或不涉及特定 Intents,就可以留空(传 [])——这在绝大多数场景下都没问题。

options 是什么?

options 是一个 UNNotificationCategoryOptions 类型的 位掩码(OptionSet) ,用于配置通知类别的行为特性。它控制了通知界面的显示方式、用户操作时的系统处理方式等。

Option 常量名 含义说明
.customDismissAction 用户手动滑掉通知时也会触发 didReceive 回调(可感知取消)
.allowInCarPlay 通知可以在 CarPlay(车载)环境中展示
.hiddenPreviewsShowTitle (iOS 11+) 通知在锁屏隐藏预览时仍显示标题
.hiddenPreviewsShowSubtitle(iOS 11+) 通知在锁屏隐藏预览时仍显示副标题
.allowAnnouncement(iOS 13+) 支持通过 VoiceOver 进行“语音播报通知”
3. 创建通知内容并绑定分类
 let content = UNMutableNotificationContent()
 content.title = "📌 每日提醒"
 content.body = "别忘了写日报!"
 content.categoryIdentifier = "TODO_CATEGORY" // 必须与分类 identifier 一致

此时,通知内容就通过 categoryIdentifier 与已注册的按钮组建立了关联,触发后系统会自动识别并展示对应按钮。

关于按钮展示的额外说明

  • 用户需要长按或下拉通知才能展开按钮(是系统交互设计,避免误触)。
  • 一个通知只能绑定一个 category,即每条通知最多只能展示一组按钮。
  • 如果你的 App 中存在多种通知交互需求,应提前注册多个分类,并在构建通知内容时灵活指定对应的 categoryIdentifier

三、让通知动起来:处理交互响应

我们介绍了如何为通知添加交互按钮和文字输入框,实现可操作的通知。接下来,本章将重点讲解用户点击按钮或输入文本后的事件处理机制,帮助你接收用户操作并执行相应逻辑。

1. 用户点击了按钮?如何拦截与识别

用户点击通知内容或按钮后,系统会调用:

 func userNotificationCenter(_ center: UNUserNotificationCenter,
                            didReceive response: UNNotificationResponse,
                            withCompletionHandler completionHandler: @escaping () -> Void) {
     
    switch response.actionIdentifier {
    case UNNotificationDefaultActionIdentifier:
        print("用户点击了通知本体")
         
    case "MARK_DONE":
        print("用户点击了“完成”按钮")
         
    case "SNOOZE":
        print("用户点击了“稍后提醒”按钮")
         
    default:
        break
    }
 
    // 告诉系统处理完成
    completionHandler()
 }

这个回调会在用户点击通知本体或按钮时触发,支持你识别用户的交互行为并执行相应逻辑。

核心参数说明:
  • response.actionIdentifier:用户点击的元素标识,需与 UNNotificationAction.identifier 预设一致。
  • UNNotificationDefaultActionIdentifier:系统内置常量,代表用户点击的是通知本体,而非按钮。
  • response.notification:包含完整的通知内容,可通过 userInfo 携带业务参数进行进一步处理。
  • completionHandler()必须调用,否则可能阻塞通知响应链。

通过判断 actionIdentifier,你可以在 App 中完成页面跳转、数据更新、任务状态处理等操作,实现真正的通知交互体验。

3. 输入类按钮的 userText 怎么拿?

对于带文字输入的按钮(UNTextInputNotificationAction),我们可以在通知交互回调中获取用户提交的文本内容,常用于“快速回复”、“反馈建议”等场景。

示例代码如下:

if let textResponse = response as? UNTextInputNotificationResponse {
    let userText = textResponse.userText
    print("用户输入了:(userText)")
    // 这里可以处理用户的回复内容,比如发送消息
}

这个方法在 UNUserNotificationCenterDelegatedidReceive response 中被触发,仅当 App 在前台后台活跃状态下,用户与通知交互时才会调用。

App 被杀死状态不能处理输入

App 若被用户划掉或系统清理内存杀死:

  • 不会收到回调
  • 不会被唤醒
  • 不能获取任何输入数据

也就是说,这种交互表面看起来“用户回复了”,但你的 App 实际上什么也没收到

那微信是怎么做到的?

微信等头部 App 可以实现“杀死状态下仍能处理回复”,靠的是:

能力 微信 / 钉钉等 普通 App
私有系统通道 与系统服务协作 ❌ 无法访问
后台处理进程 例如使用 VoIP 保活 ❌ 被严格限制
输入内容后台上传 用户输入被系统上传至服务器 ❌ 本地 App 无法触发

系统直接将用户输入上传给服务端,绕过本地 App 启动。这种机制普通 App 无法实现。

四、在系统设置中添加App的通知设置页面捷径

点击该按钮,唤起app,进入app的通知设置页面。

openSettingsFor.png

func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
    // App内跳转设置页     
    let vc = BViewController()
    present(vc, animated: true, completion: nil)
}

我们不仅要实现这个协议方法,并完成对应的页面跳转。还要在申请权对应的权限:

UNUserNotificationCenter.current().requestAuthorization(
    options: [.alert, .sound, .badge, .providesAppNotificationSettings]) { granted, error in
        
}

5.本地通知内容深度解析 — 打造丰富的通知体验

本地通知内容深度解析 — 打造丰富的通知体验

很多开发者在使用 UNMutableNotificationContent 时,往往只关注标题和正文,忽略了 Apple 提供的一系列定制化能力。本章将带你系统掌握这些能力,为你的通知赋予更多表现力和个性化。

本章将围绕 UNMutableNotificationContent 展开,详解它的各项配置能力,帮助你打造出兼具信息性与吸引力的通知内容。

一、是什么?

UNMutableNotificationContent 是 iOS 中配置本地通知内容的主要类,继承自 UNNotificationContent,用于设置标题、正文、声音、附件等视觉与听觉信息。示例:

 let content = UNMutableNotificationContent()
 content.title = "🔔 提醒"
 content.body = "你还有一项待完成的任务"
 content.sound = .default

二、常用属性

属性 类型 用途
title String 通知标题
subtitle String 子标题(在 title 下方展示)
body String 主内容,支持多行显示
badge NSNumber? 设置 App 图标的角标数字
sound UNNotificationSound? 通知音效
attachments [UNNotificationAttachment] 图像、音频或视频附件
categoryIdentifier String 配合交互动作使用
userInfo [AnyHashable: Any] 自定义字段,可用于跳转逻辑
threadIdentifier String 线程 ID,用于合并通知显示
interruptionLevel UNNotificationInterruptionLevel 设置通知优先级,影响通知的打断行为(如静默、时间敏感等)(iOS 15+)
relevanceScore Double 通知的相关性评分,系统用来决定通知的排序和优先级(iOS 15+)
filterCriteria String? 用于智能筛选通知,提高用户体验(iOS 16+)
targetContentIdentifier String? 帮助系统区分通知内容,实现更智能的展示和归类

三、通知声音:吸引注意

UNNotificationSound 是 iOS 推送通知系统中用于定义通知声音的类。

通知声音控制着通知触发时播放的声音,开发者可以使用系统默认声音,也可以指定自定义音频文件,甚至使用更特殊的“关键声音”(critical sound)来提高通知的优先级。

1. 普通通知声音

普通通知声音是最常见的通知提示音,使用系统默认音效,遵循系统音量和静音开关的控制。

方法名 作用 可用系统版本
UNNotificationSound.default 系统默认通知提示音 iOS 10.0+
init(named:) 使用自定义音频文件(放在 App Bundle) iOS 10.0+
说明
  • 这类声音不会绕过静音或勿扰模式。
  • 自定义声音需放在 App 主 Bundle,建议格式为 .caf.m4a.aiff,且时长不超过 30 秒。
  • 适用于普通提醒和非紧急通知。
示例
 // 系统默认声音
 content.sound = .default
 
 // 自定义声音
 content.sound = UNNotificationSound(named: UNNotificationSoundName("custom_sound.caf"))

2. 铃声类声音

铃声类声音类似电话铃声,声音更响亮且具有铃声特性,适合重要提示,但仍遵守系统静音规则。

方法名 作用 可用系统版本
ringtoneSoundNamed(_:) 返回指定名称的铃声声音 iOS 15.2+
说明
  • 该类型声音模拟来电铃声,提示效果明显。
  • 不绕过静音或勿扰模式。
  • 适合来电提醒或重要通知,增强用户注意力。
示例
 content.sound = UNNotificationSound.ringtoneSoundNamed(UNNotificationSoundName("ringtone.caf"))

3. 关键通知声音

关键通知声音拥有最高优先级,可以绕过静音和勿扰,确保用户接收到紧急通知,但需要用户授权。

方法名 作用 可用系统版本
defaultCritical 系统默认关键通知声音 iOS 12.0+
defaultCriticalSound(withAudioVolume:) 带自定义音量的关键通知默认声音 iOS 12.0+
criticalSoundNamed(_:) 返回指定名称的关键通知声音 iOS 12.0+
criticalSoundNamed(_:withAudioVolume:) 指定名称且带音量控制的关键通知声音 iOS 12.0+
使用关键声音的必要条件
  1. 主项目的info.plist中添加
 <key>UIBackgroundModes</key>
 <array>
    <string>critical-alert</string>
 </array>

📌 表示 App 申请使用关键通知能力,允许其在静音、勿扰等情况下播放声音。

  1. 授权中申请
 UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge, .criticalAlert]) { granted, error in
    // granted 只是表示通知授权整体成功,不能单独判断 criticalAlert
 }

3. 判断用户是否同意了权限

 UNUserNotificationCenter.current().getNotificationSettings { settings in
    switch settings.criticalAlertSetting {
    case .enabled:
        print("✅ 用户已开启关键通知")
    case .disabled:
        print("⚠️ 用户明确关闭了关键通知")
    case .notSupported:
        print("🚫 设备或系统不支持关键通知,或未通过审核")
    @unknown default:
        break
    }
 }

3. 用户手动在设置中开启 允许关键通知 开关。

设置 > 通知 > \[你的 App] > 允许关键通知 ✅ 此项必须由用户手动开启,App 无法直接引导跳转到该开关页(可跳转通知设置页,但不能直接定位到 critical 开关)。

4. 允许关键通知 需要在 App Store 审核通过后才会出现。

说明
  • 关键通知声音绕过静音和勿扰模式,优先播放。
  • 需要用户在系统设置中开启“允许关键通知”权限。
  • 适用于医疗提醒、安全告警、重要紧急事件。
  • 音量可通过部分方法自定义。
  • 必须引导用户在「设置 > 通知 > 你的App > 允许关键通知」开启权限,否则关键通知声音无效。
  • 关键通知适用于确保用户感知的重要场景,不可滥用。
示例
 content.sound = UNNotificationSound.defaultCritical
 content.sound = UNNotificationSound.defaultCriticalSound(withAudioVolume: 0.8)
 content.sound = UNNotificationSound.criticalSoundNamed(UNNotificationSoundName("critical_alert.caf"))
 content.sound = UNNotificationSound.criticalSoundNamed(UNNotificationSoundName("critical_alert.caf"), withAudioVolume: 0.7)
授权流程
  1. App 需要向系统申请“关键通知”权限 这是单独于普通通知权限的高级权限,必须在系统设置里由用户手动允许,开发者无法弹窗直接请求,只能引导用户去设置里打开。
  2. 用户开启位置 用户打开「设置 > 通知 > 你的App > 允许关键通知」后,App 才能发送关键通知声音。
  3. 发送关键通知时,系统才会允许绕过静音和勿扰 未授权情况下,即使代码中设置了关键通知声音,也会被静音或不播放。

Apple 官方明确指出,以下几类应用可以申请 Critical Alerts 权限:

场景 示例
医疗相关 血糖监测、服药提醒、生命体征监控
安全监控 家庭安防、危险警告
公共安全 紧急预警、天气警报
时间提醒 闹钟、提醒事项、日程管理
无障碍辅助 听力障碍提醒、语音辅助等

使用自定义声音时注意

  • 音频文件必须放在 App 主 Bundle 内。

  • 建议文件时长不超过 30 秒。

  • 支持的格式通常为 .caf, .m4a, .wav, .aiff

  • 播放失败时,请确认:

    • 设备未处于静音模式或勿扰模式(对于普通通知声音)。
    • 应用已获得通知声音权限。
    • 文件存在且格式正确。

四、通知附件:让通知更具吸引力

附件通知.pngattachments 是一个 [UNNotificationAttachment] 数组,支持加载本地的图片、音频、视频等文件作为通知的多媒体附件。

  • 这些资源必须先存储在设备本地的文件系统中,比如 app 的沙盒目录(Documentstmp 等)。

  • 不能直接传入 UIImage 或网络 URL,需要先把资源下载或复制到本地文件路径,然后用本地文件 URL 创建 UNNotificationAttachment

     func downloadAndAttachImage(from url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) {
        URLSession.shared.downloadTask(with: url) { localURL, response, error in
            guard let localURL = localURL else {
                completion(nil)
                return
            }
            do {
                let attachment = try UNNotificationAttachment(identifier: "remoteImage", url: localURL, options: nil)
                completion(attachment)
            } catch {
                print("创建附件失败:(error)")
                completion(nil)
            }
        }.resume()
     }
    
  • 虽然 attachments 支持添加多个附件,但在系统默认通知 UI 中通常只展示其中的一个(通常是第一个可支持展示的附件,如图片或视频)。若需展示多个附件,建议使用 Notification Content Extension 来实现自定义展示逻辑。

⚠️ 通知附件里的音频播放,是系统在通知中心的“小窗”里播放,系统会自动降低音量,确保不会突然打扰用户。所以即使你手机音量最大,通知附件的声音通常会比直接用 content.sound 播放的声音小很多。

4.1 添加图片附件

仅支持从主Bundle中,加载 PNG / JPEG / GIF / HEIC 类型的图片。

if let url = Bundle.main.url(forResource: "example", withExtension: "jpg") {
    let attachment = try? UNNotificationAttachment(identifier: "image", url: url)
    content.attachments = [attachment]
}

attachments 中加载 .xcassets里的图片 是无法直接加载的,因为:

.xcassets 中的资源在编译后会被打包进 Assets.car 文件中,并非文件系统中的独立文件,所以不能通过 URL 的方式直接访问。

可以使用 UIImage(named:) 加载 .xcassets 中的图片,将其转存为本地文件:

import UserNotifications
import UIKit

func createNotificationAttachment(fromImageNamed name: String, identifier: String = UUID().uuidString) -> UNNotificationAttachment? {
    guard let image = UIImage(named: name) else { return nil }

    // 将 UIImage 转为本地文件
    let fileManager = FileManager.default
    let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
    let fileURL = tempDir.appendingPathComponent("(UUID().uuidString).png")

    guard let imageData = image.pngData() else { return nil }

    do {
        try imageData.write(to: fileURL)
        let attachment = try UNNotificationAttachment(identifier: identifier, url: fileURL, options: nil)
        return attachment
    } catch {
        print("Attachment error: (error)")
        return nil
    }
}

4.2 添加音频或视频附件

if let url = Bundle.main.url(forResource: "earlyRiser", withExtension: "m4a") {
    let attachment = try? UNNotificationAttachment(identifier: "audio", url: url)
    content.attachments = [attachment].compactMap { $0 }
}
  • 支持:.m4a, .mp3, .caf,以及 .mov.mp4 等视频格式。
  • 附件音频在通知下拉后点击播放,但不会自动播放,需用户操作。
  • 实测音频作为附件时音量偏小,不如 sound 明显。

五、通知分组:整合内容

threadIdentifier 是 Apple 通知系统中用于将多个通知归为同一线程(thread)或对话(conversation) 的机制。用于“同类内容整合展示”,避免通知中心杂乱。

相同threadIdentifier.png

如果你发送了 3 条通知,设置如下:

let content1 = UNMutableNotificationContent()
content1.title = "每日一句"
content1.body = "你若盛开,清风自来。"
content1.threadIdentifier = "daily_quotes"

let content2 = UNMutableNotificationContent()
content2.title = "每日一句"
content2.body = "生活明朗,万物可爱。"
content2.threadIdentifier = "daily_quotes"

let content3 = UNMutableNotificationContent()
content3.title = "突发提醒"
content3.body = "你有新的粉丝"
content3.threadIdentifier = "alerts"
  • 在通知中心里:

    • content1 和 content2 会被系统归入一组 “daily_quotes” 线程通知中
    • content3 则会单独显示,属于另一个线程。

想象成微信的聊天列表:每个 threadIdentifier 是一个“会话”,多条通知属于同一个 thread。

六、自定义字段 userInfo

用于传递业务参数(如跳转页面的 ID):

content.userInfo = ["taskId": "12345", "source": "reminder"]

App 在响应通知点击时可从 UNNotification.request.content.userInfo 中提取这些字段。

七、通知控制:场景使用

Apple 从 iOS 15 起引入了一些高级通知属性,主要用于优化通知在系统中的展示优先级与筛选逻辑,适用于关键通知、重要提醒、深度整合系统通知中心的场景。

属性 引入版本 用途
interruptionLevel iOS 15+ 控制通知的打断等级,如 .timeSensitive.critical
relevanceScore iOS 15+ 设置通知的相关性评分(0~1),系统会基于此排序展示优先级
filterCriteria iOS 16+ 通知筛选标识,系统可用于聚合筛选,结合通知摘要与聚类机制使用

interruptionLevel(打断等级)

设置通知对用户的「打断程度」

content.interruptionLevel = .active
等级 说明 适用场景
.passive 低打扰,仅出现在通知中心,不播放声音、不震动,不弹横幅 营销推广、新闻推送
.active 默认级别,正常展示横幅、声音 普通提醒任务、活动通知
.timeSensitive 时间敏感,可突破专注模式,在锁屏/静音下也能展示(需用户允许) 打卡、会议提醒、计时器到期
.critical 紧急通知,可突破勿扰模式和静音模式,需要苹果特别审核权限 生命健康相关(地震预警)

relevanceScore(相关性评分)

设置一个 0.0 ~ 1.0 的浮点值,表示通知相对于其他通知的重要程度。

content.relevanceScore = 0.8
系统行为:
  • 在锁屏或通知摘要中,系统会优先展示得分更高的通知。
  • 与通知内容是否置顶、合并展示的 threadIdentifier 无关,主要影响排序。
适用场景:
  • 多条通知同时弹出时,区分主次(如某条是系统状态,另一条是营销)
  • 电商 App:交易通知 > 广告通知
  • 消息类 App:@我 > 普通消息 > 群公告
注意事项:
  • 设置此属性 不会让通知“更容易展示” ,只是“被展示时排序更靠前”。
  • 需要结合 threadIdentifier 使用更有效(同类分组后排序)。

filterCriteria(筛选标识)

这个属性的用途更偏系统侧,用于系统级通知聚类、摘要和筛选机制。

主要用于与系统机制协同优化通知聚类,开发者无法直接操控其“可见效果”

content.filterCriteria = "payment_reminder"

小结:打造有质感的通知体验

一条通知的内容,远不止于“title + body”。通过对 UNMutableNotificationContent 的深入理解和运用,可以实现:

  • 图文并茂的提醒场景(如健康打卡、早起任务)
  • 音视频强化氛围(如冥想提示、生日祝福)
  • 更智能的行为追踪(通过 userInfo 联动页面)

在下一章中,我们将继续深入通知的交互机制,探索 UNUserNotificationCenterDelegate 如何让通知在前台弹出、支持交互按钮、跳转 App 内页面,真正从“发送一条通知”走向“打造一场互动”。

4.本地通知的精准控制三角:时间、位置、情境

本地通知的精准控制三角:时间、位置、情境

本地通知无需依赖网络与服务端,触发精准、响应即时,是许多提醒类场景中更值得优先考虑的推送方式。

本章将围绕本地通知的三大触发机制——时间、位置、情境,帮助你打造“能如约而至、如影随形”的智能通知体系。

一、时间触发机制:定时通知的调度艺术

时间是通知最基本也是最常用的触发维度。iOS 提供了两种基于时间的触发器,分别适用于延迟执行定时调度场景,适合构建提醒类、日程类、打卡类通知体系。

类名 功能说明
UNTimeIntervalNotificationTrigger 延迟一段固定时间后触发
UNCalendarNotificationTrigger 指定具体时间点或周期性日期触发通知

时间间隔触发器

10 秒后弹出一句每日提醒

 let content = UNMutableNotificationContent()
 content.title = "📌 每日一句"
 content.body = "每一个不曾起舞的日子,都是对生命的辜负"
 content.sound = .default
 
 let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false)
 let request = UNNotificationRequest(identifier: "quote_001", content: content, trigger: trigger)
 
 UNUserNotificationCenter.current().add(request)

日历间隔触发器

每天早上 8 点提醒用户打开学习 App

 var dateComponents = DateComponents()
 dateComponents.hour = 8
 dateComponents.minute = 0
 
 let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
 
 let content = UNMutableNotificationContent()
 content.title = "🎓 今日计划"
 content.body = "新的一天,来学习 30 分钟吧!"
 content.sound = .default
 
 let request = UNNotificationRequest(identifier: "quote_002", content: content, trigger: trigger)
 UNUserNotificationCenter.current().add(request)

计算下一次触发时间

两种时间触发器都支持调用 nextTriggerDate() 方法,用于推算系统计划的下一次通知时间,非常适合用于:

  • 调试:验证设置是否正确
  • UI 展示:在界面上提示“将于明日 8:00 提醒”
  • 时间校验:如用户设置的时间已过去,提示重新选择
 open func nextTriggerDate() -> Date?

如果设置合法,返回预计触发的 Date,设置非法(如过去时间、无重复),返回 nil

使用说明与实践注意事项

1. 时间粒度限制

虽然可以设置为 10 秒后触发,但实际上:

  • iOS 通知触发精度约为分钟级,不会做到秒级精准。
  • 设置 timeInterval = 10,系统可能在第 8~15 秒内任意时间触发。
  • 目的是节省电量、合并唤醒操作。

📌 若对触发时效性要求极高,参考下文建议。

2. 循环触发约束

repeats = true 时,UNTimeIntervalNotificationTrigger 必须 ≥ 60 秒,否则系统会拒绝触发:

❌ 非法示例(不会触发):

 UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: true)

补充说明:

如果你设置了 timeInterval = 10repeats = true,虽然这种配置不合法,系统仍会发送第一次通知,但不会重复触发后续通知

3. 触发时间不是严格保证的
  • 系统不保证通知精确在预定时间触发,尤其是在后台、锁屏、低电量或系统调度繁忙时。
  • iOS 的系统调度机制会结合:节能策略 、优先级、用户使用习惯、当前系统资源使用情况
  • 系统会尝试在接近的合理时间窗口内发送通知,但存在几秒到几十秒的偏移。

📌 对于非常依赖时效(如闹钟、提醒类 App),建议配合:

  • interruptionLevel = .timeSensitive
  • relevanceScore、后台任务、Local Push 与系统闹钟服务组合使用
4. iOS 16+ 特性提醒
  • 在 iOS 16+ 中,系统对通知策略更加智能,可能根据用户习惯、通知类型、打断等级等进一步影响触发时机。
  • 可以结合 interruptionLevelrelevanceScore 进行优先级调整。

二、位置触发:打造“到此一游”的惊喜体验

本地通知不仅可以定时触发,也可以基于位置变化来“惊喜”推送。在用户进入或离开某个地理区域时触发通知,能很好地用于到店打卡、商圈提醒、景点互动等场景。

iOS 提供了 UNLocationNotificationTrigger,结合 CLCircularRegion 可设置一个圆形的地理围栏,实现 “地理到达 / 离开”触发通知的能力。

示例:进入商圈后提醒“别忘了打卡”

 let center = CLLocationCoordinate2D(latitude: 31.2304, longitude: 121.4737) // 上海
 let region = CLCircularRegion(center: center, radius: 200, identifier: "shanghai_center")
 region.notifyOnEntry = true
 region.notifyOnExit = false
 
 let trigger = UNLocationNotificationTrigger(region: region, repeats: false)
 
 let content = UNMutableNotificationContent()
 content.title = "📍 欢迎来到上海商圈"
 content.body = "别忘了到店打卡赢积分哦!"
 content.sound = .default
 
 let request = UNNotificationRequest(identifier: "location_checkin", content: content, trigger: trigger)
 UNUserNotificationCenter.current().add(request)

⚠️ 地理围栏最大半径为 1000 米,最小建议设置为 100 米以上。

想实现“进入”和“离开”分别触发不同通知?

需要为进入和离开事件分别构造不同的通知内容和请求:

 // 创建共享的地理区域
 let region = CLCircularRegion(center: center, radius: 200, identifier: "shanghai_center")
 region.notifyOnEntry = true
 region.notifyOnExit = true
 
 // 进入区域通知
 let enterContent = UNMutableNotificationContent()
 enterContent.title = "📍 欢迎来到上海商圈"
 enterContent.body = "别忘了到店打卡赢积分哦!"
 enterContent.sound = .default
 
 let enterTrigger = UNLocationNotificationTrigger(region: region, repeats: true)
 let enterRequest = UNNotificationRequest(identifier: "enter_checkin", content: enterContent, trigger: enterTrigger)
 UNUserNotificationCenter.current().add(enterRequest)
 
 // 离开区域通知
 let exitContent = UNMutableNotificationContent()
 exitContent.title = "👋 回见!"
 exitContent.body = "别忘了回顾你的打卡记录"
 exitContent.sound = .default
 
 let exitTrigger = UNLocationNotificationTrigger(region: region, repeats: true)
 let exitRequest = UNNotificationRequest(identifier: "exit_checkin", content: exitContent, trigger: exitTrigger)
 UNUserNotificationCenter.current().add(exitRequest)

虽然使用的是同一个 CLCircularRegion 对象,但只要 contentidentifier 不同,系统就会在进入与离开时分别触发不同内容的通知。

理解位置触发的 repeats

UNLocationNotificationTriggerrepeats 参数,用于控制该位置通知是否可以反复触发

行为说明
false 默认值。通知仅触发一次。当用户首次进入或离开地理区域后,系统就会移除该触发器,后续再进入同一区域不会再次触发,除非你重新添加请求。
true 每次进入或离开区域时,系统都会触发通知,适用于长期监控型的业务场景,如每日通勤、门店签到等。
注意点:
  • 设置为 true 时,不需要重新注册通知请求,系统会持续监听该区域。
  • 系统最多同时监控 20 个地理围栏(由 CLLocationManager 限制),超出部分会被忽略,需合理规划。
  • 由于通知内容写在 UNNotificationRequest 中,不能动态变化,如果你希望每次触发时展示不同内容,需要结合 UNNotificationServiceExtension 实现动态内容替换。

注意事项

  • 位置权限要求:必须向用户请求定位权限,推荐使用 .requestAlwaysAuthorization(),否则后台时无法触发通知。
  • App 杀死时是否生效? :只要系统还在监控地理围栏,即使 App 未运行,通知依然会被触发。
  • 省电策略影响:地理围栏的监控会受系统省电策略影响。系统会动态调节位置更新频率,特别在长时间静止或低电量时。
  • 后台定位权限建议开启:需在 Info.plist 中配置NSLocationAlwaysAndWhenInUseUsageDescription

三、情境触发:结合后台任务精确送达

除了基于时间和位置的触发,本地通知还可以在特定后台任务完成时主动发送,这在文件下载、数据同步、缓存清理等场景特别实用。

iOS 通过 BGTaskScheduler 提供了后台任务调度功能,配合本地通知实现“任务完成立刻提醒”。

1. 任务注册 — 在 App 启动时告诉系统你要执行哪些后台任务

AppDelegate 或启动流程中注册任务标识和对应处理函数:

 import BackgroundTasks
 
 BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.myapp.cleanup", using: nil) { task in
    self.handleCleanupTask(task: task as! BGProcessingTask)
 }

2. 任务执行 — 真正处理任务的地方

调度到后台时,系统会调用你注册的处理函数。这里你可以执行耗时任务,比如缓存清理:

 func handleCleanupTask(task: BGProcessingTask) {
    // 任务到期时调用,确保任务能被及时终止
    task.expirationHandler = {
        // 取消或保存状态
    }
 
    // 异步执行清理操作,模拟耗时 2 秒
    DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
        // 任务完成后,发送本地通知
        let content = UNMutableNotificationContent()
        content.title = "🧹 清理完成"
        content.body = "成功释放 240MB 空间"
        content.sound = .default
 
        // 立即发送通知,trigger 为 nil
        let request = UNNotificationRequest(identifier: "cleanup_complete", content: content, trigger: nil)
        UNUserNotificationCenter.current().add(request)
 
        // 标记任务完成
        task.setTaskCompleted(success: true)
    }
 }

3. 关键点说明

  • trigger: nil 表示通知立即发送,不依赖时间或位置触发。
  • 后台任务由系统调度,不保证立刻执行,可能延迟几分钟甚至更长时间,且有时不会被调度。
  • 任务执行时长有限制(通常约 30 秒),需在 expirationHandler 中妥善处理超时。
  • BGProcessingTask 适合执行耗时且对时间要求不严的任务(如缓存清理、数据上传)。
  • 使用前需在项目 Info.plist 添加后台任务权限和任务标识。

4. 适用场景举例

  • 清理缓存、释放空间
  • 文件下载完成后提醒用户
  • 同步或上传任务完成时通知
  • 定期后台数据处理并提示结果

下一章预告:让通知更“有内容”

掌握了通知的精准触发时机,只是构建优秀通知体验的第一步。

真正能让用户点击、记住、感受到价值的通知,往往还依赖它的内容呈现力

  • ✍️ 图文并茂的通知样式
  • 🔊 自定义音效营造氛围
  • 🎬 音视频附件带来沉浸体验
  • 🧠 配合分组、评分与打断等级,实现系统级调度优化

下一章《本地通知内容深度解析 — 打造丰富的通知体验》,将带你深入探索 UNMutableNotificationContent 的每一项能力,帮助你打造更有温度、更有表现力的本地通知。

3.本地通知的控制中枢 — 掌控调度与生命周期管理的关键

本地通知的控制中枢 — 掌控调度与生命周期管理的关键

在本章中,我们将从开发者视角,深入解析 iOS 本地通知系统的调度机制、生命周期控制以及实际使用中的管理技巧,帮助你构建稳定可靠的通知体系,避免那些“通知添加成功却悄无声息”的尴尬。

一、系统机制揭秘:添加不等于弹出

很多开发者在使用 UNUserNotificationCenter 添加通知请求时,常常会遇到这样的疑问:

我调用了添加通知的接口,控制台提示“通知已添加”,但通知迟迟没有弹出?

 let center = UNUserNotificationCenter.current()
 
 // 1. 通知内容
 let content = UNMutableNotificationContent()
 content.title = "📌 每日一句"
 content.body = "每一个不曾起舞的日子,都是对生命的辜负"
 content.sound = .defaultCritical
 content.categoryIdentifier = "DAILY_QUOTES"
 
 // 2. 通知触发器(10秒后)
 let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false)
 
 // 3. 通知请求
 let request = UNNotificationRequest(identifier: "quote_001", content: content, trigger: trigger)
 
 // 4. 添加通知
 center.add(request) { error in
    if let error = error {
        print("❌ 添加失败:(error)")
    } else {
        print("✅ 通知已添加")
    }
 }

往往看到控制台输出:

 ✅ 通知已添加

但十秒过去了,通知却没有出现。这是为什么?

这是 iOS 通知系统设计中的核心理念——添加通知 ≠ 显示通知

为什么会这样?

  • add(request) 只是把通知请求交给系统接受,并不保证一定会展示
  • 通知是否展示,依赖于用户是否授权,以及系统的调度决策
  • 未授权时,系统会默默丢弃该通知请求,且不会回调错误

Apple 的设计考量

  1. 权限与通知调度解耦 允许开发者预先安排通知,系统再基于授权状态决定展示,提高灵活性。
  2. 避免异常复杂化 添加通知不会因权限问题抛异常,简化开发流程。
  3. 隐私保护 授权状态只能通过官方接口查询,避免授权状态被试探。
重要提醒
  • 授权后,之前未授权时添加的通知不会自动补发!
  • 开发者需在添加通知前主动检测授权状态,避免添加“无效”通知。
 UNUserNotificationCenter.current().getNotificationSettings { settings in
    guard settings.authorizationStatus == .authorized else {
        print("未授权通知,不发送")
        return
    }
    UNUserNotificationCenter.current().add(request)
 }

二、通知的大脑中枢

UNUserNotificationCenter 是 iOS 中负责本地通知和远程通知调度的统一接口,提供了权限申请、添加通知、管理通知、查询状态等一系列能力。

 let center = UNUserNotificationCenter.current()

1. 申请通知权限

 center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
    print("授权结果:(granted)")
 }

2. 查询当前授权状态

 center.getNotificationSettings { settings in
    print(settings.authorizationStatus)
 }

三、通知的添加与分类管理

1. 添加通知请求

 let request = UNNotificationRequest(identifier: "id_001", content: content, trigger: trigger)
 center.add(request)

2. 分类注册(支持交互、按钮等)

 let category = UNNotificationCategory(identifier: "DAILY_QUOTES", actions: [...], intentIdentifiers: [])
 center.setNotificationCategories([category])

四、通知的生命周期管理

1. 查询待发通知

center.getPendingNotificationRequests { requests in
    print("当前待发通知数量:(requests.count)")
}

2. 删除待发通知

// 按标识删除
center.removePendingNotificationRequests(withIdentifiers: ["task_id"])
// 全部清除
center.removeAllPendingNotificationRequests()

3. 查询已发通知

通知中心中已展示的

center.getDeliveredNotifications { notifications in
    print("已发通知数量:(notifications.count)")
}

4. 删除已发通知

center.removeDeliveredNotifications(withIdentifiers: ["id_001"])
center.removeAllDeliveredNotifications()

五、通过三个identifier管理通知

在 iOS 通知系统中,UNUserNotificationCenter 通过多个标识符管理通知的展示和生命周期,分别负责不同维度的控制:

主要标识符及其职责

标识符 所属对象 作用说明 举例
request.identifier UNNotificationRequest 通知请求的唯一 ID,控制通知的添加、替换与移除。是通知的“身份证”。 替换“每日提醒”通知,防止重复提醒
content.categoryIdentifier UNMutableNotificationContent 用于绑定通知类别,关联交互动作(按钮、回复等),定义通知行为。 绑定“聊天消息”类别,显示“回复”按钮
content.threadIdentifier UNMutableNotificationContent 用于通知分组,将多个相关通知归类在一起,提升通知中心的整洁度。 将同一个聊天群的多条消息通知聚合为一组显示
1. request.identifier — 通知的唯一身份证,控制添加与替换

作用: 每个 UNNotificationRequest 都必须有一个唯一的 identifier,系统通过它来识别通知。添加相同 identifier的请求,会替换之前的通知,防止重复提醒。

举例:

假设你做一个每日提醒 App,每天早上 8 点发送一条提醒:

let content = UNMutableNotificationContent()
content.title = "每日提醒"
content.body = "新的一天,开始努力吧!"
content.sound = .default

let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
let request = UNNotificationRequest(identifier: "daily_reminder", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
  • 如果你再次添加 identifier"daily_reminder" 的通知,系统会覆盖之前的那条,避免每天收到重复的多条提醒。
  • 如果想取消这条通知,只需调用 removePendingNotificationRequests(withIdentifiers: ["daily_reminder"])
2. content.categoryIdentifier — 绑定通知类别,定义交互行为

作用: categoryIdentifier 关联到 UNNotificationCategory,定义通知可以包含哪些交互按钮和操作,例如回复、标记完成、删除等。

举例:

你做一个聊天 App,通知里带“回复”按钮:

// 1. 注册类别
let replyAction = UNTextInputNotificationAction(
    identifier: "REPLY_ACTION",
    title: "回复",
    options: [])

let category = UNNotificationCategory(
    identifier: "MESSAGE_CATEGORY",
    actions: [replyAction],
    intentIdentifiers: [],
    options: [])

UNUserNotificationCenter.current().setNotificationCategories([category])

// 2. 发送通知时绑定类别
let content = UNMutableNotificationContent()
content.title = "新消息"
content.body = "你收到一条好友消息"
content.sound = .default
content.categoryIdentifier = "MESSAGE_CATEGORY"
  • 用户收到通知时,可以直接在通知中心点击“回复”按钮,快速回复消息。
  • 如果通知没有绑定类别,则默认不显示交互按钮。
3. content.threadIdentifier — 相关通知分组,优化通知中心展示

作用: 系统根据 threadIdentifier 把属于同一组的通知折叠显示,减少通知中心杂乱感。

举例:

假设你是一个社交 App,有多个聊天群,通知中心希望把同一个群的消息通知分为一组:

let content = UNMutableNotificationContent()
content.title = "聊天群:Swift高手"
content.body = "有人发了一条新消息"
content.sound = .default
content.threadIdentifier = "chat_group_swift"  // 把该群消息通知聚合
  • 如果接收了多条 threadIdentifier 相同的通知,系统会自动将它们折叠显示成一个通知组。
  • 用户点击后可以展开查看所有同组通知,体验更清爽。

六、忽视通知管理的后果

如果对通知标识符和管理机制不理解,容易导致:

  • 用户收到大量重复或冗余的提醒,体验变差
  • 多次添加同类通知,弹窗重复干扰用户
  • 已展示的通知未被及时清理,通知中心堆积杂乱
  • 应用重启后旧通知依然存在,业务逻辑可能失效

实用管理技巧

1. 避免重复通知:利用相同请求标识符覆盖旧通知
let request = UNNotificationRequest(identifier: "daily_reminder", content: content, trigger: trigger)
center.add(request)  // 会替换已存在的 "daily_reminder"

系统自动识别 identifier,用最新请求覆盖旧请求,避免重复提醒。

2. 先移除再添加:多入口调用时保证状态一致
center.removePendingNotificationRequests(withIdentifiers: ["task_reminder"])
center.add(request)

先移除待发通知,防止不同代码路径添加重复通知导致的混乱。

下一章预告:掌控通知触发的三把钥匙

了解了通知系统的整体结构后,你可能会好奇: 通知究竟是“什么时候”、“什么地点”以及“什么条件下”触发的?

在下一章《本地通知的精准控制三角》中,我们将深入剖析通知的三大触发机制:

  • 时间触发:设定日程、定时提醒、闹钟机制
  • 📍 位置触发:到店打卡、到达后引导、地理围栏
  • 🧠 情境触发:任务完成后发出提醒、后台智能调度

这一章将从实战角度出发,帮你构建一个 “如约而至”的通知系统:不仅能准时送达,还能恰到好处地契合用户行为与环境。

swift 基础:关联引用讲解

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

前言

image.png

最近在 review 一些代码时,发现了objc_setAssociatedObjectobjc_getAssociatedObject 方法,突然忘了这两个方法的机制和作用。

于是决定深入探究一番。

关联引用的定义

objc_setAssociatedObjectobjc_getAssociatedObject 被称为关联引用(Associative References)。根据苹果的官方文档:

  • objc_setAssociatedObject 用于通过给定的 key 和 value 关联策略为指定对象设置关联值。

  • objc_getAssociatedObject 用于返回与指定对象和 key 关联的 value。

简而言之,关联引用使我们能够通过键将一个对象链接或附加到另一个对象上。我们可以将第一个对象称为负载(payload),而第二个对象称为目标(target)。这种链接确保了当目标对象被释放时,负载也可以被释放。

创建对象与字符串的关联

我们可以利用objc_setAssociatedObject 方法将负载(例如 "Example message")附加到 UIViewController 上,注意,这里的 self 就是控制器。

enum AssociatedKey {
    staticvar key: Int = 0
}

func onButtonTapped() {
    // 创建 Alert和负载
    let alert = UIAlertController(title"Title", message"Example message", preferredStyle: .alert)
    let payload = "Example message"
    // 使用 objc_setAssociatedObject 函数将负载与 Alert 关联
    objc_setAssociatedObject(self, &AssociatedKey.key, payload, .OBJC_ASSOCIATION_RETAIN)
    // 为 Alert 添加一个 OK 按钮
    let okAction = UIAlertAction(title"OK", style: .default) { [weakself] _in
        self?.handleAlertDismissed()
    }
    alert.addAction(okAction)
    // 显示 Alert 
    present(alert, animatedtrue, completion: nil)
}

代码解析

在上面的代码中,我们首先创建一个唯一的键,用于每个关联。使用静态变量作为 key 是比较推荐的做法。

接下来,我们创建一个 Alert,并将负载存储在一个变量中。这个 Alert 稍后会被调用以检索关联对象。

objc_setAssociatedObject 函数将负载与视图控制器关联。这个函数需要四个参数:源对象或目标对象、键、值或负载,以及关联策略常量。

第四个参数指定关联策略。此策略决定了在关联中,源对象(目标对象)与值(负载)之间的引用类型。它可以是“弱引用”(OBJC_ASSOCIATION_ASSIGN)、“强引用”(OBJC_ASSOCIATION_RETAIN)或复制(OBJC_ASSOCIATION_COPY)。此外,它还指定关联是以原子方式还是非原子方式进行的。

检索关联对象

我们使用objc_getAssociatedObject 函数来检索关联对象。

func handleAlertDismissed() {
    // 使用 objc_getAssociatedObject 检索关联负载
    if let payload = objc_getAssociatedObject(self, &AssociatedKey.key) {
        // 打印负载
        print("Payload is: \(payload)"// 输出 "Payload is: Example message"
    }
}

释放关键对象

如果你想解除关联并释放负载而不分配新的对象,你可以调用objc_setAssociatedObject,将值设为 nil。

objc_setAssociatedObject(self, &AssociatedKey.key, nil, .OBJC_ASSOCIATION_RETAIN)

完整代码示例

class ViewControllerUIViewController {

    // MARK: - Enums
    
    // 用于关联的静态变量键
    enum AssociatedKey {
        staticvar key: Int = 0
    }
    
    // MARK: - Properties
    
    privatevar button: UIButton = {
        let button = UIButton()
        button.setTitle("Tap me", for: .normal)
        button.backgroundColor = .blue
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    // MARK: - View Lifecycles
    
    overridefunc viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        button.addTarget(self, action: #selector(onButtonTapped), for: .touchUpInside)
    }
}

// MARK: - Private Methods
extension ViewController {
    
    func setupUI() {
        view.addSubview(button)
        button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        button.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
    }
    
    @objcfunc onButtonTapped() {
        // 创建 Alert 和负载
        let alert = UIAlertController(title: "Title", message: "Example message", preferredStyle: .alert)
        let payload = "Example message"
        
        // 使用 objc_setAssociatedObject 函数将负载与 Alert 关联
        objc_setAssociatedObject(self, &AssociatedKey.key, payload, .OBJC_ASSOCIATION_RETAIN)
        
        // 为 Alert 添加一个 OK 按钮
        let okAction = UIAlertAction(title: "OK", style: .default) { [weakself] _in
            self?.handleAlertDismissed()
        }
        alert.addAction(okAction)
        
        // 显示 Alert 
        present(alert, animated: true, completion: nil)
    }
    
    func handleAlertDismissed() {
        // 使用 objc_getAssociatedObject 检索关联负载
        iflet payload = objc_getAssociatedObject(self, &AssociatedKey.key) {
            // 打印负载
            print("Payload is: \(payload)"// 输出 "Payload is: Example message"
        }
    }
}

总结

通过关联引用,我们可以灵活地将对象之间进行关联,而不必直接修改类的结构,增加了代码的灵活性和扩展性。

通过本文的介绍,希望大家对objc_setAssociatedObjectobjc_getAssociatedObject 有一个了解,能够在实际开发中得心应手地使用这两个方法。

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

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

2.通知权限获取的艺术 - 如何优雅地让用户说“好”

通知权限获取的艺术 - 如何优雅地让用户说“好”

作为开发者,我们都有过类似的挫败感——精心设计的通知系统,却在用户首次打开 App 时就被拒绝了权限。

就像你准备了一整套旅行攻略,刚张口说“我们要去旅行”,同伴却直接摆手:“不去”。连展示价值的机会都没有。

为什么iOS通知权限如此"娇贵"?

想象一下你刚下载一个天气 App,还没看到天气界面,就弹出个冷冰冰的系统框:“允许‘晴天助手’发送通知吗?” 大多数人都会本能地点“拒绝”。这不是用户苛刻,而是我们没讲好“故事”。

系统权限的“唯一性”

  • 一次性机会:iOS 只允许系统权限弹窗显示一次,用户拒绝后 App 无法再次主动申请
  • 无法自定义:弹窗由系统控制,开发者无法自定义样式和文案
  • 挽回较困难:用户拒绝后,只能引导去“设置”手动开启,转化率极低

换句话说:你只有一次机会让用户说“好”

 UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
    // 这里决定了你所有推送功能的生死
 }

通知权限的配置选项

属性名 引入版本 是否常用 功能说明
.alert iOS 10.0+ ✅ 常用 允许展示通知横幅、锁屏提醒等“视觉内容”。
.badge iOS 10.0+ ✅ 常用 允许更新 App 图标上的角标数字(如消息数)。
.sound iOS 10.0+ ✅ 常用 允许通知播放提示音。
.carPlay iOS 10.0+ ❗少见 允许通知在 CarPlay 中展示。
.criticalAlert iOS 12.0+ 🔒 特殊权限 请求“关键通知”权限,需经 Apple 审核并用户开启。允许通知声音绕过静音和勿扰模式
.providesAppNotificationSettings iOS 12.0+ 说明性 表示 App 支持跳转通知设置页(对用户可见“通知设置”按钮)。
.provisional iOS 12.0+ 新增 请求“静默通知授权”,无需弹出授权框。通知直接展示但不会打扰用户。
.announcement iOS 13.0+(弃用于 iOS 15.0) ❌ 弃用 原用于 VoiceOver 自动读出内容,现已合并至基础授权中。
.timeSensitive iOS 15.0+(已弃用) ❌ 弃用 原用于请求“时间敏感通知”,现在系统不再需要独立授权,直接由 Entitlements 控制。

什么是 .provisional

provisional 是 iOS 12 引入的一种 渐进式通知授权机制

  • 用户 不会看到授权弹窗(不像传统的通知授权那样弹出对话框)
  • 通知会直接出现在用户的 通知中心但不会弹窗、不会震动、不会响铃
  • 用户可以从通知中心中点击“继续接收”或“关闭通知”来做决定
用户侧表现
  • 第一次收到 .provisional 通知时,系统在通知中心会带一个按钮:

    “继续接收” / “关闭通知”

  • 用户选择“继续接收”后,变为正式授权(如同点击了系统弹窗的“允许”)

让用户说"好"的三大黄金法则

法则一:先约会,再求婚

千万别在 App 冷启动阶段就弹出权限请求。用户还没弄清你是谁,就要求授权通知——就像第一次见面就求婚,注定被拒。

更好的做法是:

等用户完成一次核心操作,再自然地引导开启通知。

💡 案例:某健身 App 的策略
  1. 用户完成首次训练任务
  2. 弹出提示框:“想记录你的每日进步吗?”
  3. 提供两个选项:“稍后再说” 和 “接收进度提醒”

点击“接收提醒”后才触发系统通知权限请求。

此时用户已:

  • 体验了产品的核心价值
  • 理解了通知的作用
  • 对 App 建立了初步信任
 func showPermissionAfterWorkout() {
    let customAlert = CustomAlertView(
        title: "每日进步提醒",
        desc: "开启通知,接收训练记录和定制计划",
        confirmAction: { requestSystemPermission() }
    )
 }

法则二:给拒绝留条后路

用户拒绝了,不代表永远不需要通知。正确的策略:在合适的时机,用场景化的方式再次引导。

案例:某银行 App 的做法
  • 在转账成功页面底部放置提示按钮:“开启到账提醒 →”

  • 点击后跳转到一个设计精美的引导页:

    • 展示通知样式预览图
    • 通过时间轴说明通知触发时机
    • 提供“去设置开启通知”按钮(跳转至系统设置)

📊 实测表明,这类设计让二次开启率 提升了 3 倍

法则三:权限不是终点,而是起点

用户点击“允许通知”不是结束,而是关键的「信任窗口」的开始。

此时,你需要立刻完成一件件事:即时反馈,发送一条欢迎通知(带有个性化内容)

此时用户处于承诺一致性的心理状态,他们刚刚做了一个决定,会下意识寻找支持这个决定的证据。我们要做的,就是立即提供这种证据。在用户授权通知权限后立即发送一条个性化欢迎通知,这看似是个小细节,实则是精心设计的「心理锚点」。背后的产品逻辑和用户心理机制值得深入剖析。

为什么第一条通知如此重要?
作用 解读
消除用户疑虑 用户刚同意权限,如果不立刻展示结果,容易后悔。就像一颗"定心丸":"看,这就是您同意的通知,它很有用"。
建立质量标杆 建立质量预期。是有价值的个性化内容,用户会默认后续通知都如此优质;是广告,用户会直接关闭权限。
激活使用场景 通过具体示例展示通知将如何服务用户,比如:"当您关注的商品降价时,我们会这样提醒您"。

完美首条通知的三要素

  1. 个性称呼:让通知不那么“像广告”
  2. 明确价值:告诉用户你为什么通知他
  3. 5 秒延迟:避免与 UI 操作冲突,确保体验流畅
 func sendWelcomeNotification() {
    let content = UNMutableNotificationContent()
    content.title = "(用户名),欢迎使用智能提醒"         // 个性称呼
    content.body = "当您收藏的商品降价时,我们会立即通知您" // 明确价值
    content.sound = .default
 
    let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
    let request = UNNotificationRequest(identifier: "welcome_msg", content: content, trigger: trigger)
    UNUserNotificationCenter.current().add(request)
 }

通知节奏与内容设计:从启动期到常规运营

阶段一:价值兑现的“黄金 72 小时”

获得通知权限后的前三天,是用户最愿意接收信息的窗口期

此时发送的每一条通知,都是在帮助用户理解: “允许通知,是个明智的决定。”

高价值内容的定义

高价值 = 用户需要的 + 只有推送能提供的 + 有时效性

初期的目标是建立信任感,让用户感受到通知“有用、不烦”。别用广告式轰炸透支用户信任。好通知 = 相关性强 + 时效精准

日期 推送密度 正确示范 错误示范 价值分析
第 1 天 发送 1 条(欢迎+实用) “您收藏的《三体》有声书已上架,限时 6 折” “全场图书 5 折起” 明确兴趣 + 高关联性
第 2 天 发送 1 条(场景相关) “张医生:明天体检需空腹 8 小时,请注意” “预约立减 200 元” 刚需场景 + 时效性强
第 3 天 发送 0~1 条(高价值) “王磊评论了你的动态:这个角度太棒了!” “快来查看新消息” 社交驱动 + 精准触达
为什么是3天?

用户授权通知后的 前三天,是通知系统建立信任的黄金窗口:

  • 记忆效应:新用户在这段时间对 App 最有好奇心和耐心

  • 行为养成:良好的通知节奏可以帮助用户养成使用习惯

  • 数据验证:某头部电商实测显示, 3 天内发送 2~3 条通知

    • 30 天留存率提升 27%
    • 通知点击率提升 41%

这三天决定用户是否“继续听你说话”。

提供“退出通道”,尊重用户意愿

很多 App 在面对通知被拒或关闭时选择沉默不语,仿佛“装作没发生”。但聪明的做法是:主动把控制权交还给用户,不是通过设置埋得很深的“通知中心”,而是在通知本身就提供设置跳转,让用户轻松管理自己的偏好。

 content.userInfo = ["settings_url": "app://notification_prefs"]

通过为通知附加一个用于跳转的 userInfo 字段(如跳转到通知偏好页),你可以实现在点击通知后跳转到 App 内的设置页面,让用户修改提醒频率、内容类型等。

建议:在部分通知(如用户反馈较多的类目、首次发送类)中附上“通知设置”入口,主动邀请用户调整偏好,而不是默默让他们走向关闭通知的结局。

这种做法有三个好处:

  1. 传递尊重感:用户不会觉得自己被动接受“强行打扰”
  2. 延缓关闭通知的决策:降低用户“一键关掉所有通知”的概率
  3. 建立信任机制:让用户知道“我可以控制这个 App 怎么打扰我”

这不是技术层面的小技巧,而是一种产品哲学:在通知设计中体现“用户自主”的力量

阶段二:稳定期运营 — 从提醒工具到价值伴侣

用户进入常规使用期后,通知策略的目标是:增强用户粘性与日常留存

核心原则:让通知“出现在对的时候”
  1. 围绕用户行为驱动推送

    • 浏览某类内容超 3 次 → 推荐相关新品/活动
    • 收藏/点赞行为频繁 → 提醒状态变更或社交反馈
  2. 用好“系统级”内容更新时机

    • 节假日、促销节点、周期任务结算等
    • 示例:“您本周累计跑步 12km,超过 89% 用户”
  3. 低频触达、高关联

    • 每周 1~2 条精选通知优于日更轰炸
    • 保持“每一条都值得点开”的节奏

阶段三:通过数据持续调优

定期复盘通知策略是否有效:

指标 理想值 说明
通知点击率(近7日) ≥ 55% 判断内容吸引力
通知开启后 30 天保留率 ≥ 70% 验证推送是否对留存有正向影响
通知负面反馈率(关闭、拉黑) ≤ 2% 若升高,说明内容/频率需优化

通知运营是马拉松,不是短跑

  • 前三天是打基础期:要证明你“值得被允许”
  • 之后是维护关系期:持续提供有意义的内容
  • 始终要避免两个极端:沉默不语 & 过度打扰

做到这一点,通知就不只是提醒工具,而是产品与用户之间最默契的沟通方式。

用数据验证你的策略是否有效

好的通知设计,不是凭感觉拍脑袋,而是依靠数据驱动决策。你设计得再动人,如果用户不点、点了就关,那就是失败。

核心指标追踪

这些是衡量通知策略好坏的第一手信号,建议每 1~2 周进行一次通知策略回顾和数据复盘,及时调整节奏与内容。

指标名称 理想目标 说明
通知点击率(7 日内) 55% 判断首批通知是否足够吸引用户
权限保留率(第 7 天) 70% 用户是否愿意长期保留通知权限
负面反馈率(关闭/设置) 2% 推送是否引起反感或干扰

负面反馈率”是衡量用户对通知不满、反感或拒绝的一个关键指标,简单来说,它反映了有多少用户在接收到通知后选择了“把你静音”或“干脆关闭你”

它通常包含以下几类行为:

用户行为 含义
关闭通知权限(从系统设置中关闭) 用户明确拒绝继续接收通知
静音/隐藏通知(如关闭横幅/声音) 用户不关闭权限,但降低通知打扰强度
通知页面滑动“管理 → 关闭/减少通知” 主动通过通知界面表达“不想再看”
删除 App(极端情况下的表达) 如果通知密集干扰体验,用户可能卸载 App

AB 测试:优化不靠拍脑袋

A/B 测试是通知策略持续迭代最有效的方式。它让我们在“真实用户群体”中测试不同策略,通过对比数据差异,得出最优解

示例:权限请求的触发时机测试

触发时机 授权通过率 说明
App 启动后立即请求 38% 用户尚无信任,容易被拒绝
完成首次核心操作后 62% 用户体验过产品价值,更愿意授权
收到第一条私信后 71% 用户看到通知价值,转化率最高

结论:推迟弹窗、找对时机,远比“先下手为强”有效。

可以测试的变量有哪些?

除了弹窗时机,还可以测试这些内容:

测试维度 示例 可观察指标
引导文案 A:“开启通知不错过任何提醒” B:“你错过了3条消息,建议开启通知” 点击率、授权率
展示样式 A:弹窗 + 图示通知预览 B:引导页 + 多步引导 保留率、负面反馈
推送频率 A:每日 2 条 B:每周 3 条精选 点击率、关闭率
推送内容类型 A:基于行为推荐 B:系统运营活动 转化率、跳出率

数据驱动策略调整建议

测试完后不要止步于“得出结果”,更要做出决策:

数据表现 调整建议
点击率低 优化文案吸引力 / 提升内容相关性
保留率低,负面反馈高 减少推送频率 / 提供个性化设置选项
A/B 组差异显著 采用表现更佳方案并继续细分迭代
指标达标但用户无后续行为转化 结合通知后行为(跳转率、停留时长)进一步分析

提醒:测试内容一次只改一个维度,否则结果难以归因。

通知,是一场信任游戏,也是一场持续优化的修行

通知权限的本质,不是一次性请求的技术动作,而是一场精心设计的信任游戏

那些授权率高、点击率稳、用户不反感的 App,背后都有一套系统化的推送设计哲学——既懂人性,也懂数据。

三个永远不过时的原则:

  1. 价值先行:别急着要权限,先让用户感受到“值得”
  2. 时机精准:找准用户最满意、最信任的节点再提出请求
  3. 优雅坚持:被拒绝不是终点,而是你建立第二次信任的开始

用户拒绝的不是通知本身,而是不被尊重的打扰

策略的好坏,不能只靠直觉。通知不是一锤子买卖,而是一场数据驱动的持续优化过程

  • 授权率 决定你能不能说话
  • 点击率 决定你说的话有没有人听
  • 负面反馈率 决定你能不能继续说下去

这三项,是通知系统的“生命体征”。所以别只设计一次弹窗、写一次文案就一劳永逸,而要用 A/B 测试 不断探索最优策略,用 关键指标反馈 反复校准方向。

下一章预告:掌控通知的调度与生命周期

一旦获得了用户的授权,真正的挑战才刚刚开始。

如何定时触发通知?如何撤销、更新、查询、分组?如何与 App 生命周期配合?这些控制逻辑的背后,都依赖于 iOS 本地通知系统的调度中枢

在下一章《本地通知的控制中枢》中,我们将深入解析:

  • UNUserNotificationCenter 的使用全貌
  • 如何添加、取消、更新通知请求
  • 通知的生命周期管理与调试技巧
  • 如何构建灵活的通知调度系统

📌 通知不仅要“能发”,还要“发得对、发得稳、发得优雅” 。 下一章将带你掌握通知调度背后的所有“控制之术”。

1.iOS通知系统全解 - 总览与核心概念

iOS通知系统全解 - 总览与核心概念

在移动应用生态中,通知系统就像产品的"数字神经" ,成为连接用户最直接、最高效的沟通渠道。作为开发者,我们每天都在思考:如何让用户愿意打开通知?如何让推送既有效又不惹人厌?这些问题背后,藏着iOS通知系统的精妙设计。

为什么通知如此重要?

让我们看几个令人深思的数据:

  • 开启通知的用户,留存率是未开启用户的3-5倍 - 这就像在用户手机上获得了一张VIP通行证
  • 精心设计的推送策略能提升40%以上的活跃度 - 相当于免费的用户召回渠道
  • 70%的用户点击决定取决于通知内容的相关性 - 内容为王在这里同样适用

数据来源: Airship 2023,腾讯云2021,Braze 2023

这些数字告诉我们:通知既是留存神器,也是增长引擎。在获客成本越来越高的今天,与昂贵的广告投放相比,通知就像是一个24小时待命的用户召回大使:

  • 贴心小秘书:日历提醒、健康打卡、账单提醒
  • 促销小能手:限时优惠、新品上架、专属福利
  • 社交连接器:消息回复、点赞互动、群组更新
  • 服务小助手:外卖进度、快递追踪、预约提醒

不过,iOS的通知系统就像一位严格的管家 - 相比Android的"自由放任",它对权限控制、展示方式和推送频率都有严格要求。这既是挑战,也是机会:只有真正理解iOS通知的设计哲学,才能打造既高效又不扰民的通知体验

通知系统的进化之路

要掌握iOS通知,我们需要先了解它的成长历程:

时代 技术里程碑 带来的改变
2009 UILocalNotification(iOS 3) 只能设置简单的定时提醒
2013 远程推送支持(iOS 7) 应用可以接收服务器推送了
2016 UserNotifications框架(iOS 10) 统一了本地和远程通知,支持富媒体和分类
2017 Service Extension(iOS 11) 推送到达前可以修改内容,支持加密推送
2021 专注模式(iOS 15) 用户可以选择性屏蔽通知
2023 实时活动(iOS 16) 锁屏和灵动岛上的实时信息更新

从简单的"闹钟式"提醒,到现在支持实时更新、富媒体交互的智能系统,iOS通知已经完成了多次进化。现在的它更像是一个"微型应用",而不仅仅是消息提醒。

现代通知系统的三大飞跃

1. 架构升级:从单一到模块化

早期的通知就像一把瑞士军刀 - 功能简单但扩展性差。现在的UserNotifications框架将各个功能模块化:

  • 内容(Content):决定展示什么
  • 触发器(Trigger):决定何时展示
  • 请求(Request):将内容和触发器打包
  • 响应(Response):处理用户互动

这种设计让通知系统变得灵活而强大,也为后续的富媒体支持、行为交互扩展打下了基础。

2. 交互升级:从看到做到

现在的通知可以:

  • 显示图片、视频甚至动态内容
  • 添加交互按钮和输入框
  • 自定义UI样式

借助 UNNotificationAttachmentNotificationContentExtension,甚至可以构建交互卡片界面,再结合按钮、输入框等自定义 Action,通知已成为一种小型的“App 内嵌模块”,提升了响应率与操作转化。

3. 智能管理:系统学会"体贴"

iOS现在能:

  • 根据用户状态过滤通知(专注模式)
  • 自动整理低优先级通知(通知摘要)
  • 实时更新关键信息(实时活动)

自 iOS 15 起,系统引入了 专注模式(Focus Mode) ,允许用户按情境筛选通知。这要求开发者不仅要发送通知,还要发送“有价值”的通知,否则可能直接被过滤。

同时,通知摘要(Notification Summary)、优先级控制、实时活动(Live Activities)等机制,也在引导开发者做出更智能的推送决策。

本地通知 vs 远程推送

我们当前所使用的现代通知系统,是基于 UserNotifications 框架构建的。虽然两种通知(本地通知远程推送)使用同一套API,但适用场景不同:

特点 本地通知 远程推送
触发方式 由App本地触发 通过苹果服务器推送
网络需求 不需要网络 需要联网
典型场景 闹钟、提醒、定时任务 消息、社交互动、紧急通知
控制精度 高(精确到秒) 中(受系统策略影响)
用户感知 更自然,像系统功能 更像"外来消息"

最佳实践是两者结合:本地通知处理确定性高的提醒,远程推送应对实时性要求高的场景。

框架核心结构

整个通知系统围绕UNUserNotificationCenter展开:

 通知中心(UNUserNotificationCenter)
 ├── 发送通知(addRequest)
 ├── 管理已发送通知(get/removeDelivered)
 ├── 设置代理(setDelegate)
 └── 处理响应(Delegate)
      ├── 即将展示通知(willPresent)
      └── 收到用户响应(didReceive)

主要组件分工明确:

类名 职责说明
UNUserNotificationCenter 通知的统一入口,负责发送、取消、查询、权限请求、设置响应代理等
UNNotificationRequest 通知请求包装体,封装通知内容和触发器
UNMutableNotificationContent 描述通知展示的内容,包括标题、正文、副标题、声音、分类、附件等
UNNotificationTrigger 控制通知何时触发(时间触发、日历触发、位置触发)
UNNotificationCategory 通知分类系统,用于定义用户交互动作(按钮、输入框)并映射到不同处理逻辑
UNNotificationResponse 描述用户与通知的交互结果,如点击、按钮选择、输入内容等
UNNotificationAttachment 用于给通知添加富媒体资源(图片、音频、视频),让通知更具吸引力
UNUserNotificationCenterDelegate 响应通知展示与用户点击事件的代理协议,帮助处理前台展示和响应回调

下一章预告:优雅获取通知权限

了解了通知系统的结构与能力后,你可能已经跃跃欲试,准备让用户第一时间收到你的精彩内容。

但别忘了,通知体验的第一道门槛就是权限授权。如果用户第一次点击“拒绝”,后续想要再次争取就难上加难了。

在下一章《通知权限获取的艺术》中,我们将深入探讨:

  • 用户为什么拒绝通知?
  • 有哪些策略可以提升授权率?
  • 如何在恰当的时机提出请求?
  • 如何用“预授权引导页”赢得用户信任?

这一章将帮你从技术视角走向产品与心理的融合,打造一个让用户乐于开启通知的体验入口。

《iOS 通知系统全解》目录索引

概览篇

第 1 章:总览与核心概念

  • 通知对留存与活跃的核心价值
  • 从 UILocalNotification 到 Live Activities 的演进
  • 通知架构图解:UserNotifications 核心组件
  • 本地通知 vs 远程推送的能力矩阵

查看详情

基础篇

第 2 章:权限获取的艺术

如何让用户心甘情愿说“好”

  • 黄金时机:首次启动与场景化触发
  • 话术设计与用户感知优化
  • 引导用户进入设置页的策略

查看详情

第 3 章:本地通知的控制中枢

掌控调度与生命周期管理的关键

  • UNUserNotificationCenter 的工作机制
  • 通知标识符详解与管理实践
  • 添加、查询、删除通知的生命周期管理

查看详情

第 4 章:通知的精准控制三角

时间、位置、情境触发机制详解

  • 定时触发:interval vs calendar
  • 地理围栏触发机制与权限策略
  • 场景感知触发:任务完成即推送

查看详情

第 5 章:通知内容深度解析

打造富媒体、智能排序的通知体验

  • UNMutableNotificationContent 字段详解
  • 附件、声音、分组、打断等级配置
  • 用户信息与点击跳转联动实现

查看详情

进阶篇

第 6 章:通知交互设计全解

让通知不仅被“看到”,还能被“操作”

  • 前台通知展示机制与 delegate 实现
  • Notification Action & Category 交互设计
  • 输入框通知、设置页跳转与杀死状态行为分析

查看详情

第 7 章:推送的扩展能力 — Notification Content Extension

打造个性化的通知 UI 体验

  • 自定义 UI 的扩展机制
  • categoryIdentifier 的三重绑定策略
  • 富媒体展示与引导用户展开的技巧

查看详情

第 8 章:远程推送

让服务器主动发出消息,用户“被动接收”也能精致有感

  • 远程推送流程全览:从服务端到设备
  • 模拟推送调试技巧(Simctl & 控制台)
  • Payload 字段详解与内容限制

查看详情

第 9 章:推送的扩展能力 — Notification Service Extension(NSE)

通知展示前的“加工车间”

  • NSE 的工作流程与 30 秒执行限制
  • 标题、正文、附件、解密等高级处理逻辑
  • 如何创建、配置 NSE 与处理超时回退

查看详情

优化篇

第十章:通知的精准数据分析

用数据驱动通知优化

  • 用户行为数据采集方法:点击率、响应率、送达率
  • A/B 测试通知内容与发送时间
  • 留存与转化指标的观察与归因建议

查看详情 - todo

第十一章:通知安全与隐私合规

保护用户信息,合规推送的底线红线

  • 推送内容的加密传输与本地解密策略
  • 用户告知机制与隐私政策合规点
  • App Store 审核流程中的注意事项(敏感字段、静态检查等)

查看详情 - todo

iOS疑难Crash-iOS18.0+ BackBoardServices exit 崩溃治理

一. 背景

我们司机端AppiOS18系统开始出现了BackBoardServices库的方法触发exit调用,而exit执行之后,C++全局变量对象进行析构导致的崩溃。

具体崩溃堆栈用两种:

第一种:-[BKSHIDEventObserver init] + 0

libsystem_c.dylib ___cxa_finalize_ranges + 480

libsystem_c.dylib _exit + 32

BackBoardServices -[BKSHIDEventObserver init] + 0

BoardServices ___31-[BSServiceConnection activate]_block_invoke.182 + 124

BoardServices ___61-[BSXPCServiceConnectionEventHandler _connectionInvalidated:]_block_invoke + 196

BoardServices _BSXPCServiceConnectionExecuteCallOut + 240

BoardServices -[BSXPCServiceConnectionEventHandler _connectionInvalidated:] + 180

第二种:-[BKSHIDEventDeliveryManager _initForTestingWithService:] + 0

libsystem_c.dylib ___cxa_finalize_ranges + 480

libsystem_c.dylib _exit + 32

BackBoardServices -[BKSHIDEventDeliveryManager _initForTestingWithService:] + 0

BoardServices ___31-[BSServiceConnection activate]_block_invoke.182 + 124

BoardServices ___61-[BSXPCServiceConnectionEventHandler _connectionInvalidated:]_block_invoke + 196

BoardServices _BSXPCServiceConnectionExecuteCallOut + 240

BoardServices -[BSXPCServiceConnectionEventHandler _connectionInvalidated:] + 180

我们从苹果论坛也能找到对应的问题的相关反馈developer.apple.com/forums/thre…

因此很明显这是一个苹果系统在iOS18版本做了底层变更而引起的崩溃。

二. 原因排查

这个崩溃只发生在iOS18及以上系统,而且从统计来看,绝大部分发生在进入后台一段时间后,发生的崩溃。

从崩溃堆栈信息:

我们可以尝试分析出这个崩溃出现的主要原因:

  • 我们知道当iPhone设备被触摸、点击的时,会由系统的后台守护进程backboardd感知到,后台守护进程backboardd会调用内部的BackBoardServices服务,将触摸、点击等的数据处理打包为IOHIDEvent对象;然后调用底层的BoardServices,将相关事件通过进程间通信(IPC进程通信),传给前台的守护进程SpringBoard,前台的守护进程SpringBoard收到消息后,也是通过进程间通信(IPC进程通信)将消息转发给目标的App进行处理。

libsystem_c.dylib ___cxa_finalize_ranges + 480 /// 执行全局对象的析构和atexit注册的函数。

libsystem_c.dylib _exit + 32 /// 调用exit(0)方法

BackBoardServices -[BKSHIDEventDeliveryManager _initForTestingWithService:] + 0 /// BackBoardServices服务,校验发现参数异常

BoardServices ___31-[BSServiceConnection activate]_block_invoke.182 + 124 /// 激活XPC连接

BoardServices ___61-[BSXPCServiceConnectionEventHandler _connectionInvalidated:]_block_invoke + 196 /// 执行XPC连接失效的block回调

BoardServices _BSXPCServiceConnectionExecuteCallOut + 240 /// 执行XPC回调

BoardServices -[BSXPCServiceConnectionEventHandler _connectionInvalidated:] + 180 /// 连接失效处理

  • 从崩溃堆栈分析,是BoardServices这个跨进程通信框架,检测到跟上游的BackBoardServices提供HID事件服务,之间的连接失效了,因此执行XPC(iOS间进程间通信)回调,重新去激活跟BackBoardServicesXPC的连接,BackBoardServices调用检测方法_initForTestingWithService, 由于偏移指令是+0表示在函数入口处的参数校验发现异常,直接调用了exit方法。

libsystem_c.dylib _exit + 32 /// 为什么堆栈显示是_exit, 但对应的函数是exit(0)方法。

iOS 崩溃堆栈中 C 函数名前多出的下划线 _是 编译器遵守 ABI 规范的符号修饰结果,用于:

  • 隔离系统库与用户代码的符号命名空间;
  • 确保二进制兼容性和动态链接可靠性;
  • 简化调试工具的符号解析流程。

而子线程调用exit强制终止应用进程App,会触发C++全局变量对象的析构函数,PosBridgeImpl对象析构,触发了内部的GPSHelper对象的析构,GPSHelper在析构函数里面调用了TimerEventRunnerObserverImp::detachTimer()函数。

我们崩溃堆栈里面崩溃发生在函数TimerEventRunnerObserverImp::detachTimer() + 36, 我们在release环境相同或者相近的系统版本下,查看函数的相关汇编指令如下:

`TimerEventRunnerObserverImp::detachTimer:
->  0x10487f924 <+0>:  stp    x20, x19, [sp, #-0x20]!   ; 保存x19,x20到栈顶,sp -= 0x20(分配32字节栈空间)
    0x10487f928 <+4>:  stp    x29, x30, [sp, #0x10]     ; 保存帧指针(x29)和返回地址(x30)到sp+0x10
    0x10487f92c <+8>:  add    x29, sp, #0x10            ; 设置新帧指针(x29 = sp + 0x10)
    0x10487f930 <+12>: ldrb   w8, [x0, #0x28]          ; 加载this+0x28处的字节(bool标志)到w8
    0x10487f934 <+16>: cbz    w8, 0x10487f958           ; 若w8==0(标志为false),跳转到<+52>(直接返回)
    0x10487f938 <+20>: mov    x19, x0                   ; 保存this指针到x19(备份this)
    0x10487f93c <+24>: ldr    x0, [x0, #0x8]            ; 加载this+0x8处的指针(成员变量target_)到x0
    0x10487f940 <+28>: cbz    x0, 0x10487f954           ; 若x0==0(target_为空),跳转到<+48>
    0x10487f944 <+32>: ldr    x8, [x0]                  ; 加载target_对象的虚表指针到x8(高危点1)
    0x10487f948 <+36>: ldr    x8, [x8, #0x18]           ; 从虚表偏移0x18处加载函数指针(高危点2,崩溃位置)
    0x10487f94c <+40>: mov    x1, x19                   ; 设置参数x1 = this(TimerEventRunnerObserverImp对象)
    0x10487f950 <+44>: blr    x8                        ; 调用虚函数(实际调用target_->unregisterObserver(this))
    0x10487f954 <+48>: strb   wzr, [x19, #0x28]         ; 将0写入this+0x28(isAttached_ = false)
    0x10487f958 <+52>: ldp    x29, x30, [sp, #0x10]     ; 恢复帧指针和返回地址
    0x10487f95c <+56>: ldp    x20, x19, [sp], #0x20     ; 恢复x19,x20,sp += 0x20(释放栈空间)
    0x10487f960 <+60>: ret                              ; 函数返回

崩溃发生在偏移指令<+36>: ldr x8, [x8, #0x18] ; 从虚表偏移0x18处加载函数指针这里,而崩溃类型是EXC_BAD_ACCESS (SIGSEGV), 也就表明了成员变量target指向的对象已经被释放了, 其虚表指针无效,虚表偏移0x18,访问了无效的地址,触发了崩溃。

至于target对象被释放的原因,大概是因为多线程竞争原因,因为是第三方库,看不到具体源码也无法进行进一步的分析。

而另外一个崩溃,由于第三方库对函数符号进行了重命名,无法进行获知具体函数和对象名称,无法进行调试,但是崩溃的原因应该是一致的。

三. 解决方案

从上面的分析,我们已经清楚了崩溃的具体原因,因此我们也将对应的崩溃和原因分析告知第三方,让第三方去进行排查,但三方一直没给到对应的新的版本库,而且由于两个三方库都是重要基础库,升级造成的影响面大,所以就算三方给了修复的版本,整个升级的过程,也是比较冗长。

因此需要思考,如何在业务侧来通过其他的方法来进行修复。

  1. 方案一(失败方案)

A. 治理方案

首先想到的方法是能否通过hook,来感知exit函数的调用,因此尝试hooklibsystem_c.dylib库的exit函数和BackBoardServices的如下两个函数:

-[BKSHIDEventDeliveryManager _initForTestingWithService:] + 0

-[BKSHIDEventObserver init] + 0

然后在exit函数里面获取当前线程的堆栈,判断线程里面是否包含BackBoardServices相关库函数,如果包含,则判断是BackBoardServices调用exit,这时候直接在exit的函数里面直接调用_exit(0)方法。不走

exit(0)函数的清理流程,直接通过_exit(0),退出当前应用。

同理对BackBoardServices函数的hook也执行相应的逻辑。

同时添加降级方案,以及生效的版本范围,目前只对iOS18及以上的系统生效。

exit(0)和_exit(0)的区别:

总结:

_exit(0)会绕过清理的流程直接终结应用程序,是有一定风险,比如说不刷新I/O缓冲区、不关闭文件描述符,但这些iOS操作系统都会帮我们进行善后处理,因此可以放心。

B. 上线结果

上线之后发现压根不起作用,没有任何的埋点或者日志上报,崩溃堆栈里面也没有hook信息,显然是hook这几个方法没有生效,但我自测的时候,在debugrelease环境都是正常生效的, 。

经我这边搜集到的资料分析,给出的原因如下:

  • 我自测的时候,之所以在debugrelease进行hook系统库的函数,都能支持hook,并执行相关逻辑,主要原因是debugrelease是使用开发证书进行签名,构建的App包含调试权限(get-task-allow),可绕过部分系统保护。
  • AppStore上的线上包,之所以hook的相关代码没有效果,主要原因在于在 iOS 14+ 中引入的指针认证码(PAC)技术对函数指针的完整性验证具有严格的熔断机制。当 Hook 篡改指针触发 PAC 校验失败时,系统不会仅跳过相关代码的执行,而且触发了指令级熔断,将违规线程挂起,后续指令不再继续执行。
  • 这里判断是指令级熔断,而非其他失败处理的原因是司机可以正常使用App,没有反馈闪退和不可用等问题。

PAC 验证失败的处理机制

  1. 指令级熔断(CPU 硬件层) PAC 在 ARM 架构中由 CPU 硬件直接实现。当内核检测到被篡改的指针(如 Hook 修改的 objc_msgSendexit(0 函数指针)时:

    1. CPU 会立即抛出 EXC_BAD_ACCESS 异常(代码 0x8badf00d ),标记为 “指针完整性违规”
    2. 违规线程被强制挂起,后续指令不再执行。
  2. 内核级进程清除

    1. 内核捕获异常后,向违规进程注入 SIGKILL 信号(不可阻塞)。
    2. 进程内存被标记为 “污染状态” ,所有动态库卸载,线程栈销毁。
  3. 用户态响应

    1. 应用瞬间闪退,无崩溃日志(因日志系统未响应)。
    2. iOS 14+ 弹窗提示 “App 因安全问题终止”
  1. 方案二

由于第一种hook方案失败,我们继续探究,是否有方法可以阻止,C++全局变量对象/静态变量对象的析构函数的调用,理论上只有全局变量对象的析构函数在exit方法后,不调用,也就从中间环境拦截了这个崩溃。

A. [[clang::no_destroy]]探索

iOS 开发中,[[clang::no_destroy]]Clang 编译器提供的一个属性,用于控制变量的析构行为。以下是其核心特性和应用场景的总结:

  • 禁用析构函数调用

默认情况下,C++ 中的全局或静态变量会在程序退出时(如 main 函数结束或 exit 被调用)自动触发析构函数。而 [[clang::no_destroy]] 会显式禁止这一行为。

  • 适用对象类型

主要用于修饰 C++ 对象(如类实例、智能指针等),尤其适用于需要持久化至程序结束的全局状态(如单例、资源管理器等)。

  • 编译时控制

该属性在编译阶段生效,编译器会跳过生成析构函数的注册代码,而非运行时干预。

但这个方法的缺陷是必现要有源码,然后针对全局变量或者静态变量进行设置,但我们目前崩溃主要在三方库,没有相关源码,所以该方法暂时不适合。

B. -fno-c++-static-destructors探索

  • Xcode11以后是支持设置-fno-c++-static-destructors 标识来禁用所有的静态变量和全局变量的析构函数,

  • 配置编译设置

在 Xcode 项目中启用该标志的步骤:

打开项目:选择 Target → Build Settings → Apple Clang - Code Generation

添加编译标志:

Other C++ Flags(或 Other C Flags)中添加-fno-c++-static-destructors

默认情况下这个编译设置是关闭的,主要原因在于启用该选项是有一些副作用。

  • 资源泄漏风险

如果静态变量持有需要释放的资源(如动态内存、文件句柄、网络连接等),禁用析构会导致这些资源无法自动释放。例如:

static std::vector<int>* data = new std::vector<int>(); // 启用选项后,data 的析构函数不会调用,内存泄漏

  • 破坏 RAII 原则

C++RAII(资源获取即初始化)机制依赖析构函数实现自动资源管理。禁用静态析构会破坏这一机制,需手动管理资源,增加代码复杂度。

  • 跨平台兼容性问题

不同编译器对静态变量析构的实现可能不同,此选项可能引发不可预测的行为(如某些系统下全局对象析构顺序异常导致崩溃)。

  • 特定场景的未定义行为

若程序依赖静态变量析构完成关键操作(如日志写入、状态保存),禁用后可能导致逻辑错误或数据丢失。

虽然在编译设置中开启-fno-c++-static-destructors可能会产生一些副作用,但是在苹果操作系统里面,当进程退出的时候,即使没有通过析构函数去释放资源,苹果操作系统本身也会帮忙进行资源释放,比如操作系统会帮忙关闭文件、统一回收内存资源。

但这种方法也不起作用,原因在于第三方给过来的.framework的库,是按照他们项目编译设置出来的库,这个库不会跟随我们现在的项目编译设置,所以在我们项目里面加上这个-fno-c++-static-destructors,是无法影响到第三方库。

  1. 方案三(成功方案)

A. 治理方案

由于方案二也行不通,那是否有其他方法,能够在exit方法调用后,比C++全局变量的析构,更早执行呢?

经研究我们发现,可以通过atexit函数,注册程序正常终止时需要执行的清理函数。

atexit 允许注册一个无参数、无返回值的函数,该函数会在程序正常退出时(如调用 exit()main 函数返回时)按注册逆序执行。

atexit函数的注册是一个栈,也就是后注册的清理函数会先执行,atexit允许同一个方法,多次注册。

C++ 全局变量对象/静态变量对象之所以会在exit函数后,调用析构函数的原因是,编译器会为全局变量对象/静态变量对象隐式注册其析构函数到atexit队列里面。

因此我们必须保证业务侧atexit注册的清理函数在对应的C++的全局对象/静态全局对象的析构函数注册之后。

这里全局对象/静态全局对象,atexit注册时机是在main函数之前,而静态局部对象是在首次执行到定义的时候,去进行注册。

从崩溃堆栈或者我们自己调试来看,这里的posEngine::PosBridgeImpl::~PosBridgeImpl()析构函数的调用,只有在地图初始化的情况下,调用exit方法,才会调用这个析构,如果没有调用地图,直接调用exit方法,并不会调用这个析构函数。

因此我们可以推断这里是一个静态局部对象。

而另外一个被符号化的函数堆栈,经排查这个这个崩溃都是命中人脸识别,因此也咨询了人脸识别的三方,确认确实是人脸识别的库。

因为C++相关对象的初始化或者C++相关方法,无法进行hook,但我们可以确认这两个静态局部对象,都是在对应的地图或者人脸调用之后进行初始化的,因此我们hook了上层的对象(地图和人脸的相关对象),当对象被初始化之后,延迟一定的时间(时间可以配置)去注册atexit的清理函数(支持同一个函数多次注册),这样就保证我们注册的exit清理函数,一定会在C++静态局部对象的析构函数之前调用。

然后在我们注册的清理函数里面获取当前线程的堆栈,判断线程里面是否包含BackBoardServices相关库函数,如果包含,则判断是BackBoardServices调用exit,这时候直接在exit的函数里面直接调用_exit(0)方法。不走

exit(0)函数的清理流程,直接通过_exit(0),退出当前应用。

同时添加降级方案,以及生效的版本范围,目前只对iOS18及以上的系统生效。

B. 上线结果

上线之后,这个相关崩溃在新版本得到完全治理,同时并没有引起其他稳定性数据的劣化比如(卡顿、abort等),或者其他反馈问题。

证明该解决方法能有效治理该问题,且无负面影响。

当然最好是能让三方从源头解决问题。

四. 总结

以上主要介绍了针对这个崩溃分析和治理过程的探索和思考,当然最好的解决方法还是让第三方库的提供者,从源代码角度去解决这个问题。

若本文有错误之处或者技术上关于其他类型Crash的讨论交流的,欢迎评论区留言。

iOS 基础篇(一): char、int、long、NSInteger类型对比

1. 表格

类型 32位系统位宽 64位系统位宽 32位系统范围(十进制) 64位系统范围(十进制) 是否平台自适应 推荐使用场景
char 1字节 1字节 -128 ~ 127
(-2^7 ~ 2^7 - 1)
-128 ~ 127
(-2^7 ~ 2^7 - 1)
❌ 固定大小 ASCII字符/小整数
signed char 1字节 1字节 -128 ~ 127
(-2^7 ~ 2^7 - 1)
-128 ~ 127
(-2^7 ~ 2^7 - 1)
❌ 固定大小 明确有符号的小整数
unsigned char 1字节 1字节 0 ~ 255
(0 ~ 2^8 - 1)
0 ~ 255
(0 ~ 2^8 - 1)
❌ 固定大小 字节数据/无符号小整数
int 4字节 4字节 -2147483648 ~ 2147483647
(-2^31 ~ 2^31 - 1)
同32位系统范围 ❌ 固定大小 跨平台开发/C语言交互
long 4字节 8字节 -2147483648 ~ 2147483647
(-2^31 ~ 2^31 - 1)
-9223372036854775808 ~ 9223372036854775807
(-2^63 ~ 2^63 - 1)
❌ 需手动处理 需要极大整数的场景
NSInteger 4字节 8字节 -2147483648 ~ 2147483647
(-2^31 ~ 2^31 - 1)
-9223372036854775808 ~ 9223372036854775807
(-2^63 ~ 2^63 - 1)
✅ 自动适配 Cocoa API/iOS/macOS应用开发
NSUInteger 4字节 8字节 0 ~ 4294967295
(0 ~ 2^32 - 1)
0 ~ 18446744073709551615
(0 ~ 2^64 - 1)
✅ 自动适配 Cocoa API中的无符号整数

2. NSInteger / NSUInteger 源码 (编译时自动切换定义)

#if __LP64__ || NS_BUILD_32_LIKE_64
    typedef long NSInteger;
    typedef unsigned long NSUInteger;
#else
    typedef int NSInteger;
    typedef unsigned int NSUInteger;
#endif

3. NSInteger 类型可以提高可移植性的原因

在 Objective-C 开发中,优先使用 NSInteger 替代 int 或 long,以提高代码的可移植性。原因如下:

  1. 自动平台适配
    • 在32位系统上,NSInteger 被定义为 int(4字节)
    • 在64位系统上,NSInteger 被定义为 long(8字节)
    • 开发者无需编写条件编译代码即可适应不同架构。
  2. 数据完整性保障:
    • 在64位系统上,当整数值超过32位范围(>2,147,483,647)时,使用 int 会导致数据截断。NSInteger 在64位下使用64位存储,可安全处理大整数,避免数据丢失。
  3. 无缝API集成与代码简化
    • API兼容性:Cocoa/Cocoa Touch框架的方法参数和返回值(如数组的count、索引值)广泛使用NSInteger,直接使用该类型无需类型转换。
    • 减少条件编译:消除针对32/64位系统的条件判断代码,使代码更简洁。
    • 未来兼容:若Apple引入新架构(如128位),只需调整NSInteger的底层定义,现有代码无需修改。

SwiftUI的结果构建器ViewBuilder解释

当我们新建一个 iOS 项目时,会在 ContentView.swift 看到类似这样的代码:

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

我很好奇,为什么是这样的书写方式?Swift 是怎么创建视图的?于是我点击 VStack 进入源码,看到下面的代码。


💡 VStack 源码

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct VStack<Content> : View where Content : View {

    /// Creates an instance with the given spacing and horizontal alignment.
    ///
    /// - Parameters:
    ///   - alignment: The guide for aligning the subviews in this stack. This
    ///     guide has the same vertical screen coordinate for every subview.
    ///   - spacing: The distance between adjacent subviews, or `nil` if you
    ///     want the stack to choose a default distance for each pair of
    ///     subviews.
    ///   - content: A view builder that creates the content of this stack.
    @inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)

    /// The type of view representing the body of this view.
    ///
    /// When you create a custom view, Swift infers this type from your
    /// implementation of the required ``View/body-swift.property`` property.
    public typealias Body = Never
}

这里的初始化函数中定义了水平对齐方式、子视图之间的间距,还有一个闭包,闭包是初始化函数的最后一个参数,可以看到,示例将前面的参数默认不写,闭包写成了尾随闭包。但是,闭包前面还有一个 ViewBuilder 标记。标记有什么作用?


🧱 ViewBuilder 的作用

ViewBuilder 是结果构造器,可以将多个子视图构造成一个整体,

下面是源码:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@resultBuilder public struct ViewBuilder {

    /// Builds an expression within the builder.
    public static func buildExpression<Content>(_ content: Content) -> Content where Content : View

    /// Builds an empty view from a block containing no statements.
    public static func buildBlock() -> EmptyView

    /// Passes a single view written as a child view through unmodified.
    ///
    /// An example of a single view written as a child view is
    /// `{ Text("Hello") }`.
    public static func buildBlock<Content>(_ content: Content) -> Content where Content : View

    public static func buildBlock<each Content>(_ content: repeat each Content) -> TupleView<(repeat each Content)> where repeat each Content : View
}

⚙️ buildExpression 方法

buildExpression 方法负责处理闭包中的表达式。

下面有几个方法,前面两个 buildBlock 是视图为空和为一个视图的情况,最后的 buildBlock<each Content>(_ content: repeat each Content) 方法会将 content 中的多个视图构造成一个 TupleView。


🔎 其他辅助方法

另外还有三种语句:

✅ buildIf

public static func buildIf<Content>(_ content: Content?) -> Content? where Content : View

就是一个 if 判断。

VStack {
    if showTitle {
        Text("Title")
    }
}

✅ buildEither

public static func buildEither<TrueContent, FalseContent>(first: TrueContent) -> _ConditionalContent<TrueContent, FalseContent>
public static func buildEither<TrueContent, FalseContent>(second: FalseContent) -> _ConditionalContent<TrueContent, FalseContent>

其实就是条件语句 if else 和 switch,内部会通过 buildEither(first:)buildEither(second:) 把结果包装成 _ConditionalContent,然后在运行时根据条件选择。

示例:

VStack {
    if condition {
        Text("True")
    } else {
        Text("False")
    }
}

✅ buildLimitedAvailability

public static func buildLimitedAvailability<Content>(_ content: Content) -> AnyView where Content : View

可用性判断,方便根据不同版本显示不同视图。

示例:

if #available(iOS 15, *) {
    NewView()
} else {
    OldView()
}

🧑‍💻 自己实现的结果构建器

下面是我实现的一个结果构建器。用 @resultBuilder 标记的结构体就会告诉编译器这是一个结果构建器。当然,你也可以像官方一样在里面加入条件控制函数。

@resultBuilder
struct MyViewBuilder {
    static func buildBlock(_ components: some View...) -> [AnyView] {
        components.map { AnyView($0) }
    }
}

static func buildBlock(_ components: some View...) -> [AnyView]

  • buildBlock 是结果构建器必须实现的静态函数,用于把花括号里的视图「组合」起来。

  • components: some View...

    • 表示可以接受多个视图参数,数量不限制(就是变参)。
  • -> [AnyView]

    • 返回一个 AnyView 数组,原因是每个子视图具体类型可能不同,而 Swift 要求数组里元素同类型,所以用 AnyView 做类型擦除。

components.map { AnyView($0) }

  • 把所有传进来的视图都转换成 AnyView 并组成一个新数组返回。

📦 定义一个使用结果构建器的容器视图

struct CustomStack: View {
    let views: [AnyView]

    init(@MyViewBuilder content: () -> [AnyView]) {
        self.views = content()
    }

    var body: some View {
        VStack {
            ForEach(0..<views.count, id: .self) { index in
                views[index]
            }
        }
    }
}

使用示例

struct ContentView: View {
    var body: some View {
        CustomStack {
            Text("Hello")
                .font(.largeTitle)
            Divider()
            Image(systemName: "star.fill")
                .foregroundColor(.yellow)
            Text("This is a custom stack!")
        }
        .padding()
    }
}

🔁 添加条件控制

下面的示例代码添加了控制语句。

import SwiftUI

@resultBuilder
struct MyViewBuilder {
    static func buildBlock(_ components: [AnyView]...) -> [AnyView] {
        components.flatMap { $0 }
    }

    static func buildExpression(_ expression: some View) -> [AnyView] {
        [AnyView(expression)]
    }

    static func buildOptional(_ component: [AnyView]?) -> [AnyView] {
        component ?? []
    }

    static func buildEither(first component: [AnyView]) -> [AnyView] {
        component
    }

    static func buildEither(second component: [AnyView]) -> [AnyView] {
        component
    }

    static func buildArray(_ components: [[AnyView]]) -> [AnyView] {
        components.flatMap { $0 }
    }
}

结构体的代码不变

struct CustomStack: View {
    let views: [AnyView]

    init(@MyViewBuilder content: () -> [AnyView]) {
        self.views = content()
    }

    var body: some View {
        VStack {
            ForEach(0..<views.count, id: .self) { index in
                views[index]
            }
        }
        .padding()
        .border(Color.blue, width: 2)
    }
}

使用循环条件的视图

struct ContentView: View {
    @State private var showStars = true

    var body: some View {
        VStack(spacing: 20) {
            Button("Toggle Stars") {
                showStars.toggle()
            }

            CustomStack {
                Text("Hello, World!")
                    .font(.largeTitle)

                if showStars {
                    Image(systemName: "star.fill")
                        .foregroundColor(.yellow)
                    Text("Stars are visible ✨")
                } else {
                    Text("Stars are hidden 🌑")
                }

                ForEach(1..<4) { i in
                    Text("Item (i)")
                        .foregroundColor(.purple)
                }

                Text("End of CustomStack")
            }
        }
        .padding()
    }
}

💬 官方结果构建器示例

如果直接使用官方的结果构建器将会是这样的,这里我没有把闭包写出来。

struct Card<Content: View>: View {
    let content: Content

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    var body: some View {
        VStack {
            content
        }
        .padding()
        .background(Color.white)
        .cornerRadius(12)
        .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
    }
}

使用示例

struct ContentView: View {
    var body: some View {
        VStack(spacing: 20) {
            // ✅ 使用 Card 组件包裹
            Card(content:{ViewBuilder.buildBlock(
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundStyle(.tint),
                Text("Hello, world!")
                    .font(.headline),
                Text("欢迎来到卡片视图")
                    .font(.subheadline)
                    .foregroundColor(.gray)
                )
            })

            Card {
                Text("SwiftUI 自定义卡片")
                    .font(.title3)
                Image(systemName: "star.fill")
                    .foregroundColor(.yellow)
                Text("这是另一个示例")
            }
        }
        .padding()
        .background(Color(UIColor.systemGroupedBackground))
    }
}

⚡ 最后补充

计算属性 body 是每一个 View 都必须写的,计算属性不存储值,而是在需要的时候计算,SwiftUI 会根据 body 的定义来渲染内容,SwiftUI 会监听数据,每当数据发生变化时,就会重新调用 body,重新渲染。

由上面代码知道,初始化时会实现这个闭包,然后改变数据时,也会重新将视图添加进数组中成为一个整体。


⭐ 自定义结果构建器优势

自定义结果构建器的优势在于,它可以让我们根据自己的需求,灵活地定义视图的组合和构建逻辑。虽然官方在现有视图(比如 VStackHStack 等)中已经内置了强大的 ViewBuilder,并且也支持条件、循环等写法,我们平时直接使用就足够了。

但是,我们需要实现更复杂、更高度自定义的视图容器(例如一个表单 Form,需要对表单项进行分组、条件渲染,甚至动态插入额外内容),或者想要封装一个可重复使用、可扩展的「专用 DSL」时,自定义结果构建器就能发挥很大的优势。这不仅让代码更简洁声明式,也能让复杂布局逻辑变得更直观、更易维护。

SwiftUI研究:原生路由导航重构封装研究

一、思路来源

使用原生SwiftUI 导航时感觉特别难用,要在开始跳转的页面内返回被跳转页面。感觉代码耦合度更高了,就想尝试实现类似 flutter 中 GetX 的极简路由跳转方式,现在有了初步实现(还不完美),分享给大家。

设计思路:

  • 页面设计为 AppPage 持有 id,字符串路由,页面,参数等属性。使用之前注册到全局字典,可以根据路由在全局字典中查询页面。
  • 用一个路由管理单例类 Router 持有所有 NavigationPath,每次跳转页面时获取当前的 NavigationPath 然后根据路由查询设置参数进行跳转。

二、使用

ezgif.com-video-to-gif-converter.gif

Tab主页面
import SwiftUI

struct ContentView: View {

    @StateObject private var router = Router.shared

    @State private var showDrawer = false

  
    /// 隐藏 TabBar
    var hideTabBar: Visibility {
        let result = router.path.isEmpty
        return result ? .visible : .hidden
    }


    var body: some View {
        ZStack(alignment: .leading, content: {
            // 主界面内容
             buildTabView()
                .offset(x: showDrawer ? UIScreen.main.bounds.size.width * 0.75 : 0)
                .disabled(showDrawer) // 禁用主界面交互
                .animation(.easeInOut, value: showDrawer)
        })
    }

    func buildTabView() -> some View {
        return TabView(selection: $router.selectedTab) {
            NavigationStack(path: $router.path) {
                TabHomeView()
                    .navigationBarCustom(title: "首页", hideBack: true)
            }
            .toolbar(hideTabBar, for: .tabBar)
            .tabItem {
                Image(systemName: "house.fill")
                Text("首页")
            }
            .tag(0)

            NavigationStack(path: $router.path) {
                TabMessageView()
                    .navigationBarCustom(title: "消息", hideBack: true)
            }
            .toolbar(hideTabBar, for: .tabBar)
            .tabItem {
                Image(systemName: "message.fill")
                Text("发现")
            }
            .tag(1)

            NavigationStack(path: $router.path) {
                TabFindView()
                    .navigationBarCustom(title: "发现", hideBack: true)

            }
            .toolbar(hideTabBar, for: .tabBar)
            .tabItem {
                Image(systemName: "safari.fill")
                Text("发现")
            }
            .tag(2)

            NavigationStack(path: $router.path) {
                TabTestView()
                    .navigationBarCustom(title: "测试", hideBack: true)
            }

            .toolbar(hideTabBar, for: .tabBar)
            .tabItem {
                Image(systemName: "infinity.circle")
                Text("测试")
            }
            .tag(3)

            
            NavigationStack(path: $router.path) {
                TabProfileView()
                    .navigationBarCustom(title: "我的", hideBack: true)

            }
            .toolbar(hideTabBar, for: .tabBar)
            .tabItem {
                Image(systemName: "person.fill")
                Text("我的")
            }
            .tag(4)
        }
    }
}
第一个tab 的首页面
struct TabHomeView: View {
    var body: some View {
        return HomeView()
    }
}


// MARK: - RouterModel
struct RouterModel: Hashable {

    let name: String

    let route: String

    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
        hasher.combine(route)
    }

    

    static func == (lhs: RouterModel, rhs: RouterModel) -> Bool {
        return lhs.name == rhs.name && lhs.route == rhs.route
    }
}

struct HomeView: View {

    @StateObject private var router = Router.shared

    var items = AppRouter.pages.map({ e in
        return RouterModel(name: e.0, route: e.0)
    });

    let data: [String] = ["Item 1", "Item 2", "Item 3", "Item 4"]
    let avatar: String = "https://yl-prescription-share.oss-cn-beijing.aliyuncs.com/test/message/document/1737078705/im/msg/rec/651722301611577344.jpg";

    var body: some View {
        NavigationStack(path: $router.path) {
            List {
                SystemItemsSection()
                CustomItemsSection()
                Button("Button") {
                    router.toNamed(AppRouter.detail)
                }
            }
            .listStyle(GroupedListStyle())
            .navigationTitle("\(clsName)")
            .navigationDestination(for: AppPage<AnyView>.self) { page in
                page.makeView()

            }
        }
    }

    // MARK: - Subviews
    private func SystemItemsSection() -> some View {
        Section(header: Text("页面").font(.headline)) {
            ForEach(items, id: \.self) { item in
                RouterItemView(item: item, avatar: avatar)
                    .onTapGesture {
                        DDLog("onTapGesture")
                        router.toNamed(item.route)
                    }
            }
        }
    }

   
    private func CustomItemsSection() -> some View {
        Section(header: Text("自定义").font(.headline)) {
            CustomOneCell(showArrow: false) {
                Text("CustomOneCell")
            } detail: {
                Text("Subtitle")
                    .font(.subheadline)
                    .foregroundColor(.gray)
            }
        }
    }
}

  
// MARK: - RouterItemView
struct RouterItemView: View {

    let item: RouterModel

    let avatar: String

    @StateObject private var router = Router.shared

    var body: some View {
        ListItemView(
            avatar: avatar,
            isTitleRightHide: true,
            isSubtitleRightHide: true,
            title: {
                Text(item.name)
            },
            titleRight: {
                Text("titleRight")
                    .font(.body)
            },
            subtitle: {
                Text("")
                    .font(.body)
            },
            subtitleRight: {
                Text("subtitleRight")
                    .font(.body)
            }
        )
        .onTapGesture {
            router.toNamed(item.route)
        }
    }
}
DetailView详情页
struct DetailView: View {

    @StateObject private var router = Router.shared

//    init(path: NavigationPath = NavigationPath()) {
//        self.path = path
//    }

    var body: some View {
        NavigationStack(path: $router.path) {
            VStack(alignment: .leading, content: {
                Button("Router导航") {
                    let router = Router.shared
                    router.toNamed(AppRouter.detail)
                }

                Button( "SwiftUINavigator 导航") {
                    let navigator = SwiftUINavigator(path: $router.path)
                    navigator.push(AnyView(TabTestView()))
                }

                Button {
                    router.toNamed(AppRouter.detail)
                } label: {
                    Text("Button")
                }
            })
            .padding()
            .navigationBarCustom(title: "\(clsName)")
  //            .navigationDestination(for: AppPage<AnyView>.self) { page in
  //                page.makeView()
  //            }
           }
    }
}

三、路由源码

import SwiftUI

  
// 路由
@MainActor class AppRouter {

    static let home = "/"

    static let settings = "/settings"

    static let profile = "/profile"

    static let detail = "/detail"

    static let imageViewer = "/imageViewer"

    static let discovery = "/discovery"

    static let discoveryDetail = "/discovery/detail"

    static let collection = "/collection"

    static let notification = "/notification"

    static let editProfile = "/profile/edit"

    

    // 系统相关组件

    static let animatePage = "/animate"

    static let component = "/component"

    static let customeModifier = "/customeModifier"

    static let dynamicContent = "/dynamicContent"

    static let geometryReader = "/geometryReader"

    static let gesture = "/gesture"

    static let nav = "/nav"

    static let imageGalleryDemo = "/imageGallery"

    

    // 自定义组件

    static let wrap = "/wrap"

    static let circleLayout = "/circleLayout"

    static let pager = "/pager"

    static let unknow = "/unknow"

    static let custom = "/custom"

    static let test = "/test"

    static let pickerViewPage = "/pickerViewPage"

  


    // 第三方

    static let notificationBannerView = "/notificationBannerView"

    static let fileHelperDemo = "/FileHelperDemo"

    static let modelCodablePage = "/modelCodablePage"

    static let locationDemo = "/locationDemo"

    // 新增

    static let dataTypeDemo = "/dataTypeDemo"


    /// 路由

    static let pages: [(String, ([String: Any]) -> AnyView, ([String: Any]) -> String)] = [

        (AppRouter.home, { _ in AnyView(HomeView()) }, { _ in "首页" }),

        (AppRouter.settings, { _ in AnyView(SettingsView()) }, { _ in "设置" }),

        (AppRouter.profile, { _ in AnyView(ProfileView()) }, { _ in "个人中心" }),

        (AppRouter.detail, { args in AnyView(DetailView()) }, { args in args["title"] as? String ?? "详情" }),

        (AppRouter.imageViewer, { args in

            let images = args["images"] as? [String] ?? []

            let selectedIndex = args["selectedIndex"] as? Int ?? 0

            let isPresented = args["isPresented"] as? Bool ?? true

            return AnyView(NImagePreviewer(

                images: images,

                selectedIndex: selectedIndex,

                isPresented: .constant(isPresented)

            ))

        }, { args in

            let selectedIndex = args["selectedIndex"] as? Int ?? 0

            let images = args["images"] as? [String] ?? []

            return "\(selectedIndex + 1)/\(images.count)"

        }),

        (AppRouter.discovery, { _ in AnyView(Text("发现页面")) }, { _ in "发现" }),

        (AppRouter.discoveryDetail, { args in

            AnyView(Text(args["title"] as? String ?? "发现详情"))

        }, { args in args["title"] as? String ?? "发现详情" }),

        (AppRouter.collection, { _ in AnyView(Text("我的收藏")) }, { _ in "我的收藏" }),

        (AppRouter.notification, { _ in AnyView(Text("消息通知")) }, { _ in "消息通知" }),

        (AppRouter.editProfile, { _ in AnyView(Text("编辑个人资料")) }, { _ in "编辑个人资料" }),

        

        // 系统相关组件

        (AppRouter.animatePage, { _ in AnyView(AnimatePageView()) }, { _ in "动画页面" }),

        (AppRouter.component, { _ in AnyView(ComponentView()) }, { _ in "组件" }),

        (AppRouter.customeModifier, { _ in AnyView(CustomeModifierView()) }, { _ in "自定义修饰符" }),

        (AppRouter.dynamicContent, { _ in AnyView(DynamicContentView()) }, { _ in "动态内容" }),

        (AppRouter.geometryReader, { _ in AnyView(GeometryReaderView()) }, { _ in "几何阅读器" }),

        (AppRouter.gesture, { _ in AnyView(GestureView()) }, { _ in "手势" }),

        (AppRouter.nav, { _ in AnyView(NavView()) }, { _ in "导航" }),

        

        // 自定义组件

        (AppRouter.wrap, { _ in AnyView(WrapDemo()) }, { _ in "Wrap示例" }),

        (AppRouter.circleLayout, { _ in AnyView(CircleLayoutDemo()) }, { _ in "Circle示例" }),

        (AppRouter.pager, { _ in AnyView(PagerViewDemo()) }, { _ in "分页视图" }),

        (AppRouter.unknow, { _ in AnyView(UnknowView()) }, { _ in "未知页面" }),

        (AppRouter.custom, { _ in AnyView(CustomView()) }, { _ in "自定义视图" }),

        (AppRouter.test, { _ in AnyView(TabTestView()) }, { _ in "测试页面" }),

        (AppRouter.imageGalleryDemo, { _ in AnyView(ImageGalleryDemo()) }, { _ in "图片画廊" }),

        (AppRouter.notificationBannerView, { _ in AnyView(NotificationBannerView()) }, { _ in "导航栏通知" }),

        (AppRouter.pickerViewPage, { _ in AnyView(PickerViewPage()) }, { _ in "选择" }),

        (AppRouter.fileHelperDemo, { _ in AnyView(FileHelperDemo()) }, { _ in "文件选择" }),

  


        (AppRouter.modelCodablePage, { _ in AnyView(ModelCodablePage()) }, { _ in "模型解析" }),

        (AppRouter.locationDemo, { _ in AnyView(LocationDemo()) }, { _ in "地图功能" }),

        (AppRouter.dataTypeDemo, { _ in AnyView(DataTypeDemo()) }, { _ in "数据类型" }),

    ]
}

  

class AppPage<T: View>: Hashable {

    let id = UUID()

    let route: String

    let title: String

    let viewBuilder: ([String: Any]) -> T

    var arguments: [String: Any]
   
    init(route: String, title: String, view: @escaping ([String: Any]) -> T, arguments: [String: Any] = [:]) {
        self.route = route
        self.title = title
        self.viewBuilder = view
        self.arguments = arguments
    }

    func makeView() -> AnyView {
        AnyView(viewBuilder(arguments))
    }

    static func == (lhs: AppPage<T>, rhs: AppPage<T>) -> Bool {
        lhs.id == rhs.id
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

// 路由注册表
@MainActor class RouteRegistry {

    @MainActor static let shared = RouteRegistry()

    private var routes: [String: ([String: Any]) -> AppPage<AnyView>] = [:]

    private init() {
        registerPages(pages: AppRouter.pages)
    }

    func register<T: View>(route: String, builder: @escaping ([String: Any]) -> AppPage<T>) {

        routes[route] = { args in
            let page = builder(args)
            return AppPage(
                route: page.route,
                title: page.title,
                view: { args in AnyView(page.viewBuilder(args)) },
                arguments: args
            )
        }
    }

    func page(for route: String, arguments: [String: Any] = [:]) -> AppPage<AnyView>? {
        return routes[route]?(arguments)
    }

    /// 注册
    private func registerPages(pages: [(String, ([String: Any]) -> AnyView, ([String: Any]) -> String)]) {

        // 注册所有页面
        for (route, viewBuilder, titleBuilder) in pages {
            register(route: route) { args in
                AppPage(
                    route: route,
                    title: titleBuilder(args),
                    view: { _ in viewBuilder(args) }
                )
            }
        }
    }
}

// 路由中间件协议
protocol RouterMiddleware {
    func redirect<T: View>(_ page: AppPage<T>) -> AppPage<T>?
}

// 路由管理器
@MainActor class Router: ObservableObject {

    @MainActor static let shared = Router()

    private init() {}

   
    @Published var selectedTab: Int = 0

    @Published var pathTabs = [
        NavigationPath(),
        NavigationPath(),
        NavigationPath(),
        NavigationPath(),
        NavigationPath(),
    ]

    @Published var isPresented: Bool = false
    
    private var middlewares: [RouterMiddleware] = []

    @Published var historyTabs: [[AppPage<AnyView>]] = [[], [], [], [], []]

    /// 当前tab导航
    var path: NavigationPath {
        get {
            return pathTabs[selectedTab]
        }
        set {
            pathTabs[selectedTab] = newValue
        }
    }

    /// 当前tab历史
    var historys: [AppPage<AnyView>] {
        get {
            return historyTabs[selectedTab]
        }
        set {
            historyTabs[selectedTab] = newValue
        }
    }

    var routes: [String] {
        return historys.map({ e in
            return e.route
        });
    }

    var routeNames: [String] {
        return historys.map({ e in
            return String("\(e.route)".split(separator: ".").last ?? "");
        });
    }

    // 添加中间件
    func addMiddleware(_ middleware: RouterMiddleware) {
        middlewares.append(middleware)
    }

    // 通过路由导航
    func toNamed(_ route: String, arguments: [String: Any] = [:]) {
        guard let page = RouteRegistry.shared.page(for: route, arguments: arguments) else {
            print("⚠️ Route not found: \(route)")
            return
        }

        

        // 执行中间件
        var finalPage = page
        for middleware in middlewares {
            if let redirected = middleware.redirect(page) {
                finalPage = redirected
                break
            }
        }

        withAnimation {
            isPresented = false
            historys.append(finalPage)
            path.append(finalPage)
            log(prefix: "push >>> ")  
            withAnimation {
                isPresented = true
            }
        }
    }

    // 返回上一页
    func back(count: Int = 1) {
        withAnimation {
            isPresented = false
            if historys.count >= count {
                historys.removeLast(count)
                path.removeLast(count)
            }
            log(prefix: "pop >>> ")
            withAnimation {
                isPresented = true
            }
        }
    }

    // 返回到根页面
    func backToRoot() {
        back(count: self.path.count)
    }

    // 获取当前页面
    var currentPage: AppPage<AnyView>? {
        return historys.last
    }

    // 获取当前参数
    var currentArgs: [String: Any]? {
        currentPage?.arguments
    }

    func log(prefix: String = ""){
        let tmps = pathTabs.map { p in
            if let i = pathTabs.firstIndex(of: p) {
                return "\(i)_\(p.count)"
            }
            return ""
        }
        DDLog("\(prefix) path: \(tmps.joined(separator: ",")), routes: \(routeNames)")
    }
}

extension View {

    func navigationBarCustom(title: String, titleColor: Color = .primary, hideBack: Bool = false, onBack: (() -> Void)? = nil) -> some View {
        modifier(NavigationBarModifier(title: title, titleColor: titleColor, hideBack: hideBack, onBack: onBack))
    }

    func scaleTransitionCustom(isPresented: Bool) -> some View {
        modifier(ScaleTransition(isPresented: isPresented))
    }
}

// 视图修饰器,用于添加导航标题和返回按钮

struct NavigationBarModifier: ViewModifier {

    let title: String

    let titleColor: Color

    let hideBack: Bool

    let onBack: (() -> Void)?

    @Environment(\.dismiss) private var dismiss

    @ObservedObject private var router = Router.shared

    init(title: String, titleColor: Color = .primary, hideBack: Bool = false, onBack: (() -> Void)? = nil) {
        self.title = title
        self.titleColor = titleColor
        self.hideBack = hideBack
        self.onBack = onBack
    }

    func body(content: Content) -> some View {
        content
            .navigationTitle(title)
            .navigationBarTitleDisplayMode(.inline)
//            .toolbarColorScheme(.dark, for: .navigationBar)
//            .toolbarBackground(.black, for: .navigationBar)
//            .toolbarBackground(.visible, for: .navigationBar)
            .navigationDestination(for: AppPage<AnyView>.self) { page in
                page.makeView()
            }
            .navigationBarBackButtonHidden(true)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    if !hideBack {
                        Button {
                            onBack?() ?? router.back()
                        } label: {
                            Image(systemName: "chevron.left")
                                .foregroundColor(titleColor)
                        }
                    }
                }
            }
            .foregroundColor(titleColor)
    }
}

// 自定义转场效果

struct ScaleTransition: ViewModifier {

    let isPresented: Bool

    func body(content: Content) -> some View {
        content
            .scaleEffect(isPresented ? 1 : 0.95)
            .opacity(isPresented ? 1 : 0)
            .animation(.spring(response: 0.35, dampingFraction: 1), value: isPresented)
    }
}

四、最后

目前只是初步实现,但是近期技术方向偏向 flutter,先记录一下。

github

iOS26适配指南之UITabBarController

介绍

  • 增加了类型为UITabBarController.MinimizeBehaviortabBarMinimizeBehavior属性,用于设置 Tabbar 最小化时的行为。
  • 增加了类型为UITabAccessorybottomAccessory属性,用于在 Tabbar 的上方再添加一个 UITabAccessory(辅助内容)。
  • UISearchTab 会从 TabBar 分离出来单独显示。

使用

  • 代码。
import UIKit

// MARK: - 自定义UITabBarController
class TabBarController: UITabBarController {
    override func viewDidLoad() {
        super.viewDidLoad()

        tabs.append(configTab(UIViewController(), title: "微信", imageName: "message", identifier: "chats", badgeValue: "3"))
        tabs.append(configTab(UIViewController(), title: "通讯录", imageName: "person.2", identifier: "contacts"))
        tabs.append(configTab(UIViewController(), title: "发现", imageName: "safari", identifier: "discover"))
        tabs.append(configTab(UIViewController(), title: "我", imageName: "person", identifier: "me"))
        tabs.append(configSearchTab(UIViewController(), title: "搜索"))
        selectedTab = tabs.last
        // iOS26新增,向下滚动时,只显示第一个与UISearchTab的图标,中间显示辅助UITabAccessory
        self.tabBarMinimizeBehavior = .onScrollDown
        // iOS26新增
        self.bottomAccessory = UITabAccessory(contentView: UIToolbar())
    }

    // MARK: 设置UITab
    func configTab(_ viewController: UIViewController,
                   title: String,
                   imageName: String,
                   identifier: String,
                   badgeValue: String? = nil) -> UITab {
        let tab = UITab(title: title, image: UIImage(systemName: imageName), identifier: identifier) { tab in
            tab.badgeValue = badgeValue
            tab.userInfo = identifier
            let scrollView = UIScrollView(frame: UIScreen.main.bounds)
            scrollView.backgroundColor = .init(red: .random(in: 0 ... 1), green: .random(in: 0 ... 1), blue: .random(in: 0 ... 1), alpha: 1.0)
            scrollView.contentSize = CGSize(width: UIScreen.main.bounds.width, height: 1500)
            viewController.view.addSubview(scrollView)
            return self.configViewController(viewController: viewController, title: title)
        }
        return tab
    }

    // MARK: 设置UISearchTab
    func configSearchTab(_ viewController: UIViewController, title: String) -> UISearchTab {
        // UISearchTab,从TabBar分离出来单独显示
        let searchTab = UISearchTab { tab in
            viewController.view.backgroundColor = .init(red: .random(in: 0 ... 1), green: .random(in: 0 ... 1), blue: .random(in: 0 ... 1), alpha: 1.0)
            return self.configViewController(viewController: viewController, title: title)
        }
        return searchTab
    }

    // MARK: 设置UIViewController
    func configViewController(viewController: UIViewController, title: String) -> UINavigationController {
        let navigationController = UINavigationController(rootViewController: viewController)
        viewController.navigationItem.title = title
        return navigationController
    }
}
  • 效果。

UITabBarController.gif

❌