Как часто вы забываете писать [weak self]? Есть решение!

10 апреля 2018

Давайте поговорим о делегировании, основанном на замыканиях, зацикливаниях и универсальных типах (generics).

Ок, текущая статья будет описывать делегирование и то, как мы можем сделать его лучше, используя язык Swift. Давайте сразу перейдём к типичному примеру делегирования в стиле Cocoa.

В первую очередь, мы напишем протокол делегата для класса ImageDownloaderDelegate.

protocol ImageDownloaderDelegate: class {
    func imageDownloader(_ imageDownloader: ImageDownloader, didDownload image: UIImage)
}

Далее, мы реализуем класс ImageDownloader:

class ImageDownloader {
    
    weak var delegate: ImageDownloaderDelegate?
    
    func downloadImage(for url: URL) {
        download(url: url) { image in
            self.delegate?.imageDownloader(self, didDownload: image)
        }
    }
}

Заметьте, что делегат помечен словом weak для недопущения зацикливания. Если вы не знакомы с этой темой рекомендую вам ознакомиться с переводом подробной статьи автора NatashaTheRobot «iOS: How To Make Weak Delegates In Swift».

И теперь мы напишем пользователя для нашего класса ImageDownloader.

class Controller {
    
    let downloader = ImageDownloader()
    var image: UIImage?
    
    init() {
        downloader.delegate = self
    }
    
    func updateImage() {
        downloader.downloadImage(for: /* some image url */)
    }
}

extension Controller: ImageDownloaderDelegate {
    func imageDownloader(_ imageDownloader: ImageDownloader, didDownload image: UIImage) {
        self.image = image
    }
}

Всё написано так, как надо. У нас есть аккуратный, знакомый нам API, поэтому мы не беспокоимся об утечках памяти, так как наш делегат имеет свойство weak. Данный пример является полностью рабочим, и не содержит никаких ошибок.

"О чем же статья?" - спросите вы.

Современный Swift

На текущий день, данный паттерн делегирования в стиле Cocoa становится всё менее популярным в употреблении среди Swift разработчиков. Причины на виду: код не выглядит по-современному, так как требуется написать существенное количество шаблонного кода и коду не хватает гибкости, например, в случае с похожей реализацией делегата у нас возникнут трудности в реализации такого делегата для каких нибудь общих конструкций.

Данные причины привели к тому, что с каждым днем разработчики начинают использовать паттерн «Делегирование через замыкание». Давайте теперь перепишем наш пример в соответствии с этим паттерном.

Избавимся от протокола ImageDownloaderDelegate и его делегата в классе ImageDownloader, вместо них напишем свойство с типом замыкания:

class ImageDownloader {
    
    var didDownload: ((UIImage) -> Void)?
    
    func downloadImage(for url: URL) {
        download(url: url) { image in
            self.didDownload?(image)
        }
    }
}

И перепишем класс Controller:

class Controller {
    
    let downloader = ImageDownloader()
    var image: UIImage?
    
    init() {
        downloader.didDownload = { image in
            self.image = image
        }
    }
    
    func updateImage() {
        downloader.downloadImage(for: /* some image url */)
    }
}

Теперь код стал более компактным и читабельным. Несмотря на это, вы наверяка заметили что тут что-то не так: это была потенциальная утечка памяти.

Избавившись от свойства класса ImageDownloader delegate, мы так же утратили его слабую ссылку, которая была у него благодоря записи weak. Сейчас Controller содержит ссылку на ImageController который, в свою очередь, обратно ссылается на Controller через свое замыкание didDownload. Это классический пример зацикливания и связанной с этим утечки памяти.

Вам наверняка могло прийти в голову решение этой проблемы - необходимо использовать [weak self].

class Controller {
    
    let downloader = ImageDownloader()
    var image: UIImage?
    
    init() {
        downloader.didDownload = { [weak self] image in
            self?.image = image
        }
    }
    
    func updateImage() {
        downloader.downloadImage(for: /* some image url */)
    }
}

И код работает так как надо, но...

С точки зрения проектирования API, данный подход только усугубил ситуацию. Ранее спроектированое API само по себе было безопасным в плане отсутсвия утечек памяти. Теперь за возможными местами утечек нужно следить разработчику, который работает с API.

Ведь теперь забыть указать [weak self], очень просто. Я более чем уверен, что много кодовых баз, в том числе и боевых, страдают от этого простого, но вездесущего недуга.

Swift API Design Guidelines гласит:

Такие сущности как: методы и свойства обьявляются только один раз, а они используются многократно.

И это важно. Мы не можем постоянно надеяться на самих себя в плане написания [weak self] каждый раз, когда необходимо это сделать. Также мы не можем ожидать такого поведения от разработичков которые воспользуются нашим API. Наша обязанность как проектировщиков API - позаботиться о безопасном использовании нашего кода.

И Swift поможет нам в этом.

Давайте обратимся к сути проблемы: в 99% случаях, когда мы назначаем делегирущий коллбэк, мы должны объявлять лист захвата [weak self], но ничто не мешает нам не объявлять его. Не будет никаких ошибок, ни предупреждений - ничего. А что если мы будем навязывать правильный стиль написания замыкания?

Вот о чём я пытаюсь сказать самым простым способом:

class ImageDownloader {
    
    private var didDownload: ((UIImage) -> Void)?
    
    func setDidDownload(delegate: Object, callback: @escaping (Object, UIImage) -> Void) {
        self.didDownload = { [weak delegate] image in
            if let delegate = delegate {
                callback(delegate, image)
            }
        }
    }
    
    func downloadImage(for url: URL) {
        download(url: url) { image in
            self.didDownload?(image)
        }
    }
}

Теперь наше свойство didDownload стало приватным, и вместо пользователя вызвать setDidDownload(delegate:callback:), который обёртывает входящий объект делегата как слабую ссылку, что является правильным поведением. Вот как будет это выглядеть для Controller:

class Controller {
    
    let downloader = ImageDownloader()
    var image: UIImage?
    
    init() {
        downloader.setDidDownload(delegate: self) { (self, image) in
            self.image = image
        }
    }
    
    func updateImage() {
        downloader.downloadImage(for: /* some image url */)
    }
}

Теперь наш код без зацикливаний и утечек памяти! Он выглядит аккуратно и читабельно. Не нужно больше указывать лист захвата [weak self].

Данный подход также используется в модуле UndoManager фреймворка Foundation и в немногих других Cocoa API (ссылка).

Забегая вперёд

Реализацию ImageDownloader выше можно упростить, использовав шаблонные типы из Swift:

struct DelegatedCall {
    
    private(set) var callback: ((Input) -> Void)?
    
    mutating func delegate(to object: Object, with callback: @escaping (Object, Input) -> Void) {
        self.callback = { [weak object] input in
            guard let object = object else {
                return
            }
            callback(object, input)
        }
}

Количество шаблонного кода уменьшилось, и мы теперь имеем очень небольшой API

class ImageDownloader {
    
    var didDownload = DelegatedCall()
    
    func downloadImage(for url: URL) {
        download(url: url) { image in
            self.didDownload.callback?(image)
        }
    }
}

Код контроллера, в том числе, стал более аккуратным:

class Controller {
    
    let downloader = ImageDownloader()
    var image: UIImage?
    
    init() {
        downloader.didDownload.delegate(to: self) { (self, image) in
            self.image = image
        }
    }
    
    func updateImage() {
        downloader.downloadImage(for: /* some image url */)
    }
}

Теперь у нас всего 14 строчек кода. Компактный вид спасает от многих непреднамеренных утечек памяти.

Это то, за что я люблю Swift и его систему типов. Он дает мне повод бороться с типичными недочётами, с помощью креативного подхода. Вид API DelegatedCall говорит сам за себя. Вы видите то, что можно писать чистый, выразительный и безопасный код.

Данная техника совмещает в себе лучшее: делегирование в стиле Cocoa и делегирование через замыкания. Я использовал данный прием много раз, что решил оформить этот приём в самостоятельный пакет Delegated. Ознакомиться с ним вы можете по этой ссылке: https://github.com/dreymonde/Delegated

 

Ссылка на оригинал статьи.
Автор статьи: Oleg Dreyman
Перевел статью: Дмитрий Петухов (mail@dphov.com)

Содержание