阅读视图

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

15-错误处理

Swift 错误处理 (Error Handling)

📚 目录

📖 内容概述

错误处理是Swift编程语言中的重要特性,它提供了一种优雅的方式来处理运行时可能出现的错误情况。Swift的错误处理机制基于抛出、捕获、传播和操作可恢复错误。

🔍 详细内容

错误处理基础

错误的概念

Swift中的错误处理涉及四个关键字:

  • throw:抛出错误
  • throws:标记可能抛出错误的函数
  • try:调用可能抛出错误的函数
  • catch:捕获并处理错误

错误类型

开发过程中的常见错误
// 1. 语法错误(编译时报错)
// let x = 10 +  // 语法错误,会在编译时发现

// 2. 逻辑错误(程序运行但结果不正确)
func add(a: Int, b: Int) -> Int {
    return a * b  // 逻辑错误:应该是加法但写成了乘法
}

// 3. 运行时错误(可能导致程序崩溃)
// let array = [1, 2, 3]
// let item = array[10]  // 运行时错误:数组越界

自定义错误

定义错误类型
// 使用枚举定义错误类型
enum ValidationError: Error {
    case emptyString
    case tooShort(minimum: Int)
    case tooLong(maximum: Int)
    case invalidFormat
}

enum FileError: Error {
    case notFound
    case permissionDenied
    case corrupted
    case networkError(String)
}

enum MathError: Error {
    case divisionByZero
    case negativeSquareRoot
    case overflow
    case underflow
}
使用结构体定义错误
struct CustomError: Error {
    let code: Int
    let message: String
    let underlyingError: Error?
    
    init(code: Int, message: String, underlyingError: Error? = nil) {
        self.code = code
        self.message = message
        self.underlyingError = underlyingError
    }
}

抛出错误

基本抛出
func divide(_ numerator: Int, by denominator: Int) throws -> Double {
    if denominator == 0 {
        throw MathError.divisionByZero
    }
    return Double(numerator) / Double(denominator)
}

func validatePassword(_ password: String) throws -> Bool {
    if password.isEmpty {
        throw ValidationError.emptyString
    }
    
    if password.count < 6 {
        throw ValidationError.tooShort(minimum: 6)
    }
    
    if password.count > 20 {
        throw ValidationError.tooLong(maximum: 20)
    }
    
    return true
}
复杂错误处理
func processFile(at path: String) throws -> String {
    // 检查文件是否存在
    guard FileManager.default.fileExists(atPath: path) else {
        throw FileError.notFound
    }
    
    // 检查文件权限
    guard FileManager.default.isReadableFile(atPath: path) else {
        throw FileError.permissionDenied
    }
    
    // 读取文件内容
    do {
        let content = try String(contentsOfFile: path)
        return content
    } catch {
        throw FileError.corrupted
    }
}

处理错误

基本错误处理
func testDivision() {
    do {
        let result = try divide(10, by: 2)
        print("结果: \(result)")
    } catch MathError.divisionByZero {
        print("错误: 除数不能为零")
    } catch {
        print("其他错误: \(error)")
    }
}

do-catch语句

详细的错误捕获
func handleValidation() {
    do {
        try validatePassword("123")
        print("密码验证通过")
    } catch ValidationError.emptyString {
        print("密码不能为空")
    } catch ValidationError.tooShort(let minimum) {
        print("密码太短,至少需要 \(minimum) 个字符")
    } catch ValidationError.tooLong(let maximum) {
        print("密码太长,最多允许 \(maximum) 个字符")
    } catch ValidationError.invalidFormat {
        print("密码格式不正确")
    } catch {
        print("未知错误: \(error)")
    }
}
多个错误条件处理
func processMultipleOperations() {
    do {
        let result1 = try divide(10, by: 2)
        let result2 = try divide(20, by: 4)
        let finalResult = result1 + result2
        print("最终结果: \(finalResult)")
    } catch MathError.divisionByZero {
        print("除法错误:除数为零")
    } catch {
        print("操作失败: \(error)")
    }
}
错误类型判断
func handleFileOperation() {
    do {
        let content = try processFile(at: "/path/to/file.txt")
        print("文件内容: \(content)")
    } catch let error as FileError {
        switch error {
        case .notFound:
            print("文件不存在")
        case .permissionDenied:
            print("没有文件读取权限")
        case .corrupted:
            print("文件已损坏")
        case .networkError(let message):
            print("网络错误: \(message)")
        }
    } catch {
        print("其他错误: \(error)")
    }
}

try?和try!

try? - 可选的错误处理
func safeOperation() {
    // try? 将错误转换为可选值
    let result1 = try? divide(10, by: 2)  // Optional(5.0)
    let result2 = try? divide(10, by: 0)  // nil
    
    print("结果1: \(result1 ?? 0)")
    print("结果2: \(result2 ?? 0)")
}

// try? 等价于以下代码
func equivalentOperation() {
    var result: Double?
    do {
        result = try divide(10, by: 2)
    } catch {
        result = nil
    }
    print("结果: \(result ?? 0)")
}
try! - 强制错误处理
func forcedOperation() {
    // try! 假设操作不会失败,如果失败则程序崩溃
    let result = try! divide(10, by: 2)  // 5.0
    print("结果: \(result)")
    
    // 危险的用法 - 如果失败会导致程序崩溃
    // let badResult = try! divide(10, by: 0)  // 运行时崩溃
}

defer语句

清理资源
func readFile(fileName: String) throws -> String {
    let file = FileHandle(forReadingAtPath: fileName)
    defer {
        file?.closeFile()
        print("文件已关闭")
    }
    
    guard let file = file else {
        throw FileError.notFound
    }
    
    let data = file.readDataToEndOfFile()
    return String(data: data, encoding: .utf8) ?? ""
}
多个defer语句
func complexOperation() throws {
    print("开始复杂操作")
    
    defer {
        print("清理操作1")
    }
    
    defer {
        print("清理操作2")
    }
    
    defer {
        print("清理操作3")
    }
    
    // 模拟一些操作
    throw ValidationError.emptyString
}

// 输出顺序:
// 开始复杂操作
// 清理操作3
// 清理操作2
// 清理操作1

错误传播

错误向上传播
func lowLevelOperation() throws -> String {
    throw ValidationError.emptyString
}

func midLevelOperation() throws -> String {
    return try lowLevelOperation()
}

func highLevelOperation() throws -> String {
    return try midLevelOperation()
}

func handlePropagation() {
    do {
        let result = try highLevelOperation()
        print("操作成功: \(result)")
    } catch {
        print("操作失败: \(error)")
    }
}
错误转换
func convertError() throws -> String {
    do {
        return try lowLevelOperation()
    } catch ValidationError.emptyString {
        throw CustomError(code: 100, message: "输入验证失败")
    }
}

实践示例

网络请求错误处理
enum NetworkError: Error {
    case noConnection
    case serverError(Int)
    case invalidResponse
    case decodingError
}

class NetworkManager {
    func fetchData(from url: URL) throws -> Data {
        // 模拟网络请求
        let isConnected = true
        let statusCode = 200
        
        guard isConnected else {
            throw NetworkError.noConnection
        }
        
        guard statusCode == 200 else {
            throw NetworkError.serverError(statusCode)
        }
        
        return Data()
    }
    
    func fetchUserData(userId: Int) throws -> User {
        let url = URL(string: "https://api.example.com/users/\(userId)")!
        let data = try fetchData(from: url)
        
        do {
            return try JSONDecoder().decode(User.self, from: data)
        } catch {
            throw NetworkError.decodingError
        }
    }
}

struct User: Codable {
    let id: Int
    let name: String
}
表单验证错误处理
class FormValidator {
    func validateEmail(_ email: String) throws -> Bool {
        if email.isEmpty {
            throw ValidationError.emptyString
        }
        
        if !email.contains("@") {
            throw ValidationError.invalidFormat
        }
        
        return true
    }
    
    func validateForm(email: String, password: String) -> [Error] {
        var errors: [Error] = []
        
        do {
            try validateEmail(email)
        } catch {
            errors.append(error)
        }
        
        do {
            try validatePassword(password)
        } catch {
            errors.append(error)
        }
        
        return errors
    }
}

最佳实践

1. 错误类型设计
// 好的做法:使用枚举定义相关错误
enum DatabaseError: Error {
    case connectionFailed
    case queryFailed(String)
    case dataCorrupted
    case timeout
}

// 避免:使用通用错误类型
// struct GenericError: Error { let message: String }
2. 错误信息
extension ValidationError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .emptyString:
            return "输入不能为空"
        case .tooShort(let minimum):
            return "输入长度不能少于\(minimum)个字符"
        case .tooLong(let maximum):
            return "输入长度不能超过\(maximum)个字符"
        case .invalidFormat:
            return "输入格式不正确"
        }
    }
}
3. 错误处理策略
// 策略1:立即处理错误
func immediateHandling() {
    do {
        let result = try divide(10, by: 0)
        print("结果: \(result)")
    } catch {
        print("发生错误,使用默认值")
        let defaultResult = 0.0
        print("结果: \(defaultResult)")
    }
}

// 策略2:传播错误
func propagateError() throws {
    let result = try divide(10, by: 0)
    print("结果: \(result)")
}

// 策略3:转换为可选值
func optionalHandling() {
    let result = try? divide(10, by: 0)
    print("结果: \(result ?? 0)")
}

📝 重要提示

  1. 错误类型:实现 Error 协议,通常使用枚举
  2. 函数声明:使用 throws 标记可能抛出错误的函数
  3. 错误抛出:使用 throw 关键字抛出错误
  4. 错误处理:使用 trydo-catch 语句处理错误
  5. 资源清理:使用 defer 语句确保资源被正确释放
  6. 错误传播:错误会自动向上传播直到被捕获

🎯 总结

Swift的错误处理机制提供了一种类型安全且表达力强的方式来处理运行时错误。通过合理使用错误处理,我们可以:

  • 编写更健壮的代码
  • 提供更好的用户体验
  • 更容易调试和维护代码
  • 避免程序意外崩溃

掌握错误处理是编写高质量Swift代码的重要技能。


本文档基于Swift 5.0+版本,涵盖了错误处理的核心概念和最佳实践。

9.方法

方法

目录

  1. 实例方法
  2. self的使用
  3. mutating方法
  4. @discardableResult
  5. 类型方法

实例方法

实例方法(Instance Method)是属于特定类、结构体或枚举的实例的方法。

基本语法

struct Counter {
    var count = 0
    
    // 实例方法
    func increment() {
        count += 1
    }
    
    func increment(by amount: Int) {
        count += amount
    }
    
    func reset() {
        count = 0
    }
}

var counter = Counter()
counter.increment()
counter.increment(by: 5)
counter.reset()

实例方法的特点

  • 实例方法只能被类型的实例调用
  • 实例方法可以访问实例的属性和其他实例方法
  • 实例方法在调用时会自动获得该实例的引用

self的使用

self属性是每个实例隐式拥有的属性,完全等同于该实例本身。

基本用法

struct Point {
    var x = 0.0, y = 0.0
    
    func isToTheRightOf(x: Double) -> Bool {
        return self.x > x  // 区分参数x和属性x
    }
}

let point = Point(x: 4.0, y: 5.0)
print(point.isToTheRightOf(x: 1.0))  // true

何时使用self

通常情况下,不需要显式地写出self,Swift会自动推断。但在以下情况下需要使用:

  1. 参数名与属性名相同时
  2. 闭包中访问实例属性时
  3. 方法返回self时
struct Calculator {
    var result: Double = 0
    
    func add(_ value: Double) -> Calculator {
        result += value
        return self  // 返回自身,支持链式调用
    }
    
    func multiply(_ value: Double) -> Calculator {
        result *= value
        return self
    }
}

let calculator = Calculator()
let result = calculator.add(5).multiply(2).result  // 10

mutating方法

值类型(结构体、枚举)的实例方法默认不能修改实例的属性。如果需要修改,必须使用mutating关键字。

基本语法

struct Point {
    var x = 0.0, y = 0.0
    
    mutating func moveBy(x deltaX: Double, y deltaY: Double) {
        x += deltaX
        y += deltaY
    }
}

var point = Point(x: 1.0, y: 1.0)
point.moveBy(x: 2.0, y: 3.0)
print(point)  // Point(x: 3.0, y: 4.0)

mutating方法的特点

  • 只有值类型(结构体、枚举)需要mutating关键字
  • 类的实例方法不需要mutating关键字
  • mutating方法可以为self赋予一个全新的实例
struct Point {
    var x = 0.0, y = 0.0
    
    mutating func moveToOrigin() {
        self = Point(x: 0.0, y: 0.0)
    }
}

枚举中的mutating方法

enum TriStateSwitch {
    case off, low, high
    
    mutating func next() {
        switch self {
        case .off:
            self = .low
        case .low:
            self = .high
        case .high:
            self = .off
        }
    }
}

var lightSwitch = TriStateSwitch.low
lightSwitch.next()  // .high
lightSwitch.next()  // .off

@discardableResult

@discardableResult特性用于标记那些有返回值但调用者可以忽略返回值的方法。

基本用法

struct Stack<T> {
    var items: [T] = []
    
    mutating func push(_ item: T) {
        items.append(item)
    }
    
    @discardableResult
    mutating func pop() -> T? {
        return items.popLast()
    }
}

var stack = Stack<Int>()
stack.push(1)
stack.push(2)

// 不使用@discardableResult会产生警告
let popped = stack.pop()  // 使用返回值
stack.pop()              // 忽略返回值,不会产生警告

什么时候使用@discardableResult

  • 方法既可以用于获取值,也可以用于执行操作
  • 调用者有时关心返回值,有时不关心
  • 避免编译器产生"未使用的返回值"警告
class Logger {
    @discardableResult
    func log(_ message: String) -> String {
        let timestamp = Date().description
        let logEntry = "[\(timestamp)] \(message)"
        print(logEntry)
        return logEntry
    }
}

let logger = Logger()
logger.log("Error occurred")        // 忽略返回值
let entry = logger.log("Info log")  // 使用返回值

类型方法

类型方法(Type Method)是属于类型本身的方法,而不是属于类型的某个实例。

基本语法

struct MathUtils {
    // 类型方法
    static func abs(_ number: Int) -> Int {
        return number < 0 ? -number : number
    }
    
    static func max(_ a: Int, _ b: Int) -> Int {
        return a > b ? a : b
    }
}

// 调用类型方法
let result1 = MathUtils.abs(-10)  // 10
let result2 = MathUtils.max(5, 8) // 8

static vs class

  • static:不能被子类重写
  • class:可以被子类重写(仅限于类)
class Vehicle {
    static func staticMethod() {
        print("Vehicle static method")
    }
    
    class func classMethod() {
        print("Vehicle class method")
    }
}

class Car: Vehicle {
    // 不能重写static方法
    // override static func staticMethod() { }  // 编译错误
    
    // 可以重写class方法
    override class func classMethod() {
        print("Car class method")
    }
}

Vehicle.staticMethod()  // Vehicle static method
Car.staticMethod()      // Vehicle static method

Vehicle.classMethod()   // Vehicle class method
Car.classMethod()       // Car class method

类型方法的实际应用

struct Temperature {
    var celsius: Double
    
    init(celsius: Double) {
        self.celsius = celsius
    }
    
    // 类型方法:工厂方法
    static func fromFahrenheit(_ fahrenheit: Double) -> Temperature {
        return Temperature(celsius: (fahrenheit - 32) * 5/9)
    }
    
    static func fromKelvin(_ kelvin: Double) -> Temperature {
        return Temperature(celsius: kelvin - 273.15)
    }
}

let temp1 = Temperature(celsius: 25.0)
let temp2 = Temperature.fromFahrenheit(77.0)
let temp3 = Temperature.fromKelvin(298.15)

类型方法中的self

在类型方法中,self指向类型本身:

struct Counter {
    static var count = 0
    
    static func increment() {
        self.count += 1  // self指向Counter类型
    }
    
    static func reset() {
        count = 0  // 可以省略self
    }
}

Counter.increment()
print(Counter.count)  // 1
Counter.reset()
print(Counter.count)  // 0

总结

方法类型对比

方法类型 调用方式 访问范围 修改实例 适用类型
实例方法 实例.方法() 实例属性和方法 需要mutating 类、结构体、枚举
类型方法 类型.方法() 类型属性和方法 不涉及实例 类、结构体、枚举

关键字总结

  • mutating:值类型的实例方法修改属性时必须使用
  • @discardableResult:标记可忽略返回值的方法
  • static:定义不可重写的类型方法
  • class:定义可重写的类型方法(仅限类)
  • self:当前实例(实例方法)或类型(类型方法)的引用

最佳实践

  1. 实例方法:用于操作实例数据,提供实例相关的功能
  2. 类型方法:用于工厂方法、工具方法、类型相关的操作
  3. mutating方法:值类型需要修改自身时使用
  4. @discardableResult:既可以用于获取值也可以用于执行操作的方法
  5. 链式调用:方法返回self,支持链式编程风格

使用建议

  • 优先使用实例方法处理实例相关的逻辑
  • 使用类型方法提供工厂方法或工具函数
  • 值类型修改自身时记得使用mutating
  • 合理使用@discardableResult避免编译器警告
  • 理解self的不同含义,正确使用

7.闭包

闭包

闭包表达式

在Swift中,可以通过func定义一个函数,也可以通过闭包表达式定义一个函数。

基本语法

{
    (参数列表) -> 返回值类型 in
    函数体代码
}

示例对比

普通函数定义:

func sum(_ v1: Int, _ v2: Int) -> Int { 
    v1 + v2 
}

闭包表达式定义:

var fn = {
    (v1: Int, v2: Int) -> Int in
    return v1 + v2
}
fn(10, 20)

直接调用闭包表达式:

{
    (v1: Int, v2: Int) -> Int in
    return v1 + v2
}(10, 20)

闭包表达式的简写

func exec(v1: Int, v2: Int, fn: (Int, Int) -> Int) {
    print(fn(v1, v2))
}

简写过程

1. 完整形式:

exec(v1: 10, v2: 20, fn: {
    (v1: Int, v2: Int) -> Int in
    return v1 + v2
})

2. 省略参数类型:

exec(v1: 10, v2: 20, fn: {
    v1, v2 in 
    return v1 + v2
})

3. 省略return关键字:

exec(v1: 10, v2: 20, fn: {
    v1, v2 in v1 + v2
})

4. 使用参数名简写:

exec(v1: 10, v2: 20, fn: { $0 + $1 })

5. 使用运算符:

exec(v1: 10, v2: 20, fn: +)

尾随闭包

如果将一个很长的闭包表达式作为函数的最后一个实参,使用尾随闭包可以增强函数的可读性。

尾随闭包是一个被书写在函数调用括号外面(后面)的闭包表达式。

基本用法

func exec(v1: Int, v2: Int, fn: (Int, Int) -> Int) {
    print(fn(v1, v2))
}

exec(v1: 10, v2: 20) {
    $0 + $1
}

唯一实参的情况

如果闭包表达式是函数的唯一实参,而且使用了尾随闭包的语法,那就不需要在函数名后边写圆括号。

func exec(fn: (Int, Int) -> Int) {
    print(fn(1, 2))
}

exec(fn: { $0 + $1 })
exec() { $0 + $1 }
exec { $0 + $1 }

示例 - 数组的排序

Array的sort方法

func sort(by areInIncreasingOrder: (Element, Element) -> Bool)
/// 返回true: i1排在i2前面
/// 返回false: i1排在i2后面

各种写法示例

var nums = [11, 2, 18, 6, 5, 68, 45]

// 使用普通函数
func cmp(i1: Int, i2: Int) -> Bool {
    // 大的排在前面
    return i1 > i2
}
nums.sort(by: cmp)  // [68, 45, 18, 11, 6, 5, 2]

// 使用完整闭包表达式
nums.sort(by: {
    (i1: Int, i2: Int) -> Bool in
    return i1 < i2
})

// 简化参数类型
nums.sort(by: { i1, i2 in return i1 < i2 })

// 简化return
nums.sort(by: { i1, i2 in i1 < i2 })

// 使用参数名简写
nums.sort(by: { $0 < $1 })

// 使用运算符
nums.sort(by: <)

// 使用尾随闭包
nums.sort() { $0 < $1 }
nums.sort { $0 < $1 }

// 结果:[2, 5, 6, 11, 18, 45, 68]

忽略参数

当闭包的参数不需要使用时,可以用下划线_来忽略。

func exec(fn: (Int, Int) -> Int) {
    print(fn(1, 2))
}

exec { _, _ in 10 }  // 10

闭包概念

定义

网上有各种关于闭包的定义,个人觉得比较严谨的定义是:

一个函数和它所捕获的变量/常量环境组合起来,称为闭包。

  • 一般指定义在函数内部的函数
  • 一般它捕获的是外层函数的局部变量/常量

闭包示例

typealias Fn = (Int) -> Int

func getFn() -> Fn {
    var num = 0
    func plus(_ i: Int) -> Int {
        num += i
        return num
    }
    return plus
}

// 返回的plus和num形成了闭包
var fn1 = getFn()
var fn2 = getFn()

fn1(1) // 1
fn2(2) // 2
fn1(3) // 4
fn2(4) // 6
fn1(5) // 9
fn2(6) // 12

闭包的本质

可以把闭包想象成是一个类的实例对象:

  • 内存在堆空间
  • 捕获的局部变量/常量就是对象的成员(存储属性)
  • 组成闭包的函数就是类内部定义的方法
class Closure {
    var num = 0
    func plus(_ i: Int) -> Int {
        num += i
        return num
    }
}

var cs1 = Closure()
var cs2 = Closure()

cs1.plus(1) // 1
cs2.plus(2) // 2
cs1.plus(3) // 4
cs2.plus(4) // 6
cs1.plus(5) // 9
cs2.plus(6) // 12

闭包表达式简写

func getFn() -> Fn {
    var num = 0
    return {
        num += $0
        return num
    }
}

思考题

思考:如果num是全局变量呢?

如果num是全局变量,那么就不存在捕获外层函数局部变量的情况,严格来说就不是闭包了。


练习

练习1:多个闭包共享变量

typealias Fn = (Int) -> (Int, Int)

func getFns() -> (Fn, Fn) {
    var num1 = 0
    var num2 = 0
    
    func plus(_ i: Int) -> (Int, Int) {
        num1 += i
        num2 += i << 1
        return (num1, num2)
    }
    
    func minus(_ i: Int) -> (Int, Int) {
        num1 -= i
        num2 -= i << 1
        return (num1, num2)
    }
    
    return (plus, minus)
}

let (p, m) = getFns()
p(5) // (5, 10)
m(4) // (1, 2)
p(3) // (4, 8)
m(2) // (2, 4)

等价的类实现:

class Closure {
    var num1 = 0
    var num2 = 0
    
    func plus(_ i: Int) -> (Int, Int) {
        num1 += i
        num2 += i << 1
        return (num1, num2)
    }
    
    func minus(_ i: Int) -> (Int, Int) {
        num1 -= i
        num2 -= i << 1
        return (num1, num2)
    }
}

var cs = Closure()
cs.plus(5)  // (5, 10)
cs.minus(4) // (1, 2)
cs.plus(3)  // (4, 8)
cs.minus(2) // (2, 4)

练习2:闭包数组

var functions: [() -> Int] = []

for i in 1...3 {
    functions.append { i }
}

for f in functions {
    print(f())
}
// 输出:
// 1
// 2
// 3

等价的类实现:

class Closure {
    var i: Int
    init(_ i: Int) {
        self.i = i
    }
    func get() -> Int {
        return i
    }
}

var clses: [Closure] = []
for i in 1...3 {
    clses.append(Closure(i))
}

for cls in clses {
    print(cls.get())
}

注意事项

如果返回值是函数类型,那么参数的修饰要保持统一。

func add(_ num: Int) -> (inout Int) -> Void {
    func plus(v: inout Int) {
        v += num
    }
    return plus
}

var num = 5
add(20)(&num)
print(num)  // 25

注意:

  • 返回的函数类型是(inout Int) -> Void
  • 内部函数plus的参数也必须是inout类型
  • 调用时需要使用&来传递inout参数

自动闭包

问题场景

// 如果第1个数大于0,返回第一个数。否则返回第2个数
func getFirstPositive(_ v1: Int, _ v2: Int) -> Int {
    return v1 > 0 ? v1 : v2
}

getFirstPositive(10, 20)  // 10
getFirstPositive(-2, 20)  // 20
getFirstPositive(0, -4)   // -4

使用函数类型参数

// 改成函数类型的参数,可以让v2延迟加载
func getFirstPositive(_ v1: Int, _ v2: () -> Int) -> Int {
    return v1 > 0 ? v1 : v2()
}

getFirstPositive(-4) { 20 }

使用@autoclosure

func getFirstPositive(_ v1: Int, _ v2: @autoclosure () -> Int) -> Int {
    return v1 > 0 ? v1 : v2()
}

getFirstPositive(-4, 20)

@autoclosure特点

  • @autoclosure会自动将20封装成闭包{ 20 }
  • @autoclosure只支持() -> T格式的参数
  • @autoclosure并非只支持最后1个参数
  • 空合并运算符??使用了@autoclosure技术
  • @autoclosure、无@autoclosure,构成了函数重载
  • 为了避免与期望冲突,使用了@autoclosure的地方最好明确注释清楚:这个值会被推迟执行

实际应用示例

Swift中的空合并运算符??就是使用了@autoclosure

func ?? <T>(optional: T?, defaultValue: @autoclosure () throws -> T) rethrows -> T

这样可以避免不必要的计算:

let result = optionalValue ?? expensiveComputation()

只有当optionalValuenil时,expensiveComputation()才会被执行。


总结

闭包的核心概念

  1. 闭包表达式{ (参数) -> 返回值 in 函数体 }
  2. 简写形式:从完整形式到运算符,逐步简化
  3. 尾随闭包:提高代码可读性
  4. 闭包定义:函数 + 捕获的环境变量
  5. 内存模型:类似于类的实例对象

使用场景

  • 函数式编程
  • 异步回调
  • 数组操作(sort、map、filter等)
  • 延迟计算
  • 自定义控制流

最佳实践

  1. 能简写的地方尽量简写,提高代码简洁性
  2. 使用尾随闭包提高可读性
  3. 注意闭包的捕获机制,避免循环引用
  4. 合理使用@autoclosure进行延迟计算
  5. 理解闭包的内存模型,帮助优化性能

6.结构体和类

结构体和类

结构体

在Swift标准库中,绝大多数的公开类型都是结构体,而枚举和类只占很小一部分。

比如BoolIntDoubleStringArrayDictionary等常见类型都是结构体。

struct Date {
    var year: Int
    var month: Int
    var day: Int
}
var date = Date(year: 2019, month: 6, day: 23)

所有的结构体都有一个编译器自动生成的初始化器(initializer,初始化方法、构造器、构造方法)。

在第6行调用的,可以传入所有成员值,用以初始化所有成员(存储属性,Stored Property)。


结构体的初始化器

编译器会根据情况,可能会为结构体生成多个初始化器,宗旨是:保证所有成员都有初始值

思考:下面代码能编译通过么?

struct Point {
    var x: Int?
    var y: Int?
}
var p1 = Point(x: 10, y: 10)
var p2 = Point(y: 10)
var p3 = Point(x: 10)
var p4 = Point()

答案:可以编译通过

原因:可选项都有个默认值nil,因此可以编译通过。


自定义初始化器

一旦在定义结构体时自定义了初始化器,编译器就不会再帮它自动生成其他初始化器。

struct Point {
    var x: Int
    var y: Int
    
    init(x: Int, y: Int) {
        self.x = x
        self.y = y
    }
}

窥探初始化器的本质

以下2段代码完全等效:

方式一:默认值

struct Point {
    var x: Int = 0
    var y: Int = 0
}
var p = Point()

方式二:自定义初始化器

struct Point {
    var x: Int
    var y: Int
    
    init() {
        x = 0
        y = 0
    }
}
var p = Point()

结构体内存结构

struct Point {
    var x: Int = 0
    var y: Int = 0
    var origin: Bool = false
}
print(MemoryLayout<Point>.size)      // 17
print(MemoryLayout<Point>.stride)    // 24
print(MemoryLayout<Point>.alignment) // 8
  • size: 实际占用的内存大小
  • stride: 分配的内存大小(包含对齐)
  • alignment: 内存对齐

类的定义和结构体类似,但编译器并没有为类自动生成可以传入成员值的初始化器。

struct Point {
    var x: Int = 0
    var y: Int = 0
}
let p1 = Point()
let p2 = Point(x: 10, y: 20)  // 结构体可以
let p3 = Point(x: 10)         // 结构体可以
let p4 = Point(y: 20)         // 结构体可以
class Point {
    var x: Int = 0
    var y: Int = 0
}
let p1 = Point()              // 类只能这样
// let p2 = Point(x: 10, y: 20)  // 编译错误

类的初始化器

如果类的所有成员都在定义的时候指定了初始值,编译器会为类生成无参的初始化器。

成员的初始化是在这个初始化器中完成的。

方式一:默认值

class Point {
    var x: Int = 10
    var y: Int = 20
}
let p1 = Point()

方式二:自定义初始化器

class Point {
    var x: Int
    var y: Int
    
    init() {
        x = 10
        y = 20
    }
}
let p1 = Point()

上面2段代码是完全等效的。


结构体与类的本质区别

结构体是值类型(枚举也是值类型),类是引用类型(指针类型)

class Size {
    var width = 1
    var height = 2
}

struct Point {
    var x = 3
    var y = 4
}

func test() {
    var size = Size()
    var point = Point()
}

内存结构图(64bit环境)

栈空间:

内存地址 内存数据 说明
0x10000 3 point.x
0x10008 4 point.y
0x10010 0x90000 size对象的内存地址

堆空间:

内存地址 内存数据 说明
0x90000 0xe41a8 Size对象指向类型信息
0x90008 0x20002 引用计数
0x90010 1 size.width
0x90018 2 size.height

值类型

值类型赋值给varlet或者给函数传参,是直接将所有内容拷贝一份。

类似于对文件进行copy、paste操作,产生了全新的文件副本。属于深拷贝(deep copy)

struct Point {
    var x: Int
    var y: Int
}

func test() {
    var p1 = Point(x: 10, y: 20)
    var p2 = p1
    
    p2.x = 11
    p2.y = 22
    // 请问p1.x和p1.y是多少?
    // 答案:p1.x = 10, p1.y = 20(不受影响)
}

内存结构图

栈空间:

内存地址 内存数据 说明
0x10000 10 p2.x
0x10008 20 p2.y
0x10010 10 p1.x
0x10018 20 p1.y

值类型的赋值操作示例

var s1 = "Jack"
var s2 = s1
s2.append("_Rose")
print(s1) // Jack
print(s2) // Jack_Rose

var a1 = [1, 2, 3]
var a2 = a1
a2.append(4)
a1[0] = 2
print(a1) // [2, 2, 3]
print(a2) // [1, 2, 3, 4]

var d1 = ["max": 10, "min": 2]
var d2 = d1
d1["other"] = 7
d2["max"] = 12
print(d1) // ["other": 7, "max": 10, "min": 2]
print(d2) // ["max": 12, "min": 2]

性能优化:

  • 在Swift标准库中,为了提升性能,StringArrayDictionarySet采取了Copy On Write的技术
  • 比如仅当有"写"操作时,才会真正执行拷贝操作
  • 对于标准库值类型的赋值操作,Swift能确保最佳性能,所以没必要为了保证最佳性能来避免赋值
  • 建议:不需要修改的,尽量定义成let

值类型的重新赋值

struct Point {
    var x: Int
    var y: Int
}

var p1 = Point(x: 10, y: 20)
p1 = Point(x: 11, y: 22)

内存变化:

  • 赋值前:p1.x = 10, p1.y = 20
  • 赋值后:p1.x = 11, p1.y = 22

引用类型

引用赋值给varlet或者给函数传参,是将内存地址拷贝一份。

类似于制作一个文件的替身(快捷方式、链接),指向的是同一个文件。属于浅拷贝(shallow copy)

class Size {
    var width: Int
    var height: Int
    
    init(width: Int, height: Int) {
        self.width = width
        self.height = height
    }
}

func test() {
    var s1 = Size(width: 10, height: 20)
    var s2 = s1
    
    s2.width = 11
    s2.height = 22
    // 请问s1.width和s1.height是多少?
    // 答案:s1.width = 11, s1.height = 22(受影响)
}

内存结构图

栈空间:

内存地址 内存数据
0x10000 0x90000 s2
0x10008 0x90000 s1

堆空间:

内存地址 内存数据 说明
0x90000 0xe41a8 Size对象指向类型信息
0x90008 0x20002 引用计数
0x90010 10 width
0x90018 20 height

对象的堆空间申请过程

在Swift中,创建类的实例对象,要向堆空间申请内存,大概流程如下:

  1. Class.__allocating_init()
  2. libswiftCore.dylib_swift_allocObject_
  3. libswiftCore.dylibswift_slowAlloc
  4. libsystem_malloc.dylibmalloc

注意:

  • 在Mac、iOS中的malloc函数分配的内存大小总是16的倍数
  • 通过class_getInstanceSize可以得知:类的对象至少需要占用多少内存
class Point {
    var x = 11
    var test = true
    var y = 22
}

var p = Point()
class_getInstanceSize(type(of: p))  // 40
class_getInstanceSize(Point.self)   // 40

引用类型的重新赋值

class Size {
    var width: Int
    var height: Int
    
    init(width: Int, height: Int) {
        self.width = width
        self.height = height
    }
}

var s1 = Size(width: 10, height: 20)
s1 = Size(width: 11, height: 22)

内存变化:

  • 原对象被销毁,s1指向新的对象
  • 栈空间地址改变,堆空间创建新对象

值类型、引用类型的let

struct Point {
    var x: Int
    var y: Int
}

class Size {
    var width: Int
    var height: Int
    
    init(width: Int, height: Int) {
        self.width = width
        self.height = height
    }
}

值类型的let:

let p = Point(x: 10, y: 20)
// p.x = 11  // 编译错误:整个值不可变

引用类型的let:

let s = Size(width: 10, height: 20)
s.width = 11   // 可以修改:对象内容可变
s.height = 22  // 可以修改:对象内容可变
// s = Size(width: 1, height: 2)  // 编译错误:引用地址不可变

嵌套类型

struct Poker {
    enum Suit: Character {
        case spades = "♠", hearts = "♥", diamonds = "♦", clubs = "♣"
    }
    
    enum Rank: Int {
        case two = 2, three, four, five, six, seven, eight, nine, ten
        case jack, queen, king, ace
    }
}

print(Poker.Suit.hearts.rawValue)  // ♥

var suit = Poker.Suit.spades
suit = .diamonds

var rank = Poker.Rank.five
rank = .king

方法定义

枚举、结构体、类都可以定义方法。

class Size {
    var width = 10
    var height = 10
    
    func show() {
        print("width=\(width), height=\(height)")
    }
}

let s = Size()
s.show()  // width=10, height=10
struct Point {
    var x = 10
    var y = 10
    
    func show() {
        print("x=\(x), y=\(y)")
    }
}

let p = Point()
p.show()  // x=10, y=10
enum PokerFace: Character {
    case spades = "♠", hearts = "♥", diamonds = "♦", clubs = "♣"
    
    func show() {
        print("face is \(rawValue)")
    }
}

let pf = PokerFace.hearts
pf.show()  // face is ♥

重要概念:

  • 一般把定义在枚举、结构体、类内部的函数,叫做方法
  • 方法占用对象的内存么? 不占用
  • 方法的本质就是函数
  • 方法、函数都存放在代码段

作业

思考以下结构体、类对象的内存结构是怎样的?

struct Point {
    var x: Int
    var b1: Bool
    var b2: Bool
    var y: Int
}
var p = Point(x: 10, b1: true, b2: true, y: 20)
class Size {
    var width: Int
    var b1: Bool
    var b2: Bool
    var height: Int
    
    init(width: Int, b1: Bool, b2: Bool, height: Int) {
        self.width = width
        self.b1 = b1
        self.b2 = b2
        self.height = height
    }
}
var s = Size(width: 10, b1: true, b2: true, height: 20)

分析要点:

  • 考虑内存对齐
  • 考虑Bool类型占用的字节数
  • 考虑Int类型占用的字节数
  • 结构体存储在栈空间
  • 类对象存储在堆空间,栈空间存储引用地址

总结

结构体 vs 类的主要区别

特性 结构体
类型 值类型 引用类型
内存位置 栈空间 堆空间(对象)+ 栈空间(引用)
赋值方式 深拷贝 浅拷贝
自动初始化器 多个(memberwise) 仅无参(需要默认值)
let的含义 整个值不可变 引用地址不可变
性能 一般更高 需要堆内存管理

使用建议

  • 优先使用结构体:除非需要引用语义或继承
  • 合理使用let:不需要修改的数据尽量定义为let
  • 注意内存对齐:了解数据类型的内存布局
  • 理解拷贝机制:值类型的Copy On Write优化

Swift 5.9 新特性揭秘:非复制类型的安全与高效

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

image.png

前言

在 Swift 中,类型默认是可复制的。这种设计简化了开发过程,因为它允许值在赋值给新变量或传递给函数时轻松复制。

然而,这种便利有时会导致意想不到的问题。例如,复制单次使用的票据或重复连接数据库可能会导致无效状态或资源冲突。

为了解决这些问题,在 Swift 5.9 中引入了非复制类型。通过将类型标记为~Copyable 来实现,我们可以显式地阻止 Swift 复制它。

这保证了值的唯一所有权,并施加了更严格的约束,从而降低了出错的风险。接下来让我们详细了解一下非复制类型。

非复制类型的示例

以下是一个非复制类型的简单示例:

struct SingleUseTicket: ~Copyable {
    let ticketIDString
}

与常规值类型的行为不同,当我们将非复制类型的实例分配给新变量时,值会被移动而不是复制。如果我们尝试在稍后使用原始变量,会得到编译时错误:

let originalTicket = SingleUseTicket(ticketID: "S645")
let newTicket = originalTicket

print(originalTicket.ticketID) // 报错 'originalTicket' used after consume

需要注意的是,类不能被声明为非复制类型。所有类类型仍然是可复制的,通过保留和释放对对象的引用来实现。

非复制类型中的方法

在非复制类型中,方法可以读取、修改或消费self

借用方法

非复制类型中的方法默认是借用borrowing 的。这意味着它们只能读取实例,允许安全地检查实例而不影响其有效性。

struct SingleUseTicket: ~Copyable {
    let ticketID: String
    
    func describe() {
        print("This ticket is \(ticketID).")
    }
}

let ticket = SingleUseTicket(ticketID: "A123")

// 打印 `This ticket is A123.`
ticket.describe()

可变方法

可变方法mutating 提供了对self 的临时写访问,允许在不使实例无效的情况下进行修改。

struct SingleUseTicket: ~Copyable {
    var ticketID: String

    mutating func updateID(newID: String) {
        ticketID = newID
        print("Ticket ID updated to \(ticketID).")
    }
}

var ticket = SingleUseTicket(ticketID: "A123")

// 打印 `Ticket ID updated to B456.`
ticket.updateID(newID: "B456")

消费方法

消费方法consuming 接管self 的所有权,一旦方法完成就使实例无效。这对于完成或处置资源的任务非常有用。在调用方法后,任何尝试访问实例的操作都会导致编译错误。

struct SingleUseTicket: ~Copyable {
    let ticketID: String
    
    consuming func use() {
        print("Ticket \(ticketID) used.")
    }
}

func useTicket() {
    let ticket = SingleUseTicket(ticketID: "A123")
    ticket.use()
    
    ticket.use() // 报错 'ticket' consumed more than once
}

useTicket()

需要注意的是,我们不能消费存储在全局变量中的非复制类型,因此在我们的示例中我们将代码包装在useTicket() 函数中。

非复制类型在函数参数中的应用

当将非复制类型作为参数传递给函数时,Swift 要求我们为该函数指定所有权模型。我们可以将参数标记为借用borrowing、输入输出inout 或消费consuming,每种标记提供不同级别的访问权限,类似于类型内部的方法。

借用参数

借用所有权允许函数临时读取值,而不消耗或修改它。

func inspectTicket(_ ticket: borrowing SingleUseTicket) {
    print("Inspecting ticket \(ticket.ticketID).")
}

输入输出参数

输入输出参数inout 提供了对值的临时写访问,允许函数修改它,同时将所有权返回给调用者。

func updateTicketID(_ ticketinout SingleUseTicket, to newID: String) {
    ticket.ticketID = newID
    print("Ticket ID updated to \(ticket.ticketID).")
}

消费参数

当一个参数被标记为消费时,函数完全接管该值的所有权,使其对于调用者无效。例如,如果我们有一个消费方法,我们可以在函数中使用它,而无需担心在函数外部使用该值。

func processTicket(_ ticket: consuming SingleUseTicket) {
    ticket.use()
}

析构函数和丢弃操作符

非复制结构体和枚举可以像类一样拥有析构函数deinit,它们会在实例生命周期结束时自动运行。

struct SingleUseTicket: ~Copyable {
    let ticketID: Int
    
    deinit {
        print("Ticket deinitialized.")
        
        // 清理逻辑
    }
}

然而,当一个消费方法和一个析构函数都执行清理时,可能会有冗余操作的风险。为了解决这个问题,Swift 引入了丢弃操作符discard

通过在消费方法中使用discard self,我们可以显式阻止调用析构函数,从而避免重复逻辑:

struct SingleUseTicket: ~Copyable {
    let ticketID: Int
    
    consuming func invalidate() {
        print("Ticket \(ticketID) invalidated.")
        
        // 清理逻辑
        
        discard self
    }
    
    deinit {
        print("Ticket deinitialized.")
        
        // 清理逻辑
    }
}

另外需要注意的是,只有当我们的类型包含可轻松销毁的存储属性时,才能使用discard。不能包含引用计数、泛型。

总结

最近几年,swift 出了很多新特性,非复制类型是其中之一,实际开发中,非复制类型很少用到,但是了解这些特性,可以让我们在开发中更加得心应手。随着 Swift 的不断发展,这些类型代表了语言在性能和正确性方面的重大进步。

但是这些越来越复杂的特性也让 swift 初学者望而却步,希望这篇文章能帮助大家了解非复制类型,在实际开发中,如果需要使用非复制类型,可以参考这篇文章。

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

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

iOS Block

  1. block 本质上是是一个oc对象,内部也有isa指针。这个对象内部封装了函数调用地址以及函数调用环境(参数参数、返回值、捕获的外部变量)

  2.  int age = 20;
     void (^block)(void) = ^{
     nslog(@"age is %d",age)
     }
     
     struct __main_block_impl_0 {
     struct __block_impl impl;
     struct __main_block_desc_9 *desc
     int age
     }
    
  3.  int c = 1000; // 全局变量
     static int d = 10000; // 静态全局变量
     
     int main(int argc, const char * argv[]) {
         @autoreleasepool {
     
             int a = 10; // 局部变量
             static int b = 100; // 静态局部变量
             void (^block)(void) = ^{
                  NSLog(@"a = %d",a);
                  NSLog(@"b = %d",b);
                  NSLog(@"c = %d",c);
                  NSLog(@"d = %d",d);
              };
              a = 20;
              b = 200;
              c = 2000;
              d = 20000;
              block();
         }
         return 0;
     }
     
     // ***************打印结果***************
     2020-01-07 15:08:37.541840+0800 CommandLine[70672:7611766] a = 10
     2020-01-07 15:08:37.542168+0800 CommandLine[70672:7611766] b = 200
     2020-01-07 15:08:37.542201+0800 CommandLine[70672:7611766] c = 2000
     2020-01-07 15:08:37.542222+0800 CommandLine[70672:7611766] d = 20000
    
  4. 全局变量不会捕获,直接访问

  5. 静态局部变量,捕获的是变量的地址,所以在block外面修改值以后,也会改变

  6. 普通变量,会直接捕获。外面在修改值,block内部是新生成了一个变量,不改变值

  7. _NSGlobalBlock_如果一个block里面没有访问普通局部变量,也就是没有捕获任何值,就是这种global类型,存在数据区。继承链:_nsgloableblock_ :nsblock:nsobject

  8. 如果一个block里面访问了普通局部变量,那他就是一个_nsstackblock_,他在内存中存放在栈区,特点是其释放不受开发者控制,都是系统操作,如果对他惊醒了copy,就会把这个block复制到堆上。

如何在 visionOS 上使用 MPS 和 CIFilter 实现特殊视觉效果

说明

在 visionOS 开发中,视觉效果一直都是开发的一个难点。尽管苹果推出了 ShaderGraph 来简化 Shader 的开发,在此基础上我开源了 RealityShaderExtension 框架来帮助降低 Shader 开发的门槛,但在实际开发中,我们仍然面临两个问题:

  • 数学与几何知识要求太高,难以开发出满意的效果
  • 某些效果如 高斯模糊GaussianBlur直方图Histogram 单纯依靠 ShaderGraph 难以编写的,且运行效率不佳

image.png

UnityMaterial.gif

苹果针对 ShaderGraph 功能不够强大的弱点,给出的解决方案是:使用 LowLevelTexture + Compute Shader 更加灵活的实现各种算法功能,然而手写 Metal Compute Shader 代码依然是非常困难的。

不过,苹果有一个已经高度优化的 Compute Shader 框架:Metal Performance Shaders ,我们可以直接与 LowLevelTexture 一起使用。

同时,经过研究,在 UIKit 中常用的 CIFilter 图片处理框架,也是可以与 LowLevelTexture 一起使用的,这样就无需再手动编写各种算法代码了。

同时,不仅是图片可以处理,视频也可以继续使用 AVPlayer 播放的同时,添加 MPS/CIFilter 进行处理。

图片处理

对图片处理时,MPS 和 CIFilter 的基本步骤是一样的:

  • 处理流程: MPS/CIFilter -> LowLevelTexture -> TextureResource -> UnlitMaterial

Image(MPS)

使用 MPS 进行处理时:

  • 只需要通过 commandBufferLowLevelTesxture 中获取目标纹理 outTexture
  • 将源纹理和目标纹理传递给 MPS filter 即可。

关键代码如下:

func populateMPS(inTexture: MTLTexture, lowLevelTexture: LowLevelTexture, device: MTLDevice) {

    // Set up the Metal command queue and compute command encoder,
    .....

    // Create a MPS filter.
    let blur = MPSImageGaussianBlur(device: device, sigma: model.blurRadius)

    // set input output
    let outTexture = lowLevelTexture.replace(using: commandBuffer)
    blur.encode(commandBuffer: commandBuffer, sourceTexture: inTexture, destinationTexture: outTexture)

    
    // The usual Metal enqueue process.
    .....
}

ScreenRecording_06-28-2025 16-38-15_1.2025-07-02 14_31_36.gif

Image(CIFilter)

使用 CIFilter 进行处理时:

  • 需要根据 outTexturecommandBuffer 创建一个 CIRenderDestination
  • [可选] 为了更好与 Metal 协作,最好创建一个 GPU-Based CIContext
  • [可选] 如果遇到颜色空间显示不正确,可以设置 options 中 .workingColorSpace 为 sRGB 等。
  • 最后调用 ciContext.startTask 将处理后的图片写入 CIRenderDestination 中。

关键代码如下:

let blur = CIFilter(name: "CIGaussianBlur")

func populateCIFilter(inTexture: MTLTexture, lowLevelTexture: LowLevelTexture, device: MTLDevice) {

    // Set up the Metal command queue and compute command encoder,
    .......
    
    // Set the CIFilter inputs
    blur?.setValue(CIImage(mtlTexture: inTexture), forKey: kCIInputImageKey)
    blur?.setValue(model.blurRadius, forKey: kCIInputRadiusKey)

    // set input output
    let outTexture = lowLevelTexture.replace(using: commandBuffer)
    let render = CIRenderDestination(mtlTexture: outTexture, commandBuffer: commandBuffer)

    // Create a Context for GPU-Based Rendering
    let ciContext = CIContext(mtlCommandQueue: commandQueue,options: [.cacheIntermediates: false, .workingColorSpace: CGColorSpace(name: CGColorSpace.sRGB)!])

    if let outImage = blur?.outputImage {
        do {
            try ciContext.startTask(toRender: outImage, to: render)
        } catch  {
            print(error)
        }
    }

    // The usual Metal enqueue process.
    ......
}

ScreenRecording_06-28-2025 16-38-15_1.2025-07-02 14_32_15.gif

视频处理

视频处理要稍微复杂一些,需要创建 AVMutableVideoComposition 来从 AVPlayer 中获取视频帧信息再进行处理,处理后的视频继续在 AVPlayer 中直接播放,也可以另外导出到 LowLevelTexture 中进行显示。

注意:视频处理在老版本的(即 Xcode 16.4 中原始的) Vision Pro 模拟器中不能正常工作,在新的模拟器“Apple Vision Pro 4K” 中 使用 CIFilter 处理后的颜色显示不正确。不过在真机测试中,都是正常的。

Video(CIFilter)

  • 处理流程:[ CIFilter + AVMutableVideoComposition + AVPlayerItem ] -> VideoMaterial

好消息是,苹果针对 CIFilter 有一个简单方案:

  • 在创建 AVMutableVideoComposition 时创建一个闭包
  • 在闭包中通过 AVAsynchronousCIImageFilteringRequest 获取适合 CIFilter 处理的视频帧数据
  • 源视频数据直接传给 CIFilter 处理后,重新写入 AVAsynchronousCIImageFilteringRequest 即可播放出模糊后的视频。
let asset: AVURLAsset....


let playerItem = AVPlayerItem(asset: asset)

let composition = try await AVMutableVideoComposition.videoComposition(with: asset) { request in
    populateCIFilter(request: request)
}
playerItem.videoComposition = composition


// Create a material that uses the VideoMaterial
let player = AVPlayer(playerItem: playerItem)
let videoMaterial = VideoMaterial(avPlayer: player)

真正的处理代码也非常简单,将 CIFilter 的输出重新写入到 request 中即可:

let ciFilter = CIFilter(name: "CIGaussianBlur")

func populateCIFilter(request: AVAsynchronousCIImageFilteringRequest) {
    let source = request.sourceImage
    ciFilter?.setValue(source, forKey: kCIInputImageKey)
    ciFilter?.setValue(model.blurRadius, forKey: kCIInputRadiusKey)

    if let output = ciFilter?.outputImage {
        request.finish(with: output, context: ciContext)
    } else {
        request.finish(with: FilterError.failedToProduceOutputImage)
    }
}

ScreenRecording_06-28-2025 16-38-15_1.2025-07-02 14_27_20.gif

Video(MPS)

  • 处理流程: [ MPS + AVMutableVideoComposition + AVPlayerItem ] -> LowLevelTexture -> TextureResource -> UnlitMaterial

通过 MPS 来处理视频要更加复杂一些:

  • 我们需要自定义一个 customVideoCompositorClass ,赋值给 AVMutableVideoComposition
  • 实现它的协议方法,指定输入和输出的像素格式
  • startRequest() 中获取视频帧并转换为 MTLTexture ,由 MPS 进行处理
  • [可选] 将源视频写入回去,这样就能在 AVPlayer 中继续播放源视频

自定义一个 SampleCustomCompositor,并赋值给 composition.customVideoCompositorClass

let composition = try await AVMutableVideoComposition.videoComposition(withPropertiesOf: asset)
composition.customVideoCompositorClass = SampleCustomCompositor.self

let playerItem = AVPlayerItem(asset: asset)
playerItem.videoComposition = composition

SampleCustomCompositor 需要指定我们需要的视频帧像素格式,然后就可以在 startRequest() 中获取到对应格式的视频帧,进行模糊处理。

class SampleCustomCompositor: NSObject, AVVideoCompositing {
    .....
    // 指定我们需要的视频帧格式。一定要设置 kCVPixelBufferMetalCompatibilityKey,否则与 Metal 会出现兼容性问题,导致黑屏等
    var sourcePixelBufferAttributes: [String: any Sendable]? = [
        String(kCVPixelBufferPixelFormatTypeKey): [kCVPixelFormatType_32BGRA],
        String(kCVPixelBufferMetalCompatibilityKey): true // Critical! 非常重要
    ]
    // 我们处理后返回的视频帧格式
    var requiredPixelBufferAttributesForRenderContext: [String: any Sendable] = [
        String(kCVPixelBufferPixelFormatTypeKey):[kCVPixelFormatType_32BGRA],
        String(kCVPixelBufferMetalCompatibilityKey): true
    ]

    ....


    func startRequest(_ request: AVAsynchronousVideoCompositionRequest) {

        .....

        let requiredTrackIDs = request.videoCompositionInstruction.requiredSourceTrackIDs
        let sourceID = requiredTrackIDs[0]
        let sourceBuffer = request.sourceFrame(byTrackID: sourceID.value(of: Int32.self)!)!

       
        Task {@MainActor in
            // 将模糊后的视频输出到 LowLevelTexture 中
            populateMPS(sourceBuffer: sourceBuffer, lowLevelTexture: SampleCustomCompositor.llt!, device: SampleCustomCompositor.mtlDevice!)
        }
        // 保持原视频继续输出
        request.finish(withComposedVideoFrame: sourceBuffer)
    }


    @MainActor func populateMPS(sourceBuffer: CVPixelBuffer, lowLevelTexture: LowLevelTexture, device: MTLDevice) {

        .....

        // Now sourceBuffer should already be in BGRA format, create Metal texture directly
        var mtlTextureCache: CVMetalTextureCache? = nil
        CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &mtlTextureCache)

        let width = CVPixelBufferGetWidth(sourceBuffer)
        let height = CVPixelBufferGetHeight(sourceBuffer)
        var cvTexture: CVMetalTexture?
        let result = CVMetalTextureCacheCreateTextureFromImage(
            kCFAllocatorDefault,
            mtlTextureCache!,
            sourceBuffer,
            nil,
            .bgra8Unorm,
            width,
            height,
            0,
            &cvTexture
        )
        let bgraTexture = CVMetalTextureGetTexture(cvTexture)
  
        // Create a MPS filter with dynamic blur radius
        let blur = MPSImageGaussianBlur(device: device, sigma: Self.blurRadius)
 
        // set input output
        let outTexture = lowLevelTexture.replace(using: commandBuffer)
        blur.encode(commandBuffer: commandBuffer, sourceTexture: bgraTexture, destinationTexture: outTexture)

        // The usual Metal enqueue process.
        ....
    }
}

使用 customVideoCompositorClass + MPS ,可以在 AVPlayer 输出源视频(下图左)的同时,在 LowLevelTexture 中输出模糊后的视频(下图右): ScreenRecording_06-28-2025 16-38-15_1.2025-07-02 14_26_31.gif

参考

项目完整示例:github.com/XanderXu/MP…

参考资料:

Swift 6.2:江湖再掀惊涛浪,新功出世震四方

在这里插入图片描述

概述

江湖代有才人出,各领风骚数百年。

自 Swift 语言横空出世,便在 iOS 开发的武林中搅动风云。如今 WWDC 25 之上,Apple 闭门三年炼就的《Swift 6.2 真经》终见天日,书中所载新功个个精妙绝伦,足以让开发者们的代码功力更上一层楼。

在这里插入图片描述

在本篇武林秘闻中,各位少侠将领悟到如下奥义:

  1. 并发迷局终得解:"nonisolated (nonsending)" 与 "defaultIsolation" 双剑合璧 1.1 第一式:"nonisolated (nonsending)" 调和阴阳 1.2 第二式:"defaultIsolation" 定鼎乾坤
  2. "@concurrent" 破界令牌:独辟蹊径的旁门绝学
  3. 内存管理新心法:InlineArray 与 Span 的 "缩骨功" 3.1 InlineArray:栈上藏兵,招之即来 3.2 Span:内存视图,隔岸观火
  4. 严格内存安全的 “金钟罩”:让内存错误无处遁形
  5. 语言互操新经脉:Swift 与 C++ "打通任督二脉"
  6. 与 Java 的跨界合作
  7. 结语:新功在手,江湖我有

今日便由老夫为诸位少侠拆解其中奥秘,且看这新功如何改写江湖格局。 Let's go!!!;)


1. 并发迷局终得解:"nonisolated (nonsending)" 与 "defaultIsolation" 双剑合璧

往昔江湖,并发编程堪称开发者的 "鬼门关"。

多少英雄好汉在此折戟沉沙 —— 同步函数与异步函数如同正邪两道,运行规则大相径庭;主 Actor 调用时更是冲突不断,轻则编译器怒目相向,重则数据走火入魔,当真令人闻风丧胆。

Swift 6.2 携 "nonisolated (nonsending)" 与 "defaultIsolation" 两大神功而来,恰似倚天屠龙双剑合璧,专破这经脉错乱之症。

1.1 第一式:"nonisolated (nonsending)" 调和阴阳

此功专为理顺函数调用的 "阴阳二气" 所创。

标注此功的函数,既能保持独立姿态(nonisolated),又不会随意发送数据(nonsending),恰似一位守礼的侠客,既不依附门派,又不轻易出手伤人:

// 旧制:异步函数常因隔离问题"走火入魔"
actor DataManager {
    var value: Int = 0
    
    // 欲调用此函数,需先过编译器"三关"
    func fetchData() async -> Int {
        return value
    }
}

// 新功:nonisolated (nonsending) 让函数"独善其身"
actor DataManager {
    var value: Int = 0
    
    nonisolated(nonsending) func fetchData() async -> Int {
        // 可安全访问非隔离数据,或执行独立逻辑
        return 42
    }
}

这般写法,如同给函数戴上 "君子剑",既保持独立风骨,又不伤邻里(其它 Actor),实乃解决隔离冲突之必备良药。

1.2 第二式:"defaultIsolation" 定鼎乾坤

此功堪称主 Actor 门派的 "盟主令"—— 可在包级别(package)定下规矩:凡未明确 "叛逃" 的代码,皆默认归入主 Actor 麾下

这便如武林盟主昭告天下:"未投他派者,皆听我号令",瞬间省去无数手动标注的繁琐:

// 在Package.swift中启用盟主令
swiftSettings: [
    .defaultIsolation(MainActor.self), // 立主Actor为盟主
    .enableExperimentalFeature("NonisolatedNonsendingByDefault")
]

// 此后代码自动归入主Actor,无需再画蛇添足
class ViewModel {
    func updateUI() async {
        // 默认为主Actor内运行,可安心操作UI
        print("UI更新无虞")
    }
}

有了这盟主令,UI 相关代码自归其位,后台任务也能各安其职,当真 "物各有主,井然有序"。

在这里插入图片描述

2. "@concurrent" 破界令牌:独辟蹊径的旁门绝学

江湖之大,总有需要 "特立独行" 之时。

Swift 6.2 推出的 "@concurrent" 令牌,恰如一张 "通关文牒",持此令牌者,可脱离调用者的经脉,另辟新的隔离语境,堪称 "破界而行" 的旁门绝学。

此令牌虽威力无穷,却有铁律约束:仅能授予 "nonisolated" 函数

若将其用于 Actor 门派的招式,除非该招式已明确 "脱离门派"(标注 nonisolated),否则便是 "违规练功",日后必遭编译器反噬:

// 正道:nonisolated函数持令牌,名正言顺
actor NetworkClient {
    @concurrent
    nonisolated func fetchImage() async throws -> UIImage {
        // 脱离NetworkClient的隔离,另起炉灶
        let data = try await URLSession.shared.data(from: url)
        return UIImage(data: data.0)!
    }
}

// 禁忌:未脱离门派却持令牌,必遭反噬
actor NetworkClient {
    @concurrent // 编译器怒喝:"此等叛逆,当诛!"
    func fetchImage() async throws -> UIImage {
        // 此乃禁忌招式,万万不可学
    }
}

这令牌的妙用在于:当你需要一个 "临时工"(独立隔离的函数),又不想让它沾染主 Actor 的 "门派气息" 时,只需授予此令,便能让其 "独来独往,自成一派"。

3. 内存管理新心法:InlineArray 与 Span 的 "缩骨功"

内存管理向来是秃头少侠们的 "内功根基",根基不牢地动山摇,招式再花也难成大器。

Swift 6.2 推出的 InlineArray 与 Span,恰似两套 "缩骨功",能将内存占用压缩到极致,运行速度却如离弦之箭。

在这里插入图片描述

3.1 InlineArray:栈上藏兵,招之即来

寻常数组(Array)如同 "堆上营寨",虽容量可观,却需耗费时间搭建(堆内存分配)。

InlineArray 则是 "栈上藏兵",固定大小,随用随取,省去了营寨搭建的功夫:

// 声明一个可容纳5名"士兵"(Int)的栈上营寨
var inlineArray: InlineArray<Int, 5> = [1, 2, 3, 4, 5]

// 直接取用,无需等待营寨搭建
inlineArray[2] = 100
print(inlineArray) // [1, 2, 100, 4, 5]

此功最适合 "小股特种暗杀部队"(固定大小、数量不多的数据),如游戏中的坐标点、传感器的实时数据等,调用时快如闪电,绝不拖泥带水。

3.2 Span:内存视图,隔岸观火

Span 堪称 "内存望远镜"—— 它不持有内存,仅提供一片连续内存的 "视图",既能安全访问,又不占用额外空间,恰似隔岸观火,知全局而不添柴也:

let buffer: [UInt8] = [0x01, 0x02, 0x03, 0x04]

// 用望远镜观察内存,从索引1开始,看3个元素
let span = buffer[1..<4].withSpan { $0 }
print(span.count) // 3
print(span[0]) // 0x02

在解析二进制数据(如网络协议、文件格式)时,Span 能让你 "按图索骥",无需复制数据即可精准操作,实乃 "事半功倍" 之法。

4. 严格内存安全的 “金钟罩”:让内存错误无处遁形

内存安全问题一直是 iOS 开发中的一个 “心腹大患”,稍有不慎就可能导致程序崩溃、数据丢失等严重后果。

Swift 6.2 引入了严格内存安全特性,就像是为程序穿上了一层坚固的 “金钟罩”,能够有效地抵御各种内存错误的侵袭。

在以往的开发中,指针操作、内存分配与释放等操作常常隐藏着许多危险,少侠们需要花费大量的精力去确保内存的正确使用。而现在,启用严格内存安全特性后,编译器会对代码进行更加严格的检查,一旦发现潜在的内存安全问题,就会及时发出警告。

在这里插入图片描述

例如,在 Xcode 中,我们可以在项目的构建设置中将 “Strict Memory Safety” 设置为 “yes” 来启用这一特性。重新构建后,编译器会仔细检查代码中的每一处内存操作,如是否存在悬空指针、是否有内存泄漏等问题。

在这里插入图片描述

如果发现问题,编译器会给出详细的错误提示,帮助微秃少侠们及时修复,就像在江湖中,有了一位明察秋毫的武林前辈时刻提醒我们招式中的破绽,让我们能够及时修正,避免陷入东方不败的危险境地。

5. 语言互操新经脉:Swift 与 C++ "打通任督二脉"

江湖之中,门派林立,Swift 与 C++ 便如两大武学世家,各有精妙却隔阂甚深。

Swift 6.2 新修的 "互操经脉",终于让两派高手得以 "切磋武艺,互通有无"。

在这里插入图片描述

如今在 Swift 中调用 C++ 代码,恰似 "少林高僧学武当太极",招式转换自然流畅:

// C++中的"铁砂掌"函数
// int strike(int strength, int times);

// Swift中直接施展,无需翻译
import CppMartialArts

let damage = strike(100, 3) // 调用C++函数,如探囊取物
print("造成伤害:\(damage)")

更妙的是,C++ 的类也能在 Swift 中 "返璞归真",仿佛戴上 "易容面具",外观是 Swift 类,内里却是 C++ 的筋骨:

// C++的"Sword"类在Swift中可用
let mySword = CppSword(length: 1.2)
mySword.sharpen() // 调用C++方法
let damage = mySword.cut(target: "enemy")

这般互通,恰似武林大会上各派高手同台竞技,取长补短,当真 "海纳百川,有容乃大"。

6. 与 Java 的跨界合作

Swift 6.2 还为与 Java 的互操作性提供了更好的支持。

在这里插入图片描述

在一些跨平台开发场景中,Swift 与 Java 的交互需求日益增长。现在,Swift 6.2 使得 Swift 代码与 Java 代码之间的通信和协作变得更加容易,仿佛在两个不同的武林世界之间搭建了一座坚固的桥梁。

例如,在某些需要与 Java 后端服务进行交互的 iOS 应用中,Swift 6.2 的新特性可以帮助开发者更高效地实现数据传输和功能调用,大大提升了开发效率,让微秃少侠们能够在不同语言的 “江湖” 中自由穿梭,实现更强大的应用功能,并且希望少掉几根头发。

7. 结语:新功在手,江湖我有

Swift 6.2 的诸位新功,或解并发之困,或强内存之基,或通语言之隔,恰如为开发者打通了 "任督二脉",从此代码之路再无阻塞。

江湖路远,挑战常新,但只要手握这些新功秘籍,便能 "运筹帷幄之中,决胜千里之外"。诸位少侠,何不即刻闭关修炼,待功成之日,便是横行代码江湖之时!

在这里插入图片描述

记住,真正的高手,从不困于招式,而是善用利器。Swift 6.2 这柄神兵已交你手,接下来,便看你如何在开发的江湖中,写下属于自己的传奇!

感谢各位秃头少侠们的观赏,我们青山不改、绿水长流,江湖再见、后会有期!8-)

苹果内购IAP(一) Transaction 数据含义

以下是你提供的 StoreKit 2 Transaction JSON 各字段含义解析:

字段 类型 含义
transactionId String 本次交易的唯一标识符(Apple 服务器生成)。每次用户购买或续订时都不同。
originalTransactionId String 原始交易 ID。对于自动续订订阅,首次购买时生成,后续续订会复用该 ID,用来关联同一订阅链。
webOrderLineItemId String 用于 App Store 后端报表的行项目 ID,可用于跨平台(如 Web、iOS)或者后台对账。
bundleId String App 的 Bundle Identifier,表示是哪一个应用发起了这笔交易。
productId String 购买的内购商品标识符(Product Identifier),即你在 App Store Connect 中配置的 ID。
subscriptionGroupIdentifier String 订阅组 ID,属于同一组的订阅产品互斥,同组内同一用户只能激活一个订阅方案。
purchaseDate Number 本次交易的购买时间,Unix 毫秒数(UTC)。例如 1752148768000 表示 2025‑12‑10 08:19:28 UTC。
originalPurchaseDate Number 原始购买时间,Unix 毫秒数。对于续订,将是首次购买的时间。
expiresDate Number 订阅或试用期的到期时间,Unix 毫秒数。到期后如果未续订,就视为订阅结束。
quantity Int 购买的数量。对于订阅通常为 1,消费型商品可能大于 1。
type String 内购类型,此处 "Auto-Renewable Subscription" 表示自动续订订阅;其他可能值还有 "Non-Consumable""Consumable""Non-Renewing Subscription"
deviceVerification String Base64 编码的设备验证令牌,用于防篡改校验,可发送到服务器并与 Apple 校验。
deviceVerificationNonce String 随机生成的唯一值,与 deviceVerification 配合使用,保证验证请求的唯一性。
inAppOwnershipType String 购买归属类型:
"PURCHASED":用户已购买
"FAMILY_SHARED":通过家庭共享获得
"UNKNOWN":未知或无权访问
signedDate Number 交易数据在设备上签名的时间,Unix 毫秒数,用于校验数据新鲜度。
environment String 交易环境:
"Sandbox":沙盒测试
"Production":正式环境
transactionReason String 触发此次交易的原因:
"PURCHASE":用户主动购买
"RENEWAL":自动续订
"INTERACTIVE_RENEWAL":用户在订阅管理界面手动续订等
storefront String App Store 区域码(ISO 国家/地区简码),如 "CHN" 表示中国大陆。
storefrontId String App Store 地区标识符,用于更精细的区域分发。
price Int 以最小货币单位表示的价格。例如 59000 表示 ¥590.00(CNY)。
currency String 货币代码,如 "CNY""USD"
appTransactionId String App 级别的交易 ID,可用于客户端内部追踪。同一个 transactionId,可能在不同平台(iOS/macOS)下有不同 appTransactionId

💡 小贴士

  • 时间字段 (purchaseDateexpiresDate 等) 都是以毫秒为单位的 Unix 时间戳,需根据时区转换展示给用户。
  • 服务器校验:通常你会将 deviceVerification + deviceVerificationNonce 或整个 JSON 发送到后台,由后台调用 Apple 验证接口进行二次校验,确保证据未被篡改。
  • 续订关联originalTransactionId 贯穿同一次订阅链,方便后台做续订统计和账单归因。

以上字段含义能帮助你在客户端和后台实现精确的订阅状态管理与数据对账。 🚀

type 字段一般都有什么样是值

type 字段对应了内购项目的种类,在 StoreKit 2 的 Transaction JSON 中,你常见到以下几种值:

  • Consumable
    消耗型商品,一次购买后即“用完”,可重复购买(如游戏内金币、道具)。
  • Non-Consumable
    非消耗型商品,一次购买永久有效(如解锁专业版功能、永久皮肤)。
  • Non-Renewing Subscription
    非自动续期订阅,到期后需要用户手动续订(如半年课程访问权限)。
  • Auto-Renewable Subscription
    自动续期订阅,到期后由系统自动续费(如月度/年度会员)。

transactionReason 一般都有什么值,含义什么

transactionReason 表示触发此次交易的原因,在 StoreKit 2 的 Transaction JSON 中,常见的取值有三种:

  • PURCHASE
    用户主动发起的首次购买或在 App 内直接购买订阅时产生的交易。Apple Developer
  • RENEWAL
    自动续订订阅到期后,由系统在后台自动发起的续订交易。Apple Developer
  • INTERACTIVE_RENEWAL
    用户在 App Store 的订阅设置界面(或通过 App 内提供的入口)主动续订已过期或已取消的订阅时产生的交易。Stack Overflow

📌 小贴士

  • 这三种原因涵盖了 用户购买系统自动续订用户手动续订 的主要场景。
  • 当你在 Transaction.updates 或者通过 App Store Server API 查询到交易 JSON 时,可以根据 transactionReason 字段判断当前是首次购买、自动续订,还是交互式续订,以便做不同的业务逻辑处理(如提示用户“已续订”或“请手动续订”)。

WWDC 25 风云再起:SwiftUI 7 Charts 心法从 2D 到 3D 的华丽蜕变

在这里插入图片描述

概述

在 iOS 开发这个波谲云诡的江湖中,SwiftUI 可谓是一位后起之秀,以其简洁明快的招式迅速在 UI 框架领域中崭露头角。

在这里插入图片描述

而其中的 Charts 框架,更是如同江湖中的 “数据可视化宝典”那样,让各位秃头少侠们能够轻松将复杂的数据转化为直观易懂的图表。

在本篇武林秘籍中,列位少侠将会领悟如下招式:

    1. 江湖起源——原有 Charts 框架的武学修炼
    1. 江湖变革——SwiftUI Charts 3D 图表来袭
    1. 轻功飞行——SurfacePlot 打造梦幻曲面图
    1. 握剑诀——交互与视角
    1. 3D 图表修炼的注意事项
    • 5.1 避免数据过载
    • 5.2 谨慎选择图表类型
    • 5.3 性能优化不容忽视
    1. 尾声:3D 图表开启数据可视化新纪元

那还等什么呢?各位初入江湖的豪客和“大虾”们,请随老夫一起进入图表的美妙世界吧!Let's go!!!;)


1. 江湖起源——原有 Charts 框架的武学修炼

何曾几时,WWDC 2020(SwiftUI 4.0,iOS 16)江湖风起云涌,数不清的英雄豪杰和兵器纷繁复杂,数据可视化在其中占据了举足轻重的地位。

而正是在这样的背景下,SwiftUI 的 Charts 框架横空出世,犹如一位初出茅庐的少年剑客,虽然招式简单,却也灵活迅捷,迅速得到了武林各大门派的认可。

在这里插入图片描述

初入江湖的菜鸟侠客们可以通过它迅速绘制出柱状图、折线图和饼状图等基础武学,简单易懂,入门容易。记得那年,当 18 岁的列位秃头少侠们在代码中添加 Chart 时,心中便涌起一股非凡的成就感,仿佛宝子们也成为了数据江湖中的一员。

如下便是一个简单的柱状图的代码示例,犹如初入武林的少年,凭借一把利剑便能出奇制胜:

import SwiftUI
import Charts

struct SimpleBarChart: View {
    let data: [Double] = [2, 3, 5, 7, 11, 13]

    var body: some View {
        Chart(data) { value in
            BarMark(x: .value("Value", value))
        }
        .chartXAxis {
            AxisMarks()
        }
    }
}

这就是“基本剑法”,以简洁利落见长,正如初出茅庐的剑客,刚刚踏入这个江湖那样的飘逸洒脱:

在这里插入图片描述

随着修炼的深入,少侠们会逐渐意识到,这种图表只能为大家在江湖中打下些许基础,但它也暴露出了一些不足。

在这里插入图片描述

虽然二十步之内,便可知敌人风吹草动,然而一旦对手修炼到更高的境界,单靠这种平凡的武学便显得不足以应对复杂的数据纷争。

2. 江湖变革——SwiftUI Charts 3D 图表来袭

谁能想象,江湖中的一场风云变幻,竟会让 Charts 框架焕发新生。

在WWDC 25 上,苹果总舵主为这门熟悉的武功奥义又注入了新的活力,推出了 Chart3D。这不再是寻常的剑法,而是进入了三维的殿堂。就像一位绝世大侠,早已超越了平面世界的束缚,开始在三维空间中闪展腾挪。

在这里插入图片描述

这时的 Chart3D,犹如一位有了深厚内力的高手,能在三维空间中挥洒自如。宝子们不再仅仅是直线和曲线的过客,而是能在空间中将数据点、曲面、视角交织成一幅立体图景。

无论是点的组合,还是面的铺展,或是交互的旋转,每一处都透露着数据与现实世界的紧密联系。

快看这段代码,仿佛是大侠抬手之间,便可将复杂的数据织入眼前的三维世界那样畅快淋漓:

struct ContentView: View {
    let data = [(2,5), (3,7), (5,11), (7,8), (11,20), (13,10)]

    var body: some View {
        NavigationStack {
            Chart3D(data.indices, id: \.self) { index in
                let item = data[index]
                PointMark(
                    x: .value("X", item.0),
                    y: .value("Y", item.1),
                    z: .value("Z", index)
                )
                .foregroundStyle(.red.gradient)
            }
            .chartXAxisLabel("X轴")
            .chartYAxisLabel("Y轴")
            .chartZAxisLabel("Z轴")
            .navigationTitle("Charts 心法展示")
        }
    }
}

此时,数据不仅停留在纸面上,而是跃然于三维空间之中,犹如剑客挥舞长剑,刺破苍穹:

在这里插入图片描述

现在,列为微秃小少侠们可以随心所欲地操控每一个数据点的位置,仿佛掌握了整个空间的节奏,就问你们赞不赞呢?

3. 轻功飞行——SurfacePlot 打造梦幻曲面图

在武林中,总有一些高手以轻盈如风的身法著称,他们步伐矫健,时隐时现。而 SurfacePlot 就如同这般,能够以平滑的曲面将两个变量之间的关系展现得淋漓尽致。

在这里插入图片描述

它能让小伙伴们不再拘泥于线条和点,而是化繁为简,把复杂的数据关系化作一张优美的曲面,轻盈地在三维空间中随意漂浮。

如果宝子们想描绘一条像“乾坤大挪移”般自由流畅的曲线,那便可以借助 SurfacePlot 来用数学函数得偿所愿。

下面的代码犹如一套行云流水的剑法,将数学的深奥与图形的简洁相结合,点滴之间尽显工艺之精妙:

import SwiftUI
import Charts

struct SurfacePlotChart: View {
    var body: some View {
        NavigationStack {
            Chart3D {
                SurfacePlot(x: "X", y: "Y", z: "Z") { x, z in
                    let h = hypot(x, z)
                    return sin(h) / h * 2
                }
                .foregroundStyle(.heightBased)
            }
            .chartXScale(domain: -10...10)
            .chartZScale(domain: -10...10)
            .chartYScale(domain: -0.23...10)
            .navigationTitle("Chart3D 心法传功")
        }
    }
}

这就像是武林中的一招“飞燕回旋”,优雅、流畅,同时又极具威力:

在这里插入图片描述

当各位微秃侠客们把数学函数转化为三维曲面,它就不再只是抽象的公式,而是化身为一场精彩的武林争斗,令人叹为观止。

4. 握剑诀——交互与视角

然而,江湖变幻莫测,如何在纷繁复杂的数据中保持清醒的视角,便成为了每个剑客面临的严峻挑战。

Chart3D 的强大之处不仅仅在于它能够描绘出三维的世界,更在于它提供了丰富的交互功能,能够根据需要调整视角,让宝子们从不同角度观看同一数据的图表,仿佛随时可以恣意改变剑法招式那般美妙。

在这里插入图片描述

以下代码示范了如何通过手势控制图表的旋转角度,并且设置初始的视角与相机投影,这种灵活性犹如大侠挥剑的自由度,让小伙伴们在数据的世界里遨游无碍:

struct ContentView: View {
    @State var data = [(Int,Int)]()
    
    private func createData() {
        for _ in 0...99 {
            let x = Int.random(in: 0...100)
            let y = Int.random(in: 0...100)
            data.append((x, y))
        }
    }
    
    @State private var pose = Chart3DPose(
        azimuth: .degrees(20),    // 水平角度
        inclination: .degrees(15) // 垂直角度
    )

    var body: some View {
        NavigationStack {
            Chart3D(data.indices, id: \.self) { index in
                let item = data[index]
                PointMark(
                    x: .value("X", item.0),
                    y: .value("Y", item.1),
                    z: .value("Z", index)
                )
                .foregroundStyle(.red.gradient)
            }
            .task {
                createData()
            }
            .chartXAxisLabel("X轴")
            .chartYAxisLabel("Y轴")
            .chartZAxisLabel("Z轴")
            .chart3DPose($pose)
            .navigationTitle("Charts 心法展示")
        }
    }
}

宝子们可以像大侠操控剑气那般,调整图表的角度与视野,每一次变化,都能带来全新的观感与体验:

在这里插入图片描述

同样我们略施小计,之前的曲面图也可以如法炮制:

在这里插入图片描述

从此,数据的世界也因此变得不再单调,而是充满了无限可能。

5. 3D 图表修炼的注意事项

5.1 避免数据过载

虽然 3D 图表能够展示丰富的数据信息,但在使用时也要注意避免数据过载。过多的数据点或过于复杂的数据维度,会让图表变得混乱不堪,就像武林高手在战斗中面对过多的敌人,反而会陷入困境。开发者需要对数据进行筛选和精简,突出重点,确保图表清晰可读性。

5.2 谨慎选择图表类型

不同的图表类型适用于不同的数据展示场景,在使用 3D 图表时,要根据数据的特点和分析目的,谨慎选择合适的图表样式。

例如,3D 柱状图适合用于对比数据,3D 散点图适合分析数据之间的关系,而 3D 饼图则不太适合这些场景,因为在三维空间中,饼图的角度和比例可能会让人产生视觉误解。

选择合适的图表类型,就像武林高手选择了合适的兵器,才能发挥出最大威力。

5.3 性能优化不容忽视

由于 3D 图表的渲染和计算量较大,容易对应用的性能产生影响。因此,在开发过程中,要注重性能优化。

可以采用异步加载数据、使用高效的数据结构和算法、合理利用缓存等方法,确保图表的加载和交互丝一般流畅顺滑,不给用户带来卡顿体验。否则,就像内力不足的武林高手,招式施展起来也会大打折扣。

6. 尾声:3D 图表开启数据可视化新纪元

从最初的 2D 图表到如今的 3D 图表,SwiftUI 7 的 Charts 框架在数据可视化的江湖中不断进化,为开发者们提供了越来越强大的工具。

3D 图表的出现,不仅让数据可视化变得更加生动、直观,也为武林高手们开辟了一片全新的江湖。

在这里,宝子们可以凭借自己的智慧和技艺,运用 3D 图表这一绝世神功,将数据的魅力展现得淋漓尽致,为用户带来前所未有的数据探索体验。

在这里插入图片描述

在未来的 iOS 开发江湖中,3D 图表必将成为开发者们手中的一把利器,助力他们在数据可视化领域中披荆斩棘,创造出更多令人惊叹的应用。而每一位开发者,都将在这个充满机遇与挑战的江湖中,书写属于自己的传奇故事。让我们怀揣着对技术的热爱和追求,勇敢地踏入这片新江湖,探索 3D 图表的无限未来吧!

此时,数据的江湖,已经不再是一个简单的平面,而是充满了三维空间的无限元宇宙,正如你们已然成为了这片江湖中举世无双的大侠一样,棒棒哒!

那么,感谢各位少侠的观赏!再会啦!8-)

Flutter与iOS混合开发交互

1、安装Flutter环境

1、下载SDK并安装

docs.flutter.cn/get-started…

2、 配置环境

如果 ~/.zshenv 文件存在,请在文本编辑器中打开 Zsh 环境变量文件 ~/.zshenv。如果不存在,请创建 ~/.zshenv
export PATH=$HOME/development/flutter/bin:$PATH加入到文件的最后面

创建Flutter项目

以Flutter为主

以Flutter为主:意思是直接创建完整的flutter项目,里面就已经包含了iOS、Android等工程。直接用即可

在需要的目录中 执行 flutter create aiflutter

配置

进入iOS文件夹

这里需要注意: 需要用到CocosPods将Flutter作为组件导入到项目,但是Flutter并没有直接生成Podfile文件。需要自己init一个

在进行 Podfile install时,会有警告。 如果想要去掉警告,需要按照以下方式修改。但是修改之后会运行不起来

image.png

正确的应该是选中Debug.xcconfig、Release.xcconfig

image.png

直接运行会报错:

Command PhaseScriptExecution failed with a nonzero exit code

这是由于Run Script的脚本找不到正确路径

需要修改 Podfile文件

具体如下:

source 'https://github.com/CocoaPods/Specs.git'

# Uncomment this line to define a global platform for your project

platform :ios, '13.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
  'Debug' => :debug,
  'Profile' => :release,
  'Release' => :release,
}

def flutter_root
  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
  unless File.exist?(generated_xcode_build_settings_path)
    raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
  end

  File.foreach(generated_xcode_build_settings_path) do |line|
    matches = line.match(/FLUTTER_ROOT\=(.*)/)
    return matches[1].strip if matches
  end
  raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end

require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

flutter_ios_podfile_setup

target 'Runner' do
  use_frameworks!

  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
  target 'RunnerTests' do
    inherit! :search_paths
  end
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
  end
end

在使用过程中, 因为iOS工程是其他人创建后给我的,在进行pod install的时候,出现了路径找不到的报错: 修改这个路径

image.png

此时如果直接运行,将会报错

Unable to load contents of file list: '/Target Support Files/Pods-Runner/Pods-Runner-frameworks-Debug-input-files.xcfilelist'

需要回到Flutter项目目录下,执行flutter run.
其实在执行flutter create flutterdemo完成的时候,就已经提示了

In order to run your application, type:
  $ cd test
  $ flutter run
Your application code is in test/lib/main.dart.

报错

如果报错: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.

Build Settings中搜索ENABLE_USER_SCRIPT_SANDBOXING 将其设置为NO,如果原本是NO,设置为YES后运行一次后再改为NO

错误处理
1、确保执行过flutter run
2、在Build Settings中搜索ENABLE_USER_SCRIPT_SANDBOXING 将其设置为NO
3、使用pod init新建一个podfile文件并修改里面的内容
4、PROJECT -> info -> Configurations中的Debug、Release 设置为对应的 Debug.xcconfig、Release.xcconfig
5、确认ios/Flutter路径下的Generated.xcconfig中的配置FLUTTER_ROOT、FLUTTER_APPLICATION_PATH是否正常

总结

1、安装FlutterSDK并配置其环境

2、使用命令创建Flutter项目flutter create flutterdemo

3、执行cd flutterdemo 和 flutter run命令

4、导入podfile文件并执行pod install命令

以iOS为主

以iOS为主意思是:手动创建一个iOS工程,将Flutter作为一个组件导入到iOS项目中

1、创建一个iOS工程AIIOSDemo,并进行pod
2、在同级目录下新建Flutter项目:flutter create -t module my_flutter\

image.png 3、在podfile中引入flutter

source 'https://github.com/CocoaPods/Specs.git'

platform :ios, '15.0'


# 1、在文件顶部添加 flutter_application_path
flutter_application_path = '../my_flutter'     #这里是刚才创建的flutter module名称
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')


target 'iOSDemo' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  pod 'SnapKit'
  
  pod 'LookinServer', :subspecs => ['Swift'], :configurations => ['Debug']
    
  
  // 2、引入路径
  install_all_flutter_pods(flutter_application_path)
  
end


# 3、添加这个 post_install 块
post_install do |installer|
  flutter_post_install(installer)
end

页面跳转

从原生跳转到Flutter页面 可参考链接

在使用Flutter之前,需要先注册GeneratedPluginRegistrant

//在AppDelegate中定义全局的flutterEngine
 lazy var flutterEngine: FlutterEngine = FlutterEngine(name: "com.brainco.gameEngine")


 private func initEngine() {
     // 在用到Flutter之前,要先注册这个方法
     //这个要在跳转方法之前运行环境,也可以在appdelegate里面启动就初始化,环境运行需要时间,单写在跳转方法里面靠前位置是不可以的。
     flutterEngine.run();
     GeneratedPluginRegistrant.register(with: flutterEngine);
 }
 
  • 直接以FlutterViewController为页面 在原生页面初始化按钮,并添加点击事件,在事件中实现以下代码:
   
    func jumpToFlutterPage() -> Void {
        
        let flutterEngine = (UIApplication.shared.delegate as? AppDelegate)?.flutterEngine
        let flutterViewController = FlutterViewController(engine: flutterEngine!, nibName: nil, bundle: nil)
        
        /*
        // 可以通过MethodChannel传递参数
        let channel = FlutterMethodChannel(
            name: "com.example.app/flutter",
            binaryMessenger: flutterViewController.binaryMessenger
        )
        
        // 可选 -- 设置初始路由或传递参数
        channel.invokeMethod("initialRoute", arguments: "/targetPage")
         
        // 可选 -- 设置监听,执行Flutter调用原生的方法
        channel.setMethodCallHandler{[weak self] (call, result) in
            guard let strongSelf = self else { return }
            print("flutter 给到我 method:\(call.method) arguments:\(String(describing: call.arguments))")
        }
         */
        self.navigationController?.pushViewController(flutterViewController, animated: true)
    }
  • 将Flutter作为ChildViewController加入原生的viewController

class FlutterCustomViewController: BaseViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        
        let flutterEngine = (UIApplication.shared.delegate as? AppDelegate)?.flutterEngine
        let flutterViewController = FlutterViewController(engine: flutterEngine!, nibName: nil, bundle: nil)
        
        /*
        // 可以通过MethodChannel传递参数
        let channel = FlutterMethodChannel(
            name: "com.example.app/flutter",
            binaryMessenger: flutterViewController.binaryMessenger
        )
        
        // 可选 -- 设置初始路由或传递参数
        channel.invokeMethod("initialRoute", arguments: "/targetPage")
         
        // 可选 -- 设置监听,执行Flutter调用原生的方法
        channel.setMethodCallHandler{[weak self] (call, result) in
            guard let strongSelf = self else { return }
            print("flutter 给到我 method:\(call.method) arguments:\(String(describing: call.arguments))")
        }
         */
        
        self.addChild(flutterViewController)
        self.view.addSubview(flutterViewController.view)
        flutterViewController.view.snp.makeConstraints{make in
            make.left.right.top.bottom.equalToSuperview()
        }
    }

iOS与Flutter交互

Flutter 与原生存在三种交互方式 可参考链接

三种 Channel 之间互相独立,各有用途,但它们在设计上却非常相近。每种 Channel 均有三个重要成员变量:

  • name: 【重要参数】String类型,代表 Channel 的名字,也是其唯一标识符需要和Fluter中的定义保持一致
  • messager:【重要参数】BinaryMessenger 类型,代表消息信使,是消息的发送与接收的工具
  • codec: MessageCodec 类型或 MethodCodec 类型,代表消息的编解码器

MethodChannel

一般用于传递方法调用(method invocation)通常用于Flutter调用原生中某个方法

举例:使用场景-Flutter需要获取原生生成的用户UUID,并传递UUID做存储操作


// 引入Flutter
import Flutter

@objc class AppDelegate: FlutterAppDelegate {

// 枚举的方式定义方法名
enum FlutterMethodType: String {
    case saveUUID       = "saveUUID"        ///< 保存 UUID
    case getUUID        = "getUUID"         ///< 获取 UUID
}


    let controller = window?.rootViewController as! FlutterViewController

// 初始化参数,并设置回调handle

     func MethodChannelRegist(controller: FlutterViewController) {
        let methodChannel_channer = FlutterMethodChannel(
            name: "com.example/ai/snowflake",
            binaryMessenger: controller.binaryMessenger
        )
        methodChannel_channer.setMethodCallHandler { [weak self] (call, result) in
            guard let self = self else { return }
            self.flutterMethodChanner_channer(call: call, result: result)
        }
    }
    
    // flutter 调用 swift
    private func flutterMethodChanner_channer(call: FlutterMethodCall, result: FlutterResult) -> Void {
        if call.method == FlutterMethodType.getUUID.rawValue {
            let uuid = "uuid"
            result(uuid)
        }else if call.method == FlutterMethodType.saveUUID.rawValue {
            let success = true
            result(success)
        } else {
            result(FlutterMethodNotImplemented)
        }
    }
    
}

image.png

BasicMessageChannel

它是可以双端通信的,Flutter 端可以给 iOS 发送消息,iOS 也可以给 Flutter 发送消息。

// 全局,方便随时可以发送消息
 var basicMessageChannel: FlutterBasicMessageChannel? = nil
 
 // 其他和MethodChannel基本一致
 func BasicMessageChannelRegist(controller: FlutterViewController) {
        basicMessageChannel = FlutterBasicMessageChannel(name: "com.example/ai/snowflake",
                                                             binaryMessenger: controller.binaryMessenger)
        
        basicMessageChannel?.setMessageHandler { [weak self] (call, result) in
            guard let self = self else { return }
            self.flutterMethodChanner_channer(call: call as! FlutterMethodCall, result: result)
        }
        
        // 相比MethodChannel 最重要的区别就是这个 可以主动向Flutter发送消息
        basicMessageChannel?.sendMessage(["name":"隔壁老王","age":25])
        
    }
    
    // flutter 调用 swift
    private func flutterMethodChanner_channer(call: FlutterMethodCall, result: FlutterResult) -> Void {
        if call.method == "methodOne" {
            
        }else if call.method == "methodTwo" {
            
        } else {
            result(FlutterMethodNotImplemented)
        }
    }
 

image.png

EventChannel

只能是原生发送消息给 Flutter 端,例如监听手机电量变化,网络变化,传感器等。

func eventChannelRegist(controller: FlutterViewController) {
        let eventChannel = FlutterEventChannel(
          name: "com.example.demo/event",
          binaryMessenger: controller.binaryMessenger
        )
        eventChannel.setStreamHandler(self)
    }
    
    // MARK: FlutterStreamHandler
    var eventSink: FlutterEventSink? = nil
    func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        self.eventSink = events
        return nil
    }
    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        self.eventSink = nil
        return nil
    }
    
    func sendEvent(data: Any) {
        eventSink?(data) // 主动发送数据到 Flutter
      }

image.png

记录一次Flutter项目上传App Store Connect出现“Validation failed”错误的问题

描述

  • Flutter老项目,在flutter3.7之前的版本创建的
  • 现在用Flutter 3.16.9版本适配后,运行iOS都很正常
  • Xcode上 Product -> Archive 打包成功
  • 上传到App Store Connect,在validate环节报错,如下:

企业微信截图_b6551138-9586-4b55-aca0-420e99c9e670.png 错误日志:

...
Invalid Bundle. The bundle Runner.app/Frameworks/App.framework does not support the minimum OS Version specified in the Info.plist.
...

分析

现阶段App Store Connect 要求上传的ipa包的支持的最低系统版本要高于iOS 12.0,应该是项目里的配置或者一些第三方库的支持版本小于12.0了。需要从项目本身和第三方库两个方面着手处理。

处理

  1. 项目支持版本号设置:项目/ios/Podfile文件上面设置 platform :ios, '12.0' image.png

  2. 调整第三方库的支持版本号,同样是修改 项目/ios/Podfile文件 image.png

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0'
    end
  end
end

3.修改AppFrameworkInfo.plist文件里的MinimumOSVersion的值,一些老项目MinimumOSVersion的值都小于12.0(本次是有11.0调整为12.0)。 image.png

GoogleAdsOnDeviceConversion 库的作用与用法

GoogleAdsOnDeviceConversion 库详细报告

1. 概述

GoogleAdsOnDeviceConversion 是一个专为 iOS 应用设计的软件开发工具包(SDK),用于实现设备端转换测量(On-Device Conversion Measurement)。它的核心功能是帮助开发者在用户设备上直接收集和处理广告转换数据(如应用安装、重新安装等),以支持隐私保护的广告效果归因。该库特别适用于无法直接集成 Google Analytics for Firebase (GA4F) 的场景,或者需要独立 SDK 的情况。

1.1 主要作用

  • 隐私保护:通过在设备端处理转换数据,减少敏感数据传输,符合严格的隐私法规(如 GDPR)。
  • 独立性:提供独立于 Firebase 的解决方案,适合多样化的开发需求。
  • 广告效果优化:帮助广告主了解 iOS 应用广告的效果,优化营销策略。

1.2 适用场景

  • iOS 应用开发者希望在保护用户隐私的同时,测量广告转换效果。
  • 无法或不愿使用 Google Analytics for Firebase 的项目。
  • 需要与第三方归因平台(如 Adjust)集成的场景。

2. 集成方式

GoogleAdsOnDeviceConversion 支持通过 CocoaPods 和 Swift Package Manager 集成。以下是详细步骤:

2.1 使用 CocoaPods

  1. 打开项目的 Podfile,添加以下行:
    pod 'GoogleAdsOnDeviceConversion'
    
  2. 在终端中运行以下命令:
    pod install --repo-update
    

2.2 使用 Swift Package Manager

  1. 在 Xcode 中,点击 File > Add Packages
  2. 在搜索栏输入以下 GitHub 仓库 URL:
    https://github.com/googleads/google-ads-on-device-conversion-ios-sdk
    
  3. 选择版本(建议选择 "Up to Next Major Version"),然后点击 Add Package
  4. Xcode 将自动解析并下载依赖项。

2.3 注意事项

  • 如果您的应用使用 Firebase Analytics 11.14.0 或更高版本,GoogleAdsOnDeviceConversion 可能已自动包含,无需手动添加。
  • 确保您的 Xcode 版本为 16.0 或更高,目标 iOS 版本为 12.0 或更高。

3. 使用方法

以下是如何在 Swift 中使用 GoogleAdsOnDeviceConversion 的详细步骤。

3.1 Swift 中的使用

在 Swift 项目中,您需要执行以下步骤:

3.1.1 导入库

在需要使用该库的 Swift 文件顶部添加:

import GoogleAdsOnDeviceConversion
3.1.2 设置首次启动时间

在应用启动时(例如,在 AppDelegateSceneDelegateapplication(_:didFinishLaunchingWithOptions:) 方法中),设置应用的首次启动时间:

ConversionManager.sharedInstance.setFirstLaunchTime(Date())

此步骤对于准确的转换归因至关重要。

3.1.3 获取转换信息

使用以下方法获取聚合的转换信息,通常用于应用安装(.installation):

ConversionManager.sharedInstance.fetchAggregateConversionInfo(for: .installation) { aggregateConversionInfo, error in
    if let info = aggregateConversionInfo {
        // 使用 aggregateConversionInfo,例如作为 odm_info 参数
        print("Aggregate Conversion Info: \(info)")
    } else if let error = error {
        print("Error fetching conversion info: \(error)")
    }
}
  • aggregateConversionInfo 是一个字符串(例如 "abcdEfadGdaf"),可作为 odm_info 查询参数传递给广告平台。

3.3 示例代码

以下是一个完整的 Swift 示例,展示如何在应用启动时设置和获取转换信息:

import UIKit
import GoogleAdsOnDeviceConversion

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // 设置首次启动时间
        ConversionManager.sharedInstance.setFirstLaunchTime(Date())
        
        // 获取转换信息
        ConversionManager.sharedInstance.fetchAggregateConversionInfo(for: .installation) { aggregateConversionInfo, error in
            if let info = aggregateConversionInfo {
                print("Aggregate Conversion Info: \(info)")
                // 示例:将 info 传递给广告平台
                // let url = URL(string: "https://your-ad-platform.com?odm_info=\(info)")!
            } else if let error = error {
                print("Error: \(error.localizedDescription)")
            }
        }
        return true
    }
}

4. 与其他 SDK 的集成

4.1 与 Firebase Analytics

  • 如果您的应用使用 Firebase Analytics 11.14.0 或更高版本,GoogleAdsOnDeviceConversion 可能已自动包含,无需手动添加。
  • 若需手动集成,确保 Podfile 中未重复添加依赖。

4.2 与 Adjust SDK

如果您使用 Adjust SDK 进行归因,可以通过 Adjust 的 ODM 插件启用设备端转换测量:

  • Podfile 中添加:
    pod 'Adjust/AdjustGoogleOdm'
    pod 'GoogleAdsOnDeviceConversion', '2.0.0'
    
  • 在应用启动时尽早调用 Adjust SDK 的 initSdk 方法,例如:
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        Adjust.initSdk(with: yourConfig)
        return true
    }
    
  • 如果需要延迟 SDK 初始化,可使用 Adjust 的 First Session Delay 功能。
  • Adjust SDK 会自动处理首次启动时间捕获,确保归因准确。

5. 注意事项

以下是使用 GoogleAdsOnDeviceConversion 时需要注意的关键点:

方面 细节
首次启动时间 确保 setFirstLaunchTime() 设置的日期是应用的实际首次启动时间,否则可能影响归因准确性。
地域限制 该功能在欧洲经济区(EEA)、英国和瑞士不可用(参考 Google Ads Help)。
Firebase 集成 使用 Firebase Analytics 11.14.0 或更高版本时,无需手动添加此库。
版本兼容性 Adjust SDK 已测试版本 2.0.0 的 GoogleAdsOnDeviceConversion,建议验证新版本的兼容性。
隐私合规性 确保您的应用披露如何使用 Google 服务处理数据(参考 Google Ads 隐私披露)。

6. 总结

GoogleAdsOnDeviceConversion 库是 iOS 应用开发者精确衡量 Google Ads 广告系列带来的应用安装和后续应用内操作的关键工具。其主要目标是在严格遵守用户隐私标准的前提下,增强广告系列的优化和报告能力 。  

从功能上看,它是一个“设备端转化衡量插件”,旨在与 Firebase Analytics SDK 和 Google 的应用归因合作伙伴 (AAP) SDK 协同工作 。这种集成表明它并非一个独立的广告 SDK,而是一个专门的实用工具,用于在更广泛的 Google Ads 和 Firebase 生态系统中提高转化数据的精确度。  

该库在设计上持续强调“隐私保护” ,并特别提及“去标识化的临时信号” 。这表明 Google 正在对不断变化的隐私法规(如通用数据保护条例 (GDPR))和平台级变化(如 Apple 的应用跟踪透明度 (ATT) 框架)做出战略性和前瞻性的调整。通过在用户设备上直接进行归因,而无需将可识别的个人数据传输到设备之外,Google 旨在提供强大而有效的衡量能力,这些能力本身就符合现代隐私期望。这种方法将该库定位为在隐私受限环境中保持广告有效性的必要适应。

SwiftUI 新手必读:如何用纯 SwiftUI 在应用中实现分段控制?

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

前言


在现代应用程序开发中,分段控制(Segmented Control)是一种常用的界面元素,它由多个水平排列的部分组成,每个部分都可以作为一个互斥的按钮供用户选择。

在 SwiftUI 中,虽然没有专门的视图来实现分段控制,但苹果将其视为 Picker 的一种变体,这种设计理念其实是非常合理的,因为它们在功能上具有相似性。

接下来就来看看如何在 SwiftUI 中创建分段控制。

分段控制的概念

在 UIKit 中,我们熟悉的 UISegmentedControl 是专门用于实现这种控件的,而在 SwiftUI 中,Picker 则是其等效的实现方式。

Picker 是 SwiftUI 中用于选择互斥值的一种控件,与 UIKit 的 UIPickerView 类似。通过使用 Picker,我们可以轻松地创建分段控制。

如何创建分段控制

既然分段控制是 Picker 的一种变体,我们可以通过创建一个 Picker 视图,并通过 pickerStyle 修饰符应用 SegmentedPickerStyle() 来实现。以下是一个简单的示例:

struct ContentView: View {
    @Stateprivatevar selectedColorIndex = 0
    privatelet colors = ["红色""绿色""蓝色"]
    var body: some View {
        VStack {
            Picker("选择颜色", selection: $selectedColorIndex, content: {
                ForEach(0..<colors.count) { index in
                    Text(colors[index]).tag(index)
                }
            })
            .pickerStyle(SegmentedPickerStyle()) // <1>
            Text("选中的颜色 : \(colors[selectedColorIndex])")
        }
    }
}

在这个示例中,我们定义了一个 Picker,用于选择用户喜欢的颜色,并通过 SegmentedPickerStyle() 使其显示为分段控制的样式。

看下效果:

image.png

使用场景与建议

虽然分段控制和 Picker 在功能上相似,但它们在使用场景上有一些区别。

分段控制通常用于在不同的视图之间切换,例如在地图应用中,用户可以通过分段控制在地图、公交和卫星视图之间切换。而 Picker 则更适合用于从一长串选项中进行选择。

结论

SwiftUI 通过将分段控制视为 Picker 的一种变体,使得开发者能够以一种简洁而直观的方式实现这一功能。通过合理使用分段控制,我们可以为用户提供更友好的界面交互体验。

希望这篇文章能够帮助你更好地理解和使用 SwiftUI 中的分段控制。如果你有任何问题或建议,请在评论区告诉我们!

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

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

哪些产品符合免备案的骚操?看看你的产品符合吗?

前言

免备案其实是AppStore对于个人开发者示好的一种方式,也体现出AppStore更多的一个包容性。

由于后台询问是否符合AppStore免备案的骚操作有点多,所以干脆单独拿出来讲一下。希望可以帮助到更多对此感到困惑的同行们

其实这条福利更多是提供给单机的产品。

如何界定单机?

单机的界定并不是我觉得是单机,那就是单机!

从传统意义上来讲,单机就是不联网的App。但是对于AppStore来讲并没有这个严格的界定。上篇文章关于免备案骚操作工具的产品,其实本身也是存在网络请求的。

首当其冲的就是AppStore自带的内购请求,作为个人开发者唯一变现的通道,其实本身依旧依赖于网络请求的渲染。

另外,顺利免备案的应用也调用的其他网络请求。但其实核心具备以下要素:

1.不进行任何信息的收集,同时本身免登录、不记录用户收集,不追踪用户设备。

2.低频调用接口。因为工具类需要使用一些基本鉴权功能,防止被流量攻击以及用户基本管理。

3第三方广告API。作为白嫖用户最好的通道,也是产品最低的保障,这点无可或缺。这里主要是代指国内知名厂商,比如:头条的穿山甲、腾讯的优量汇、快手SDK以及百度常青藤。 其他非大厂的变现SDK建议慎重使用。

不使用任何接口

最简单粗暴符合,免备案的条件的其实就是屏蔽任何网络请求。那么,聪明的你就要问了,这还怎么变现?

其实只能用同样最简单粗暴的付费下载!如果,既要安全,又要收集,还要长久。那么还是花点钱备案吧,免得提心吊胆。

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

相关推荐

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

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

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

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

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

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

知识星球

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

依赖注入(六):架构黄金标准:为何选择Coordinator,以及如何用好它

在前面的分享中,我们已经建立了对“显式依赖注入”的深刻认同。现在,我们面临最后一个,也是最关键的架构决策:如何组织我们应用的导航逻辑?我们是应该改良现有的Router模式,还是全面转向Coordinator模式?

我将首先清晰地辨析CoordinatorRouter的本质区别,以阐明我们做出选择的理由。然后,我将结合MVVM和现代并发框架,为大家呈现一套完整的Coordinator模式最佳实践。

第一部分:正本清源 - Coordinator vs. Router

在很多讨论中,CoordinatorRouter经常被混用,但它们在设计哲学和实现细节上有着天壤之别。

特性 Router (URL-based 或 Protocol-based) Coordinator
核心隐喻 全局“电话总机”或“URL调度中心” 专职“旅行团导游”
核心职责 响应一个标识符(URL或协议),触发一个未知的跳转 管理一个完整、具象的业务流程(Use Case)
调用方式 Router.open("app://profile?id=123") profileCoordinator.start(userId: "123")
参数传递 通常是弱类型(字符串),难以传递复杂对象 强类型,可以通过构造函数或方法传递任何对象
依赖管理 隐式(黑盒):调用方不知道目标页面需要什么 显式(白盒):Coordinator负责为页面注入其所有依赖
导航控制 全局或分散,导航逻辑(push/present)与业务流分离 内聚,由Coordinator自身完全控制其流程内的导航
生命周期 通常是全局单例 有自己的生命周期,可以随业务流创建和销毁

结论:为何必须选择Coordinator?

Router模式的根本问题在于它是一个服务定位器(Service Locator)。它鼓励了“主动查询”的行为,隐藏了依赖关系,牺牲了类型安全和可测试性。任何试图改良Router的努力,都只是在为一个有缺陷的地基做表面装修。

Coordinator模式是一次架构思想的升维。它天生就与依赖注入思想完美契合:

  • 它自身通过DI被创建和配置。
  • 它作为DI的执行者,为它所管理的MVVM栈注入依赖。
  • 它将导航逻辑(“How”)与业务意图(“What”)清晰地分离开来。

因此,我们的决策是明确且坚定的:在应用内部导航中,全面采用Coordinator模式,彻底摒弃基于服务定位器的Router模式。 仅在需要响应外部事件(推送、H5)时,可以保留一个极轻量的URLDispatcher,其唯一作用是解析URL并启动一个根Coordinator


第二部分:最佳实践 - Coordinator + MVVM + 现代并发

现在,让我们进入实战环节。以下是一套完整的、值得在所有新项目中推行的最佳实践。

  • DI容器 (Swinject): 超级工厂。只在组合根中配置,负责创建一切。
  • Coordinator: 交通指挥。负责业务流、依赖组装和导航。
  • MVVM (View - ViewModel): 场景呈现者。ViewModel处理逻辑和状态,View负责渲染。

2. 核心原则:清晰的通信边界

  • Coordinator -> ViewModel: 通过构造函数注入依赖(如Service)。
  • ViewModel -> Coordinator: 绝对不能反向持有Coordinator的引用! ViewModel通过响应式信号(Combine/AsyncStream)或闭包向外发送导航“意图”。
  • ViewModel <-> View: 通过@StateObject/@ObservedObject@Published属性进行双向绑定。

3. 完整示例:一个支持回调的“商品选择”流程

想象一个场景:在创建订单页面,需要弹出一个商品选择页面,选择完商品后,需要将商品ID回调给创建订单页面。

步骤1:定义Coordinator协议和父子关系

protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get set }
    func start()
}

// 方便地管理子Coordinator
extension Coordinator {
    func addChild(_ childCoordinator: Coordinator) {
        childCoordinators.append(childCoordinator)
    }
    
    func removeChild(_ childCoordinator: Coordinator) {
        childCoordinators = childCoordinators.filter { $0 !== childCoordinator }
    }
}

步骤2:实现ProductSelectionCoordinator

import Combine

class ProductSelectionCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    private let resolver: Resolver

    // 使用Combine的Subject来传递回调结果,类型安全
    let selectionResult = PassthroughSubject<String, Never>()
    private var cancellables = Set<AnyCancellable>()

    init(resolver: Resolver, navigationController: UINavigationController) {
        self.resolver = resolver
        self.navigationController = navigationController
    }

    func start() {
        let viewModel = resolver.resolve(ProductSelectionViewModel.self)!
        let viewController = ProductSelectionViewController(viewModel: viewModel)
        
        // 监听ViewModel的输出
        viewModel.didSelectProduct
            .sink { [weak self] productId in
                // 1. 将结果通过自己的Subject发射出去
                self?.selectionResult.send(productId)
            }
            .store(in: &cancellables)
        
        viewModel.didCancel
            .sink { [weak self] in
                // 2. 空转结果,表示取消
                self?.selectionResult.send(completion: .finished)
            }
            .store(in: &cancellables)

        // 通常以模态形式弹出
        navigationController.present(UINavigationController(rootViewController: viewController), animated: true)
    }
}

步骤3:实现ProductSelectionViewModel

import Combine

class ProductSelectionViewModel: ObservableObject {
    private let productService: ProductServicing
    
    // 输出给Coordinator的信号
    let didSelectProduct = PassthroughSubject<String, Never>()
    let didCancel = PassthroughSubject<Void, Never>()
    
    // 输出给View的状态
    @Published var products: [Product] = []
    
    init(productService: ProductServicing) {
        self.productService = productService
    }
    
    // 供View调用的方法
    func selectProduct(at index: Int) {
        let productId = products[index].id
        didSelectProduct.send(productId)
    }
    
    func cancelButtonTapped() {
        didCancel.send()
    }

    @MainActor
    func fetchProducts() async {
        self.products = await productService.fetchAll()
    }
}

步骤4:在父Coordinator (CreateOrderCoordinator) 中使用它

class CreateOrderCoordinator: Coordinator {
    // ...
    func showProductSelection() {
        let selectionCoordinator = resolver.resolve(ProductSelectionCoordinator.self, argument: self.navigationController)!
        addChild(selectionCoordinator)
        
        selectionCoordinator.selectionResult
            .sink(receiveCompletion: { [weak self] _ in
                // 无论成功或取消,流程结束,都移除子Coordinator
                self?.removeChild(selectionCoordinator)
                self?.navigationController.dismiss(animated: true)
            }, receiveValue: { [weak self] productId in
                // 成功获取到商品ID,更新自己的ViewModel
                self?.viewModel.update(with: productId)
            })
            .store(in: &cancellables)
            
        selectionCoordinator.start()
    }
    // ...
}

4. 易错点与最终建议

  • 生命周期管理是关键: 必须通过addChildremoveChild来正确管理Coordinator的生命周期,否则将导致内存泄漏。
  • 通信必须单向: ViewModel绝不能知道Coordinator的存在。通信永远是ViewModel -> Coordinator的单向信号。
  • DI容器的纯洁性: ViewModelView中绝对不能出现container.resolve的代码。依赖必须在初始化时注入。

总结:一套值得信赖的架构

通过将DI容器CoordinatorMVVM三者有机结合,我们建立了一个分层清晰、职责单一、高度可测的黄金架构。它解决了传统MVC和Router模式的种种弊病,为我们构建复杂、可维护的大型应用提供了坚实的基础。

在第七篇中,我将会介绍一个 MVVMC 的实际使用示例,敬请期待。

依赖注入(五):DI是一种思想,而非特定工具——工厂、抽象与组合根

在过去的四篇文章中,我们从DI的基础理论聊到手写容器,再到Swinject框架实战,最后还探讨了它在声明式UI中的应用。至此,我们对“使用DI容器”已经有了非常深入的了解。

但是,如果我们将目光仅仅局限在Swinject这样的“DI容器”上,就可能会陷入“手里拿着锤子,看什么都是钉子”的思维定式。

今天,我们要回归本源,再次强调一个核心观点:依赖注入(DI)是一种设计思想,而不是某一个特定的工具或框架。 理解了这一点,我们就能在不同的场景下,选择最恰当的方式来实现解耦,而不是一味地追求“上容器”。

1. DI思想的多种实现形态

DI思想的本质是将依赖的创建和管理责任从使用者内部转移到外部。实现这一目标,我们有多种武器可选。

a. 手动DI (Manual DI)

这其实是我们最开始接触,也是最朴素的DI形式。它不借助任何框架,直接在代码中通过构造函数或属性来传递依赖。

// 在某个负责组装的类(比如一个Factory或者Coordinator)中
func makeProfileScene() -> UIViewController {
    // 手动创建和注入依赖
    let apiService = APIService()
    let userCache = UserCache()
    let userManager = UserManager(api: apiService, cache: userCache)
    let viewModel = ProfileViewModel(manager: userManager)
    let viewController = ProfileViewController(viewModel: viewModel)
    return viewController
}

优点:

  • 零依赖: 不需要引入任何第三方框架。
  • 极其直观: 代码如何执行一目了然,没有“黑魔法”。
  • 编译时安全: 所有依赖关系都在编译时确定。

缺点:

  • 代码冗长: 当依赖链很长或很复杂时,组装代码会变得非常繁琐。

适用场景:

  • 小型项目或独立模块。
  • 在应用的“组合根”(后面会详谈)进行最高层的对象组装。
  • 当你希望对依赖的创建有最细粒度的控制时。

b. 工厂模式 (Factory Pattern)

工厂模式是面向对象设计中的经典模式,它本身就是DI思想的一种体现。工厂类封装了创建对象的复杂逻辑,调用者只需向工厂请求一个对象,而无需关心其内部是如何被创建和组装的。

// 一个专门负责创建ViewController的工厂
class ViewControllerFactory {
    // 工厂自身也可能有依赖,通过构造函数注入
    private let apiService: APIService
    private let userManager: UserManager

    init(apiService: APIService, userManager: UserManager) {
        self.apiService = apiService
        self.userManager = userManager
    }

    func makeLoginViewController() -> LoginViewController {
        let viewModel = LoginViewModel(apiService: self.apiService)
        return LoginViewController(viewModel: viewModel)
    }

    func makeProfileViewController() -> ProfileViewController {
        let viewModel = ProfileViewModel(manager: self.userManager)
        return ProfileViewController(viewModel: viewModel)
    }
}

优点:

  • 封装创建逻辑: 将复杂的创建过程从业务代码中分离出来。
  • 职责单一: 工厂的职责就是创建,非常清晰。

适用场景:

  • 当对象的创建过程比较复杂,包含一些配置或判断逻辑时。
  • 作为DI容器的一种轻量级替代方案,用于管理某一类特定对象(如所有ViewController)的创建。

c. 服务协议 (Service Protocol) 与“协议式服务发现”的辨析

我们一直在强调面向协议编程是DI的基石。在Swift的生态中,协议(Protocol)是一个极其强大的工具,它也被用于一些组件化方案中,实现所谓的“协议式服务发现”。我们必须清晰地辨析它与我们所提倡的依赖注入模式的区别。

“协议式服务发现”模式剖析

这种模式通常会有一个全局的管理者(比如叫RouterServiceComponentManager),并结合泛型来实现服务的注册和发现。

// ---- 这种模式的典型实现 ----

// 1. 公共模块定义协议
public protocol ProfileServiceProvider {
    func getProfileViewController(for userId: String) -> UIViewController
}

// 2. 在Profile组件中实现并“注册”
class ProfileModule: ProfileServiceProvider { /*...*/ }
// 通过某种机制,比如启动时扫描或手动编码,将 `ProfileServiceProvider.self` 和 `ProfileModule.new` 关联起来
RouterService.shared.register(service: ProfileServiceProvider.self, implementation: ProfileModule.init)

// 3. 在使用方,通过泛型协议“发现”服务
// 这个 `rs` 属性可能是一个通过 @dynamicMemberLookup 实现的语法糖
let profileVC = RouterService.rs.profileServiceProvider.getProfileViewController(for: "123")
navigationController.push(profileVC)

// 泛型解析的背后逻辑
extension RouterService {
    func service<T>(for protocolType: T.Type) -> T? {
        // ... 从注册表中查找并返回实现类的实例 ...
    }
}
// 所以 RouterService.rs.profileServiceProvider 实际上是调用了 service(for: ProfileServiceProvider.self)

为什么我们要警惕并避免这种模式?

尽管这种方式利用了协议和泛型,看起来很“Swift-y”,并且实现了模块间的解耦,但它从根本上违反了依赖注入的核心原则,并退化为了**服务定位器(Service Locator)**模式,其危害我们在第一篇中已经深入讨论过:

  1. 隐藏依赖(幽灵依赖): 任何一个需要ProfileServiceProvider的类,在其公开接口(如init)上完全看不出来。依赖关系深埋在实现细节中,你需要通读代码才能发现它调用了RouterService。这严重破坏了代码的可读性和可维护性。
  2. 耦合到全局定位器: 所有业务代码都与RouterService.shared这个全局单例紧密耦合。这使得代码单元测试变得极其困难。为了测试一个ViewModel,你必须去处理或模拟这个全局的RouterService,而不是简单地在创建ViewModel时传入一个Mock对象。
  3. 职责不清: ViewModel或ViewController的职责应该是处理业务逻辑和UI状态,而不应该关心它的依赖是从哪里来的。让它自己去“定位”服务,是典型的职责不清。

我们的选择:明确的依赖注入

依赖注入坚持:一个类需要什么,就必须在它的构造函数里明确声明。

// 依赖注入的方式
class SomeCoordinator {
    private let profileServiceProvider: ProfileServiceProvider // 依赖是明确的成员变量

    init(profileServiceProvider: ProfileServiceProvider) { // 通过构造函数注入
        self.profileServiceProvider = profileServiceProvider
    }
    
    func showProfile() {
        let vc = profileServiceProvider.getProfileViewController(for: "123")
        // ...
    }
}

结论: “协议式服务发现”是一种伪装成现代模样的服务定位器。它解决了模块解耦,却以牺牲代码清晰度、可测试性和职责单一性为代价。因此,在我们的实践中,应当明确拒绝这种模式,始终选择通过构造函数进行显式依赖注入。

2. 架构的关键节点:“组合根” (Composition Root)

我们一直在说“依赖由外部提供”,那么这个“外部”的尽头是哪里?应用程序总得有一个地方,负责创建所有这些依赖,并将它们“粘合”在一起。这个地方,就叫做组合根 (Composition Root)

组合根是应用中唯一一个可以引用具体实现,并将它们与业务代码中的抽象(协议)连接起来的地方。

在典型的iOS App中,组合根通常位于:

  • UIKit App: AppDelegateSceneDelegate
  • SwiftUI App: @main 入口的 App struct。
// 在SceneDelegate中,这里是我们的组合根
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    var assembler: Assembler! // Assembler是组合根的一部分

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        
        // --- 这是组合根的核心区域 ---
        setupDIContainer()
        
        let rootCoordinator = assembler.resolver.resolve(AppCoordinator.self)!
        // --------------------------

        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = rootCoordinator.start() // 启动应用
        window?.makeKeyAndVisible()
    }

    private func setupDIContainer() {
        assembler = Assembler([
            ServiceAssembly(),
            ViewModelAssembly(),
            CoordinatorAssembly()
            // ...所有模块的Assembly都在这里被组装
        ])
    }
}

理解“组合根”的概念至关重要,因为它回答了“DI容器应该在哪里被创建和使用”的问题。答案是:DI容器本身(或其Assembler)应该只存在于组合根中。 其他所有业务代码(ViewModel、Service等)都应该是“纯洁”的,它们不应该知道DI容器的存在,只通过构造函数接收自己需要的依赖。

这可以防止我们将DI容器滥用为我们第一篇中批评的“服务发现器”。

3. 总结与思想升华

依赖注入是一种深刻影响我们代码组织方式的设计思想。它的目标是追求高内聚、低耦合

  • DI容器(如Swinject) 是实现这一思想的强大工具,特别适合管理复杂应用中的众多依赖。
  • 手动DI和工厂模式 则是更轻量、更直接的实现方式,在简单场景下同样有效。
  • 面向协议编程 是实现DI价值的基石。
  • 组合根 是我们应用DI原则,同时又保持架构清晰的“圣地”。

作为架构师或资深开发者,我们需要具备根据不同项目规模和复杂度,选择最合适DI实现方式的能力。可能是一个全功能的DI容器,也可能只是一组精心设计的工厂类,甚至只是在组合根中的手动注入。

理解了DI是一种思想而非特定工具,我们就拥有了更大的架构自由度和灵活性。

在最后一篇中,我们将进行一场关键的辩证讨论:我们团队中已经存在的“路由解耦”方案,与我们现在提倡的DI思想,究竟是竞争关系还是可以协同作战的盟友?我们将给出最终的架构决策建议。

敬请期待!

依赖注入(四):当DI遇见声明式UI,从Flutter Riverpod反思SwiftUI的最佳实践

前三篇我们已经为iOS原生开发的DI打下了坚实的基础。现在,让我们把目光投向更广阔的领域,看看在现代化的声明式UI范式下,DI的思想是如何演进和应用的。这对于我们组内同时拥有Swift和Flutter技术栈的同学来说,尤其有价值。

今天,我们要讨论一个非常前沿且重要的话-题:依赖注入在声明式UI框架中应该如何实践?

随着SwiftUI和Flutter的兴起,我们的UI构建方式已经从命令式(“去做这个,然后做那个”)转变为声明式(“UI应该是这个状态”)。这种转变不仅仅影响了视图层,它也深刻地改变了我们对状态管理和依赖注入的思考方式。

组内有很多Flutter的同事对Riverpod框架非常熟悉。这是一个绝佳的机会,我们可以通过对比Riverpod的设计哲学,来反思和探索在SwiftUI中进行依赖注入的最佳实践。

1. 求同存异,先看Flutter的Riverpod

对于不熟悉Riverpod的Swift同学,可以把它简单理解为一个“超级强大”的DI和状态管理框架。它的核心是 Provider

一个Provider就是一个“提供者”,它可以向UI(在Flutter中是Widget)提供任何东西:

  • 一个服务的实例(如APIService
  • 一个计算后的值(如从多个状态组合出的新值)
  • 一个完整的ViewModel(在Flutter中通常叫StateNotifierChangeNotifier

Riverpod的核心特点:

  1. 与UI框架深度融合: Provider的生命周期和作用域可以与Widget树紧密绑定。当一个Widget不再需要某个Provider时,Provider可以被自动销毁(autoDispose),非常高效地管理内存。
  2. 天生的响应式: 当一个Provider所提供的数据发生变化时,所有“监听”了这个Provider的UI组件都会自动重建(刷新),以展示最新的状态。
  3. 编译时安全: 它摆脱了Flutter早期DI框架Provider需要依赖BuildContext(上下文)的缺点,可以在任何地方安全地访问,且没有运行时风险。
  4. 不仅仅是DI,更是状态管理: 这是最关键的一点。Riverpod通过Provider统一了“依赖注入”和“状态管理”这两个概念。你可以用同样的方式去“提供”一个无状态的APIService和一个有状态的CounterViewModel
// Riverpod 示例
// 1. 提供一个服务
final apiServiceProvider = Provider((ref) => ApiService());

// 2. 提供一个ViewModel(StateNotifier)
final counterProvider = StateNotifierProvider<Counter, int>((ref) {
  // 可以在这里读取其他provider!实现了依赖注入
  final apiService = ref.watch(apiServiceProvider);
  return Counter(apiService); // Counter是StateNotifier的子类
});

// 3. 在UI中使用
class MyScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 读取ViewModel的状态
    final count = ref.watch(counterProvider);
    // 读取ViewModel本身以调用其方法
    final counterNotifier = ref.read(counterProvider.notifier);

    return Scaffold(
      body: Center(child: Text('$count')),
      floatingActionButton: FloatingActionButton(
        onPressed: () => counterNotifier.increment(),
      ),
    );
  }
}

2. 对比Swift世界的DI:核心差异

通过观察Riverpod,我们可以发现它与我们之前讨论的Swinject等传统DI框架的核心差异:

  • 关注点不同:

    • Swinject (传统DI): 更关注 “对象图的构建” (Object Graph Construction)。它的核心任务是在应用启动时或需要时,正确地创建和连接好所有对象。它对UI层是“无知”的。
    • Riverpod: 更关注 “状态的提供与消费” (State Provision and Consumption)。它与UI层紧密耦合,其设计初衷就是为了服务于响应式的UI刷新。
  • 生命周期/作用域不同:

    • Swinject: 它的作用域(.container, .graph)与Container实例和resolve调用相关,与UI组件的生命周期没有直接关系。
    • Riverpod: Provider的生命周期可以和Widget的生命周期完全同步,实现了更精细、自动化的管理。

3. SwiftUI中的DI最佳实践

那么,在SwiftUI中,我们应该如何借鉴这些思想呢?

a. 苹果的原生方案:@EnvironmentObject

SwiftUI提供了一个原生的DI机制:@EnvironmentObject。你可以把一个对象注入到视图环境中,任何子视图都可以从中读取。

// 在根视图注入
let userSettings = UserSettings()
ContentView().environmentObject(userSettings)

// 在子视图中接收
struct SettingsView: View {
    @EnvironmentObject var settings: UserSettings

    var body: some View {
        Text("Username: \(settings.username)")
    }
}

优点:

  • 非常简单,苹果原生支持。

缺点:

  • 类型不安全: 如果你忘记在父视图注入environmentObject,App会在运行时直接崩溃。
  • 全局污染: 它更像一个“全局变量”,适用于传递真正全局的、与UI显示相关的状态(如主题、用户设置),但如果用它来注入大量的服务和ViewModel,会使依赖关系变得混乱和隐式。
  • 不适合注入服务: 它的设计初衷是传递ObservableObject,用于驱动UI刷新,而不是注入无状态的服务。

结论:@EnvironmentObject可用,但要谨慎。它适合做简单的UI状态分发,而非完整的DI解决方案。

b. 主流方案:“DI容器 + ViewModel”模式

社区目前公认的最佳实践,是结合我们前几篇学到的知识,将传统DI容器(如Swinject)与SwiftUI的@StateObject / @ObservedObject结合起来。

这个模式的思路是:DI容器负责幕后的对象创建,SwiftUI负责前台的状态观测和UI刷新,二者各司其职。

实践步骤:

  1. 在组合根中设置DI容器: 和以前一样,在App的入口处(@mainApp结构体或SceneDelegate)创建和配置我们的AssemblerContainer

    // App.swift
    @main
    struct MyApp: App {
        let assembler: Assembler
        
        init() {
            assembler = Assembler([
                NetworkAssembly(),
                ViewModelAssembly()
            ])
        }
    
        var body: some Scene {
            WindowGroup {
                // 从容器中解析出根视图的ViewModel
                // 注意这里,我们只解析一次,然后交给SwiftUI管理
                let rootViewModel = assembler.resolver.resolve(RootViewModel.self)!
                RootView(viewModel: rootViewModel)
            }
        }
    }
    
  2. 为View注入其专属的ViewModel: View不应该直接接触DI容器。View的唯一依赖就是它的ViewModel。ViewModel通过构造函数注入它所需要的所有服务。

    // ProductDetailView.swift
    struct ProductDetailView: View {
        // 使用@StateObject确保ViewModel的生命周期与View绑定
        // viewModel由父视图(或Coordinator)创建并传入
        @StateObject var viewModel: ProductDetailViewModel
    
        var body: some View {
            VStack {
                Text(viewModel.productName)
                if viewModel.isLoading {
                    ProgressView()
                }
                Button("Add to cart") {
                    viewModel.addToCart()
                }
            }
            .onAppear(perform: viewModel.fetchProduct)
        }
    }
    
  3. ViewModel从DI容器中创建,并注入依赖: 这一步发生在ViewModelAssembly中,我们之前已经很熟悉了。

    // ViewModelAssembly.swift
    class ViewModelAssembly: Assembly {
        func assemble(container: Container) {
            // ViewModel必须是 .transient
            container.register(ProductDetailViewModel.self) { (r, productId: String) in
                ProductDetailViewModel(
                    productId: productId,
                    apiService: r.resolve(APIService.self)!, // 自动解析服务
                    cartService: r.resolve(CartService.self)!
                )
            }.inObjectScope(.transient)
        }
    }
    

这个模式的巨大优势:

  • 职责清晰: Swinject管创建,ViewModel管业务逻辑和状态,View管渲染。
  • 强类型安全: 所有依赖都通过构造函数注入,编译时就能发现错误。
  • 可测试性极高: 你可以轻松地为ProductDetailViewModel创建mock的APIServiceCartService来进行单元测试,完全不用依赖任何UI。
  • 与SwiftUI和谐共存: 它没有破坏SwiftUI的声明式和响应式特性,而是为其提供了一个坚实的、可预测的数据和逻辑后端。

总结与思考

通过对比Riverpod,我们发现,虽然它在与UI的结合度上做得更“原生”,但其核心思想——将依赖(服务)和状态(ViewModel)统一通过某种机制(Provider)提供给UI——是相通的。

在SwiftUI中,我们虽然没有一个像Riverpod一样大一统的框架,但通过 “Swinject (DI容器) + @StateObject (状态管理)” 的组合,我们实现了一个逻辑上等价且非常强大的模式。

  • Riverpod的ref.watch(someProvider) ≈ SwiftUI的@StateObject / @ObservedObject 它们都负责监听状态变化并触发UI刷新。
  • Riverpod的Provider定义 ≈ Swinject的container.register 它们都负责定义如何创建依赖和状态对象。

最终,我们为SwiftUI应用构建了一个清晰的分层架构:

View -> ViewModel (状态和业务逻辑) -> Services (无状态的原子能力)

而依赖注入,就是将这些层次优雅地“粘合”在一起的最佳胶水。

在下一篇中,我们将回到更广义的架构讨论,看看DI思想的其他实现方式,比如通过路由(Coordinator)模式,并给出最终的架构选型建议。

敬请期待!

依赖注入(三):Swinject实战,玩转生命周期与循环依赖

在前两篇文章中,我们建立了DI的思想共识,并通过手写一个迷你容器揭开了DI框架的神秘面纱。我们知道了,一个好的DI容器能帮我们自动管理对象的创建和生命周期,把我们从繁琐的手动组装中解放出来。

今天,我们将进入实战环节,聚焦于iOS社区中最流行和强大的DI框架之一:Swinject。我们将学习如何优雅地在项目中使用它,并重点攻克两个核心难点:生命周期的正确使用循环依赖的解决方案

1. 为什么选择Swinject?

在众多Swift DI框架中,Swinject脱颖而出,因为它:

  • 功能强大且成熟: 支持多种生命周期、属性注入、循环依赖解决、模块化注册等高级功能。
  • 纯Swift实现: 充分利用了Swift的类型系统,提供了较好的类型安全。
  • 社区活跃: 拥有完善的文档和庞大的用户群体,遇到问题容易找到解决方案。

2. Swinject核心组件:Container, Assembly, Assembler

想象一下,如果把所有服务的注册代码都写在一个地方,那将是新的灾难。Swinject通过三个核心概念帮助我们进行模块化管理:

  • Container: 这就是我们熟悉的DI容器本身,负责注册和解析服务。
  • Assembly: 这是一个协议,我们可以创建遵循此协议的类,将相关联的依赖注册逻辑组织在一起。比如,所有网络相关的依赖可以放在NetworkAssembly里,所有ViewModel相关的可以放在ViewModelAssembly里。这极大地提高了代码的可读性和维护性。
  • Assembler: 这是一个“组装工”,它的作用是把多个Assembly实例“组装”到一个Container中。这是我们将模块化配置应用到主容器的方式。

一个典型的组织结构:

// 1. 定义模块化的 Assembly
class NetworkAssembly: Assembly {
    func assemble(container: Container) {
        container.register(NetworkServicing.self) { _ in
            NetworkService()
        }
        // ... 其他网络相关的注册
    }
}

class ViewModelAssembly: Assembly {
    func assemble(container: Container) {
        container.register(ProductDetailViewModel.self) { r in
            // Swinject自动解析依赖!
            // r 是一个解析器(Resolver),可以用来获取其他依赖
            ProductDetailViewModel(networkService: r.resolve(NetworkServicing.self)!)
        }
        // ... 其他ViewModel相关的注册
    }
}

// 2. 在应用的组合根(如AppDelegate)使用 Assembler
let assembler = Assembler([
    NetworkAssembly(),
    ViewModelAssembly()
])
let container = assembler.resolver // Assembler内部会创建一个Container,并通过resolver属性暴露出来

3. 重点精讲:Swinject的生命周期(Scope)最佳实践

正如我们在第二篇中强调的,错误地使用生命周期是DI中最常见的错误。Swinject提供了比我们手写版本更丰富的Scope选项。

  • .transient (瞬时): 默认作用域。每次resolve都会创建一个新实例。
  • .container (容器/单例): 在容器的生命周期内,只创建一个实例。后续resolve都返回这同一个实例。这等同于我们手写的singleton
  • .graph (对象图): 这是Swinject一个非常强大且独特的作用域。当resolve一个对象A时,如果其依赖链(A -> B -> D, A -> C -> D)中有多处需要同一个依赖D,.graph能保证在这一次resolve调用中,它们获取到的是同一个D的实例。但如果下次你再次resolve A,你会得到一个全新的A、B、C、D对象图。
  • .weak (弱引用单例): 类似.container,但容器只弱引用该实例。当没有其他地方强引用它时,它会被释放。下次resolve时会重新创建。

团队使用的“黄金法则”

为了避免混淆和误用,我们可以建立一个简单的团队规范:

  1. 无状态或需全局共享的服务(APIService, DatabaseService, UserSession)=> 使用 .container

    container.register(NetworkServicing.self) { _ in
        NetworkService()
    }.inObjectScope(.container) // 明确指定为容器作用域
    
  2. ViewModel 或任何与特定UI/场景绑定的对象 => 永远使用 .transient

    container.register(ProductDetailViewModel.self) { r in
        ProductDetailViewModel(networkService: r.resolve(NetworkServicing.self)!)
    }.inObjectScope(.transient) // 或者不写,因为这是默认值
    

    切记: 将ViewModel注册为.container是导致页面状态混乱的罪魁祸首!

  3. 一次性业务流中的共享依赖 => 谨慎评估使用 .graph

    • 场景: 假设我们有一个“创建订单”的流程,这个流程的Coordinatorresolve出来。这个Coordinator创建了Step1ViewModelStep2ViewModel。如果这两者都需要一个临时的OrderDraft对象来共享草稿数据,那么将OrderDraft注册为.graph作用域是最合适的。这样,在整个“创建订单”流程中,数据得以共享,而当流程结束后,下次再发起创建时,会是一个全新的、干净的OrderDraft

4. 重点攻坚:优雅地解决循环依赖

A依赖B,同时B又依赖A时,构造函数注入会失败,因为在创建A时需要一个完整的B,而在创建B时又需要一个完整的A,这会形成一个死循环。

一个真实的业务例子:

  • AuthenticationManager:负责用户的登录、登出流程。
  • APIService:负责所有网络请求。它需要AuthenticationManager来获取token,并添加到请求头中。
  • 循环点: 当登录失败(如token过期)时,APIService需要通知AuthenticationManager执行登出或刷新token的操作。

这样就形成了 AuthenticationManager -> APIService -> AuthenticationManager 的循环。

Swinject 提供了 initCompleted 这个回调来完美解决此问题。它将注入过程分为两步:

  1. 实例化: 先调用init方法创建对象,此时有循环关系的属性暂时为空。
  2. 属性注入: 在对象创建完成后,Swinject调用initCompleted闭包,此时再去解析并设置那个导致循环的属性。

代码实践:

class CircularDependenciesAssembly: Assembly {
    func assemble(container: Container) {
        
        // 注册 APIService
        container.register(APIService.self) { r in
            APIService(authManager: r.resolve(AuthenticationManager.self)!)
        }.inObjectScope(.container)
        
        
        // 注册 AuthenticationManager,并解决循环依赖
        container.register(AuthenticationManager.self) { _ in
            AuthenticationManager()
        }
        .inObjectScope(.container)
        .initCompleted { r, authManager in
            // 在 authManager 初始化完成后,
            // 再将 APIService 实例注入给它。
            // 此时 APIService 已经可以被正常 resolve 出来了。
            authManager.apiService = r.resolve(APIService.self)!
        }
    }
}

// 对应的类定义
class AuthenticationManager {
    // 使用属性注入来打破循环
    var apiService: APIService!
    
    func login() { /* ... */ }
}

class APIService {
    // 使用构造函数注入
    private let authManager: AuthenticationManager
    
    init(authManager: AuthenticationManager) {
        self.authManager = authManager
    }
}

通过这种方式,我们将强依赖关系(构造函数注入)和弱一些的、可延迟设置的依赖关系(属性注入)结合起来,优雅地打破了初始化时的死循环。

总结与避坑

今天我们掌握了Swinject的核心用法。请记住:

  • 使用Assembly来模块化你的依赖注册。
  • 严格遵守生命周期使用法则,特别是ViewModel必须是.transient
  • 利用initCompleted来解决循环依赖问题。

最后,一个重要的避坑指南: 不要把DI容器本身当作依赖注入到你的业务类中! 如果你发现自己正在写 MyViewModel(container: container),然后在其内部调用container.resolve(XXX.self),那么你又回到了我们第一篇中批评的“服务发现”模式的老路上了。这会再次隐藏依赖,让代码变得难以理解和测试。DI容器应该只存在于应用的“组合根”和少数负责对象创建的“工厂”或“Coordinator”中。

在下一篇中,我们将拓宽视野,看看在声明式的UI世界里,特别是对比Flutter的Riverpod,SwiftUI的依赖注入又有哪些新的玩法和思考。

敬请期待!

❌