普通视图

发现新文章,点击刷新页面。
今天 — 2025年4月15日掘金 iOS

Swift 中的async和await

2025年4月15日 16:43

asyncawait 是 Swift 5.5 引入的用于处理异步编程的关键字,它们使得处理异步任务变得更加简单和直观。它们提供了一种新的方式来管理异步操作,相比传统的回调函数或闭包,async/await 更接近同步代码的写法,让代码更加易读和可维护。

1. 什么是异步编程

异步编程是指程序在执行任务时,不需要等待任务完成才能继续执行其他任务。传统的同步编程方式会导致程序等待某个操作完成(比如网络请求、磁盘读写等),直到任务完成后才会继续执行,可能会造成性能瓶颈。异步编程允许程序在等待某个操作时去执行其他任务,从而提高效率。

2. asyncawait 的基本概念

  • async: 用于标记一个函数为异步函数。异步函数会返回一个 Task 类型,可以在执行时暂停,直到结果准备好。
  • await: 用于暂停函数的执行,直到异步操作完成并返回结果。

3. 如何使用 asyncawait

3.1 标记异步函数

首先,你需要使用 async 关键字来定义一个异步函数,表示这个函数包含异步操作,并且可能需要一些时间来执行。

func fetchData() async -> String {
    // 模拟网络请求
    return "Data fetched"
}

在上面的例子中,fetchData() 是一个异步函数,它返回一个字符串。函数内部的操作可能会是一个耗时操作,虽然这里没有具体的异步代码,但它表示这段代码可以用异步方式进行处理。

3.2 调用异步函数

要调用一个异步函数,你必须在一个异步上下文中使用 await 关键字。await 会暂停当前的代码执行,直到异步函数返回结果。

func exampleUsage() async {
    let result = await fetchData()  // 等待异步函数返回结果
    print(result)
}

3.3 异步任务的创建

你可以使用 Task 来创建异步任务。Task 允许你在异步上下文之外执行异步代码。

Task {
    let result = await fetchData()
    print(result)
}

Task 是一个异步任务,它会自动创建一个新的异步上下文来执行异步代码。这是非常有用的,当你需要在不直接处于异步函数内部的地方执行异步代码时。

4. asyncawait 与传统的闭包回调对比

在传统的异步编程中,我们可能会使用闭包来处理异步操作的回调:

func fetchData(completion: @escaping (String) -> Void) {
    // 模拟网络请求
    DispatchQueue.global().async {
        let data = "Data fetched"
        completion(data)
    }
}

在上面的代码中,fetchData 接受一个回调闭包 completion,并通过它返回结果。调用时我们需要手动处理回调:

fetchData { result in
    print(result)
}

而使用 asyncawait 后,你可以这样写:

func fetchData() async -> String {
    // 模拟网络请求
    return "Data fetched"
}

Task {
    let result = await fetchData()
    print(result)
}

相比使用闭包,asyncawait 更加简洁、直观。

5. 错误处理

在异步函数中,错误处理通常使用 do-catch 语句来处理。你可以在异步函数中抛出错误,并使用 await 来捕获和处理它们。

enum DataError: Error {
    case invalidData
}

func fetchData() async throws -> String {
    // 模拟可能抛出错误的网络请求
    let success = false
    if !success {
        throw DataError.invalidData
    }
    return "Data fetched"
}

func exampleUsage() async {
    do {
        let result = try await fetchData()
        print(result)
    } catch {
        print("Error: \(error)")
    }
}

在上述代码中,fetchData() 可能会抛出 DataError.invalidData 错误,调用它时需要使用 try await 来捕获和处理错误。

6. 并发执行多个异步任务

你可以使用 async let 来并行执行多个异步任务,并且在最后获取它们的结果。这是一个非常强大的功能,尤其是当你需要同时处理多个异步操作时。

func fetchData1() async -> String {
    return "Data 1 fetched"
}

func fetchData2() async -> String {
    return "Data 2 fetched"
}

func exampleUsage() async {
    async let data1 = fetchData1()  // 异步并发任务
    async let data2 = fetchData2()  // 异步并发任务
    
    let result1 = await data1  // 等待结果
    let result2 = await data2  // 等待结果
    
    print(result1)
    print(result2)
}

在上面的代码中,data1data2 会并行执行,最终我们使用 await 来获取它们的结果。

7. 总结

asyncawait 是处理异步操作的核心工具,它们通过提供一种类似同步代码的结构,使得异步编程更加简单和清晰。使用这些特性,你可以:

  • 简化代码,使其更加易读和维护。
  • 避免回调地狱(callback hell)和嵌套的闭包。
  • 更容易进行错误处理。
  • 使并发执行变得简单,减少手动管理异步任务的复杂度。

遍历子视图及其子视图(递归和迭代遍历)

作者 Lafar
2025年4月15日 15:13

递归遍历

含义

递归遍历是指在函数的定义中使用函数自身的方法来实现遍历。在遍历视图层次结构时,一个函数会检查当前视图的子视图,然后对每个子视图递归调用自身,以继续遍历该子视图的子视图,依此类推,直到没有更多的子视图为止。

示例代码(Swift 遍历视图)

swift

func recursiveTraverseSubviews(_ view: UIView) {
    for subview in view.subviews {
        print(subview)
        recursiveTraverseSubviews(subview)
    }
}

在这个示例中,recursiveTraverseSubviews 函数遍历当前视图的所有子视图,打印每个子视图,并对每个子视图再次调用 recursiveTraverseSubviews 函数,从而实现递归遍历。

迭代遍历

含义

迭代遍历是使用循环结构(如 forwhile 等)来重复执行特定操作,从而实现遍历的目的。在遍历视图层次结构时,通常会使用一个数据结构(如栈或队列)来存储待处理的视图,然后在循环中不断从数据结构中取出视图进行处理,并将其未处理的子视图添加到数据结构中,直到数据结构为空。

示例代码(Swift 遍历视图)

swift

func iterativeTraverseSubviews(_ view: UIView) {
    var stack = [UIView]()
    stack.append(view)
    while !stack.isEmpty {
        let currentView = stack.removeLast()
        for subview in currentView.subviews {
            print(subview)
            stack.append(subview)
        }
    }
}

在这个示例中,使用一个栈 stack 来存储待处理的视图。首先将根视图添加到栈中,然后在 while 循环中,不断从栈中取出视图,打印其所有子视图,并将这些子视图添加到栈中,直到栈为空。

区别

实现方式

  • 递归:通过函数自身调用实现,代码简洁,易于理解,尤其是对于具有递归结构的问题(如树的遍历)。
  • 迭代:使用循环结构和数据结构(如栈、队列)来实现,代码相对复杂,但对于某些问题,迭代实现可能更高效。

内存使用

  • 递归:每次递归调用都会在调用栈上分配新的栈帧,当递归深度很大时,可能会导致栈溢出错误。
  • 迭代:通常只需要固定大小的额外内存(如栈或队列),不会受到递归深度的影响。

性能

  • 递归:由于函数调用的开销和栈帧的分配,递归实现的性能可能不如迭代。
  • 迭代:循环结构的执行效率通常较高,尤其是在处理大规模数据时。

可读性和可维护性

  • 递归:对于简单的递归问题,代码可读性高,易于理解和维护。但对于复杂的递归逻辑,可能会使代码难以理解。
  • 迭代:代码结构相对复杂,但对于熟悉循环和数据结构的开发者来说,也具有较好的可读性和可维护性。
import UIKit

// 递归方式遍历子视图
func recursiveTraverseSubviews(_ view: UIView) {
    for subview in view.subviews {
        print("递归遍历: \(subview)")
        recursiveTraverseSubviews(subview)
    }
}

// 迭代方式遍历子视图
func iterativeTraverseSubviews(_ view: UIView) {
    var stack = [UIView]()
    stack.append(view)

    while !stack.isEmpty {
        let currentView = stack.removeLast()
        for subview in currentView.subviews {
            print("迭代遍历: \(subview)")
            stack.append(subview)
        }
    }
}

// 示例使用
let mainView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
let subview1 = UIView(frame: CGRect(x: 10, y: 10, width: 50, height: 50))
let subview2 = UIView(frame: CGRect(x: 70, y: 10, width: 50, height: 50))
let subsubview = UIView(frame: CGRect(x: 10, y: 10, width: 20, height: 20))

subview1.addSubview(subsubview)
mainView.addSubview(subview1)
mainView.addSubview(subview2)

print("开始递归遍历")
recursiveTraverseSubviews(mainView)

print("\n开始迭代遍历")
iterativeTraverseSubviews(mainView)    

学习笔记 - Swfit 6.1 - 语法概览

作者 忘川三
2025年4月15日 15:04

获取版本号

swift -version

Hello world

print("Hello, world!")

末尾不需要分号

常量(let);变量(var)

var myVariable = 42
myVariable = 50
let myConstant = 42

可以显式声明变量类型,若没有则隐式推断,类似下面的Double

let implicitInteger = 70
let implicitDouble = 70.0
let explicitDouble: Double = 70

赋值同一类型

let label = "The width is "
let width = 94
// 去掉String报错
// Binary operator '+' cannot be applied to operands of type 'String' and 'Int'
let widthLabel = label + String(width)

字符串中通过\(变量)的方法得到变量的字符串表示

let apples = 3
let oranges = 5
let appleSummary = "I have \(apples) apples."
let fruitSummary = "I have \(apples + oranges) pieces of fruit."

多行文本的写法

// """ """ 包含的内容
let quotation = """
        Even though there's whitespace to the left,
        the actual lines aren't indented.
            Except for this line.
        Double quotes (") can appear without being escaped.

        I still have \(apples + oranges) pieces of fruit.
        """

数组/字典通过 [] 遍历

var fruits = ["strawberries", "limes", "tangerines"]
fruits[1] = "grapes"


var occupations = [
    "Malcolm": "Captain",
    "Kaylee": "Mechanic",
 ]
occupations["Jayne"] = "Public Relations"

自动扩容

fruits.append("blueberries")
print(fruits)

空数组/字典

fruits = []
occupations = [:]

// 指定类型
let emptyArray: [String] = []
let emptyDictionary: [String: Float] = [:]

控制流

循环: for-in,while,repeat-while 条件: if

let individualScores = [75, 43, 103, 87, 12]
var teamScore = 0
for score in individualScores {
    if score > 50 {
        teamScore += 3
    } else {
        teamScore += 1
    }
}
print(teamScore)
// Prints "11"

if + 赋值

let scoreDecoration = if teamScore > 10 {
    "🎉"
} else {
    ""
}
print("Score:", teamScore, scoreDecoration)

属于语法糖,少写一个赋值

var n = 2
while n < 100 {
    n *= 2
}
print(n)
// Prints "128"


var m = 2
// 这个其它语言中一般是用do, 用repeat可能是为了强调循环?
repeat {
    m *= 2
} while m < 100
print(m)
// Prints "128"
var total = 0
for i in 0..<4 {
    total += i
}
print(total)
// Prints "6"

for i in 0..<4, i的遍历区间是0,1,2,3

可选型(optional)

  1. 类型后面跟问号
  2. if let name = optionalName 会解包,能确定namenil还是有具体的值
var optionalString: String? = "Hello"
print(optionalString == nil)
// Prints "false"


var optionalName: String? = "John Appleseed"
var greeting = "Hello!"
if let name = optionalName {
    greeting = "Hello, \(name)"
    print(greeting)
}

还有种处理可选型的方法是通过??

let nickname: String? = nil
let fullName: String = "John Appleseed"
let informalGreeting = "Hi \(nickname ?? fullName)"

nickname 有值则用nickname的值,没有值则用??后的值。这是种默认值的写法,更健壮的处理当数据可能为nil的情况。

switch 的写法

let vegetable = "red pepper"
switch vegetable {
case "celery":
    print("Add some raisins and make ants on a log.")
case "cucumber", "watercress":
    print("That would make a good tea sandwich.")
case let x where x.hasSuffix("pepper"):
    print("Is it a spicy \(x)?")
default:
    print("Everything tastes good in soup.")
}
// Prints "Is it a spicy red pepper?"

case类型可以是String,这点比Objective-C方便很多,编程语言是给人读的。

去掉default语句会报Switch must be exhaustive编译错误,这是编程的实践,避免开发者遗漏

未使用的变量_

let interestingNumbers = [
    "Prime": [2, 3, 5, 7, 11, 13],
    "Fibonacci": [1, 1, 2, 3, 5, 8],
    "Square": [1, 4, 9, 16, 25],
]
var largest = 0
for (_, numbers) in interestingNumbers {
    for number in numbers {
        if number > largest {
            largest = number
        }
    }
}
print(largest)

访问for循环时没有使用到字典的key,用_可以告诉编译器这件事方便优化

// 如果非要定义了不用,会给一个警告
Immutable value 'key' was never used; consider replacing with '_' or removing it

函数和闭包

  1. 函数关键字 func
  2. -> 后跟返回值
  3. 函数定义对参数的声明和objective-c类似
func greet(person: String, day: String) -> String {
    return "Hello \(person), today is \(day)."
}
greet(person: "Bob", day: "Tuesday")

通过元组(tuple)可以处理多个值

func calculateStatistics(scores: [Int]) -> (min: Int, max: Int, sum: Int) {
    var min = scores[0]
    var max = scores[0]
    var sum = 0


    for score in scores {
        if score > max {
            max = score
        } else if score < min {
            min = score
        }
        sum += score
    }


    return (min, max, sum)
}
let statistics = calculateStatistics(scores: [5, 3, 100, 3, 9])
print(statistics.sum)
// Prints "120"
print(statistics.2)
// Prints "120"

嵌套函数

func returnFifteen() -> Int {
    var y = 10
    func add() {
        y += 5
    }
    add()
    return y
}
returnFifteen()

这个见的少,只见过类似概念的Java里的内部类

函数作返回值,参数

Functions are a first-class type. This means that a function can return another function as its value.

类型:规定了变量可以取的值得范围,以及该类型的值可以进行的操作。根据类型的值的可赋值状况,可以把类型分为三类: 1、一级的(first class)。该等级类型的值可以传给子程序作为参数,可以从子程序里返回,可以赋给变量。大多数程序设计语言里,整型、字符类型等简单类型都是一级的。 2、二级的(second class)。该等级类型的值可以传给子程序作为参数,但是不能从子程序里返回,也不能赋给变量。 3、三级的(third class)。该等级类型的值连作为参数传递也不行。

函数当返回值

func makeIncrementer() -> ((Int) -> Int) {
    func addOne(number: Int) -> Int {
        return 1 + number
    }
    return addOne
}
var increment = makeIncrementer()
increment(7)

函数当参数

func hasAnyMatches(list: [Int], condition: (Int) -> Bool) -> Bool {
    for item in list {
        if condition(item) {
            return true
        }
    }
    return false
}
func lessThanTen(number: Int) -> Bool {
    return number < 10
}
var numbers = [20, 19, 7, 12]
// lessThanTen 函数作为参数
hasAnyMatches(list: numbers, condition: lessThanTen)

闭包

函数是一种特殊的闭包,有名字的闭包(closures)

// {} 内的是闭包
numbers.map({ (number: Int) -> Int in
    let result = 3 * number
    return result
})

简化闭包写法

省略return

let mappedNumbers = numbers.map({ number in 3 * number })
print(mappedNumbers)

当闭包是函数的唯一参数时,可以完全省略括号

let sortedNumbers = numbers.sorted { $0 > $1 }
print(sortedNumbers)

对象和类

class Shape {
    var numberOfSides = 0
    func simpleDescription() -> String {
        return "A shape with \(numberOfSides) sides."
    }
}

创建对象

var shape = Shape()
shape.numberOfSides = 7
var shapeDescription = shape.simpleDescription()

初始化函数

class NamedShape {
    var numberOfSides: Int = 0
    var name: String


    init(name: String) {
       self.name = name
    }


    func simpleDescription() -> String {
       return "A shape with \(numberOfSides) sides."
    }
}

子类重写父类方法

class Square: NamedShape {
    var sideLength: Double


    init(sideLength: Double, name: String) {
        self.sideLength = sideLength
        super.init(name: name)
        numberOfSides = 4
    }


    func area() -> Double {
        return sideLength * sideLength
    }


    override func simpleDescription() -> String {
        return "A square with sides of length \(sideLength)."
    }
}
let test = Square(sideLength: 5.2, name: "my test square")
test.area()
test.simpleDescription()

属性访问器getter && setter

class EquilateralTriangle: NamedShape {
    var sideLength: Double = 0.0


    init(sideLength: Double, name: String) {
        self.sideLength = sideLength
        super.init(name: name)
        numberOfSides = 3
    }


    var perimeter: Double {
        get {
             return 3.0 * sideLength
        }
        set {
            sideLength = newValue / 3.0
        }
    }


    override func simpleDescription() -> String {
        return "An equilateral triangle with sides of length \(sideLength)."
    }
}
var triangle = EquilateralTriangle(sideLength: 3.1, name: "a triangle")
print(triangle.perimeter)
// Prints "9.3"
triangle.perimeter = 9.9
print(triangle.sideLength)
// Prints "3.3000000000000003"

计算属性

class TriangleAndSquare {
    var triangle: EquilateralTriangle {
        willSet {
            square.sideLength = newValue.sideLength
        }
    }
    var square: Square {
        willSet {
            triangle.sideLength = newValue.sideLength
        }
    }
    init(size: Double, name: String) {
        square = Square(sideLength: size, name: name)
        triangle = EquilateralTriangle(sideLength: size, name: name)
    }
}
var triangleAndSquare = TriangleAndSquare(size: 10, name: "another test shape")
print(triangleAndSquare.square.sideLength)
// Prints "10.0"
print(triangleAndSquare.triangle.sideLength)
// Prints "10.0"
triangleAndSquare.square = Square(sideLength: 50, name: "larger square")
print(triangleAndSquare.triangle.sideLength)
// Prints "50.0"

枚举和结构体

enum Rank: Int {
    case ace = 1
    case two, three, four, five, six, seven, eight, nine, ten
    case jack, queen, king


    func simpleDescription() -> String {
        switch self {
        case .ace:
            return "ace"
        case .jack:
            return "jack"
        case .queen:
            return "queen"
        case .king:
            return "king"
        default:
            return String(self.rawValue)
        }
    }
}
let ace = Rank.ace
let aceRawValue = ace.rawValue

rawValue 默认0开始,然后递增

枚举的 case 值是实际值,而不仅仅是编写其原始值的另一种方式。事实上,在没有有意义的原始值的情况下,可以不必提供原始值。

enum Suit {
    case spades, hearts, diamonds, clubs


    func simpleDescription() -> String {
        switch self {
        case .spades:
            return "spades"
        case .hearts:
            return "hearts"
        case .diamonds:
            return "diamonds"
        case .clubs:
            return "clubs"
        }
    }
}
let hearts = Suit.hearts
let heartsDescription = hearts.simpleDescription()

并发

异步方法的关键字 async,调用异步方法前面加await

func fetchUserID(from server: String) async -> Int {
    print("fetchUserID")
    if server == "primary" {
        return 97
    }
    return 501
}

func fetchUsername(from server: String) async -> String {
    print("fetchUsername")
    let userID = await fetchUserID(from: server)
    if userID == 501 {
        return "John Appleseed"
    }
    return "Guest"
}

func connectUser(to server: String) async {
    // 异步调用
    async let userID = fetchUserID(from: server)
    async let username = fetchUsername(from: server)
    // 等待返回后执行下一句
    let greeting = await "Hello \(username), user ID \(userID)"
    print(greeting)
}

Task {
    await connectUser(to: "primary2")
}

等待任务组

let userIDs = await withTaskGroup(of: Int.self) { group in
    for server in ["primary", "secondary", "development"] {
        group.addTask {
            return await fetchUserID(from: server)
        }
    }


    var results: [Int] = []
    for await result in group {
        results.append(result)
    }
    return results
}

协议与扩展

使用关键字Protocol定义协议

protocol ExampleProtocol {
     var simpleDescription: String { get }
     mutating func adjust()
}

类,枚举和结构体都可以实现协议

class SimpleClass: ExampleProtocol {
     var simpleDescription: String = "A very simple class."
     var anotherProperty: Int = 69105
     func adjust() {
          simpleDescription += "  Now 100% adjusted."
     }
}
var a = SimpleClass()
a.adjust()
let aDescription = a.simpleDescription


struct SimpleStructure: ExampleProtocol {
     var simpleDescription: String = "A simple structure"
     mutating func adjust() {
          simpleDescription += " (adjusted)"
     }
}
var b = SimpleStructure()
b.adjust()
let bDescription = b.simpleDescription

结构体的adjust方法前有mutating修饰用于修改结构体的成员

可以使用扩展来为已有的类型添加方法

extension Int: ExampleProtocol {
    var simpleDescription: String {
        return "The number \(self)"
    }
    mutating func adjust() {
        self += 42
    }
 }
print(7.simpleDescription)
// Prints "The number 7"

异常捕获

通过实现Error协议来表示错误类型

enum PrinterError: Error {
    case outOfPaper
    case noToner
    case onFire
}

throws 关键字来抛出异常

func send(job: Int, toPrinter printerName: String) throws -> String {
    if printerName == "Never Has Toner" {
        throw PrinterError.noToner
    }
    return "Job sent"
}

可以使用 do-catch 来捕获异常,try 修饰可能会抛出异常的代码

do {
    let printerResponse = try send(job: 1040, toPrinter: "Bi Sheng")
    print(printerResponse)
} catch {
    print(error)
}
// Prints "Job sent"

多种异常的处理

do {
    let printerResponse = try send(job: 1440, toPrinter: "Gutenberg")
    print(printerResponse)
} catch PrinterError.onFire {
    print("I'll just put this over here, with the rest of the fire.")
} catch let printerError as PrinterError {
    print("Printer error: \(printerError).")
} catch {
    print(error)
}
// Prints "Job sent"

defer关键词修饰的代码会在函数所有代码执行完成后,函数return返回前执行。 无论代码是否抛出异常都会执行。它一般用于建立或清理代码。

有点类似finally 部分,可以避免异常的时候没释放内存。这在某个函数有多个返回出口的时候特别有用。

var fridgeIsOpen = false
let fridgeContent = ["milk", "eggs", "leftovers"]


func fridgeContains(_ food: String) -> Bool {
    fridgeIsOpen = true
    defer {
        fridgeIsOpen = false
    }


    let result = fridgeContent.contains(food)
    return result
}
if fridgeContains("banana") {
    print("Found a banana")
}
print(fridgeIsOpen)

泛型

泛型: <类型>

func makeArray<Item>(repeating item: Item, numberOfTimes: Int) -> [Item] {
    var result: [Item] = []
    for _ in 0..<numberOfTimes {
         result.append(item)
    }
    return result
}
makeArray(repeating: "knock", numberOfTimes: 4)

可以对方法,函数,类,枚举,结构体应用泛型

// where T.Element: Equatable , T.Element == U.Element表示:只有元素遵循 Equatable 协议且内部的类型一致时才可以使用anyCommonElements方法
func anyCommonElements<T: Sequence, U: Sequence>(_ lhs: T, _ rhs: U) -> Bool
    where T.Element: Equatable, T.Element == U.Element
{
    for lhsItem in lhs {
        for rhsItem in rhs {
            if lhsItem == rhsItem {
                return true
            }
        }
    }
   return false
}
anyCommonElements([1, 2, 3], [3])

参考

  1. Swift-Doc
  2. 什么是 First-class function?
  3. Swift系列之关于Swift defer的正确使用
  4. Swift限定泛型、协议扩展或约束的where

设计一种机制检测UIViewController的内存泄漏

作者 亮亮哥
2025年4月14日 21:36

一、核心设计思路

  1. 基于对象释放的延迟检测

    • 原理:在 UIViewController 被销毁(如 pop 或 dismiss)时,延迟一定时间(如 2-3 秒)后检查其是否仍存在强引用。若存在,则判定为内存泄漏。
    • 实现:通过 Hook UIViewController 的 viewDidDisappear: 方法触发检测逻辑,利用 weak 弱指针观察对象是否存活。
  2. 循环引用链分析

    • 若检测到泄漏,进一步通过工具(如 FBRetainCycleDetector)分析对象间的强引用关系,定位循环引用链条。
  3. 白名单与误判处理

    • 支持白名单机制,排除单例、缓存对象等无需释放的场景。

二、具体实现步骤

1. Hook 生命周期方法

使用 Method Swizzling 替换 UIViewController 的 viewDidDisappear: 方法,在视图消失时启动检测:

    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        swizzleMethod([self class], @selector(viewDidDisappear:), @selector(swizzled_viewDidDisappear:));
    });
}

- (void)swizzled_viewDidDisappear:(BOOL)animated {
    [self swizzled_viewDidDisappear:animated];
    if (self.isMovingFromParentViewController || self.isBeingDismissed) {
        [self willDeallocCheck]; // 触发泄漏检测
    }
}

2. 延迟检测存活状态

通过 dispatch_after 延迟检查对象是否释放:

    if ([self isInWhitelist]) return NO; // 跳过白名单对象
    __weak id weakSelf = self;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
        __strong id strongSelf = weakSelf;
        if (strongSelf) {
            [strongSelf assertNotDealloc]; // 触发断言或弹窗报警
        }
    });
    return YES;
}

- (void)assertNotDealloc {
    NSAssert(NO, @"%@ 发生内存泄漏!", NSStringFromClass([self class]));
}

3. 遍历视图树检测子对象

检查 UIViewController 的视图及其子视图是否泄漏:

    [self.view.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if (![obj willDealloc]) {
            NSLog(@"子视图 %@ 可能泄漏", obj);
        }
    }];
}

4. 结合循环引用检测工具

集成 FBRetainCycleDetector,在断言触发时自动分析引用链:

    FBRetainCycleDetector *detector = [FBRetainCycleDetector new];
    [detector addCandidate:self];
    NSSet *retainCycles = [detector findRetainCycles];
    NSLog(@"循环引用链:%@", retainCycles);
}

三、优化与注意事项

  1. 性能优化

    • 仅在 Debug 模式 启用检测,避免影响线上性能。
    • 使用缓存机制减少重复检测。
  2. 误判处理

    • 区分延迟释放真实泄漏:某些对象可能因异步任务延迟释放,需多次检测确认。
    • 支持动态白名单,允许开发者标记无需检测的类。
  3. 扩展性

    • 支持自定义检测时间阈值(如从 2 秒调整为 5 秒)。
    • 可扩展至检测其他对象(如 UIView、自定义模型)。

四、验证与工具对比

方法 优点 缺点
手动检测 dealloc 简单直接,无侵入性 需反复操作,无法自动化
Instruments 全面分析内存分配与泄漏 操作复杂,需主动触发,实时性差
MLeaksFinder 自动报警,精准定位泄漏对象 需结合其他工具分析循环引用
本方案 自动化 + 循环引用分析 + 低侵入性 需集成第三方库(如 FBRetainCycleDetector)

五、实践建议

  1. 开发阶段集成:通过 CocoaPods 引入检测库(如 MLeaksFinder),仅启用 Debug 配置。
  2. CI/CD 流程:在自动化测试中增加内存泄漏检测步骤,结合日志分析工具统计泄漏点。
  3. 团队规范:在代码 Review 中强制要求修复泄漏报警,避免技术债务累积。

通过上述机制,开发者可高效定位并修复 UIViewController 内存泄漏问题,提升应用稳定性。

Swift 中重要特性——逃逸闭包@escaping

2025年4月14日 20:31

@escaping 是 Swift 中一个非常重要的特性,通常用于闭包(closure)的参数,尤其是在处理异步操作或回调时。它用于标记闭包参数“逃逸”出了函数的作用域,即闭包的生命周期超出了函数的执行范围。

为什么需要 @escaping

在 Swift 中,闭包默认是非逃逸(non-escaping)的,也就是说,闭包只能在函数调用过程中执行,并且不会保存到外部的变量或常量中。这样做的目的是提高性能,因为闭包不需要被保持,编译器可以进行优化。

然而,在处理异步操作时,比如网络请求或定时器,我们需要将闭包传递出去,让它在函数执行完毕后(甚至在函数退出后)继续执行。这时,闭包需要逃逸出函数的作用域,这时就需要使用 @escaping 来显式标记这个闭包参数。

@escaping 的作用

@escaping 表示闭包会逃逸出函数的作用域,可以在函数返回后被执行。这通常用于处理异步回调或者其他延迟执行的场景。

关键点

  • 非逃逸闭包(non-escaping closure):闭包只能在函数内部执行,并且会在函数返回前执行完毕。默认情况下,函数的闭包参数是非逃逸的。
  • 逃逸闭包(escaping closure):闭包可以在函数返回之后仍然执行,通常用于异步回调。

例子:非逃逸闭包

如果没有使用 @escaping,闭包是非逃逸的,不能存储到函数外部。

func performTask(task: () -> Void) {
    task()  // 这里闭包被执行并且在函数内完成
}

例子:逃逸闭包

当闭包需要在函数执行完毕后仍然执行,通常会标记为 @escaping。最典型的例子是异步操作,例如网络请求或定时器。

func fetchData(completion: @escaping (Data?) -> Void) {
    DispatchQueue.global().async {
        // 模拟网络请求
        let data = Data()
        completion(data)  // 闭包会在函数返回后执行
    }
}

在上面的例子中,completion 闭包会逃逸出 fetchData 函数,因为它是在一个异步线程中执行的,函数返回后闭包才会被调用。

逃逸闭包与内存管理

由于逃逸闭包的生命周期可能超过函数的执行时间,它可能会导致内存管理问题。逃逸闭包会被持有到函数执行完成后,因此需要特别小心避免强引用循环(retain cycles)。

通常,为了避免强引用循环,我们会将闭包声明为 weakunowned,从而防止闭包持有对象的强引用。

使用 weakunowned 避免循环引用

func fetchData(completion: @escaping (Data?) -> Void) {
    DispatchQueue.global().async { [weak self] in
        // 使用 weak 或 unowned 防止循环引用
        guard let self = self else { return }
        let data = Data()
        completion(data)
    }
}

使用 @escaping 的实际场景

@escaping 主要用于异步操作或回调函数,它的作用是使闭包可以在函数执行完毕后,甚至在函数返回后继续执行。

  1. 网络请求回调:在网络请求成功或失败后执行回调操作。
  2. 定时器回调:在定时器触发时执行闭包操作。
  3. UI 更新回调:例如,在多线程中更新 UI,闭包可能需要在主线程执行。

总结

  • @escaping 标记闭包为逃逸闭包,即它可能在函数返回后被调用。
  • 逃逸闭包通常用于处理异步操作、回调等情况。
  • 逃逸闭包的生命周期可能会超过函数的作用域,因此需要注意内存管理,避免出现强引用循环。

微软收紧插件、谷歌发力云端,Xcode 何去何从? | 肘子的 Swift 周报 #079

作者 东坡肘子
2025年4月15日 08:01

issue79.webp

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

微软收紧插件、谷歌发力云端,Xcode 何去何从?

不久前,微软对 Github Copilot 进行了相当程度的功能增强,除了添加了对 MCP 的支持外,在 AI 交互模式上也提供了与 Cursor 对齐的 Agent 模式,至此,Github Copilot 大大缩小了与其他 AI 代码工具领先者之间的差距。考虑到其更低的定价策略( $10/月 ),明显微软已做好了全力进入商业 AI 辅助市场/服务的准备。

对于开发者来说,良性的竞争意味着会带来更好的产品和更有性价比的服务。但由于目前不少 AI 编程工具与 VSCode 都使用了相同或接近的底层实现,并且选用了类似的大模型组合,这意味着过段时间,这些工具之间的差异会越来越小,提供更有性价比、更具独特价值的功能就变成了这些工具获取用户的主要手段。

或许是为了保证 VSCode 的独特性,在 VSCode 生态中一些非常重要的,由微软开发的插件(Remote SSH、Pylance、Python Debugger、C/C++ 等)在最新的更新版中已经无法在 VSCode 之外的编辑器中使用了。这些插件虽然闭源但一直允许第三方编辑器使用,现在却突然弹出“只能在微软产品中使用”的提示。尽管这并没有违法微软在开发这些插件时的授权,但这种突然的屏蔽行为还是引起了很多其他编辑器使用者的不便。考虑到在整个 VSCode 生态中,微软提供了大量的优秀插件,如果未来其中相当一部分只能运行在 VSCode 中,那么会明显提高 VSCode 在这些编辑器工具中的竞争力,并影响不少开发者的选择。

面对这一挑战,Cursor 团队已迅速推出应急修复,并计划开发长期解决方案,逐步转向支持社区开源替代品。这一事件实际上反映了微软已将 Cursor 视为真正的竞争对手——一个凭借 AI 能力,在某些场景下体验超越 VSCode 的挑战者。

与此同时,谷歌在上周推出的 Firebase Studio 又将 AI 编辑器推向了新的领域。这款融合了 Project IDX、Genkit 和 Gemini 的平台不仅支持通过自然语言快速生成原型,还能通过 AI 聊天方式迭代应用,并提供一键部署到 Firebase App Hosting 的能力。通过与谷歌云端服务的深入捆绑,不仅加速了开发、调试的过程,也大幅降低了部署的难度。可以预见,同样具备云端服务优势的微软也很快会在 VSCode 上提供类似的体验。就像当前的浏览器、搜索引擎一样,AI 开发工具将逐步从开发者的桌面工具过渡成各个大公司绑定开发者的入口。

单纯从商业角度来说这并没有什么问题,但这意味着仍在开发 AI 编辑器的小公司、小团队的机会窗口将更加的小了。他们不得不在两条路中选择:要么在 AI 编程体验上做出真正的差异化,要么依靠社区力量维持生态,成为一个真正独立的开发工具。一个逐渐缺少了活力的市场将扼杀创意,尽管从历史上来看几乎每个领域都会走向类似的结局,只是没想到在 AI 时代,大公司的行动会如此的迅速,如此的果决。

在这场开发工具的变革浪潮中,苹果的 Xcode 似乎还未展现出清晰的 AI 集成战略。作为苹果生态的核心开发工具,Xcode 长期以来依靠其与平台的无缝集成成为苹果开发者的不二之选。然而,当 AI 正以前所未有的速度重塑开发体验时,静观其变已不再是明智之举。如果不能在未来的一两年中有重要突破,不仅开发者体验会滞后,创新生态也可能流失。苹果会在 WWDC 2025 上交出怎样的答案?这将决定其平台在下一代开发范式中的地位。

前一期内容全部周报列表

本期助力

需要在 iPhone 上调试 HTTPS?

试试 Proxyman!这是一款顶级的 macOS 应用,只需点击几下,即可轻松捕获和调试 HTTP(s) 流量。支持 iOS设备和模拟器。

🚀 立即试用 →

原创

用 Swift 构建 WASM 应用

随着 Swift 6.1 版本的正式发布,SwiftWasm 也迎来了重大升级。这一里程碑式的更新标志着 SwiftWasm 首次实现了完全基于官方 Swift 开源工具链的构建——告别了自定义补丁的时代,不仅显著简化了开发者的安装流程,大幅节省了系统存储空间,更为重要的是,这种纯正构建方式极大降低了平台的维护成本,为 Swift 生态系统注入了新的活力。在本文中,我们将探索如何利用 Swift 构建 WebAssembly 应用,带你领略 Swift 跨平台开发的无限可能。

近期推荐

结构化并发的行为准则 (Rules of Structured Concurrency)

Swift 并发中的任务可分为结构化(Structured)与非结构化(Unstructured)两类,核心差异在于是否具备父子任务关系,以及是否能自动管理生命周期、错误传播与取消逻辑。在这篇文章中,Vitaly Batrakov 基于“任务树”模型,总结出结构化并发的三大核心规则(Error、Group Completion、Group Cancellation,简称 EGG 🥚),为 Swift 并发机制提供了清晰、系统的理解路径,推荐给并发初学者与进阶开发者阅读。

swift-markdown 的自定义能力边界 (The Limits of swift-markdown Customization)

swift-markdown 是苹果最初开发的 Markdown 解析与构建库,提供了优雅的访问结构和基于 Visitor 模式的遍历机制。Christian Tietze 在文章中分享了他在构建 Markdown 处理管线过程中遇到的诸多限制:虽然读取和遍历功能完善,但在插入节点、修改结构、扩展元素等方面受限严重。无法新增节点类型、缺乏插入/组合能力、formatter 不可扩展,让这个库成为“看起来灵活、实际封闭”的典型代表。吐槽之余,Christian 也对 AST 工具的设计边界提出了不少值得参考的思考。

如何发布你的 macOS Swift 命令行工具 (Distribute Your Swift CLIs for macOS)

目前,许多 Swift CLI 工具仍依赖 Mint 或 Mise 实现“安装即编译”,虽然简化了维护流程,但对用户而言存在编译慢、易失败等问题。Pedro Piñera 认为,更理想的方式是发布预编译二进制文件,并结合 GitHub Releases + UBI 实现一键安装。再配合 Mise,可提供更完善的版本管理体验。在本文中,Pedro 提供了一套可复用的构建与发布脚本流程,覆盖 fat binary 构建、压缩、签名上传至 GitHub,并适配 CI 与本地环境。

你的项目适合用 SwiftData 吗?(Should You SwiftData?)

SwiftData 自发布以来一直颇具争议,很多开发者仍在观望是否应该投入使用。在本文中,Leo G Dion 结合自己开发 Bushel 的经验,分享了他的思考。他认为,对于倾向于使用 Apple 官方框架、喜欢 ORM 编程范式、重视长期维护成本与新技术支持(如 SwiftUI、Swift 6、宏等)的开发者来说,SwiftData 是值得考虑的方案。此外,文中还列出了一些替代方案,如 GRDB、CoreStore、Boutique 等,供不同需求的开发者参考。

我为什么不再子类化 UITableViewCell / UICollectionViewCell (Why I Never Subclass UITableViewCell or UICollectionViewCell)

在开发购物类 App 的过程中,Srđan Stanić 起初沿用了常规模式:为 UITableViewUICollectionView 分别子类化 UITableViewCell / UICollectionViewCell,用以构建商品列表 UI。但随着产品设计不断演进,他逐渐遇到以下问题:相同 UI 需在多个上下文中复用;每次复用都必须重新实现布局逻辑;为适配某种 UI 承载方式,不得不引入不必要的复杂容器。于是他选择反向而行:不再子类化 Cell,而是将布局独立为一个纯 UIView,根据使用场景再嵌入到不同的 Cell 或容器中。这不仅提升了复用性和可维护性,也能轻松适配 SwiftUI,通过泛型 Cell 容器进一步简化样板代码。

WWDC25 的愿望单 (WWDC25 Wishes)

又到了每年喜闻乐见的 WWDC 愿望清单时节,Majid Jabrayilov 的关注点主要聚焦在开发工具的改进:

  • Swift Assist 与 AI 工具链:呼吁 Apple 尽快补上 AI 辅助开发的短板,并建议推出 Xcode 专属的 MCP server,构建 Copilot 式开发体验;
  • Project.swift 项目配置文件:希望能像 Package.swift 一样,用纯 Swift 描述项目配置,提升版本控制友好性,同时让 VS Code 等非 Xcode 工具更具可操作性;
  • SwiftUI 的 Recycling View 支持:期待 SwiftUI 引入像 UITableView/UICollectionView 一样的视图重用机制,解决复杂列表场景中的性能瓶颈。

Majid 提出的每一项都非常戳我,尤其是项目配置的现代化 —— 在当前多 IDE 并用的开发环境中,已经显得越来越迫切。

TextRenderer 演示合集 —— Prismic

从 iOS 18 开始,开发者可以通过自定义 TextRenderer 协议的实现,在 SwiftUI 的 Text 渲染前对文本进行变换,从而打造更具视觉冲击力的动态文字效果。Paul Hudson 创建了 Prismic,这是一个收录了多种 TextRenderer 示例效果的开源项目,既包含纯 Swift 实现的样式,也包含基于 Metal Shader 的高级扭曲与颜色特效。Paul 鼓励开发者基于该项目进行扩展,贡献更多创意实现。

想了解如何构建这些效果?推荐阅读:用 TextRenderer 构建绚丽动感的文字效果

活动

AdventureX 2025 解除报名的年龄限制

在 2024 年,AdventureX 曾规定参赛者年龄需在 26 岁以下。而在 2025 年,我们决定面向所有人开放报名。虽然 26 岁以下的青年创作者与学生依然将构成参赛者的主要群体,但我们也将开放至多 100 个不限年龄名额,欢迎那些虽不在年龄范围内、却依然怀抱年轻心态与创作热情的朋友加入。

我们希望借此机会,促成跨代创作者的深度交流,在同一个现场分享经验、碰撞灵感。

往期内容

THANK YOU

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

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

昨天 — 2025年4月14日掘金 iOS

使用渲染管线渲染图元

作者 异次元客
2025年4月14日 15:01

渲染一个简单的 2D 三角形。 代码传送门

概述

此示例展示了如何配置渲染管线并将其作为渲染通道的一部分,用于在视图中绘制一个简单的二维彩色三角形。示例为每个顶点提供了位置和颜色信息,渲染管线使用这些数据来渲染三角形,并在三角形顶点指定的颜色之间进行插值。

image.png


Metal 渲染管线理解

渲染管线负责处理绘图指令,并将数据写入渲染通道的目标中。此管线包含多个阶段,其中一些阶段可以通过着色器编程控制,而其他阶段则具有固定或可配置的行为。这个示例主要关注管线的三个关键阶段:顶点阶段、光栅化阶段和片段阶段。顶点阶段和片段阶段是可编程的,因此你可以使用 Metal 着色语言 (MSL) 为它们编写函数。而光栅化阶段的行为是固定的。

image.png

渲染开始于一个绘图指令,该指令包括顶点数量以及要渲染的图元类型。例如,这是本示例中的绘图指令:

// 绘制三角形。
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
                  vertexStart:0
                  vertexCount:3];

顶点阶段为每个顶点提供数据。当处理足够的顶点后,渲染管线对图元进行光栅化,确定在渲染目标中哪些像素位于图元边界内。片段阶段决定这些像素在渲染目标中要写入的值。

本示例的其余部分演示了如何编写顶点和片段函数,如何创建渲染管线状态对象,以及最后如何编码一条使用此管线的绘图指令。这提供了对如何利用 Metal 构建高效的图形渲染流程的基本了解,特别是针对那些希望深入理解图形编程底层细节的人。通过这种方式,开发者可以更好地控制图形渲染过程,实现高性能和高质量的视觉效果。


自定义渲染管线如何处理数据

顶点函数为单个顶点生成数据,片段函数为单个片段生成数据,但您需要决定它们的工作方式。您需根据目标配置管线的各个阶段,这意味着您知道希望管线生成什么结果以及它如何生成这些结果。

决定将哪些数据传递到渲染管线中,并将哪些数据传递到管线的后续阶段。通常有三个地方可以实现这一点:

  • 管线的输入数据:由您的应用程序提供并传递给顶点阶段。
  • 顶点阶段的输出数据:传递给光栅化阶段。
  • 片段阶段的输入数据:由您的应用程序提供或由光栅化阶段生成。

在此示例中,管线的输入数据是顶点的位置及其颜色。为了演示顶点函数中通常执行的变换类型,输入坐标被定义在一个自定义坐标空间中,以视图中心为原点用像素表示。这些坐标需要转换为 Metal 的坐标系。

声明一个 AAPLVertex 结构体,使用 SIMD 向量类型存储位置和颜色数据。为了在内存布局中共享单一定义,在通用头文件中声明该结构体,并在 Metal 着色器和应用程序中导入它。

typedef struct
{
    vector_float2 position;
    vector_float4 color;
} AAPLVertex;

SIMD 类型在 Metal 着色语言中非常常见,您还应该在应用程序中使用 simd 库来使用它们。SIMD 类型包含多个特定数据类型的通道,因此将位置声明为 vector_float2 意味着它包含两个 32 位浮点值(即 x 和 y 坐标)。颜色则使用 vector_float4 存储,因此它们具有四个通道——红、绿、蓝和透明度 (RGBA)。

在应用程序中,输入数据通过一个常量数组指定:

static const AAPLVertex triangleVertices[] =
{
    // 2D 位置, RGBA 颜色
    { {  250,  -250 }, { 1, 0, 0, 1 } },
    { { -250,  -250 }, { 0, 1, 0, 1 } },
    { {    0,   250 }, { 0, 0, 1, 1 } },
};

顶点阶段为每个顶点生成数据,因此需要提供颜色和变换后的位置。声明一个 RasterizerData 结构体,包含位置和颜色值,同样使用 SIMD 类型。

struct RasterizerData
{
    // 此成员的 [[position]] 属性表明,当此结构体从顶点函数返回时,
    // 此值是顶点的裁剪空间位置。
    float4 position [[position]];

    // 由于此成员没有特殊属性,光栅化阶段会将其值与三角形其他顶点的值
    // 进行插值,然后将插值后的值传递给每个片段的片段着色器。
    float4 color;
};

输出位置(详见下文)必须定义为 vector_float4。颜色的声明方式与输入数据结构中相同。

您需要告诉 Metal 哪个字段在光栅化数据中提供位置数据,因为 Metal 不会对结构体中的字段强制任何特定的命名约定。使用 [[position]] 属性限定符注解位置字段,以声明该字段保存输出位置。

片段函数只需将光栅化阶段的数据传递给后续阶段,因此不需要任何额外的参数。

声明顶点函数

声明顶点函数,包括其输入参数和输出数据。类似于使用 kernel 关键字声明计算函数,您可以使用 vertex 关键字声明顶点函数。

vertex RasterizerData
vertexShader(uint vertexID [[vertex_id]],
             constant AAPLVertex *vertices [[buffer(AAPLVertexInputIndexVertices)]],
             constant vector_uint2 *viewportSizePointer [[buffer(AAPLVertexInputIndexViewportSize)]])

第一个参数 vertexID 使用 [[vertex_id]] 属性限定符,这是另一个 Metal 关键字。当执行渲染命令时,GPU 会多次调用您的顶点函数,为每个顶点生成唯一值。

第二个参数 vertices 是一个包含顶点数据的数组,使用之前定义的 AAPLVertex 结构体。

为了将位置转换为 Metal 的坐标系,函数需要绘制三角形的目标视口大小(以像素为单位),因此将其存储在 viewportSizePointer 参数中。

第二个和第三个参数具有 [[buffer(n)]] 属性限定符。默认情况下,Metal 会自动为每个参数分配参数表中的槽位。当您为缓冲区参数添加 [[buffer(n)]] 限定符时,您明确告诉 Metal 使用哪个槽位。显式声明槽位可以使您更轻松地修改着色器,而无需同时更改应用程序代码。在共享头文件中声明这两个索引的常量。

函数的输出是一个 RasterizerData 结构体。

编写顶点函数

您的顶点函数必须生成输出结构体的所有字段。使用 vertexID 参数索引到 vertices 数组中,读取该顶点的输入数据。同时,获取视口的尺寸。

float2 pixelSpacePosition = vertices[vertexID].position.xy;

// 获取视口大小并转换为浮点类型。
vector_float2 viewportSize = vector_float2(*viewportSizePointer);

顶点函数必须以裁剪空间坐标(clip-space coordinates)的形式提供位置数据,这些坐标是通过四维齐次向量 (x, y, z, w) 表示的 3D 点。光栅化阶段会将输出位置的 xyz 坐标除以 w,以生成归一化设备坐标(normalized device coordinates)。归一化设备坐标与视口大小无关。

image.png

归一化设备坐标使用左手坐标系,并映射到视口中的位置。图元会被裁剪到此坐标系中的一个盒子内,然后进行光栅化。裁剪盒子的左下角坐标为 (-1.0, -1.0),右上角坐标为 (1.0, 1.0)。正的 z 值指向远离摄像机的方向(进入屏幕)。z 坐标的可见部分在 0.0(近裁剪平面)和 1.0(远裁剪平面)之间。

将输入坐标系转换为归一化设备坐标系。

image.png

由于这是一个 2D 应用程序,不需要齐次坐标,因此首先为输出坐标写入一个默认值,其中 w 值设置为 1.0,其他坐标设置为 0.0。这意味着坐标已经在归一化设备坐标空间中,顶点函数应在该坐标空间中生成 (x, y) 坐标。将输入位置除以视口大小的一半以生成归一化设备坐标。由于此计算使用 SIMD 类型,两个通道可以同时除以一个操作完成。执行除法并将结果放入输出位置的 xy 通道中。

out.position = vector_float4(0.0, 0.0, 0.0, 1.0);
out.position.xy = pixelSpacePosition / (viewportSize / 2.0);

最后,将颜色值复制到 out.color 返回值中。

out.color = vertices[vertexID].color;

编写片元函数

片元是渲染目标可能发生的更改。光栅化器确定渲染目标中哪些像素被图元覆盖。只有像素中心位于三角形内部的片元才会被渲染。

image.png

片元函数处理来自光栅化器的单个位置的传入信息,并为每个渲染目标计算输出值。这些片元值由管线的后续阶段处理,最终写入渲染目标。

注意
片元被称为“可能的更改”,是因为片元之后的管线阶段可以配置为拒绝某些片元或更改写入渲染目标的内容。在此示例中,片元阶段计算的所有值都会直接写入渲染目标。

此示例中的片元着色器接收与顶点着色器输出相同的参数。使用 fragment 关键字声明片元函数。它接受一个参数,即顶点阶段提供的相同 RasterizerData 结构体。添加 [[stage_in]] 属性限定符以表明此参数由光栅化器生成。

objc
深色版本
fragment float4 fragmentShader(RasterizerData in [[stage_in]])

如果您的片元函数写入多个渲染目标,则必须声明一个包含每个渲染目标字段的结构体。由于此示例只有一个渲染目标,因此直接指定一个浮点向量作为函数的输出。该输出是要写入渲染目标的颜色。

光栅化阶段为每个片元的参数计算值,并使用这些值调用片元函数。光栅化阶段将其颜色参数计算为三角形顶点颜色的混合值。片元越接近某个顶点,该顶点对最终颜色的贡献越大。

image.png

返回插值后的颜色作为函数的输出。

return in.color;

创建渲染管线状态对象

现在函数已完成,您可以创建一个使用它们的渲染管线。首先,获取默认库并为每个函数获取一个 MTLFunction 对象。

id<MTLLibrary> defaultLibrary = [_device newDefaultLibrary];

id<MTLFunction> vertexFunction = [defaultLibrary newFunctionWithName:@"vertexShader"];
id<MTLFunction> fragmentFunction = [defaultLibrary newFunctionWithName:@"fragmentShader"];

接下来,创建一个 MTLRenderPipelineState 对象。渲染管线有更多阶段需要配置,因此使用 MTLRenderPipelineDescriptor 配置管线。

MTLRenderPipelineDescriptor *pipelineStateDescriptor = [[MTLRenderPipelineDescriptor alloc] init];
pipelineStateDescriptor.label = @"Simple Pipeline";
pipelineStateDescriptor.vertexFunction = vertexFunction;
pipelineStateDescriptor.fragmentFunction = fragmentFunction;
pipelineStateDescriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat;

_pipelineState = [_device newRenderPipelineStateWithDescriptor:pipelineStateDescriptor
                                                         error:&error];

除了指定顶点和片段函数外,还需要声明管线绘制的所有渲染目标的像素格式。像素格式 (MTLPixelFormat) 定义了像素数据的内存布局。对于简单格式,此定义包括每像素的字节数、存储在像素中的通道数以及这些通道的位布局。由于此示例只有一个渲染目标并且由视图提供,因此将视图的像素格式复制到渲染管线描述符中。您的渲染管线状态必须使用与渲染通道兼容的像素格式。在此示例中,渲染通道和管线状态对象都使用视图的像素格式,因此它们始终相同。

当 Metal 创建渲染管线状态对象时,管线会配置为将片段函数的输出转换为渲染目标的像素格式。如果您想针对不同的像素格式进行渲染,则需要创建不同的管线状态对象。您可以在多个管线中重用相同的着色器,以针对不同的像素格式进行渲染。


设置视口

现在您已经有了渲染管线状态对象,可以使用渲染命令编码器渲染三角形。首先设置视口,以便 Metal 知道要绘制到渲染目标的哪个部分。

// 设置绘制区域。
[renderEncoder setViewport:(MTLViewport){0.0, 0.0, _viewportSize.x, _viewportSize.y, 0.0, 1.0}];

设置渲染管线状态

设置要使用的渲染管线状态。

[renderEncoder setRenderPipelineState:_pipelineState];

将参数数据传递到顶点函数

通常,您使用缓冲区 (MTLBuffer) 将数据传递到着色器。然而,当只需要向顶点函数传递少量数据(如本示例)时,可以直接将数据复制到命令缓冲区中。

示例将两个参数的数据都复制到命令缓冲区中。顶点数据从示例中定义的数组中复制,而视口数据则从用于设置视口的同一变量中复制。

在此示例中,片段函数仅使用从光栅化器接收到的数据,因此没有需要设置的参数。

[renderEncoder setVertexBytes:triangleVertices
                       length:sizeof(triangleVertices)
                      atIndex:AAPLVertexInputIndexVertices];

[renderEncoder setVertexBytes:&_viewportSize
                       length:sizeof(_viewportSize)
                      atIndex:AAPLVertexInputIndexViewportSize];

编码绘制命令

指定图元类型、起始索引和顶点数量。当三角形被渲染时,顶点函数会分别使用 012vertexID 值调用。

// 绘制三角形。
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
                  vertexStart:0
                  vertexCount:3];

与《使用 Metal 绘制到屏幕》类似,结束编码过程并提交命令缓冲区。不过,您可以使用相同的步骤编码更多的渲染命令。最终图像会按命令指定的顺序渲染。(为了性能,GPU 可以并行处理命令甚至部分命令,只要最终结果看起来是按顺序渲染即可。)


实验颜色插值

在此示例中,颜色值在三角形上进行了插值。这通常是您想要的效果,但有时您希望某个顶点生成的值在整个图元上保持恒定。为此,可以在顶点函数的输出上指定 flat 属性限定符。现在尝试一下。在示例项目中找到 RasterizerData 的定义,并为其颜色字段添加 [[flat]] 限定符。

float4 color [[flat]];

再次运行示例。渲染管线会在整个三角形上统一使用第一个顶点(称为引发顶点)的颜色值,并忽略其他两个顶点的颜色。您可以通过在顶点函数的输出上添加或省略 flat 限定符来混合使用平面着色和插值值。Metal 着色语言规范还定义了其他属性限定符,可用于修改光栅化行为。

CocoaPods 私有库Spec Repo搭建与使用指南

作者 duly
2025年4月14日 14:48

一、创建私有 Spec Repo

  1. 创建 Git 仓库
    在 Git 服务器(如 GitHub、GitLab)上新建一个空仓库,例如 PrivateSpecs,用于存放私有库的 podspec 文件。
  2. 添加 Spec Repo 到本地
    前往文件夹 ~/.cocoapods/repos
    打开终端,在终端切换到当前目录下,然后进行pod repo add操作 在终端执行以下命令,将私有仓库添加到 CocoaPods 的仓库列表:
#
pod repo add PrivateSpecs git@github.com:your-username/PrivateSpecs.git

替换 your-username 和仓库地址为实际信息,注意使用SSH或者HTTPS方式获取代码。~/.cocoapods/repos的目录下新增加PrivateSpecs文件夹。

二、创建私有库

  1. 生成模板项目
    在 Git 服务器(如 GitHub、GitLab)上新建一个空仓库,例如 DLYCenterModule。存放私有库代码 使用 CocoaPods 模板生成私有库:

    pod lib create DLYCenterModule
    

    按提示选择配置(语言、Demo 等)。

截屏2025-04-14 11.14.51.png

  1. 配置项目 cd 到Example文件下,然后pod install下,更新Example项目的pod。如图项目中的podspec文件,更改spec。修改s.homepage和s.source为自己git项目内容。新增加的源码放到DLYCenterModule/Classes/目录下。

截屏2025-04-14 11.28.22.png

3.推送代码到 Git 仓库

cd DLYCenterModule
git add .
git commit -m "Initial commit"
git remote add origin git@github.com:your-username/DLYCenterModule.git
git push -u origin master

4.打Tag并推送

    #注意 tag和s.version = '0.1.0' 的保持一致
    git tag 0.1.0
    git push --tags

三、验证与发布私有库

  1. 本地验证 podspec

    pod lib lint --allow-warnings
    

    若存在警告但可忽略,使用 --allow-warnings

  2. 推送 podspec 到私有 Spec Repo

    pod repo push PrivateSpecs DLYCenterModule.podspec --allow-warnings
    

3.搜索 私有库是否成功

#更新PrivateSpecs
pod repo update PrivateSpecs
#搜索 DLYCenterModule
pod search DLYCenterModule

四、使用私有库

  1. 配置 Podfile
    在项目的 Podfile 中添加私有源和依赖:

    # 公有源(可选)
    source 'https://github.com/CocoaPods/Specs.git'
    # 私有源
    source 'https://github.com/your-username/PrivateSpecs.git'
    
    target 'YourProject' do
      pod 'DLYCenterModule'
    end
    
  2. 安装依赖

    pod install
    

截止目前已有15.6w应用惨遭下架,你的应用还好么?

作者 iOS研究院
2025年4月14日 11:23

前言

正常人问候一般都是问一句:你吃么?

但是在iOSer之间问候一般都是提一句:你过了么?

跟很多同行聊天,每每提及Appstore审核都如坐针毡,头皮发麻!

当然了对于那些合规化的产品完全不慌,提审如同喝水一般简单

毕竟合不合规,要不要合规,能不能合规,都不是一个iOSer开发所能决定的。之有余前关于3.2f封号的严重性可能不够直观,于是今天统计了一把Appstore第一季度肃清的应用让更多的开发者意识到产品合规的重要性

备注:以下数据均来自第三方数据统计,仅供参考。

2025年1月

累计下架:44574

wechat_2025-04-14_104006_336.png

2025年2月

累计下架:37139

wechat_2025-04-14_104043_221.png

2025年3月

累计下架:55150

wechat_2025-04-14_104112_705.png

2025年4月

累计下架:20819

wechat_2025-04-14_104209_605.png

总结

在这些被下架15.6w应用中,游戏占比仅为10%。所以客观来说游戏里无论是代码,还是原创度都相对于普通的应用类产品更安全,也更遵守规则。

对于常规应用来讲依旧是重灾区,如果你的应用没有这种感觉,那么恭喜你!请你继续保持良好的开发习惯,做一个遵循苹果开发者指南的良民!

细心的读者也发现了在截图中存在了重新上架的产品,那么这些产品是什么情况呢?

  • 开发者账号到期,重新续费
  • 触发苹果调查,解除误会后恢复上架(比如:封号倒计时)

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

使用 Metal 绘制视图的内容

作者 异次元客
2025年4月14日 10:24

创建一个 MetalKit 视图和渲染通道以绘制视图的内容。

概述

在这个示例中,您将学习使用 Metal 渲染图形内容的基础知识。您将使用 MetalKit 框架创建一个利用 Metal 绘制视图内容的视图。然后,您将编码一个渲染通道的命令,以背景色擦除视图。

示例代码传送门

注意

MetalKit 自动化了窗口系统任务、加载纹理和处理 3D 模型数据。更多信息,请参见 MetalKit。

准备一个 MetalKit 视图以进行绘制

MetalKit 提供了一个名为 MTKView 的类,它是 NSView(在 macOS 中)或 UIView(在 iOS 和 tvOS 中)的子类。MTKView 处理了许多与将您使用 Metal 绘制的内容显示到屏幕上的相关细节。

MTKView 需要引用一个 Metal 设备对象,以便内部创建资源,因此您的第一步是将视图的 device 属性设置为现有的 MTLDevice。

_view.device = MTLCreateSystemDefaultDevice();

MTKView 上的其他属性允许您控制其行为。要将视图的内容擦除为纯背景色,您需要设置其 clearColor 属性。您可以通过指定红、绿、蓝和 alpha 值使用 MTLClearColorMake(::::) 函数创建颜色。

_view.clearColor = MTLClearColorMake(0.0, 0.5, 1.0, 1.0);

由于在此示例中您不会绘制动画内容,请配置视图,使其仅在需要更新内容时(例如当视图形状改变时)才进行绘制:

_view.enableSetNeedsDisplay = YES;

委托绘制职责

MTKView 依赖您的应用程序向 Metal 发出命令以生成视觉内容。MTKView 使用委托模式在应该进行绘制时通知您的应用程序。要接收委托回调,请将视图的 delegate 属性设置为符合 MTKViewDelegate 协议的对象。

_view.delegate = _renderer;

委托实现两个方法:

  • 当内容大小发生变化时,视图会调用 mtkView(_:drawableSizeWillChange:) 方法。这发生在包含视图的窗口被调整大小时,或在设备方向改变时(在 iOS 上)。这允许您的应用程序适应渲染分辨率以匹配视图的大小。
  • 当需要更新视图的内容时,视图会调用 draw(in:) 方法。在此方法中,您创建一个命令缓冲区,编码告诉 GPU 绘制什么内容以及何时在屏幕上显示它的命令,并将该命令缓冲区排队以供 GPU 执行。这有时被称为绘制一帧。您可以将帧视为生成显示在屏幕上的单个图像所需完成的所有工作。在交互式应用程序(如游戏)中,您每秒可能会绘制多帧。

在这个示例中,一个名为 AAPLRenderer 的类实现了委托方法并承担了绘制的责任。视图控制器创建此类的一个实例,并将其设置为视图的委托。

创建渲染通道描述符

当您绘制时,GPU 会将结果存储到纹理中,这些纹理是包含图像数据并可由 GPU 访问的内存块。在此示例中,MTKView 创建了绘制视图所需的所有纹理。它创建了多个纹理,以便能够在显示一个纹理的内容的同时向另一个纹理进行渲染。

要进行绘制,您需要创建一个渲染通道,即绘制到一组纹理的一系列渲染命令。在渲染通道中使用的纹理也称为渲染目标。要创建渲染通道,您需要一个渲染通道描述符,即 MTLRenderPassDescriptor 的实例。在此示例中,不是配置自己的渲染通道描述符,而是请求 MetalKit 视图为您的应用创建一个。

MTLRenderPassDescriptor *renderPassDescriptor = view.currentRenderPassDescriptor;
if (renderPassDescriptor == nil)
{
    return;
}

渲染通道描述符描述了一组渲染目标及其在渲染通道开始和结束时应如何处理。渲染通道还定义了一些不属于此示例的其他渲染方面。视图返回具有指向视图纹理之一的颜色附件的渲染通道描述符,并根据视图属性配置渲染通道。默认情况下,这意味着在渲染通道开始时,渲染目标会被擦除为与视图的 clearColor 属性匹配的纯色,在渲染通道结束时,所有更改都会保存回纹理。

因为视图的渲染通道描述符可能为 nil,所以在创建渲染通道之前,应该测试确保渲染通道描述符对象是非 nil 的。

创建渲染通道

通过使用 MTLRenderCommandEncoder 对象将其编码到命令缓冲区中来创建渲染通道。调用命令缓冲区的 makeRenderCommandEncoder(descriptor:) 方法并传入渲染通道描述符。

id<MTLRenderCommandEncoder> commandEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];

在此示例中,您不编码任何绘制命令,因此渲染通道唯一做的事情就是擦除纹理。调用编码器的 endEncoding 方法以指示该通道已完成。

[commandEncoder endEncoding];

将可绘制对象呈现到屏幕

绘制到纹理不会自动将新内容显示在屏幕上。实际上,只有某些纹理可以呈现在屏幕上。在 Metal 中,能够显示在屏幕上的纹理由可绘制对象管理,要显示内容,您需要呈现可绘制对象。

MTKView 自动创建可绘制对象来管理其纹理。读取 currentDrawable 属性以获取拥有渲染通道目标纹理的可绘制对象。视图返回一个 CAMetalDrawable 对象,这是一个连接到 Core Animation 的对象。

id<MTLDrawable> drawable = view.currentDrawable;

调用命令缓冲区上的 present(_:) 方法,并传入可绘制对象。

[commandBuffer presentDrawable:drawable];

此方法告诉 Metal 在命令缓冲区计划执行时,Metal 应与 Core Animation 协作在渲染完成后显示纹理。当 Core Animation 呈现纹理时,它将成为视图的新内容。在此示例中,这意味着被擦除的纹理将成为视图的新背景。这一变化与其他 Core Animation 为屏幕上用户界面元素所做的视觉更新同步发生。

提交命令缓冲区

既然已经发出了帧的所有命令,提交命令缓冲区。

[commandBuffer commit];

SwiftUI-国际化(二)

作者 YungFan
2025年4月14日 08:50

介绍

SwiftUI-国际化一文中,我们详细介绍了国际化的内容。在 Xcode 15 之后,Apple 提供了一种新的国际化方式,通过引入String Catalog,使得处理国际化更加高效与便捷。

特点

  • Info.plist 文件国际化需要新建一个String Catelog,必须命名为InfoPlist.xcstrings
  • 文本国际化需要新建一个String Catelog,必须命名为Localizable.xcstrings
  • Xcode 为xcstrings文件提供了可视化的编辑界面,并且会显示每一种语言的国际化完成比例。
  • 编译时可以自动提取需要国际化的内容到xcstrings文件。

案例

  1. 配置国际化语言。
  2. 新建Localizable.xcstrings
  3. SwiftUI 代码。
import SwiftUI

struct ContentView: View {
    let temperature = 10

    var body: some View {
        VStack {
            // 纯文本
            Text(String(localized: "title", defaultValue: "Kindly Reminder"))

            // 自定义View
            MessageView(String(localized: "message", defaultValue: "Weather Information"))

            // 插值
            Text(String(localized: "weather",
                        defaultValue: "Weather is \(String(localized: "localizedWeather", defaultValue: "Sunny"))"))

            Text(String(localized: "temperature",
                        defaultValue: "Temperature is \(temperature) ℃"))
        }
        .padding()
    }
}

struct MessageView: View {
    let message: String

    init(_ message: String) {
        self.message = message
    }

    var body: some View {
        Text(message)
    }
}
  1. 编译项目,可以自动提取需要国际化的内容到xcstrings文件。
  2. 在 Xcode 提供的可视化的界面进行国际化内容的编辑,并且会显示每一种语言的国际化完成比例。

编辑英文.png编辑中文.png

  1. 运行并且测试。

效果

  • 英文。 英文效果.png

  • 中文。 中文效果.png

widget重建

2025年4月13日 20:47

Flutter Widget重建(Rebuild)全面深度解析

下面是一个全面且深入的分析:

1. 显式触发的重建

1.1 setState() 调用

void incrementCounter() {
  setState(() {
    counter++;
  });
}

机制:标记Element为dirty,注册框架重建请求。

1.2 markNeedsBuild() 直接调用

// 在自定义RenderObjectWidget中
element.markNeedsBuild();

机制:较底层API,setState()内部实际调用此方法。

2. 配置/数据驱动的重建

2.1 父Widget重建

class ParentWidget extends StatefulWidget {
  @override
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  int counter = 0;
  
  @override
  Widget build(BuildContext context) {
    print("Parent rebuild");
    return Column(
      children: [
        Text("Counter: $counter"),
        ChildWidget(),  // ChildWidget会随Parent重建
        ElevatedButton(
          onPressed: () => setState(() => counter++),
          child: Text("Increment"),
        ),
      ],
    );
  }
}

机制:父Widget重建导致子Widget树重新创建。

2.2 InheritedWidget变化

class MyInheritedWidget extends InheritedWidget {
  final int data;
  
  MyInheritedWidget({required this.data, required Widget child})
      : super(child: child);
  
  @override
  bool updateShouldNotify(MyInheritedWidget oldWidget) {
    return data != oldWidget.data;
  }
  
  static MyInheritedWidget of(BuildContext context) {
    return context.dependOnInheritedWidget<MyInheritedWidget>();
  }
}

// 使用方
Widget build(BuildContext context) {
  final myData = MyInheritedWidget.of(context).data;  // 建立依赖
  return Text('Data: $myData');
}

机制:当InheritedWidget数据变化,所有依赖它的Widget都会重建。这是Provider、Theme、MediaQuery等的工作原理。

2.3 didUpdateWidget触发

class MyStatefulWidget extends StatefulWidget {
  final int data;
  MyStatefulWidget({required this.data});
  
  @override
  _MyStatefulWidgetState createState() => _MyStatefulWidgetState();
}

class _MyStatefulWidgetState extends State<MyStatefulWidget> {
  @override
  void didUpdateWidget(MyStatefulWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.data != oldWidget.data) {
      // 响应配置变化
      setState(() {
        // 更新内部状态
      });
    }
  }
}

机制:父Widget传入的配置变化,可在didUpdateWidget中处理。

3. 系统/环境变化触发的重建

3.1 屏幕旋转(方向变化)

class OrientationAwareWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final orientation = MediaQuery.of(context).orientation;
    print("Orientation: $orientation");
    
    return orientation == Orientation.portrait
        ? Column(children: [RedBox(), BlueBox()])
        : Row(children: [RedBox(), BlueBox()]);
  }
}

机制

  • 旋转屏幕时,系统更新MediaQuery
  • MediaQuery是InheritedWidget
  • 依赖MediaQuery的Widget会重建

3.2 键盘显示/隐藏

class KeyboardAwareWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final bottomInset = MediaQuery.of(context).viewInsets.bottom;
    print("Keyboard height: $bottomInset");
    
    return Container(
      padding: EdgeInsets.only(bottom: bottomInset),
      child: TextField(),
    );
  }
}

机制

  • 键盘弹出时,viewInsets.bottom增加
  • MediaQuery更新,依赖它的Widget重建

3.3 系统设置变化

3.3.1 字体大小变化
class FontScaleAwareWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final textScaleFactor = MediaQuery.of(context).textScaleFactor;
    print("Text scale factor: $textScaleFactor");
    
    return Text(
      "This text adapts to system font size",
      style: TextStyle(fontSize: 16 * textScaleFactor),
    );
  }
}
3.3.2 深色模式切换
class ThemeAwareWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final isDarkMode = Theme.of(context).brightness == Brightness.dark;
    print("Dark mode: $isDarkMode");
    
    return Container(
      color: isDarkMode ? Colors.grey[800] : Colors.white,
      child: Text(
        "Theme adaptive text",
        style: TextStyle(
          color: isDarkMode ? Colors.white : Colors.black,
        ),
      ),
    );
  }
}

机制

  • 系统设置变化时,Flutter框架更新MediaQuery/Theme
  • 作为InheritedWidget,依赖它们的Widget重建

3.4 语言/区域设置变化

class LocaleAwareWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context);
    print("Current locale: $locale");
    
    return Text(
      locale.languageCode == 'zh' ? "你好" : "Hello",
    );
  }
}

机制

  • 系统语言变更时,Localizations Widget更新
  • 依赖Localizations的Widget重建

4. 应用状态变化触发的重建

4.1 应用生命周期变化

class AppLifecycleAwareWidget extends StatefulWidget {
  @override
  _AppLifecycleAwareWidgetState createState() => _AppLifecycleAwareWidgetState();
}

class _AppLifecycleAwareWidgetState extends State<AppLifecycleAwareWidget> with WidgetsBindingObserver {
  AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
  
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }
  
  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }
  
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    setState(() {
      _lifecycleState = state;
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("App lifecycle: $_lifecycleState");
    
    return Text(
      "Current state: $_lifecycleState",
      style: TextStyle(
        color: _lifecycleState == AppLifecycleState.resumed
            ? Colors.green
            : Colors.red,
      ),
    );
  }
}

机制

  • 应用进入前台/后台时,通过WidgetsBindingObserver回调
  • 手动调用setState触发重建

4.2 内存压力事件

class MemoryPressureAwareWidget extends StatefulWidget {
  @override
  _MemoryPressureAwareWidgetState createState() => _MemoryPressureAwareWidgetState();
}

class _MemoryPressureAwareWidgetState extends State<MemoryPressureAwareWidget> with WidgetsBindingObserver {
  bool _isUnderMemoryPressure = false;
  
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }
  
  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }
  
  @override
  void didHaveMemoryPressure() {
    setState(() {
      _isUnderMemoryPressure = true;
    });
    
    // 释放一些资源
    Future.delayed(Duration(seconds: 5), () {
      if (mounted) {
        setState(() {
          _isUnderMemoryPressure = false;
        });
      }
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return _isUnderMemoryPressure
        ? SimpleImageWidget()  // 低内存模式,简化显示
        : HighQualityImageWidget();  // 正常模式,高质量显示
  }
}

机制

  • 系统内存不足时触发didHaveMemoryPressure
  • 手动调用setState降级UI

5. 路由和导航触发的重建

5.1 路由变化

class RouterAwareWidget extends StatefulWidget {
  @override
  _RouterAwareWidgetState createState() => _RouterAwareWidgetState();
}

class _RouterAwareWidgetState extends State<RouterAwareWidget> with RouteAware {
  String _routeStatus = "Active";
  RouteObserver<PageRoute>? _routeObserver;
  
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    
    _routeObserver = Router.of(context).routeObserver as RouteObserver<PageRoute>;
    _routeObserver?.subscribe(this, ModalRoute.of(context) as PageRoute);
  }
  
  @override
  void dispose() {
    _routeObserver?.unsubscribe(this);
    super.dispose();
  }
  
  @override
  void didPush() {
    setState(() {
      _routeStatus = "Pushed";
    });
  }
  
  @override
  void didPop() {
    setState(() {
      _routeStatus = "Popped";
    });
  }
  
  @override
  void didPushNext() {
    setState(() {
      _routeStatus = "Inactive (new route pushed)";
    });
  }
  
  @override
  void didPopNext() {
    setState(() {
      _routeStatus = "Active (returned to this route)";
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return Text("Route status: $_routeStatus");
  }
}

机制

  • 路由变化时,RouteObserver提供回调
  • 手动调用setState响应路由事件

5.2 Focus变化

class FocusAwareWidget extends StatefulWidget {
  @override
  _FocusAwareWidgetState createState() => _FocusAwareWidgetState();
}

class _FocusAwareWidgetState extends State<FocusAwareWidget> {
  late FocusNode _focusNode;
  bool _hasFocus = false;
  
  @override
  void initState() {
    super.initState();
    _focusNode = FocusNode();
    _focusNode.addListener(_onFocusChange);
  }
  
  void _onFocusChange() {
    setState(() {
      _hasFocus = _focusNode.hasFocus;
    });
  }
  
  @override
  void dispose() {
    _focusNode.removeListener(_onFocusChange);
    _focusNode.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return TextField(
      focusNode: _focusNode,
      decoration: InputDecoration(
        labelText: "Input",
        border: OutlineInputBorder(),
        fillColor: _hasFocus ? Colors.blue.withOpacity(0.1) : null,
        filled: _hasFocus,
      ),
    );
  }
}

机制

  • Focus变化时,FocusNode触发监听器
  • 手动调用setState更新UI

6. 开发相关的重建触发

6.1 热重载(Hot Reload)

机制

  • 热重载时,Flutter重新运行build方法
  • 保留现有的State对象状态
  • Widget树从修改的地方开始重建

6.2 热重启(Hot Restart)

机制

  • 热重启时,整个应用重新初始化
  • 所有State都被重置
  • 完整的Widget树重建

7. 特殊重建场景

7.1 AnimationBuilder触发的重建

class PulsatingCircle extends StatefulWidget {
  @override
  _PulsatingCircleState createState() => _PulsatingCircleState();
}

class _PulsatingCircleState extends State<PulsatingCircle> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 1),
    )..repeat(reverse: true);
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        print("AnimatedBuilder rebuilding"); // 每帧都会打印
        return Container(
          width: 100 + 50 * _controller.value,
          height: 100 + 50 * _controller.value,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: Colors.blue.withOpacity(0.5 + 0.5 * _controller.value),
          ),
        );
      },
    );
  }
}

机制

  • 动画每帧触发AnimatedBuilder重建
  • 重建限制在AnimatedBuilder范围内

7.2 FutureBuilder/StreamBuilder触发的重建

class DataLoadingWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: fetchData(),
      builder: (context, snapshot) {
        print("FutureBuilder rebuilding, state: ${snapshot.connectionState}");
        
        if (snapshot.connectionState == ConnectionState.waiting) {
          return CircularProgressIndicator();
        } else if (snapshot.hasError) {
          return Text("Error: ${snapshot.error}");
        } else {
          return Text("Data: ${snapshot.data}");
        }
      },
    );
  }
  
  Future<String> fetchData() async {
    await Future.delayed(Duration(seconds: 2));
    return "Hello from the future!";
  }
}

机制

  • Future/Stream状态变化时自动触发重建
  • 重建限制在Builder范围内

7.3 LayoutBuilder触发的重建

class SizeResponsiveWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        print("LayoutBuilder: width=${constraints.maxWidth}, height=${constraints.maxHeight}");
        
        // 基于可用空间切换布局
        if (constraints.maxWidth > 600) {
          return WideLayout();
        } else {
          return NarrowLayout();
        }
      },
    );
  }
}

机制

  • 父级尺寸变化时LayoutBuilder重建
  • 可检测组件自身尺寸变化

8. 重建优化策略

8.1 使用const构造器

// 优化前
IconButton(
  icon: Icon(Icons.add),
  onPressed: () => setState(() => counter++),
)

// 优化后
IconButton(
  icon: const Icon(Icons.add), // 不会重建
  onPressed: () => setState(() => counter++),
)

8.2 使用RepaintBoundary隔离重绘

class OptimizedListItem extends StatelessWidget {
  final int index;
  
  const OptimizedListItem({Key? key, required this.index}) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    print("Building item $index");
    
    return RepaintBoundary(
      child: ListTile(
        title: Text("Item $index"),
        // 复杂内容
        trailing: ComplexWidget(),
      ),
    );
  }
}

8.3 使用缓存和记忆化

class MemoizedWidget extends StatelessWidget {
  final int id;
  final String data;
  
  // 使用缓存
  static final Map<int, Widget> _cache = {};
  
  const MemoizedWidget({Key? key, required this.id, required this.data}) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    // 检查缓存
    if (!_cache.containsKey(id)) {
      print("Cache miss for id $id");
      _cache[id] = _buildExpensiveWidget(id, data);
    } else {
      print("Cache hit for id $id");
    }
    
    return _cache[id]!;
  }
  
  Widget _buildExpensiveWidget(int id, String data) {
    // 假设这是一个计算密集型组件
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Text("Data: $data for ID $id"),
      ),
    );
  }
}

8.4 细粒度状态管理

// 不好的实践 - 整个列表重建
class IneffectiveListWidget extends StatefulWidget {
  @override
  _IneffectiveListWidgetState createState() => _IneffectiveListWidgetState();
}

class _IneffectiveListWidgetState extends State<IneffectiveListWidget> {
  List<bool> itemStates = List.generate(100, (_) => false);
  
  void toggleItem(int index) {
    setState(() {
      itemStates[index] = !itemStates[index];
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("Building entire list");
    return ListView.builder(
      itemCount: 100,
      itemBuilder: (context, index) {
        return ListTile(
          title: Text("Item $index"),
          trailing: Checkbox(
            value: itemStates[index],
            onChanged: (_) => toggleItem(index),
          ),
        );
      },
    );
  }
}

// 好的实践 - 只重建单个项
class EfficientListWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print("Building list container once");
    return ListView.builder(
      itemCount: 100,
      itemBuilder: (context, index) {
        return ItemWidget(index: index);
      },
    );
  }
}

class ItemWidget extends StatefulWidget {
  final int index;
  
  const ItemWidget({Key? key, required this.index}) : super(key: key);
  
  @override
  _ItemWidgetState createState() => _ItemWidgetState();
}

class _ItemWidgetState extends State<ItemWidget> {
  bool checked = false;
  
  @override
  Widget build(BuildContext context) {
    print("Building just item ${widget.index}");
    return ListTile(
      title: Text("Item ${widget.index}"),
      trailing: Checkbox(
        value: checked,
        onChanged: (value) {
          setState(() {
            checked = value!;
          });
        },
      ),
    );
  }
}

总结

Flutter中Widget重建的触发机制非常丰富,了解这些可以帮助你更好地诊断性能问题并优化应用。关键是要意识到:

  1. 不仅是setState:重建可能来自多种系统级别事件
  2. 重建不等于绘制:Element和RenderObject层有自己的优化
  3. 重建成本因Widget而异:大多数重建很轻量,但仍要注意避免不必要重建
  4. 控制重建范围:通过拆分StatefulWidget和使用缓存机制优化

掌握这些重建机制,可以构建既响应用户操作又流畅高效的Flutter应用。

Flutter中从setState()到屏幕更新的完整流程

2025年4月13日 20:04

Flutter中从setState()到屏幕更新的完整流程

当在Flutter应用中调用setState()时,会触发一系列精确的步骤,最终导致UI更新。以下是这个过程的详细分解,配合具体示例:

1. setState()调用与标记阶段

步骤详解

  • 调用setState()并执行其回调函数
  • 标记当前State对象为"dirty"
  • 向引擎注册一个新帧的请求

源码层次的工作

@protected
void setState(VoidCallback fn) {
  // 断言确保不在build过程中调用setState
  assert(_debugLifecycleState != _StateLifecycle.defunct);
  
  // 执行回调函数,通常是更新一些状态变量
  fn();
  
  // 核心:标记当前State需要重建
  _element!.markNeedsBuild();
}

其中_element.markNeedsBuild()会:

  1. 将Element标记为dirty
  2. 将Element添加到BuildOwner的_dirtyElements列表
  3. 调用SchedulerBinding.instance.scheduleFrame()请求新帧

例子 - 计数器状态更新

class CounterState extends State<Counter> {
  int count = 0;
  
  void incrementCounter() {
    // 调用setState并提供匿名回调
    setState(() {
      count++; // 状态变更
      print("状态已更新为: $count"); // 立即执行
    });
    
    // 此时Element已被标记为dirty,但屏幕尚未更新
    print("setState已调用完毕,但屏幕尚未更新");
  }
  
  @override
  Widget build(BuildContext context) {
    print("build方法被调用,count = $count");
    return Text('Count: $count');
  }
}

incrementCounter()被调用时,输出顺序是:

状态已更新为: 1
setState已调用完毕,但屏幕尚未更新
...等待引擎触发新帧...
build方法被调用,count = 1

2. 调度帧阶段

步骤详解

  • scheduleFrame()通知Flutter引擎有工作要做
  • 引擎会等待下一个vsync信号(通常是16.67ms,对应60fps)
  • vsync信号到达时,引擎调用handleBeginFramehandleDrawFrame

源码工作流程

void scheduleFrame() {
  if (_hasScheduledFrame || !_framesEnabled)
    return;
  
  // 通知引擎需要在下一个vsync信号处理新帧
  window.scheduleFrame();
  _hasScheduledFrame = true;
}

然后在vsync时:

// 简化的流程
void _handleBeginFrame(Duration rawTimeStamp) {
  // 处理动画和各种回调
}

void _handleDrawFrame() {
  // 1. 运行所有微任务
  // 2. 构建所有dirty Elements
  // 3. 布局和绘制
  buildOwner.buildScope(renderViewElement);
  pipelineOwner.flushLayout();
  pipelineOwner.flushCompositingBits();
  pipelineOwner.flushPaint();
  // 4. 合成并发送到GPU
  renderView.compositeFrame();
}

例子 - 动画状态更新

class AnimatedBoxState extends State<AnimatedBox> with SingleTickerProviderStateMixin {
  late AnimationController controller;
  double width = 100.0;
  
  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: Duration(seconds: 1),
      vsync: this,
    )..addListener(() {
      setState(() {
        // 这会在每一帧被调用,随着动画值变化
        width = 100.0 + controller.value * 100.0;
        print("内部状态更新: width = $width");
      });
    });
    
    controller.forward();
  }
  
  @override
  Widget build(BuildContext context) {
    print("绘制宽度为 $width 的box");
    return Container(width: width, height: 100, color: Colors.blue);
  }
}

这个例子中,动画控制器在每一帧触发setState:

  1. 动画开始,请求首帧
  2. vsync信号到达,运行动画tick
  3. 动画tick更新值并调用setState
  4. 标记Element为dirty
  5. 在同一帧内完成构建和渲染
  6. 重复步骤2-5直到动画完成

3. 构建阶段(Build Phase)

步骤详解

  • BuildOwner遍历所有dirty elements
  • 调用每个dirty element的rebuild()方法
  • Element调用关联State的build()方法
  • 与之前的Widget tree进行比较,更新Element树

源码关键部分

void buildScope(Element context) {
  // ... 
  
  try {
    // 按深度排序,确保父元素先于子元素重建
    _dirtyElements.sort(Element._sort);
    
    // 处理所有需要重建的元素
    int dirtyCount = _dirtyElements.length;
    int index = 0;
    
    while (index < dirtyCount) {
      // 获取并重建Element
      final Element element = _dirtyElements[index];
      element.rebuild(); // 这里会调用widget.build()
      
      // ...处理潜在新增的dirty elements
    }
  } finally {
    // ...
  }
}

例子 - 层级嵌套重建

class ParentWidget extends StatefulWidget {
  @override
  ParentWidgetState createState() => ParentWidgetState();
}

class ParentWidgetState extends State<ParentWidget> {
  bool showDetails = false;
  
  void toggleDetails() {
    setState(() {
      showDetails = !showDetails;
      print("父级状态变更: showDetails = $showDetails");
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("父级build开始");
    return Column(
      children: [
        ElevatedButton(
          onPressed: toggleDetails,
          child: Text('Toggle Details'),
        ),
        ChildWidget(showDetails: showDetails),
      ],
    );
  }
}

class ChildWidget extends StatefulWidget {
  final bool showDetails;
  
  ChildWidget({required this.showDetails});
  
  @override
  ChildWidgetState createState() => ChildWidgetState();
}

class ChildWidgetState extends State<ChildWidget> {
  @override
  Widget build(BuildContext context) {
    print("子级build开始, showDetails=${widget.showDetails}");
    return widget.showDetails 
        ? Card(child: Text('详细信息...'))
        : SizedBox.shrink();
  }
}

当点击按钮时,重建顺序是:

父级状态变更: showDetails = true
父级build开始
子级build开始, showDetails=true

关键流程是:

  1. 点击按钮触发toggleDetails()
  2. setState()标记ParentWidgetState的Element为dirty
  3. 下一帧开始构建阶段
  4. 按深度排序后先重建父Element
  5. 父Element的重建导致子Widget重新创建
  6. 子Element检测到Widget配置(showDetails)变化,并更新

4. 布局阶段(Layout Phase)

步骤详解

  • RenderObject树接收到Element的更新
  • 标记需要重新布局的RenderObject
  • 从上到下计算约束(constraints)
  • 从下到上确定尺寸(sizes)

工作流程

void flushLayout() {
  // 确保不在布局过程中再次触发布局
  try {
    while (_nodesNeedingLayout.isNotEmpty) {
      final List<RenderObject> dirtyNodes = _nodesNeedingLayout.toList();
      _nodesNeedingLayout.clear();
      
      // 按深度排序,确保父节点先于子节点布局
      dirtyNodes.sort((a, b) => a.depth - b.depth);
      
      // 依次对每个节点进行布局
      for (final RenderObject node in dirtyNodes) {
        if (node._needsLayout && node.owner == this)
          node._layoutWithoutResize();
      }
    }
  } finally {
    // ...
  }
}

例子 - 复杂布局计算

class ResponsiveContainer extends StatefulWidget {
  @override
  ResponsiveContainerState createState() => ResponsiveContainerState();
}

class ResponsiveContainerState extends State<ResponsiveContainer> {
  bool useWideLayout = false;
  
  void toggleLayout() {
    setState(() {
      useWideLayout = !useWideLayout;
      print("布局状态变更: useWideLayout = $useWideLayout");
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("构建响应式容器");
    return Column(
      children: [
        ElevatedButton(
          onPressed: toggleLayout,
          child: Text('切换布局'),
        ),
        useWideLayout
            ? Row(
                children: [
                  Expanded(child: ColoredBox(color: Colors.red, child: SizedBox(height: 100))),
                  Expanded(child: ColoredBox(color: Colors.blue, child: SizedBox(height: 100))),
                ],
              )
            : Column(
                children: [
                  ColoredBox(color: Colors.red, child: SizedBox(height: 100, width: double.infinity)),
                  ColoredBox(color: Colors.blue, child: SizedBox(height: 100, width: double.infinity)),
                ],
              ),
      ],
    );
  }
}

布局流程:

  1. 点击按钮更改useWideLayout并触发setState
  2. 重建Widget树,现在结构从Column变为Row(或反之)
  3. Element树更新,创建/复用子Elements
  4. RenderObject树收到更新,标记需要重新布局
  5. 布局引擎从根部开始,向下传递约束:
    • 父RenderObject向Row/Column传递约束
    • Row/Column向其子项传递约束
  6. 子RenderObject确定自己的尺寸并向上报告:
    • 颜色块根据约束确定尺寸
    • Row/Column收集子项尺寸并确定自己的最终尺寸

5. 绘制阶段(Paint Phase)

步骤详解

  • 遍历标记为需要重绘的RenderObject
  • 创建或更新绘制记录(Layer)
  • 将绘制命令记录到Layer中

源码工作流程

void flushPaint() {
  try {
    final List<RenderObject> dirtyNodes = _nodesNeedingPaint.toList();
    _nodesNeedingPaint.clear();
    
    // 按深度排序,确保父节点先于子节点绘制
    dirtyNodes.sort((a, b) => a.depth - b.depth);
    
    // 依次对每个节点进行绘制
    for (final RenderObject node in dirtyNodes) {
      if (node._needsPaint && node.owner == this) {
        if (node._layer == null)
          node._repaintBoundary = null;
        node._paint();
      }
    }
  } finally {
    // ...
  }
}

例子 - 自定义绘制与动画

class AnimatedCircleState extends State<AnimatedCircle> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  Color _color = Colors.blue;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    )..addListener(() {
      setState(() {
        // 空的setState,只是为了触发重绘
        print("请求重绘,动画值: ${_controller.value}");
      });
    });
    
    _controller.repeat(reverse: true);
  }
  
  void changeColor() {
    setState(() {
      _color = _color == Colors.blue ? Colors.red : Colors.blue;
      print("颜色更改为: $_color");
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("构建自定义绘制组件");
    return Column(
      children: [
        ElevatedButton(
          onPressed: changeColor,
          child: Text('更改颜色'),
        ),
        CustomPaint(
          painter: CirclePainter(
            color: _color,
            progress: _controller.value,
          ),
          size: Size(200, 200),
        ),
      ],
    );
  }
}

class CirclePainter extends CustomPainter {
  final Color color;
  final double progress;
  
  CirclePainter({required this.color, required this.progress});
  
  @override
  void paint(Canvas canvas, Size size) {
    print("执行paint方法, progress=$progress");
    final center = Offset(size.width / 2, size.height / 2);
    final radius = 50.0 + 30.0 * progress;
    
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.fill;
      
    canvas.drawCircle(center, radius, paint);
  }
  
  @override
  bool shouldRepaint(CirclePainter oldDelegate) {
    return oldDelegate.color != color || oldDelegate.progress != progress;
  }
}

绘制流程:

  1. 动画tick或按钮点击触发setState
  2. Element重建并更新RenderObject属性
  3. RenderObject标记为需要重绘
  4. 绘制阶段,CustomPaint的RenderObject调用CirclePainter的paint方法
  5. CirclePainter根据当前color和progress绘制圆形
  6. 绘制命令被收集到对应的Layer中

6. 合成与渲染阶段

步骤详解

  • 构建完整的Layer树
  • 将Layer树提交给Flutter引擎
  • 引擎将Layer树转换为Skia(GPU渲染库)命令
  • GPU执行命令进行光栅化,产生像素
  • 显示器在下一次刷新时显示这些像素

流程简化

// 在绘制完成后,Flutter框架调用以下方法
ui.SceneBuilder _sceneBuilder = ui.SceneBuilder();

// 每个Layer都会添加到场景中
void addToScene(ui.SceneBuilder builder) {
  // ... Layer特定的添加逻辑
}

// 最后提交整个场景
ui.Scene scene = _sceneBuilder.build();
window.render(scene);

例子 - RepaintBoundary与Layer优化

class OptimizedUIState extends State<OptimizedUI> {
  int topCounter = 0;
  int bottomCounter = 0;
  
  void incrementTop() {
    setState(() {
      topCounter++;
      print("顶部计数器更新: $topCounter");
    });
  }
  
  void incrementBottom() {
    setState(() {
      bottomCounter++;
      print("底部计数器更新: $bottomCounter");
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("主UI构建");
    return Column(
      children: [
        ElevatedButton(
          onPressed: incrementTop,
          child: Text('更新顶部'),
        ),
        // 使用RepaintBoundary创建独立的Layer
        RepaintBoundary(
          child: Builder(builder: (context) {
            print("顶部区域构建: $topCounter");
            return Container(
              height: 100,
              color: Colors.amber,
              alignment: Alignment.center,
              child: Text('顶部计数: $topCounter', style: TextStyle(fontSize: 24)),
            );
          }),
        ),
        ElevatedButton(
          onPressed: incrementBottom,
          child: Text('更新底部'),
        ),
        // 另一个独立Layer
        RepaintBoundary(
          child: Builder(builder: (context) {
            print("底部区域构建: $bottomCounter");
            return Container(
              height: 100,
              color: Colors.lightBlue,
              alignment: Alignment.center,
              child: Text('底部计数: $bottomCounter', style: TextStyle(fontSize: 24)),
            );
          }),
        ),
      ],
    );
  }
}

渲染优化流程:

  1. 点击"更新顶部"按钮时:

    顶部计数器更新: 1
    主UI构建
    顶部区域构建: 1
    底部区域构建: 0
    
  2. 在绘制阶段:

    • 两个RepaintBoundary各自创建独立的PictureLayer
    • 顶部容器的绘制命令记录到第一个Layer
    • 底部容器的绘制命令记录到第二个Layer
  3. 再次点击"更新顶部"按钮:

    顶部计数器更新: 2
    主UI构建
    顶部区域构建: 2
    底部区域构建: 0
    
  4. 这次的绘制过程:

    • 只有顶部的PictureLayer需要重绘
    • 底部的Layer可以被复用,因为其内容没有变化
    • 这就是Layer树的合成优化
  5. 最后,所有Layer组合成一个Scene:

    • 根ContainerLayer包含所有子Layer
    • Scene被提交给Flutter引擎
    • 引擎将Scene转换为GPU命令
    • 显示器在下一个vsync显示结果

7. 总结:完整流程图

setState()到屏幕更新的完整路径:

  1. 触发阶段

    • setState()被调用
    • 更新State中的数据
    • 标记Element为dirty
    • 请求新帧
  2. 调度阶段

    • 等待下一个vsync信号
    • 处理开始帧回调
  3. 构建阶段

    • 遍历dirty elements
    • 调用build()方法
    • 更新Element树
  4. 布局阶段

    • 计算尺寸和位置
    • 自上而下传递约束
    • 自下而上确定尺寸
  5. 绘制阶段

    • 记录绘制命令
    • 创建或更新Layer
  6. 合成与渲染阶段

    • 构建Layer树
    • 提交给引擎
    • GPU渲染
    • 显示器展示

这个过程是Flutter实现流畅60fps动画的核心机制,通过精确控制每个阶段的工作,确保高效的UI渲染。

昨天以前掘金 iOS

在H5页面的SSR中,客户端需要做哪些工作?

2025年4月13日 12:38

1、前言

作为前端开发,对于H5页面的SSR,我们一般只关心webview启动之后的工作,如数据请求、水合、渲染等。实际上,H5页面的SSR,需要和客户端高度配合,才能实现所需效果。比如在笔者的上一篇文章【 手把手带你实现 Vite+React 的简易 SSR 改造【含部分原理讲解】】中,简要提到了流式SSR+FCC优化这一工作便主要是借助客户端的缓存实现相应优化:
20250413105055.png

笔者除了前端开发外,对客户端开发也有一定了解(主要是iOS开发、flutter开发等),因此本文从iOS客户端的角度讲解所需要关注的细节,让读者对整个SSR流程更加了解。

2、客户端核心逻辑梳理

20250413115312.png

2-1 入口启动注册

在App启动时进行SSR模块的初始化(主要是注册自定义的 URL 协议类 SSRURLProtocol):

[RequestSSRHandler setup];

2-2 URL拦截和处理

  • SSRURLProtocol
    • 使用 NSURLProtocol 来拦截特定的网络请求。这些拦截基于请求 URL 的某些标志位或头字段。
    • canInitWithRequest: 方法用于判断是否对请求进行拦截,避免重复拦截,并进行必要的日志记录和异常处理。

2-3 请求发送与响应处理

  • RequestSSRHandler:
    • setup 方法设置与应用相关的请求参数,如 appkeyappverutdid 等,并进行统一管理。
    • sendRequest: 方法将请求发送给后端,可选择缓存命中的内容直接响应,优化响应时间。
    • ssrRequest:didReceiveResponse: 处理响应,检查缓存数据是否可用并适用的判断逻辑。
    • 对于特定错误进行降级处理(如网络错误),通过 requestOnline:receiver: 方法发起普通网络请求。

2-4 缓存管理

  • 缓存数据 ( SSRCacheData ) :

    • 负责序列化缓存的 HTML 数据,并保存相关的版本、过期时间等,用于快速响应请求。
    • 提供 getCacheHtmlsetCacheHtml 方法来管理缓存数据的存取,加速处理过程。
  • 缓存存储 ( FCCStorage ) :

    • 管理缓存的持久化。
    • 提供方法来保存、获取和移除缓存数据,以确保缓存的有效利用。
    • 使用特定查询参数和用户标识决定缓存的唯一键值,使缓存管理更具灵活性。

2-5 响应版本和开关校验

  • 在处理请求和响应时,对版本和开关的启用状态进行检查,确保缓存的正确性和适用性。
  • 确保在版本不匹配或开关关闭的情况下替代渲染方法,以保障应用的稳定性。

2-6 请求上下文管理

  • SSRRequestContext:
    • 管理请求的状态和数据,包括是否启用首 chunk 缓存、是否命中缓存、是否复用完缓存等。
    • produceResponse:produceData: 负责处理接收到的响应和数据,根据缓存状态进行处理,包括使用缓存、替代渲染、保存缓存等。
    • 提供 matchFirstChunkCache, saveFirstChunkCache, 和 matchFCC:cacheData:response: 等方法来管理首 chunk 缓存数据的匹配和存取。

2-7 错误处理与降级策略

  • 在请求失败的情况下,如果满足特定条件,会自动降级请求为普通在线请求,以保证系统的稳定性和用户体验。

3、附上WebViewController应有的一些逻辑

  1. WebView初始化与配置

    • 使用WKWebView,进行URL拦截。
    • 支持下拉刷新、自定义导航栏、状态栏样式等。
  2. 请求处理与拦截

    • 登录拦截。
    • URL安全校验,防止恶意链接。
    • 路由拦截,处理本地协议跳转。
  3. 性能监控与埋点

    • 页面加载时间统计。
    • 错误监控与上报。
    • 应用启动阶段H5页面加载的性能追踪。
  4. UI交互

    • 显示加载状态(自定义Loading动画)。
    • 处理横竖屏切换。
    • 导航栏返回按钮和关闭按钮的逻辑。
  5. 其他功能

    • Cookie同步、第三方验证、字体注入等。

了解客户端处理的这些逻辑之后,可以考虑h5页面首屏进一步的性能优化:webview预热和文档预请求,具体的实现逻辑需要笔者和读者一起去学习探索:
7CE0DEC0-5FBB-41A3-AC06-487735635733.png

Trae + SwiftUI 1 小时实现一个单词本 Mac App

作者 冯志浩
2025年4月13日 10:31

前言

在 AI 发展越来越好的现在,它的应用已经不仅仅限制于帮我们生成问题的答案,还可以直接通过对自然语言的理解帮助我们直接生成对象的代码。对于某些简单的场景,如模版代码实现、结构简单的 UI 绘制等,它现在已经做得很好,这对于程序员的生产力提升还是非常有帮助的。

接下来,我通过一个简单的单词本应用,来给大家展示一下 Trae 的真实体验。

应用功能

首先,我们需要将应用的功能通过自然语言去描述出来,比如这个单词本 App,主要包含三个功能,单词本、错词和已掌握三个模块,每个模块都是以列表的形式进行展示。单词本中的单词如果不熟悉可以添加到错词中,如果很熟悉就添加到已掌握中,且支持 SwiftData 。

下面是我梳理的需求描述:

  • 新建一个 Swift 文件,文件名为 Word,并在里面实现一个 Word 类,包含 title 字符串类型、isError 布尔类型、isMaster 布尔类型,需要支持 SwiftData
  • 生成一个长度为 50 的数组 words,元素为 Word 类型,title 为随机的英文单词,10 个元素 isErrortrue,5 个元素 isMastertrue,其余的 isErrorisMasterfalse
  • 在侧边栏实现三个按钮,标题分别为单词本,错题,已掌握,点击按钮切换右侧视图。
  • 单词本、错词、已掌握三个 detail 都为列表形式。
  • 单词本列表内容为 words 中的所有元素,表格样式包含一个文本展示单词,两个按钮,一个按钮是添加到错词,若该模型的 isErrortrue 隐藏该按钮,若为 false 才显示。点击该按钮,将该条数据模型的 isError 赋值为 true。一个按钮是已掌握,若该模型的 isMastertrue 隐藏该按钮,若为 false 才显示。点击该按钮,将该条数据模型的 isMaster 赋值为 true
  • 错词列表内容为 words 中 isErrortrue 的所有元素,表格样式包含一个文本展示单词,一个按钮已掌握,点击该按钮,将该条数据模型的 isMaster 赋值为 trueisError 赋值为 false
  • 已掌握列表内容为 wordsisMastertrue 的所有元素,表格样式包含一个文本展示单词,一个按钮移除,点击该按钮,将该条数据模型的 isMaster 赋值为 false

梳理完,我们就可以通过 Trae 进行代码创建了。

Trae

首先,我们创建一个 SwiftUI 的 macOS app,然后通过 Trae 打开该项目。接着在 AI 对话流中,通过 #Folder 来选定当前文件夹,将第一条需求复制进去点击回车即可生成。

截屏2025-04-13 10.15.14.png

对话流中会生成代码的详细解释,右侧是代码实现,头部有拒绝和接受的选项,点击接受,代码就会自动写入项目中。

其余的需求描述我们需要 #File 选定相应的文件进行需求转代码实现。这里就不一一举例赘述了。

下面让我们来看下 Trae 实现的效果:

录屏2025-04-13 10.18.01.gif

小瑕疵

在代码实现过程中,虽然大部分代码都是正确可编译通过的,但还是碰到了下面的两个小问题:

  • if words.isEmpty { generateWords() } 直接写在了 View 中,代码视图如下:
var body: some View {
NavigationSplitView {
    VStack {}
} detail: {
    if words.isEmpty { generateWords() } // 这里会编译报错
    List(words, id: \.self) { word in
    ...
    }
}

正确的代码:

var body: some View {
NavigationSplitView {
    VStack {}
} detail: {
    if words.isEmpty { generateWords() } // 这里会编译报错
    List(words, id: \.self) { word in
    ...
    }.onAppear {
        if words.isEmpty {
            generateWords()
        }
    }
}
  • if !word.isMaster 写成了 if!word.isMaster,这个错误感觉有点不应该...

总结

从这个小例子的使用感受上来说,对开发者的帮助肯定是正大于负的,比我想象中的要聪明很多。希望大家能够拥抱变化,早早的享受到 AI 的红利。

音视频基础能力之 iOS 视频篇(六):使用Metal进行视频渲染

作者 声知视界
2025年4月12日 22:13

涉及硬件的音视频能力,比如采集、渲染、硬件编码、硬件解码,通常是与客户端操作系统强相关的,就算是跨平台的多媒体框架也必须使用平台原生语言的模块来支持这些功能

本系列文章将详细讲述移动端音视频的采集、渲染、硬件编码、硬件解码这些涉及硬件的能力该如何实现,其中的示例代码,在我们的 Github 仓库 MediaPlayground 中都能找到,与文章结合着一起理解,效果更好

本文为该系列文章的第 6 篇,将详细讲述在 iOS 平台下如何使用 Metal 实现视频画面的渲染,对应了我们 MediaPlayground 项目中 SceneVCVideoRenderMetal.m 文件涉及到的内容

往期精彩内容,可参考

音视频基础能力之 iOS 视频篇(一):视频采集

音视频基础能力之 iOS 视频篇(二):视频硬件编码

音视频基础能力之 iOS 视频篇(三):视频硬件解码

音视频基础能力之 iOS 视频篇(四):使用OpenGL进行视频渲染(上)

音视频基础能力之 iOS 视频篇(五):使用OpenGL进行视频渲染(下)

前言

之前 2 期文章讲了如何使用 OpenGL 做视频渲染,这次总算轮到 Metal 了。从开发的经验来看,兜兜转转这么多年,Metal 还是凭借自身过硬的实力证明了自己的,它的普及率在不断的提高,随着老旧 Apple 设备的逐渐淘汰,新设备对于 Metal 的支持也做的越来越好,可以说 Apple 生态下的渲染引擎,Metal 是当之无愧的老大

简单介绍Metal

Metal 渲染引擎是 Apple 为旗下操作系统专门打造的一套底层图形和计算编程接口,它能让开发者充分利用设备 GPU的强大性能,实现高性能的图形渲染和并行计算,目标就是代替原本的 OpenGL,为 Apple 生态提供更强大、更高效的图形能力。Metal 的高性能,来源于与 Apple 生态的强绑定,不像 OpenGL 标准要兼顾跨平台的通用性,因此更能发挥出 Apple 硬件设备的实力

宏观流程

在 OpenGL 的文章中,我们详细介绍了图形渲染的思路和流程,其实渲染引擎换成 Metal 之后,整体思路和流程是不变的,关键的渲染要素也都是不变的,只不过在 Metal 中换了一种写法。因此我们来快速回顾下图形渲染的流程

1.jpg

微观细节

下面用一个例子展开讲 Metal 渲染的细节

场景:将视频采集之后得到的 NV12 图像数据渲染在屏幕上

注意:因为原始图像数据的格式是 NV12,根据 NV12 格式的特点,数据会分为 Y 和 UV 两个平面,因此要有 2 个输入图像才能正常进行渲染,宏观流程会变成这个样子

2.jpg

系统框架

要在 iOS 上调用 Metal 的接口,需要引入头文件

#import <Metal/Metal.h>

必要资源

MTLDevice

MTLDevice 顾名思义,就是渲染设备,可以理解为 GPU 的抽象体现,与 Metal 渲染引擎相关的资源都由它来分配

id<MTLDevice> device = MTLCreateSystemDefaultDevice();

MTLLibrary

MTLLibrary 用于访问着色器程序,通过 MTLDevice 来创建

id<MTLLibrary> default_library = [device newDefaultLibrary];

MTLCommandQueue

MTLCommandQueue 用于操作渲染管线,通过 MTLDevice 来创建

id<MTLCommandQueue> command_queue = [device newCommandQueue];

采样纹理

与 OpenGL 一样,纹理的数据来源分为 2 种

  1. 如果原始图像数据被包装在 CVPixelBuffer 中,也就是我们例子中的场景,那么这块纹理是由系统创建并管理生命周期的,我们只需要拿到它就好。为了能拿到它,我们需要创建 CVMetalTextureCacheRef。注意:这个 cache 的生命周期需要我们自行管理
CVMetalTextureCacheCreate(kCFAllocatorDefault, NULL, device, NULL, &texture_cache_);

2. 如果原始图像数据本身已经在内存中(有可能是读取本地图片得到的,也有可能是通过软件解码得到的),就需要自行创建纹理并维护其生命周期 (本文中的例子用不到,建议放在一起对比着看,加深理解)

MTLTextureDescriptor* descriptor = [[MTLTextureDescriptor alloc] init];
descriptor.width = pixel_width;
descriptor.height = pixel_height;
descriptor.pixelFormat = MTLPixelFormatR8Unorm;// 对于 YUV 的 Y 分量
texture_ = [metal_device_ newTextureWithDescriptor:descriptor];
MTLRegion region = MTLRegionMake2D(0, 0, pixel_width, pixel_height);
[texture_ replaceRegion:region mipmapLevel:0 withBytes:pixel_data bytesPerRow:pixel_width];

渲染目标

Metal 中没有 OpenGL 那样 frame buffer 的概念,或者就算是有,存在感也不是那么强。涉及渲染目标的内容就是 MTLRenderPassDescriptor,但它其实就像是个携带了一堆参数的 config 而已,因此我们把渲染目标约等于 MTLTexture 也是问题不大的。

根据渲染目标的不同用途,有 2 种创建方式

  1. 想要渲染的结果能在屏幕上展示,也就是我们例子中的场景,就需要从 CAMetalLayer 中去拿到 MTLTexture。在 iOS 中需要自定义 UIView,修改 layerClass 为 CAMetalLayer
+ (Class)layerClass {
    return [CAMetalLayer class];
}

2. 如果渲染目标并不需要在屏幕上展示,只做离屏渲染,只需要创建 MTLRenderPassDescriptor 和 MTLTexture (本文中的例子用不到,建议放在一起对比着看,加深理解)

MTLTextureDescriptor* texture_descriptor = [[MTLTextureDescriptor alloc] init];
texture_descriptor.width = width;
texture_descriptor.height = height;
texture_descriptor.pixelFormat = MTLPixelFormatBGRA8Unorm;
texture_descriptor.usage = MTLTextureUsageShaderRead | MTLTextureUsageRenderTarget;

id<MTLTexture> texture = [metal_device_ newTextureWithDescriptor:texture_descriptor];

render_pass_descriptor_ = [MTLRenderPassDescriptor renderPassDescriptor];
render_pass_descriptor_.colorAttachments[0].clearColor = MTLClearColorMake(0.0f, 0.0f, 0.0f, 1.0f);
render_pass_descriptor_.colorAttachments[0].texture = texture;
render_pass_descriptor_.colorAttachments[0].loadAction = MTLLoadActionClear;
render_pass_descriptor_.colorAttachments[0].storeAction = MTLStoreActionStore;

着色器程序

着色器的作用在 OpenGL 的篇章里已经讲过,这里直接上代码。Metal 的着色器代码会单独放在 .metal 文件中,写好着色器程序,通过名称获取到 MTLFunction,再关联到 MTLRenderPipelineState。MTLRenderPipelineState 顾名思义就是跟渲染管线有关的资源

id<MTLFunction> vertex_function = [default_library newFunctionWithName:@"BasicVertexShader"];// from MetalBaseShader.metal
id<MTLFunction> fragment_function = [default_library newFunctionWithName:@"NV12FragmentShader"];// from MetalBaseShader.metal
MTLRenderPipelineDescriptor* pipeline_descriptor = [[MTLRenderPipelineDescriptor alloc] init];
pipeline_descriptor.vertexFunction = vertex_function;
pipeline_descriptor.fragmentFunction = fragment_function;
pipeline_descriptor.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm;
NSError* error = nil;
self.render_pipeline_state = [device newRenderPipelineStateWithDescriptor:pipeline_descriptor error:&error];
if (error) {
  NSAssert(!error, @"init shader failed, error:%@", error);
}

单次渲染流程

前期准备工作都做完后,就可以让渲染管线真正跑起来了,分为以下几个步骤

第 1 步:将输入的图像 A1 和 A2 的数据,关联到用于采样的纹理

本例中,由于原始图像数据的格式是 NV12,因此需要准备 2 个用于采样的纹理,宏观流程图中的 A1 和 A2 分别对应 Y 平面和 UV 平面。根据图像来源的不同,分为 2 种方式,跟之前准备采样纹理时一样

  1. 如果原始图像数据被包装在 CVPixelBuffer 中,也就是我们例子中的场景,那么从 CVPixelBuffer 中就能拿到纹理
// NV12 的 Y 分量
CVMetalTextureRef cv_texture = nullptr;
CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault, texture_cache_, pixel_buffer, NULL, MTLPixelFormatR8Unorm, pixel_width, pixel_height, 0, &cv_texture);
id<MTLTexture> texture = CVMetalTextureGetTexture(cv_texture);
CFRelease(cv_texture);

// NV12 的 UV 分量
CVMetalTextureRef cv_texture = nullptr;
CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault, texture_cache_, pixel_buffer, NULL, MTLPixelFormatRG8Unorm, pixel_width/2, pixel_height/2, 1, &cv_texture);
id<MTLTexture> texture = CVMetalTextureGetTexture(cv_texture);
CFRelease(cv_texture);

2. 如果原始图像数据本身已经在内存中,就需要将内存中的数据传递到之前创建好的纹理中 (本文中的例子用不到,建议放在一起对比着看,加深理解)

MTLTextureDescriptor* descriptor = [[MTLTextureDescriptor alloc] init];
descriptor.width = pixel_width;
descriptor.height = pixel_height;
descriptor.pixelFormat = MTLPixelFormatR8Unorm;// 双平面 YUV 的 Y 分量

id<MTLTexture> texture = [device newTextureWithDescriptor:descriptor];
MTLRegion region = MTLRegionMake2D(0, 0, pixel_width, pixel_height);
[texture replaceRegion:region mipmapLevel:0 withBytes:pixel_data bytesPerRow:pixel_width];
MTLTextureDescriptor* descriptor = [[MTLTextureDescriptor alloc] init];
descriptor.width = pixel_width/2;
descriptor.height = pixel_height/2;
descriptor.pixelFormat = MTLPixelFormatRG8Unorm;// 双平面 YUV 的 UV 分量

id<MTLTexture> texture = [device newTextureWithDescriptor:descriptor];
MTLRegion region = MTLRegionMake2D(0, 0, pixel_width/2, pixel_height/2);
[texture replaceRegion:region mipmapLevel:0 withBytes:pixel_data bytesPerRow:pixel_width];

第 2 步:准备渲染目标。本案例做屏上渲染,需要从 CAMetalLayer 拿到目标纹理

// 屏上渲染
id<CAMetalDrawable> layer_drawable = [[self.display_view getMetalLayer] nextDrawable];
id<MTLTexture> target_texture = layer_drawable.texture;

//
MTLRenderPassDescriptor* render_pass_descriptor = [MTLRenderPassDescriptor renderPassDescriptor];
render_pass_descriptor.colorAttachments[0].clearColor = MTLClearColorMake(0.0f, 0.0f, 0.0f, 1.0f);
render_pass_descriptor.colorAttachments[0].texture = target_texture;
render_pass_descriptor.colorAttachments[0].loadAction = MTLLoadActionClear;
render_pass_descriptor.colorAttachments[0].storeAction = MTLStoreActionStore;

第 3 步:创建 MTLRenderCommandEncoder,这个对象很重要,用于配置渲染管线中的各个要素

// 创建 render command encoder
id<MTLRenderCommandEncoder> render_command_encoder = [command_buffer renderCommandEncoderWithDescriptor:render_pass_descriptor];
// 设置渲染区域
[render_command_encoder setViewport:(MTLViewport){0.0f, 0.0f, [self.display_view getMetalLayer].drawableSize.width, [self.display_view getMetalLayer].drawableSize.height, 0.0f, 1.0f}];

第 4 步:关联着色器与当前的渲染操作

[render_command_encoder setRenderPipelineState:self.render_pipeline_state];

第 5 步:关联采样纹理和片段着色器

[render_command_encoder setFragmentTexture:texture_list_[0]->GetTexture() atIndex:MetalShaderTextureIndex0];
[render_command_encoder setFragmentTexture:texture_list_[1]->GetTexture() atIndex:MetalShaderTextureIndex1];

第 6 步:将顶点坐标传递给顶点着色器

id<MTLBuffer> vertex_coordinates_buffer = [device newBufferWithBytes:MetalDefaultVertexCoordinates length:sizeof(MetalDefaultVertexCoordinates) options:MTLResourceStorageModeShared];
[render_command_encoder setVertexBuffer:vertex_coordinates_buffer offset:0 atIndex:MetalShaderIndexVertexCoordinates];

第 7 步:将纹理坐标传递给顶点着色器,然后会由顶点着色器透传给片段着色器

id<MTLBuffer> texture_coordinates_buffer = [device newBufferWithBytes:MetalDefaultTextureCoordinates length:sizeof(MetalDefaultTextureCoordinates) options:MTLResourceStorageModeShared];
[render_command_encoder setVertexBuffer:texture_coordinates_buffer offset:0 atIndex:MetalShaderIndexTextureCoordinates];

第 8 步:调用绘制方法;渲染结果需要上屏显示的话,需要在 command buffer 执行 commit 之前,调用 command buffer 的 presentDrawable 方法,离屏渲染则不需要

可以看到 Metal 所体现的思想就是把渲染操作的指令批量进行打包,放在 command buffer 中,然后批量进行处理,其实 OpenGL 也是类似的,调用 OpenGL 接口只是把指令发给了 GPU,GPU 什么时候执行其实并不那么明确,Metal 在流程控制上也更方便,代码中使用了 waitUntilCompleted 来等待当前渲染操作完成,这在链式渲染操作中是很常见的手段

// render
[render_command_encoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4];
[render_command_encoder endEncoding];
  
// show render result on screen
[command_buffer presentDrawable:layer_drawable];
  
// commit render commands
[command_buffer commit];
[command_buffer waitUntilCompleted];

到此为止,单次的渲染流程就走完了,在本例中,对应着视频采集单次回调的图像数据从开始渲染直到在屏幕上展示的过程

释放资源

相比 OpenGL,Metal 的资源回收就方便很多了,唯一要注意的是采样纹理,如果纹理的数据来自 CVPixelBuffer,需要手动释放 CVMetalTextureCacheRef,其他的资源都可以由 ARC 机制进行内存管理,释放资源无需进行额外操作,解除强引用即可

CFRelease(texture_cache_);

写在最后

以上就是本文的所有内容了,详细介绍了在 iOS 平台下如何使用 Metal 实现视频画面的渲染

本文为音视频基础能力系列文章的第 6 篇

往期精彩内容,可参考

音视频基础能力之 iOS 视频篇(一):视频采集

音视频基础能力之 iOS 视频篇(二):视频硬件编码

音视频基础能力之 iOS 视频篇(三):视频硬件解码

音视频基础能力之 iOS 视频篇(四):使用OpenGL进行视频渲染(上)

音视频基础能力之 iOS 视频篇(五):使用OpenGL进行视频渲染(下)

后续精彩内容,敬请期待

音视频基础能力系列文章的示例代码,在我们的 Github 仓库 MediaPlayground 中都能找到,与文章结合着一起理解,效果更好

如果您觉得以上内容对您有所帮助的话,欢迎关注我们运营的公众号声知视界,会定期的推送音视频技术、移动端技术为主轴的科普类、基础知识类、行业资讯类等文章

音视频学习笔记十六——图像处理之OpenCV基础一

2025年4月12日 17:21

题记:前文介绍GPUImage滤镜链的原理,但实际上要写出效果,还需要理解其中图片处理的过程,所以本章开始会介绍一些OpenCV基础相关。图像处理需要用到很多专业的算法,本人业余学习略知皮毛,只是庶竭驽钝叙其所得,在音视频学习Demo有一些的示例。文章或代码若有错误,也希望大佬不吝赐教。

opencv绘图.jpg

一、OpenCV简介

OpenCV(Open Source Computer Vision Library)是一个开源的计算机视觉和机器学习库,广泛应用于人脸识别与生物识别、自动驾驶、工业检测等,核心功能包括:

  • 图像处理:滤波、边缘检测、几何变换、颜色空间转换、直方图均衡化等。
  • 视频分析:运动检测、光流检测等。
  • 特征提取与匹配:SIFT、SURF、ORB、角点检测等。
  • 目标检测与识别:Haar级联分类器(人脸检测)、HOG+SVM(行人检测)、深度学习模型(YOLO、SSD)。
  • 机器学习:支持向量机(SVM)、神经网络等算法。

二、基础操作

2.1. 输入/输出

// 读取图像
cv::Mat img = cv::imread("xxx/xxx.jpg", cv::IMREAD_COLOR);
// 保存图像
cv::imwrite("xxx/xxx.jpg", img);

cv::imshow(winname, img)创建窗口显示,移动端没有实现,iOS端转换为UIImage:

- (UIImage *)matToUIImage:(const cv::Mat&)mat {
    NSData *data = [NSData dataWithBytes:mat.data length:mat.elemSize() * mat.total()];
    CGColorSpaceRef colorSpace = mat.channels() == 1 ? CGColorSpaceCreateDeviceGray() : CGColorSpaceCreateDeviceRGB();
    
    CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)data);
    CGImageRef imageRef = CGImageCreate(mat.cols, mat.rows, 8, 8 * mat.channels(), mat.step[0], colorSpace, kCGImageAlphaNone|kCGBitmapByteOrderDefault, provider, NULL, false, kCGRenderingIntentDefault);
    UIImage *image = [UIImage imageWithCGImage:imageRef];
    CGImageRelease(imageRef);
    CGDataProviderRelease(provider);
    CGColorSpaceRelease(colorSpace);
    
    return image;
}

2.2. Mat对象

Mat基本是OpenCV中基本操作单元,可以从图片中读取(channels为BGR注意与移动端常用的RGB区别),也可以创建空矩阵。

// 空矩阵
cv::Mat emptyMat;

// 指定尺寸和类型(行,列,数据类型)
cv::Mat mat(480, 640, CV_8UC3);          // 3通道 8位无符号(BGR图像)
cv::Mat floatMat(100, 100, CV_32FC1);    // 单通道浮点矩阵

// 初始化值
cv::Mat redMat(100, 100, CV_8UC3, cv::Scalar(0, 0, 255)); // 全红色图像
cv::Mat ones = cv::Mat::ones(3, 3, CV_32F); // 全1矩阵

Mat的数据实际存储在u(UMatData)中,而data的内存管理,使用引用计数,可以使用mat.u->refcount查看引用的计数。

  • 浅拷贝:默认赋值或传参,共享数据内存

    cv::Mat shallow = mat;
    
  • 深拷贝:独立内存

    cv::Mat deep = mat.clone();
    // 或
    mat.copyTo(deep);
    

2.2.1. 访问和修改像素

  • 单通道(灰度):
uchar pixel = mat.at<uchar>(y, x); // 读取 (y,x) 处的值(注意行列顺序!)
mat.at<uchar>(y, x) = 255;         // 修改
  • 多通道(如 BGR 图像):
cv::Vec3b& pixel = mat.at<cv::Vec3b>(y, x); 
pixel[0] = 255; // 蓝色通道
pixel[1] = 0;   // 绿色通道
pixel[2] = 0;   // 红色通道
  • 使用指针高效遍历:
for (int i = 0; i < mat.rows; i++) {
    uchar* row = mat.ptr<uchar>(i);
    for (int j = 0; j < mat.cols; j++) {
        row[j] = ...; // 修改像素
    }
}

2.2.2. 图像处理操作

  • 调整大小:
cv::Mat resized;
cv::resize(inputMat, resized, cv::Size(newWidth, newHeight));
  • 颜色空间转换:
cv::Mat gray;
cv::cvtColor(colorMat, gray, cv::COLOR_BGR2GRAY);
  • 旋转:
cv::Mat rotated;
cv::rotate(inputMat, rotated, cv::ROTATE_90_CLOCKWISE);
旋转.jpg
  • 裁剪 ROI(Region of Interest):
int x = (cols - 200) / 2;
int y = (rows - 200) / 2;
cv::Rect roi_rect(x, y, 200, 200);
roi显示.jpg

矩阵运算

cv::Mat A = ... , B = ... , C;
cv::add(A, B, C);           // 矩阵加法
cv::multiply(A, B, C);      // 逐元素乘法
C = A * B;                  // 矩阵乘法(非逐元素)
cv::transpose(A, C);        // 转置

数据类型转换

cv::Mat floatMat;
mat.convertTo(floatMat, CV_32F, 1.0/255.0); // 转为浮点并归一化
  • 单通道显示

cv::split分离通道操作,注意是按照BGR的顺序,所以R通道为channels[2]

std::vector<cv::Mat> channels;
cv::split(mat, channels);

// 创建零矩阵并合并三通道
cv::Mat zeroMat = cv::Mat::zeros(mat.size(), CV_8UC1);
std::vector<cv::Mat> mergedChannels{zeroMat, zeroMat, channels[2]};
cv::Mat des;
cv::merge(mergedChannels, des);
红色通道.jpg

Blend效果

// 调整img2尺寸与输入图像匹配
cv::resize(img2, img2, mat.size(), 0, 0, cv::INTER_LINEAR);
// 使用addWeighted进行混合
cv::addWeighted(mat, 0.6, img2, 0.4, 0.0, blended);
融合.jpg

二值操作

cv::Mat gray, dst;
// 转换为灰度图像
cv::cvtColor(mat, gray, cv::COLOR_BGR2GRAY);

// 应用Otsu二值化
cv::threshold(gray, dst, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);
return dst;
二值效果.jpg

三、形态学处理

形态学是一类基于图像形状的图像处理技术,以下图为例,看一下形态学的变化。形态学是对实际上会对各个通道进行独立操作,默认是对单通道图像(如灰度图或二值图)操作,所以一般使用二值图看效果。

burr.jpg

3.1. 腐蚀

腐蚀操作原理是取邻域最小值,如下图,处理像素点1时,检查周围像素点(MORPH_RECT),取色值最小的点(右下),所以当前的点变成黑色。换个角度,黑色像素点会把周围点都变成黑色,像黑色来腐蚀了白色。

腐蚀原理.jpg

代码如下:

cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::morphologyEx(mat, mat, cv::MORPH_ERODE, kernel);

效果如图,整体变小了,毛刺少了很多。

腐蚀.jpg

3.2. 膨胀

膨胀操作原理是取邻域最大值,就是和腐蚀相反的操作。如下图,处理像素点1时,检查周围像素点(MORPH_RECT),取色值最大的点(右下),所以当前的点变成白色。换个角度,白色像素点会把周围点都染白,像白色像素进行了膨胀。

膨胀原理.jpg

代码如下:

cv::Mat dilated;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::dilate(mat, dilated, kernel);

效果如图,整体变大了,毛刺变得更粗壮了。

膨胀.jpg

3.3. 开运算

上述两种运算都会原来的形状(变大或缩小),而先腐蚀后膨胀就是开运算。开运算一般用于去噪,如下图,先腐蚀会让黑色区域变大,从而中间的白色噪点消失,再膨胀白色区域恢复(原来的噪点消失)。

开运算.jpg

代码如下:

cv::Mat opened;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5,5));
cv::morphologyEx(mat, opened, cv::MORPH_OPEN, kernel);

效果如图,可以通过改变kernel大小调整效果(5x5效果):

开运算效果.jpg

3.4. 闭运算

闭运算是开运算相反的操作先膨胀再腐蚀,运用孔洞填充,如下图字母T,由于打印或拍摄问题,有些像素点缺失。先膨胀就可以把区域连通,再腐蚀恢复成原来大小。

闭运算.jpg

代码如下:

cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5,5));
cv::morphologyEx(mat, mat, cv::MORPH_CLOSE, kernel);

效果如图,可以通过改变kernel大小调整效果(5x5效果),连通了毛刺中间的区域:

闭操作效果.jpg

3.5. 礼帽

礼帽操作是用原图-开运算,开运算作用是去毛刺,那么礼帽的作用就是获取图片中的毛刺,提取亮细节。

代码如下:

cv::Mat result;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::morphologyEx(mat, result, cv::MORPH_BLACKHAT, kernel);

效果如图,获取到毛刺:

礼帽.jpg

3.6. 黑帽

黑帽操作是用闭运算-原图,闭运算作用是连通,那么黑帽的作用就是提取暗细节。

黑帽原理.jpg

代码如下:

cv::Mat result;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::morphologyEx(mat, result, cv::MORPH_BLACKHAT, kernel);
❌
❌