普通视图

发现新文章,点击刷新页面。
昨天以前掘金专栏-iOS学习社区

Swift 中的任务和任务组

2024年9月3日 15:05

原文地址

在上一篇文章探索 Swift 中的 async/await: 构建更清晰的并发模型中提到,如果在不支持并发的同步调用环境中调用一个异步方法,会造成编译错误。今天这篇文章中,我们将介绍如何通过任务和任务组来创建异步函数的使用环境。

任务

Swift 中的任务是 WWDC 2021 引入的并发框架的一部分。任务允许我们从非并发方法创建并发环境,使用async/await调用方法。

当首次使用任务时,你可能会注意到调度队列和任务之间的相似之处。两者都允许在不同线程上以特定优先级调度工作。然而,任务有着明显的不同之处,并通过简化调度队列的繁琐性使我们的开发变得更加轻松。

任务的创建和执行

创建一个任务:

let basicTask = Task {
    return "This is the result of the task"
}

print(await basicTask.value)

任务也可以抛出错误:

let basicTask = Task {
    // .. perform some work
    throw ExampleError.somethingIsWrong
}

do {
    print(try await basicTask.value)
} catch {
    print("Basic task failed with error: (error)")
}

任务在创建后立即运行,不需要显式启动。因此,只有在需要开始它的工作时才创建任务。

在上一篇文章探索 Swift 中的 async/await: 构建更清晰的并发模型中描述的 async_let 语法会隐式创建一个子任务。

取消任务

使用 cancel() 来取消任务:

basicTask.cancel()
处理取消任务

checkCancellation() 和 isCancelled 都可以用于检查任务取消,但它们的作用方式有所不同:

  1. Task.checkCancellation():
  • 作用:  主动抛出错误来终止任务。当调用 checkCancellation() 时,如果任务已被取消,它会抛出一个 CancellationError,从而终止当前任务的执行。

  • 使用场景:

    • 你需要在异步操作的特定位置明确地检查取消状态,并在任务被取消时立即停止执行。
    • 你希望利用 Swift 的错误处理机制来处理任务取消的情况。
  • 例子:

Task {
    // ... 一些异步操作
    try Task.checkCancellation() // 检查是否被取消,如果被取消则抛出错误
    // ... 继续执行其他操作 (如果任务没有被取消)
}

2. Task.isCancelled:

  • 作用:  被动地判断任务是否被取消。它是一个布尔属性,用于判断当前任务是否已被标记为取消。

  • 使用场景:

    • 你需要在异步操作中周期性地检查取消状态,并在任务被取消时执行一些清理操作或改变行为。
    • 你希望更灵活地控制任务取消后的行为,而不仅仅是抛出错误。
  • 例子:

Task {
    while !Task.isCancelled { // 循环执行,直到任务被取消
        // ... 执行一些操作
        try await Task.sleep(nanoseconds: 1_000_000_000) // 模拟 1 秒的延迟
    }
    // ... 任务被取消后执行一些清理操作
}

3. 总结:

  • checkCancellation() 用于主动抛出错误来终止任务,适用于需要立即停止执行的情况。
  • isCancelled 用于被动地判断任务是否被取消,适用于需要更灵活地控制取消行为的情况。
监听取消任务
let task = await Task.withTaskCancellationHandler {
    // ...
} onCancel: {
    print("Canceled!")
}

// ... some time later...
task.cancel()  // Prints "Canceled!"

设置优先级

每个任务都有其优先级,类似于使用任务队列时的QOS,代码如下:

截屏2024-08-20 21.26.17.png

任务状态

Swift 中的 Task 有多种状态,反映了其生命周期中的不同阶段。 可以使用 Task.State 枚举类型来访问这些状态。

  • running: 任务正在执行中。这是任务创建后的初始状态,一直持续到任务完成或被取消。
  • suspended: 任务已暂停执行,等待某些条件满足后才能继续。例如,任务可能正在等待网络请求完成或等待某个资源可用。
  • cancelled: 任务已被取消,可以使用 Task.isCancelled 属性或调用 Task.checkCancellation() 方法来检查任务是否已被取消。
  • completed: 任务已成功完成。您可以使用 Task.value 属性来获取任务的结果。

任务组

Swift 中的任务组用来组合多个并行任务,并等待所有任务完成后返回结果。可以将任务组看作一个包含多个动态添加的子任务的容器。子任务可以并行或串行执行,但是只有在其子任务完成后,Task Group才会被标记为完成。

如何使用任务组

使用 withTaskGroup(of:returning:body:)或 withThrowingTaskGroup(of:returning:body:) 函数来创建任务组。添加到任务组中的所有子任务都会自动并发的开始执行。

  1. 使用任务组,返回最终的结果集,例子如下:
let images = await withTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
    let photoURLs = await listPhotoURLs(inGallery: "Amsterdam Holiday")
    for photoURL in photoURLs {
        taskGroup.addTask { await downloadPhoto(url: photoURL) }
    }

    return await taskGroup.reduce(into: [UIImage]()) { partialResult, name in
        partialResult.append(name)
    }
}

2. 错误处理, 例子如下:

let images = try await withThrowingTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
    let photoURLs = try await listPhotoURLs(inGallery: "Amsterdam Holiday")
    for photoURL in photoURLs {
        taskGroup.addTask { try await downloadPhoto(url: photoURL) }
    }

    return try await taskGroup.reduce(into: [UIImage]()) { partialResult, name in
        partialResult.append(name)
    }
}

在上面的例子中,如果子任务失败,任务组不会失败;为了在子任务失败时,任务组也失败,需要在迭代的时候使用 next()来代替 while reduce(),代码如下:

let images = try await withThrowingTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
    let photoURLs = try await listPhotoURLs(inGallery: "Amsterdam Holiday")
    for photoURL in photoURLs {
        taskGroup.addTask { try await downloadPhoto(url: photoURL) }
    }

    var images = [UIImage]()

    /// Note the use of `next()`:
    while let downloadImage = try await taskGroup.next() {
        images.append(downloadImage)
    }
    return images
}

next() 方法接收来自各个任务的错误,让你可以相应地处理这些错误。在这种情况下,我们将错误传递到组闭包,使整个任务组失败。此时,任何其他正在运行的子任务都将被取消。

避免并发改变

不要在创建 TaskGroup 的任务之外的地方修改它。具体来说,以下行为是不被允许的:

  • 将 TaskGroup 传递给其他任务,并在这些任务中添加子任务。
  • 在其他任务中直接修改 TaskGroup 的状态。

因为并发会导致数据竞争,当多个任务同时修改一个 TaskGroup 时,可能会导致数据不一致,甚至程序崩溃。

取消任务组

你可以使用cancelAll()方法显式地取消任务组中的所有子任务。使用addTaskUnlessCancelled()方法添加的任务,只有在任务组没有被取消的情况下才会开始执行。如果任务组已经被取消,这个任务就不会被添加到任务组中,也不会开始执行。

let photos = await withTaskGroup(of: Optional<Data>.self) { group in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        let added = group.addTaskUnlessCancelled {
            guard !Task.isCancelled else { return nil }
            return await downloadPhoto(named: name)
        }
        guard added else { break }
    }


    var results: [Data] = []
    for await photo in group {
        if let photo { results.append(photo) }
    }
    return results
}

关于如何监听取消的任务,请看任务相关部分的讲解。

参考

  1. www.avanderlee.com/concurrency…
  2. www.avanderlee.com/concurrency…
  3. docs.swift.org/swift-book/…

探索 Swift 中的 async/await: 构建更清晰的并发模型

2024年8月21日 11:02

[原文地址](mp.weixin.qq.com/s?__biz=Mzg…

异步是 iOS 开发中非常常见的操作,之前通常会通过 completion 回调的方式来返回异步结果,为了更好的解释这一点,我们先看一个例子:

fetchImages { result in
    switch result {
    case .success(let images):
        print("Fetched (images.count) images.")
    case .failure(let error):
        print("Fetching images failed with error (error)")
    }
}

即使在这种简单的情况下,由于代码必须编写为一系列完成处理程序,最终会编写嵌套闭包。在这种风格下,具有深度嵌套的更复杂代码很快就会变得笨拙不堪。例子如下:

fetchImages { result in
    switch result {
    case .success(let images):
        print("Fetched (images.count) images.")
        
        resizeImages(images) { result in
            switch result {
            case .success(let images):
                print("Decoded (images.count) images.")
            case .failure(let error):
                print("Decoding images failed with error (error)")
            }
        }
    case .failure(let error):
        print("Fetching images failed with error (error)")
    }
}

传统异步编程的痛点:

  • 回调地狱:
    传统的异步编程方式通常使用回调函数,当一个异步操作完成时,会调用回调函数来处理结果。如果有多个异步操作需要依次执行,就会导致代码嵌套层级过深,难以理解和维护。
  • 错误处理复杂:
    在传统的异步编程中,错误处理需要在回调函数中进行,这使得错误处理逻辑分散,难以追踪。

async/await

Swift 中的 async/await 是一个用于编写异步代码的语法糖,它让异步代码看起来像同步代码一样,从而提高代码的可读性和可维护性。

通过 async/await 来改造文章开头的代码:

do {
    let images = try await fetchImages()
    let resizedImages = try await resizeImages(images)    
    print("Fetched (images.count) images.")
} catch {
    print("Fetching images failed with error (error)")
}

执行顺序是线性的,因此很容易跟踪和理解,代码的可读性增强了。当我们执行复杂的任务时,理解异步代码将会更加容易。

async/await 优势:

  • 更易读的代码:
    async/await 使得异步代码看起来更像同步代码,消除了回调函数带来的嵌套结构,使代码更易于理解和维护。
  • 更简洁的错误处理:
    async/await 允许使用 try/catch 块来处理异步操作中的错误,与同步代码的错误处理方式一致。
  • 更安全的并发:
    async/await 基于 Swift 的结构化并发模型,可以有效地避免数据竞争和死锁等并发问题。

async/await 使用

在异步函数的定义前添加 async 关键字,表示该函数是一个异步函数。如果函数需要标记抛出错误使用 throws。如果函数有返回值,需要在返回箭头(->)之前写上 async。

func fetchImages() async throws -> [UIImage] {
    // perform data request
}

在需要等待异步操作完成的地方使用 await 关键字。

do {
    let images = try await fetchImages()
    print("Fetched (images.count) images.")
} catch {
    print("Fetching images failed with error (error)")
}

异步函数在执行过程中,需要等待某些事情时,它也可以在中间暂停。在异步函数或方法的主体内部,你可以标记每一个需要被暂停执行的地方。

并行执行异步函数

使用 await 调用异步函数一次只运行一段代码。在异步代码运行时,调用者会等待该代码执行完成,然后再继续执行下一行代码。例如:

let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])

let photos = [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

可以使用 async-let 来实现异步代码的并行执行:

async let firstPhoto = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto = downloadPhoto(named: photoNames[2])

let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

异步函数使用环境

首次使用 async-await 时,可能会遇到下面的错误:

17240607763095.png

这个错误是因为在不支持并发的同步调用环境中调用了一个异步方法而引起的。我们可以通过将我们的 fetchData 方法也定义为异步来解决这个错误:

func fetchData() async {
    do {
        try await fetchImages()
    } catch {
        // .. handle error
    }
}

然而,这样做会将错误转移到不同的位置。相反,我们可以使用 Task.init 方法从一个支持并发的新任务中调用异步方法,并将结果分配给我们的视图模型中的属性:

final class ContentViewModel: ObservableObject {
    
    @Published var images: [UIImage] = []
    
    func fetchData() {
        Task { @MainActor in
            do {
                self.images = try await fetchImages()
            } catch {
                // .. handle error
            }
        }
    }
}

参考资料

  1. docs.swift.org/swift-book/…
  2. www.avanderlee.com/swift/async…

使用Xcode预览快速查看应用程序界面

2024年7月19日 14:06

[原文地址](mp.weixin.qq.com/s?__biz=Mzg…

在应用程序开发过程中,设计和调整界面是一个重要的环节。为了更高效地进行界面设计和开发,苹果在Xcode 15中引入了预览功能,让开发者能够通过代码对应用程序的视图进行更改,并在预览画布中即时查看这些更改的结果。

然而,在Xcode 16中,苹果进一步优化了预览功能,为开发者提供了更强大的工具和功能来提升开发体验。下面我们来一起探索一下Xcode 16中增强的预览功能吧!

如何显示预览视图

当你在Xcode中创建一个视图时,你可以在预览画布中显示它。预览画布展示了你的视图在不同设备的各种配置下的显示情况;

截屏2024-07-18 20.49.34.png Swift预览宏是一段代码片段,用于创建和配置你的视图。你可以使用其中一个预览宏,例如Preview(_:body:),告诉Xcode要显示什么内容。要手动显示或隐藏预览画布,请在Xcode菜单中选择"Editor > Canvas"。

class WeatherViewController: UIViewController {
    // ...
}


// A UIKit UIViewController preview.
#Preview {
    let viewController = WeatherViewController()
    viewController.title = "Current Weather"
    return viewController
}


class WeatherView: UIView {
    var icon: UIImage?
}


// A UIKit UIView preview.
#Preview {
    let view = WeatherView()
    if let image = UIImage(systemName: "sun.max.fill") {
        view.icon = image
    }
    return view
}

与预览视图的交互模式

实时模式

当你在实时模式下与预览视图进行交互,就像在设备或模拟器上来展现和操作你的视图一样。使用该预览模式来测试控制逻辑、动画、文本输入以及对异步代码的响应。 截屏2024-07-18 20.04.01.png

选择模式

在选择模式下,预览会显示视图的快照,以便您可以在画布中与视图的UI元素进行交互。在预览中选择一个控件会突出显示源代码编辑器中相应的代码行。双击某些文本视图,例如标签(Label),会将焦点移动到源代码编辑器,以便您可以快速进行更改。 截屏2024-07-18 20.06.58.png

控制预览的显示方式

  1. 可以使用设备设置来控制预览在特定设备上的显示方式。例如,要查看您的视图在暗黑模式、横竖屏和超大文本下的外观;

截屏2024-07-18 20.10.43.png

  1. 还可以使用变体模式(Variant mode)可以查看你的视图在给定配置的不同变体下的外观。 17213053595690.png
  • 以下是预览画布支持的几种常用变体:
    • 颜色模式变体:预览画布提供了浅色和深色两种颜色模式的预览。
    • 方向变体:预览画布可显示你的视图在各种纵向和横向方向的外观。
    • 动态类型变体:预览画布支持以应用程序的所有辅助功能文本大小显示视图。

通过利用设备的设置功能和变体模式,你可以更全面地测试和优化你的视图,以确保其在不同设备、颜色方案和辅助功能设置下都能以最佳方式呈现给用户。这有助于提升你的应用程序的可用性和用户体验质量。

使用代码来配置预览

除了Xcode提供的预览选项之外,您还可以以代码方式自定义和配置要重复使用的预览。

例如,您可以为每个预览添加一个名称,以便更容易跟踪每个预览显示的内容。当您将预览的名称作为字符串传递给预览宏时,该名称将显示在预览画布中预览的标题中。

#Preview("2x2 Grid Portrait") {
   Content()
}

还可以通过将一个或多个配置特性作为可变参数列表传递给预览宏,来控制预览的显示方式。例如,要在左横向方向中显示您的视图,请通过将landscapeLeft类型属性传递给init(_:traits:body:)预览初始化器,告诉Xcode要显示的方向。

#Preview("Camera setting sunning day", traits: .landscapeLeft) {
    let viewController = CameraViewController()
    if let image = UIImage(systemName: "sun.max.fill") {
        viewController.lastImage = image
    }
    return viewController
}

总结

在创建视图时,只传递视图显示所需的数据,避免传递获取数据的对象;对象会使设置视图预览变得更复杂且性能较差。

相反,使用视图所需的最少数据创建视图,优先选择简单的不可变数据类型。以这种方式创建视图可以更轻松地测试和预览视图,并使其性能更好。

iOS UITableView estimatedRowHeight 小记

2021年2月19日 23:38

原文地址

estimatedRowHeight 是 iOS7.0 以后引入的属性,用来预估列表视图的高度。下面看一下官网的解释:

大概的意思是:

为行高提供一个非负的预估值,可以提高列表视图的加载性能。如果列表包含高度可变的行,则在加载表时计算这些行的所有高度可能会非常昂贵。估算允许您将几何体计算的一些成本从加载时间推迟到滚动时间。

其默认值是 automaticDimension,这意味着表视图会默认选择一个预估高度供你使用。将该值设置为0将禁用估计高度,这将导致表视图请求每个单元格的实际高度。如果表使用自调整大小的单元格,则此属性的值不能为0。

使用预估高度时,表视图会自动管理从滚动视图继承的 contentOffset 和 contentSize 属性。不要试图直接读取或修改这些属性。

注意:estimatedRowHeight 在 iOS11 之前默认值为0,在 iOS11 之后,默认值为automaticDimension。

例子

下面我们通过一个例子来从下面两个方面来了解这个属性:

  • tableView:heightForRowAtIndexPath: 和 tableView:cellForRowAtIndexPath 执行次数
  • contentSize 的变化情况。
- (void)viewDidLoad {
    [super viewDidLoad];
    for (NSInteger i = 0; i < 100; ++i) {
        NSString *text = [NSString stringWithFormat:@"%ld", i];
        [self.dataSource addObject:text];
    }
    
    [self.listView reloadData];
    
        [self.listView addObserver:self forKeyPath:@"contentSize" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
}

- (NSMutableArray *)dataSource {
    if (!_dataSource) {
        _dataSource = [NSMutableArray arrayWithCapacity:10];
    }
    
    return _dataSource;
}

- (UITableView *)listView {
    if (!_listView) {
        _listView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain];
        _listView.delegate = self;
        _listView.dataSource = self;
        [self.view addSubview:_listView];
    }
    
    return _listView;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.dataSource.count;
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    NSLog(@"height at row:%ld", indexPath.row);
    return 200;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"testCell"];
    if (!cell) {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"testCell"];
    }
    
    cell.textLabel.text = self.dataSource[indexPath.row];
    NSLog(@"cell at row:%ld", indexPath.row);
    return cell;
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
    if ([keyPath isEqualToString:@"contentSize"]) {
         NSLog(@"contentSize:%@",  NSStringFromCGSize(self.listView.contentSize));
    }
}

禁用 estimatedRowHeight 属性:

打印结果如下:

2021-02-17 15:44:21.220452+0800 CategoryDemo[39013:1055808] height at row:0
...
2021-02-17 15:44:21.247421+0800 CategoryDemo[39013:1055808] height at row:49
2021-02-17 16:10:23.095358+0800 CategoryDemo[40146:1088361] contentSize:{414, 10000}
2021-02-17 15:44:21.261236+0800 CategoryDemo[39013:1055808] cell at row:0
2021-02-17 15:44:21.262052+0800 CategoryDemo[39013:1055808] height at row:0
2021-02-17 15:44:21.263151+0800 CategoryDemo[39013:1055808] cell at row:1
2021-02-17 15:44:21.263665+0800 CategoryDemo[39013:1055808] height at row:1
2021-02-17 15:44:21.264298+0800 CategoryDemo[39013:1055808] cell at row:2
2021-02-17 15:44:21.264718+0800 CategoryDemo[39013:1055808] height at row:2
2021-02-17 15:44:21.265399+0800 CategoryDemo[39013:1055808] cell at row:3
2021-02-17 15:44:21.265783+0800 CategoryDemo[39013:1055808] height at row:3
2021-02-17 15:44:21.266447+0800 CategoryDemo[39013:1055808] cell at row:4
2021-02-17 15:44:21.266824+0800 CategoryDemo[39013:1055808] height at row:4

通过打印结果可以看出:

  • tableView:heightForRowAtIndexPath:方法会先全部执行一遍。
  • 只加载可见区域内的 cell。
  • 加载 cell 时又调用了一遍 tableView:heightForRowAtIndexPath: 方法。
  • 一次生成 contentSize,值不会变化。

不禁用 estimatedRowHeight 属性:

打印结果如下:

2021-02-17 16:17:57.565084+0800 CategoryDemo[43013:1104211] contentSize:{414, 2200}
2021-02-17 16:17:57.581135+0800 CategoryDemo[43013:1104211] cell at row:0
2021-02-17 16:17:57.582222+0800 CategoryDemo[43013:1104211] height at row:0
...
2021-02-17 16:17:57.684118+0800 CategoryDemo[43013:1104211] cell at row:19
2021-02-17 16:17:57.684557+0800 CategoryDemo[43013:1104211] height at row:19
2021-02-17 16:17:57.685262+0800 CategoryDemo[43013:1104211] contentSize:{414, 5320}
  • 加载 20 个 cell(如果20个cell的高度小于tableview的可见区,则加载可见区内的cell),可能会影响展现埋点。
  • 只在加载 cell 时调用一遍 tableView:heightForRowAtIndexPath: 方法。
  • contentSize 的值会变化。

总结

  • 在禁用预估高度时,系统会先把所有 cell 的实际高度先计算出来,也就是先执行tableView:heightForRowAtIndexPath:代理方法,接着用获取的 cell 实际高度的总和计算tableView 的 contentSize,然后才显示tableViewCell的内容。在这个过程中,如果实际高度计算比较复杂的话,就会消耗更多的性能。

  • 在使用预估高度时,系统会先使用预估高度来计算 tableView 的 contentSize, 因此 contentSize 的高度会动态变化,如果差值为0,tableView 的 contentSize 高度不再变化。由于使用预估高度代替了实际高度的计算,减少了实际高度计算时的性能消耗,但是这种实际高度和预估高度差值的动态变化在滑动过快时可能会产生跳跃现象,所以预估高度和真实高度越接近越好。

一次 category 的误用引发的 crash

2021年1月28日 23:17

原文地址

在最近的一次开发中,不小心在自定义的 UIViewController 的 category 中重写了 dealloc 方法,导致项目中莫名出现了许多野指针的 crash,虽然重写 dealloc 方法会引发一些不确定的行为,但是为什么会引发 crash 呢?带着疑问又重新温习了下 category 的源码。

Category 的底层实现

可以在 objc4 源码的 objc-runtime-new.m 文件中看到它的实现,如下:

typedef struct category_t *Category;

struct category_t {
    const char *name; // Category 的名字
    classref_t cls; // 要扩展的类
    struct method_list_t *instanceMethods; // category的实例方法列表
    struct method_list_t *classMethods; // category类方法列表
    struct protocol_list_t *protocols; // category的协议列表
    struct property_list_t *instanceProperties; // category的属性列表

    method_list_t *methodsForMeta(bool isMeta) {
        if (isMeta) return classMethods;
        else return instanceMethods;
    }

    property_list_t *propertiesForMeta(bool isMeta) {
        if (isMeta) return nil; // classProperties;
        else return instanceProperties;
    }
};

通过源码可以看出,category 其实就是一个 category_t 类型的结构体,它维护要扩展类和分类的相关信息。

扩展:从源码中可以看出,分类的结构体中并没有成员变量的存储方式,这就解释了为什么在分类中无法添加
成员变量了。
此外还需要注意的是,虽然分类中会提供了属性列表的的存储方式,但它并不会帮我们自动生成成员变量,它
只会生成setter getter方法的声明,具体还需要我们自己去实现。

category 是如何加载的

OC 的 runtime 是通过 dyld 动态加载的,而 _objc_init() 方法是 runtime 被加载后第一个执行的方法。我们从_objc_init()开始来追溯 category 的加载过程。

首先看下 _objc_init() 的实现,如下:

void _objc_init(void)
{
    static bool initialized = false;
    if (initialized) return;
    initialized = true;
    
    // fixme defer initialization until an objc-using image is found?
    environ_init();
    tls_init();
    static_init();
    lock_init();
    exception_init();
        
    // Register for unmap first, in case some +load unmaps something
    _dyld_register_func_for_remove_image(&unmap_image);
    dyld_register_image_state_change_handler(dyld_image_state_bound,
                                             1/*batch*/, &map_2_images);
    dyld_register_image_state_change_handler(dyld_image_state_dependents_initialized, 0/*not batch*/, &load_images);
}

我们看下 dyld_register_image_state_change_handler(dyld_image_state_bound, 1/*batch*/, &map_2_images),这里注册了一个回调,当 dyld_image 状态为 dyld_image_state_bound 时,触发map_2_images用来将 image Map到内存 ,其实现如下:

const char *
map_2_images(enum dyld_image_states state, uint32_t infoCount,
             const struct dyld_image_info infoList[])
{
    rwlock_writer_t lock(runtimeLock);
    return map_images_nolock(state, infoCount, infoList);
}

这里会调用 map_images_nolock 方法,map_images_nolock 的源码很多,我们就不在这里列出来了,感兴趣的同学可以自己去查阅源码。在 map_images_nolock 的实现中,我们会发现一个重要函数_read_images,它用来初始化 Map 后的 image。

继续查看 _read_images 的源码,在 Discover categories 的代码段中,会调用一个关键函数 remethodizeClass,其实现如下:

static void remethodizeClass(Class cls)
{
    category_list *cats;
    bool isMeta;

    runtimeLock.assertWriting();

    isMeta = cls->isMetaClass();

    // Re-methodizing: check for more categories
    if ((cats = unattachedCategoriesForClass(cls, false/*not realizing*/))) {
        if (PrintConnecting) {
            _objc_inform("CLASS: attaching categories to class '%s' %s", 
                         cls->nameForLogging(), isMeta ? "(meta)" : "");
        }
        
        attachCategories(cls, cats, true /*flush caches*/);        
        free(cats);
    }
}

这里会调用 attachCategories 函数将类别中的方法、属性、协议附加到类上,源码如下:

static void 
attachCategories(Class cls, category_list *cats, bool flush_caches)
{
    if (!cats) return;
    if (PrintReplacedMethods) printReplacements(cls, cats);

    bool isMeta = cls->isMetaClass();

    // fixme rearrange to remove these intermediate allocations
    method_list_t **mlists = (method_list_t **)
        malloc(cats->count * sizeof(*mlists));
    property_list_t **proplists = (property_list_t **)
        malloc(cats->count * sizeof(*proplists));
    protocol_list_t **protolists = (protocol_list_t **)
        malloc(cats->count * sizeof(*protolists));

    // Count backwards through cats to get newest categories first
    int mcount = 0;
    int propcount = 0;
    int protocount = 0;
    int i = cats->count;
    bool fromBundle = NO;
    while (i--) {
        auto& entry = cats->list[i];

        method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
        if (mlist) {
            mlists[mcount++] = mlist;
            fromBundle |= entry.hi->isBundle();
        }

        property_list_t *proplist = entry.cat->propertiesForMeta(isMeta);
        if (proplist) {
            proplists[propcount++] = proplist;
        }

        protocol_list_t *protolist = entry.cat->protocols;
        if (protolist) {
            protolists[protocount++] = protolist;
        }
    }

    auto rw = cls->data();

    prepareMethodLists(cls, mlists, mcount, NO, fromBundle);
    rw->methods.attachLists(mlists, mcount);
    free(mlists);
    if (flush_caches  &&  mcount > 0) flushCaches(cls);

    rw->properties.attachLists(proplists, propcount);
    free(proplists);

    rw->protocols.attachLists(protolists, protocount);
    free(protolists);
}

attachCategories 函数中,首先会进行一些内存分配的工作,然后获取分类的方法、属性和协议,并放到指定的数组中,最后调用 attachLists 方法将分类和原类中的方法、属性、和协议进行了合并。看下 attachLists 函数的实现:

void attachLists(List* const * addedLists, uint32_t addedCount) {
        if (addedCount == 0) return;

        if (hasArray()) {
            // many lists -> many lists
            uint32_t oldCount = array()->count;
            uint32_t newCount = oldCount + addedCount;
            setArray((array_t *)realloc(array(), array_t::byteSize(newCount)));
            array()->count = newCount;
            memmove(array()->lists + addedCount, array()->lists, 
                    oldCount * sizeof(array()->lists[0]));
            memcpy(array()->lists, addedLists, 
                   addedCount * sizeof(array()->lists[0]));
        }
        else if (!list  &&  addedCount == 1) {
            // 0 lists -> 1 list
            list = addedLists[0];
        } 
        else {
            // 1 list -> many lists
            List* oldList = list;
            uint32_t oldCount = oldList ? 1 : 0;
            uint32_t newCount = oldCount + addedCount;
            setArray((array_t *)malloc(array_t::byteSize(newCount)));
            array()->count = newCount;
            if (oldList) array()->lists[addedCount] = oldList;
            memcpy(array()->lists, addedLists, 
                   addedCount * sizeof(array()->lists[0]));
        }
    }

这个函数中,通过 memmove 和 memcpy 的操作,将分类的方法、属性、协议列表放入了类对象中原本存储的方法、属性、协议列表的前面

为什么会出现 crash

我们通过一个例子来复现下文章开头提到的 crash , 代码如下:

// 在 UIViewController+Test.m 的类别中重写 dealloc 方法
- (void)dealloc {

}

// TestAViewController.m
@interface TestAViewController ()

@property (nonatomic, strong) TestBViewController *bViewController;

@end

@implementation TestAViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    self.view.backgroundColor = UIColor.whiteColor;
    _bViewController = [TestBViewController new];
    __weak typeof (self) weakSelf = self;
    _bViewController.willDismiss = ^{
        __strong typeof(weakSelf) strongSelf = weakSelf;
        [strongSelf.navigationController popViewControllerAnimated:YES];
    };
    
    [self setDefinesPresentationContext:YES];
    [self.bViewController setModalPresentationStyle:UIModalPresentationCurrentContext];
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    if (!self.isFirst) {
        [self presentViewController:self.bViewController animated:YES completion:nil];
    }

    self.isFirst = YES;
};

// TestBViewController.m 中有个关闭按钮 点击执行下面的方法
- (void)onDismiss {
    if (self.willDismiss) {
        self.willDismiss();
    }
    
    
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
        if (self.presentingViewController) {
            [self dismissViewControllerAnimated:YES completion:^{
                
            }];
        }
    });
}

运行程序,点击 TestBViewController 中的关闭按钮,出现如下 crash:

*** -[TestAViewController retain]: message sent to deallocated instance 0x7fba53d161d0

从这条日志可以看出,是因为访问了已经释放的对象地址,导致的 crash.

分析:因为在类别中重写了 dealloc 方法,会导致 UIViewController 本身的 dealloc 方法不会执行,这样就不会释放 dealloc 中的成员变量,指向这些成员变量的指针会变成野指针,如果通过野指针不小心访问了已经释放的 UIViewController 对象的地址,就会出现上面的 crash.

总结

虽然 category 为我们提供了许多便利,但是我们在使用时也有多加小心,以免掉入陷阱,下面用苹果官网的一段话作为结束:

Avoid Category Method Name Clashes
Because the methods declared in a category are added to an existing class, 
you need to be very careful about method names.

If the name of a method declared in a category is the same as a method in the 
original class, or a method in another category on the same class (or even a 
superclass), the behavior is undefined as to which method implementation is used 
at runtime. This is less likely to be an issue if you’re using categories with 
your own classes, but can cause problems when using categories to add methods to 
standard Cocoa or Cocoa Touch classes.

----扫一扫关注公众号,get更多技术好文-----

iOS性能优化之图片最佳实践

2020年7月24日 11:56

原文地址

UIImage 是用来处理图像数据的高级类,UIImageViewUIKit 提供的用于显示 UIImage 的类。若采用 MVC 模型进行类比,UIImage 可以看作模型对象(Model),UIImageView 是一个视图(View)。它们都肩负着各自的职责:

UIImage 负责加载图片内容, UIImageView 负责显示和渲染它。

这看似是一个简单的单向过程,但实际情况却复杂的多,因为渲染是一个连续的过程,而不是一次性事件。这里还有一个非常关键的隐藏阶段,对衡量 app 性能至关重要,这个阶段被称为解码。

图片解码

在讨论解码之前,先了解下缓冲区的概念。

缓冲区:是一块连续的内存区域,用来表示一系列元素组成的内存,这些元素具有相同的尺寸,并通常具有相同的内部结构。

图像缓冲区:它是一种特定缓冲区,它保存了某些图像在内存中的表示。此缓冲区的每个元素,描述了图像中每个像素的颜色和透明度。因此这个缓冲区在内存中的大小与它包含的图像大小成正比。

帧缓冲区:它保存了 app 中实际渲染后的输出。因此,当 app 更新其视图层次结构时,UIKit 将重新渲染 app 的窗口及其所有视图到帧缓冲区中。帧缓冲区中提供了每个像素的颜色信息,显示硬件降读取这些信息用来点亮显示器上对应的像素。

如果 app 中没有任何改变,则显示硬件会从帧缓冲区中取出上次看到的相同数据。但是如果改变了视图内容,UIKit会重新渲染内容,并将其放入帧缓冲区,下一次显示硬件从帧缓冲区读取内容时,就会获取到新的内容。

数据缓冲区:包含图像文件的数据缓冲区,通常以某些元数据开头,这些元数据描述了存储在数据缓冲区中的图像大小和图像数据本身。

下面看下图像渲染到帧缓冲区的详细过程:

这块区域将由图像视图进行渲染填充。我们已经为图像视图分配一个 UIImage,它有一个表示图像文件内容的数据缓冲区。我们需要用每个像素的数据来填充帧缓冲区,为了做到这一点,UIImage 将分配一个图像缓冲区,其大小等于包含在数据缓冲区中的图像大小,并执行称为解码的操作,这就是将 JPEGPNG 或其它编码的图像数据转换为每个像素的图像信息。然后取决于我们图像视图的内容模式,当 UIKit 要求图像视图进行渲染时,它会将数据复制到帧缓冲区的过程中对来自图像缓冲区的数据进行复制和缩放。

解码阶段是 CPU 密集型的,特别是对于大型图像。因此,不是每次 UIKit 要求图像视图渲染时都执行一次这个过程。UIImage 绑定在图像缓冲区上,所以它只执行一次这个过程。因此,在你的 app 中,对于每个被解码的图像,都可能会持续存在大量的内存分配,这种内存分配与输入的图像大小成正比,而与帧缓冲区中实际渲染的图像视图大小没有必然联系,这会对内存产生相当不利的后果。

减少 CPU 的使用率

我们可以使用一种称为向下采样的技术来实现这一目标。

我们可以通过这种下采样技术来节省一些内存。本质上,我们要做的就是捕捉该缩小操作,并将其放入缩略图的对象中,最终达到降低内存的目的,因为我们将有一个较小的解码图像缓冲区。

这样,我们设置了一个图像源,创建了一个缩略图,然后将解码缓冲区捕获到 UIImage 中,并将该 UIImage 分配给我们的图像视图。接下来我们就可以丢弃包含图片数据的数据缓冲区,最终结果就是我们的 app 中将具有一个更小的长期内存占用足迹。

下面看下如何使用代码来实现这一过程:

  • 首先,创建一个 CGImageSource 对象
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!

KCGImageSourceShouldCache 参数为 false,用来告诉 Core Graphic 框架我们只是在创建一个对象,来表示存储在该 URL 的文件中的信息,不要立即解码这个图像,只需要创建一个表示它的对象,我们需要来自此 URL 的文件信息。

  • 然后在水平和垂直轴上进行计算,该计算基于期望的图片大小以及我们要渲染的像素和点大小:
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
                         kCGImageSourceShouldCacheImmediately: true,
                         kCGImageSourceCreateThumbnailWithTransform: true,
                         kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary

这里也创建了一个缩略图选项的字典,最重要的是 CacheImmediately 这个选项,通过这个选项,告诉 Core Graphics,当我们要求你创建缩略图时,这就是你应该为我创建解码缓冲区的确切时刻。因此,我们可以确切的控制何时调用 CPU 来进行解码。

  • 最后,我们创建缩略图,即拿到返回的 CGImage 。
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)

其完整代码如下:

func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
        let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
        let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
        let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
        let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
                                 kCGImageSourceShouldCacheImmediately: true,
                                 kCGImageSourceCreateThumbnailWithTransform: true,
                                 kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
        let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)
        return UIImage(cgImage: downsampledImage)
    }

在 UICollectionView 中的使用

我们可能会在创建单元格时,直接使用下采样技术来生成的图片,代码如下:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! MyCollectionViewCell
    cell.layoutIfNeeded()
    let imageViewSize = cell.imageView.bounds.size
    let scale = collectionView.traitCollection.displayScale
    cell.imageView.image = downsample(imageAt: "", to: imageViewSize, scale: scale)
}

这样确实会减少内存的使用量,但这并不能解决我们的另一个问题。这些问题在可滚动的视图中是非常常见的。

当我们滚动页面时,CPU 相对比较空闲或它所做的工作可以在显示硬件需要帧缓冲的下一个副本之前完成,所以,当帧缓冲被更新时,我们能看到流畅的效果,并且显示硬件能及时获得新帧。

但是,如果我们将显示另一行图像,将单元格交回 UICollectionView 之前,我们要求 core Graphics 解码这些图像,这将会花费很长的 CPU 时间,以至于我们不得不重新渲染帧缓冲区,但显示器硬件按固定的时间间隔运行,因此,从用户的角度来看,app 好像卡住了一样。

这不仅会造成信息粘连,还会有明显的响应性后果,也对电池寿命有不利的影响。

我们可以使用两种技术来平滑我们的 CPU 使用率:

第一个是预取,它的基本思想是:预取允许 UICollectionView 告知我们的数据源,它当前不需要一个单元格,但它在不久的将来需要,因此,如果你有任何工作要做,也许现在可以提前开始。这允许我们随时间的推移,分摊 CPU 的使用率,因此,我们减少了CPU 使用的峰值。

另一种技术是在后台执行工作,既然我们已经随时间分散了工作量,我们也可以将这些技术分散到可用的 CPU 上。

这样做的效果是使你的 app 具有更强的响应性,并且该设备具有更长的电池寿命。

具体代码如下:

func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
// Asynchronously decode and downsample every image we are about to show
for indexPath in indexPaths {
DispatchQueue.global(qos: .userInitiated).async {
let downsampledImage = downsample(images[indexPath.row])
DispatchQueue.main.async { 
 self.update(at: indexPath, with: downsampledImage)
 }
}
}
 }

我们在全局兵法队列中来使用下采样技术,但这里有个潜在的缺陷,就是有可能会引起线程爆炸。当我们要求系统去做比 CPU 能够做的工作更多的工作时,就会发生这种情况。

为类避免线程爆炸,我们现在不是简单的将工作分配到全局异步队列中,而是创建一个串行队列,并且在预取方法的实现中,异步的将任务分配到该队列中,实现如下:

let serialQueue = DispatchQueue(label: "Decode queue") 
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
// Asynchronously decode and downsample every image we are about to show
for indexPath in indexPaths {
serialQueue.async {
let downsampledImage = downsample(images[indexPath.row])
DispatchQueue.main.async { self.update(at: indexPath, with: downsampledImage)
}
}
 }

扫一扫关注公众号,get更多技术好文

❌
❌