Bagaimana cara mengganti target-aksi dan mendelegasikan dengan penutupan

Apple menyediakan berbagai opsi untuk memproses data dan acara di aplikasi iOS. Pemrosesan acara UIControl terjadi melalui pola target-aksi. Dokumentasi untuk UIControl mengatakan sebagai berikut:
Mekanisme tindakan-target menyederhanakan kode yang Anda tulis untuk menggunakan kontrol di aplikasi Anda
Mari kita lihat contoh memproses klik tombol:

private func setupButton() {
    let button = UIButton()
    button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
}
// -   
@objc private func buttonTapped(_ sender: UIButton) { }

Konfigurasi dan pemrosesan klik tombol terletak secara terpisah dari satu sama lain dalam kode. Karena itu, Anda harus menulis lebih banyak kode daripada yang Anda inginkan. Masalah muncul dengan peningkatan jumlah acara dan kontrol.

UITextField menggunakan pola delegasi untuk mengedit dan memvalidasi teks . Kami tidak akan membahas pro dan kontra dari pola ini, baca lebih lanjut di sini .

Berbagai metode pemrosesan data dalam satu proyek sering mengarah pada fakta bahwa kode menjadi lebih sulit untuk dibaca dan dipahami. Pada artikel ini, kita akan mencari cara untuk membawa semuanya ke gaya tunggal menggunakan sintaks penutupan yang mudah.

Mengapa itu perlu?


Awalnya, kami melakukan solusi siap pakai di GitHub dan bahkan menggunakan Closures untuk beberapa waktu . Tetapi seiring berjalannya waktu, kami harus meninggalkan solusi pihak ketiga, karena kami menemukan kebocoran memori di sana. Dan beberapa fitur-fiturnya tampak tidak nyaman bagi kami. Kemudian diputuskan untuk menulis solusi Anda sendiri.

Kami akan sangat senang dengan hasilnya ketika, menggunakan penutupan, kita bisa menulis ini:

textField.shouldChangeCharacters { textField, range, string in
    return true
}

Tujuan dasar:

  • Sebagai penutup, sediakan akses ke textField, dengan tetap mempertahankan tipe aslinya. Ini untuk memanipulasi objek sumber dalam penutupan, misalnya, dengan mengklik tombol untuk menunjukkan indikator tanpa menutup jenis.
  • , . , .touchUpInside onTap { }, shouldChangeCharacters UITextField , .


Gagasan utamanya adalah bahwa kita akan memiliki objek pengamat yang akan mencegat semua pesan dan menyebabkan penutupan.

Pertama, kita harus memutuskan bagaimana menjaga pengamat. Swift memberi kita kekuatan untuk memilih. Misalnya, kita dapat membuat objek singleton tambahan yang akan menyimpan kamus, di mana kuncinya adalah id unik dari objek yang diamati, dan nilainya adalah pengamat itu sendiri. Dalam hal ini, kita harus mengelola siklus hidup objek secara manual, yang dapat menyebabkan kebocoran memori atau hilangnya informasi. Anda dapat menghindari masalah seperti itu jika Anda menyimpan objek sebagai objek terkait.

Buat protokol ObserverHolder dengan implementasi default sehingga setiap kelas yang sesuai dengan protokol ini memiliki akses ke pengamat:

protocol ObserverHolder: AnyObject {
    var observer: Any? { get set }
}

private var observerAssociatedKey: UInt8 = 0

extension ObserverHolder {
    var observer: Any? {
        get {
            objc_getAssociatedObject(self, &observerAssociatedKey)
        }
        set {
            objc_setAssociatedObject(
                self, 
                &observerAssociatedKey, 
                newValue, 
                .OBJC_ASSOCIATION_RETAIN_NONATOMIC
            )
        }
    }
}

Sekarang sudah cukup untuk menyatakan kepatuhan dengan protokol untuk UIControl:

extension UIControl: ObserverHolder { }

UIControl (dan semua keturunan, termasuk UITextField) memiliki properti baru tempat pengamat akan disimpan.

Contoh UITextFieldDelegate


Pengamat akan menjadi delegasi untuk UITextField, yang berarti bahwa ia harus mematuhi protokol UITextFieldDelegate. Kami membutuhkan tipe T generik untuk menyimpan tipe asli UITextField. Contoh dari objek seperti itu:

final class TextFieldObserver<T: UITextField>: NSObject, UITextFieldDelegate {
    init(textField: T) {
        super.init()
        textField.delegate = self
    }
}

Setiap metode delegasi akan membutuhkan penutupan terpisah. Di dalam metode tersebut, kami akan melemparkan tipe ke T dan menyebabkan penutupan yang sesuai. Kami akan menambahkan kode TextFieldObserver, dan sebagai contoh kami hanya akan menambahkan satu metode:

var shouldChangeCharacters: ((T, _ range: NSRange, _ replacement: String) -> Bool)?

func textField(
    _ textField: UITextField,
    shouldChangeCharactersIn range: NSRange,
    replacementString string: String
) -> Bool {
    guard 
        let textField = textField as? T, 
        let shouldChangeCharacters = shouldChangeCharacters 
    else {
        return true
    }
    return shouldChangeCharacters(textField, range, string)
}

Kami siap menulis antarmuka baru dengan penutup:

extension UITextField {
    func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) { }
}

Terjadi kesalahan, kompilator membuat kesalahan:
'Cukup' hanya tersedia dalam protokol atau sebagai hasil dari metode di kelas; maksud Anda 'UITextField'
Protokol kosong akan membantu kami, dengan ekstensi yang mana kami akan menulis antarmuka baru ke UITextField, sambil membatasi Self:

protocol HandlersKit { }

extension UIControl: HandlersKit { }

extension HandlersKit where Self: UITextField {
    func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) { }
}

Kode mengkompilasi, tetap membuat TextFieldObserver dan menunjuknya sebagai delegasi. Selain itu, jika pengamat sudah ada, maka Anda perlu memperbaruinya agar tidak kehilangan penutupan lain:

func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) {
    if let textFieldObserver = observer as? TextFieldObserver<Self> {
        textFieldObserver.shouldChangeCharacters = handler
    } else {
        let textFieldObserver = TextFieldObserver(textField: self)
        textFieldObserver.shouldChangeCharacters = handler
        observer = textFieldObserver
    }
}

Hebat, sekarang kode ini berfungsi dan siap digunakan, tetapi dapat ditingkatkan. Kami akan membuat dan memperbarui TextFieldObserver dalam metode terpisah, hanya penugasan penutupan akan berbeda, yang akan kami sampaikan dalam bentuk blok. Perbarui kode yang ada di ekstensi HandlersKit:

func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) {
    updateObserver { $0.shouldChangeCharacters = handler }
}

private func updateObserver(_ update: (TextFieldObserver<Self>) -> Void) {
    if let textFieldObserver = observer as? TextFieldObserver<Self> {
        update(textFieldObserver)
    } else {
        let textFieldObserver = TextFieldObserver(textField: self)
        update(textFieldObserver)
        observer = textFieldObserver
    }
}

Perbaikan tambahan


Tambahkan kemampuan untuk metode berantai. Untuk melakukan ini, setiap metode harus mengembalikan Self dan memiliki atribut @discardableResult:

@discardableResult
public func shouldChangeCharacters(
    handler: @escaping (Self, NSRange, String) -> Bool
) -> Self

private func updateObserver(_ update: (TextFieldObserver<Self>) -> Void) -> Self {
    ...
    return self
}

Sebagai penutup, akses ke UITextField tidak selalu diperlukan, dan agar di tempat-tempat seperti itu Anda tidak harus menulis ` _ in` setiap kali , kami menambahkan metode dengan penamaan yang sama, tetapi tanpa Self yang diperlukan:

@discardableResult
func shouldChangeCharacters(handler: @escaping (NSRange, String) -> Void) -> Self {
    shouldChangeCharacters { handler($1, $2) }
}

Berkat pendekatan ini, Anda dapat membuat metode yang lebih nyaman. Misalnya, mengubah teks dengan UITextField terkadang lebih mudah ketika teks akhir diketahui:

@discardableResult
public func shouldChangeString(
    handler: @escaping (_ textField: Self, _ from: String, _ to: String) -> Bool
) -> Self {
    shouldChangeCharacters { textField, range, string in
        let text = textField.text ?? ""
        let newText = NSString(string: text)
            .replacingCharacters(in: range, with: string)
        return handler(textField, text, newText)
    }
}

Selesai! Dalam contoh yang ditunjukkan, kami mengganti satu metode UITextFieldDelegate, dan untuk mengganti metode yang tersisa, kami perlu menambahkan penutup pada TextFieldObserver dan ke ekstensi protokol HandlersKit sesuai dengan prinsip yang sama.

Mengganti target-aksi dengan penutupan


Perlu dicatat bahwa menyimpan satu pengamat untuk tindakan target dan delegasi dalam formulir ini memperumitnya, jadi kami sarankan menambahkan objek terkait lainnya ke UIControl untuk acara. Kami akan menyimpan objek terpisah untuk setiap acara, kamus sangat cocok untuk tugas seperti itu:

protocol EventsObserverHolder: AnyObject {
    var eventsObserver: [UInt: Any] { get set }
}

Jangan lupa untuk menambahkan implementasi default untuk EventsObserverHolder, buat kamus kosong segera di pengambil:

get {
    objc_getAssociatedObject(self, &observerAssociatedKey) as? [UInt: Any] ?? [:]
}

Pengamat akan menjadi target untuk satu acara:

final class EventObserver<T: UIControl>: NSObject {
    init(control: T, event: UIControl.Event, handler: @escaping (T) -> Void) {
        self.handler = handler
        super.init()
        control.addTarget(self, action: #selector(eventHandled(_:)), for: event)
    }
}

Dalam objek seperti itu, cukup untuk menyimpan satu penutupan. Setelah menyelesaikan tindakan, seperti pada TextFieldObserver, kami menyajikan jenis objek dan menyebabkan penutupan:

private let handler: (T) -> Void

@objc private func eventHandled(_ sender: UIControl) {
    if let sender = sender as? T {
        handler(sender)
    }
}

Deklarasi Kepatuhan Protokol untuk UIControl:

extension UIControl: HandlersKit, EventsObserverHolder { }

Jika Anda sudah mengganti delegasi dengan penutupan, maka Anda tidak perlu mencocokkan HandlersKit lagi.
Tetap menulis antarmuka baru untuk UIControl. Di dalam metode baru, buat pengamat dan simpan di dalam eventsObserver menggunakan key event.rawValue:

extension HandlersKit where Self: UIControl {

    @discardableResult
    func on(_ event: UIControl.Event, handler: @escaping (Self) -> Void) -> Self {
        let observer = EventObserver(control: self, event: event, handler: handler)
        eventsObserver[event.rawValue] = observer
        return self
    }
}

Anda dapat menambah antarmuka untuk acara yang sering digunakan:

extension HandlersKit where Self: UIButton {

    @discardableResult
    func onTap(handler: @escaping (Self) -> Void) -> Self {
        on(.touchUpInside, handler: handler)
    }
}

Ringkasan


Hore, kami berhasil mengganti target-aksi dan mendelegasikan dengan penutupan dan mendapatkan antarmuka tunggal untuk kontrol. Tidak perlu memikirkan kebocoran memori dan menangkap kontrol sendiri dalam penutupan, karena kami memiliki akses langsung ke mereka.

Kode lengkap di sini: HandlersKit . Ada lebih banyak contoh dalam repositori ini untuk: UIControl, UIBarButtonItem, UIGestureRecognizer, UITextField dan UITextView.

Untuk wawasan yang lebih dalam tentang topik ini, saya juga mengusulkan untuk membaca artikel tentang EasyClosure dan melihat solusi untuk masalah dari sisi lain.

Kami menyambut umpan balik dalam komentar. Sampai!

All Articles