在处理异步操作时,内存管理通常特别棘手,因为它们往往要求我们将某些超出它们定义的范围的对象保留在内存中,同时仍然确保这些对象最终以可预测的方式被释放。

尽管Apple的Combine框架可以使管理这种长期引用变得稍微简单一些——因为它鼓励我们将异步代码建模为管道, 除了一系列嵌套的闭包之外,还有许多潜在的内存管理陷阱需要我们不断注意。

在本文中,让我们看看如何避免这些陷阱,特别是当涉及到self引用和可取消引用时。

A cancellable manages the lifetime of a subscription

Combine的可取消协议(which we typically interact with through its type-erased AnyCancellable wrapper)让我们控制给定订阅应该保持活动和活动的时间。顾名思义,一旦可取消的资源被释放(或手动取消),它所绑定的订阅将自动失效-这就是为什么几乎所有的Combine的订阅api(如sink)在调用时返回一个AnyCancellable。

例如,下面的时钟类型持有对AnyCancellable实例的强引用,该实例是它从计时器发布者上调用sink得到的,只要它的时钟实例还在内存中,订阅就会一直处于激活状态——除非通过将其属性设置为nil来手动删除可取消对象:

class Clock: ObservableObject {
    @Published private(set) var time = Date().timeIntervalSince1970
    private var cancellable: AnyCancellable?

    func start() {
        cancellable = Timer.publish(
            every: 1,
            on: .main,
            in: .default
        )
        .autoconnect()
        .sink { date in
            self.time = date.timeIntervalSince1970
        }
    }

    func stop() {
        cancellable = nil
    }
}

然而,尽管上面的实现完美地管理了它的AnyCancellable实例和它所代表的计时器订阅,它在内存管理方面确实有一个很大的缺陷。因为我们在我们的sink闭包中捕获self,而且我们的cancellable(它是由self拥有的)将保持订阅活着,只要它还在内存中,我们将以一个循环引用结束——或者换句话说,一个内存泄漏。

解决这个问题的最初想法可能是使用Combine的赋值操作符(along with a quick Data-to-TimeInterval transformation using map),从而能够将管道的结果直接赋值给时钟的时间属性,如下所示:

class Clock: ObservableObject {
    ...

    func start() {
        cancellable = Timer.publish(
            every: 1,
            on: .main,
            in: .default
        )
        .autoconnect()
        .map(\.timeIntervalSince1970)
        .assign(to: \.time, on: self)
    }

    ...
}

然而,上述方法仍然会导致self被保留,因为assign操作符会保留对传递给它的每个对象的强引用。相反,在我们当前的设置中,我们将不得不求助于一个老式的“weak self”来捕获一个对我们的封闭时钟实例的弱引用,这将打破我们的循环引用:

class Clock: ObservableObject {
    ...

    func start() {
        cancellable = Timer.publish(
            every: 1,
            on: .main,
            in: .default
        )
        .autoconnect()
        .map(\.timeIntervalSince1970)
        .sink { [weak self] time in
            self?.time = time
        }
    }

    ...
}

有了上面的内容,一旦不再被任何其他对象引用,每个时钟实例就可以被重新分配,这反过来又会导致AnyCancellable也被重新分配,而我们的Combine管道也将被适当地解散。太棒了!

Assigning output values directly to a Published property

另一个值得记住的选项是(在iOS 14和macOS Big Sur中)我们也可以直接连接一个Combine管道到一个published属性。然而,尽管在许多不同的情况下这样做是非常方便的,但是这种方法并没有给我们任何可取消的返回——这意味着我们没有任何方法来取消这样的订阅。

对于我们的时钟类型,我们可能仍然能够使用这种方法——如果我们可以删除我们的start和stop方法,而在初始化时自动启动每个时钟,否则我们可能会以重复订阅结束。 如果这些是我们愿意接受的折衷方案,那么我们可以将实现改为:

class Clock: ObservableObject {
    @Published private(set) var time = Date().timeIntervalSince1970

    init() {
        Timer.publish(
            every: 1,
            on: .main,
            in: .default
        )
        .autoconnect()
        .map(\.timeIntervalSince1970)
        .assign(to: &$time)
    }
}

当调用上述类型的assign时,我们将传递一个对已发布属性的投影值的直接引用,并以一个&符号作为前缀,以使该值成为可变的(因为assign使用inout关键字)。 要了解有关该模式的更多信息,请查看关于值和引用类型的基础文章。

上述方法的美妙之处在于,Combine现在将根据我们的时间属性的生命周期自动管理我们的订阅——这意味着我们仍然避免了任何引用周期,同时也显著减少了我们必须自己编写的簿记代码的数量。因此,对于只配置一次并直接绑定到Published属性的管道,使用上面的assign操作符重载通常是一个很好的选择。

Weak property assignments

接下来,让我们看一个稍微复杂一点的示例,在这个示例中,我们实现了ModelLoader,它允许我们从给定的URL加载和解码可解码的模型。通过使用单个cancellable属性,我们的加载器可以在触发新的数据加载管道时自动取消任何以前的数据加载管道 - 当属性的值被替换时,任何先前分配的AnyCancellable实例将被重新分配。

下面是ModelLoader类型当前的样子:

class ModelLoader<Model: Decodable>: ObservableObject {
    enum State {
        case idle
        case loading
        case loaded(Model)
        case failed(Error)
    }

    @Published private(set) var state = State.idle

    private let url: URL
    private let session: URLSession
    private let decoder: JSONDecoder
    private var cancellable: AnyCancellable?
    ...
    func load() {
        state = .loading
        cancellable = session
            .dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Model.self, decoder: decoder)
            .map(State.loaded)
            .catch { error in
                Just(.failed(error))
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] state in
                self?.state = state
            }
    }
}

虽然自动取消旧请求阻止了我们简单地将数据加载管道的输出连接到我们的Published属性,如果我们想避免每次使用上述模式(即加载一个值并将其赋值给一个属性)时都必须手动捕获对self的弱引用,我们可以引入以下发布者扩展——它增加了我们前面看到的标准赋值操作符的弱捕获版本:

extension Publisher where Failure == Never {
    func weakAssign<T: AnyObject>(
        to keyPath: ReferenceWritableKeyPath<T, Output>,
        on object: T
    ) -> AnyCancellable {
        sink { [weak object] value in
            object?[keyPath: keyPath] = value
        }
    }
}

有了上面的内容,我们现在可以简单地调用weakAssign,当我们想要将给定发布者的输出赋值给使用弱引用捕获的对象的属性时,就像这样:

class ModelLoader<Model: Decodable>: ObservableObject {
    ...

    func load() {
        state = .loading

        cancellable = session
            .dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Model.self, decoder: decoder)
            .map(State.loaded)
            .catch { error in
                Just(.failed(error))
            }
            .receive(on: DispatchQueue.main)
            .weakAssign(to: \.state, on: self)
    }
}

新的weakAssign方法纯粹是语法糖吗?是的。但它比我们以前用的更好吗?还是的🙂

Capturing stored objects, rather than self

使用Combine时经常遇到的另一种情况是,我们需要访问某个操作符中的特定属性,例如为了执行嵌套异步调用。

为了说明这一点,假设我们想通过使用数据库来自动存储所加载的每个模型来扩展ModelLoader—一个使用通用Stored类型包装那些模型实例的操作(例如为了添加本地元数据,例如ID或模型版本)。 为了能够在flatMap这样的操作符中访问该数据库实例,我们可以再次捕获self的弱引用,如下所示:

class ModelLoader<Model: Decodable>: ObservableObject {
    enum State {
        case idle
        case loading
        case loaded(Stored<Model>)
        case failed(Error)
    }

    ...
    private let database: Database
    ...

    func load() {
        state = .loading

        cancellable = session
            .dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Model.self, decoder: decoder)
            .flatMap {
                [weak self] model -> AnyPublisher<Stored<Model>, Error> in
    
                guard let database = self?.database else {
                    return Empty(completeImmediately: true)
                        .eraseToAnyPublisher()
                }
    
                return database.store(model)
            }
            .map(State.loaded)
            .catch { error in
                Just(.failed(error))
            }
            .receive(on: DispatchQueue.main)
            .weakAssign(to: \.state, on: self)
    }
}

所以使用上面的flatMap操作符,是因为我们的数据库也是异步的,并返回另一个表示当前保存操作的发布者。

然而,正如上面的示例所示,从map或flatMap等操作符内的展开保护语句返回一个合理的默认值有时会很棘手。 上面我们使用了Empty,它可以工作,但它确实给我们原本相当优雅的管道增加了大量额外的冗余。

值得庆幸的是,这个问题很容易解决(至少在这种情况下)。我们所要做的就是直接捕获数据库属性,而不是捕获self。这样,我们不必处理任何可选的,现在可以简单地在flatMap闭包中调用数据库的store方法——像这样:

class ModelLoader<Model: Decodable>: ObservableObject {
    ...

    func load() {
        state = .loading

        cancellable = session
            .dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Model.self, decoder: decoder)
            .flatMap { [database] model in
                database.store(model)
            }
            .map(State.loaded)
            .catch { error in
                Just(.failed(error))
            }
            .receive(on: DispatchQueue.main)
            .weakAssign(to: \.state, on: self)
    }
}

作为额外的好处,我们甚至可以将我们想要直接调用的数据库方法传递到本例中的flatMap中 -因为它的签名完全匹配flatMap在这个上下文中期望的闭包(并且由于Swift支持第一类函数的事实):

class ModelLoader<Model: Decodable>: ObservableObject {
    ...

    func load() {
        state = .loading

        cancellable = session
            .dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Model.self, decoder: decoder)
            .flatMap(database.store)
            .map(State.loaded)
            .catch { error in
                Just(.failed(error))
            }
            .receive(on: DispatchQueue.main)
            .weakAssign(to: \.state, on: self)
    }
}

因此,在可能的情况下,最好避免在组合操作符中捕获self,而调用其他可以存储并作为属性直接传递给各种操作符的对象。

Conclusion

虽然Combine提供了许多api和特性,可以帮助我们更容易地编写和维护异步代码,但在管理引用及其底层内存时,仍然需要我们小心谨慎。在错误的位置捕获对self的强引用通常仍然会导致一个循环引用,如果一个可取消的对象没有被正确地释放,那么订阅活动的时间可能会比预期的更长。

原文链接