Podrzecz

  • by

Photo by Patrick Tomasso on Unsplash

Taka historia przytrafiła mi się w pracy. Podczas przeglądania PR natknąłem się na kawałek kodu, który wyglądał mniej więcej tak:

extension UIView {
    func originalAllSubViewsOf<T: UIView>(type: T.Type) -> [T] {
        var all = [T]()
        func getSubview(view: UIView) {
            if let aView = view as? T { all.append(aView) }
            guard view.subviews.isEmpty == false else { return }
            view.subviews.forEach { getSubview(view: $0) }
        }
        getSubview(view: self)
        return all
    }
}

Zostawiłem komentarz o treści could it be done with a filter?.

Kontekst

Jeżeli masz jakiekolwiek doświadczenie z UIKitem to jestem pewien rozpoznasz tą metodę w mgnieniu oka. Potrzeba jej posiadania wydaje się oczywista.

Jeżeli to dla Ciebie (#podlasie) nowość to takie grzebanie wewnątrz hierarchii widoków i wyciąganie z niej wszystkich będących jakiegoś konkretnego typu pojawia się raczej prędzej niż później. Powiedzmy trzeba wszystkim labelkom zmienić kolor czcionki lub cokolwiek innego.

Pewnie, można użyć subviews ale co gdy te widoki są częścią pod widoku i do nich też trzeba się dobrać? No właśnie ten przypadek załatwia to rozszerzenie.

Zobaczmy jak to działa. Będziemy potrzebować trochę widoków i trochę własnych klas. Do roboty! 😉

class A: UIView {}
class B: UIView {}
let viewHierarchy: B = {
    let view = B()
    view
        .addSubview({
            let subview = UIView()
                subview
                    .addSubview(A())
            return subview
            }())
    view
        .addSubview(UILabel())
    view
        .addSubview({
            let subview = UIView()
            subview
                .addSubview(UILabel())
            subview
                .addSubview(A())
            subview
                .addSubview({
                    let subSubView = A()
                    subSubView
                        .addSubview(B())
                    return subSubView
                }())
            return subview
            }())
    return view
}()

Podsumowując:

  • 3 instancje UIView — mały haczyk bo wszystkie widoki są instancjami UIView 😉
  • 2 instancje UILabel
  • 3 instancje klasy A
  • 2 instancje klasy B

Jeżeli masz problem aby to sobie jakoś wyobrazić to poniżej nieco bardziej graficzna forma:

 ┌──────┬───┐
 │  B   │ ┌─┴────┬───────┐
 ├──────┘ │UIView│   ┌───┴─┐
 │        ├──────┘   │  A  │
 │        │          └───┬─┘
 │        └─┬────────────┘
 │        ┌─┴────┬───────┐
 │        │UIView│   ┌───┴───┐
 │        ├──────┘   │UILabel│
 │        │          └───┬───┘
 │        └─┬────────────┘
 │        ┌─┴────┬───────┐
 │        │UIView│   ┌───┴───┐
 │        ├──────┘   │UILabel│
 │        │          └───┬───┘
 │        │          ┌───┴─┐
 │        │          │  A  │
 │        │          └───┬─┘
 │        │          ┌───┴─┬─────┐
 │        │          │  A  │ ┌───┴─┐
 │        │          ├─────┘ │  B  │
 │        │          │       └───┬─┘
 │        │          └───┬───────┘
 │        └─┬────────────┘
 └──────────┘

Skoro mamy już na czym wywołać metodę z rozszerzenia to weźmy ją na jazdę próbną. Nie będziemy nic robić z tymi instancjami. Wystarczy, że je wszystkie wyciągniemy z tej hierarchii.

let allAs = viewHierarchy.originalAllSubViewsOf(type: A.self)
let allBs = viewHierarchy.originalAllSubViewsOf(type: B.self)
let allLs = viewHierarchy.originalAllSubViewsOf(type: UILabel.self)
let allVs = viewHierarchy.originalAllSubViewsOf(type: UIView.self)

print("We have", allAs.count, "of type", type(of: allAs).Element)
print("We have", allBs.count, "of type", type(of: allBs).Element)
print("We have", allLs.count, "of type", type(of: allLs).Element)
print("We have", allVs.count, "of type", type(of: allVs).Element)

// Console Output:
// We have 3 of type A
// We have 2 of type B
// We have 2 of type UILabel
// We have 9 of type UIView

Jak widać tego typu metoda może być bardzo przydatna. Szczególnie w przypadku gdzie takich widoków widocznych na ekranie jest duża.

Co jest jeszcze bardzo fajne to z tej metody dostajemy tablicę widoków a nie jakiegoś potworka, którego musimy rzutować do tego co akurat jest potrzebne. Fajne api do używania… ale 😉

Kod tej funkcji trochę trudno się czyta. Mamy zagnieżdzoną funkcje, która się sama wywołuje. Jest sprawdzenie czy widok na którym jest wywołana metoda przypadkiem nie jest też poszukiwanym typem i czy powinien być dodany do tablicy all. Pełni ona funkcję akumulatora na wartości. Co jest trochę średnie to ta tablica jest na zewnątrz wewnętrznej metody… jednak to wszystko jest detalem implementacyjnym bo mamy fajne API więc…

Coś za coś 😉 Nazwa funkcji mówi dużo więcej o intencji developera niż implementacja.

Czy można lepiej?

Co znaczy lepiej. Na ten moment powiemy, że lepiej jest wtedy gdy będzie mniej kodu i użyjemy dostarczone przez Swift funkcje wyższego rzędu.

Tak więc po chwili zabawy i przestawiania literek powstało coś takiego:

extension UIView {
    func views<T>(of type: T.Type) -> [T] {
        subviews
            .reduce(
                (self as? T).map{ [$0] } ?? [],
                { (acc, subview) in acc + subview.views(of: type) }
        )
    }
}

Trzeba przyznać, że implementacja zredukowała się dość znacząco 😉 Na co warto zwrócić uwagę to jak funkcja oblicza czy element na którym została wywołana metoda powinna być dodana do kolekcji jako element początkowy czy nie: (self as? T).map{ [$0] } ?? []. W swoich osobistych projektach mam garść funkcji i operatorów, które przenoszą tą intencję jeszcze bardziej ale… uważam, że poszło bardzo zgrabnie. Kod jest gęsty i przekazuje intencję bardzo jasno.

Następnie mamy implementacje partial result block. I tam również znaczenie jest przekazane bardzo jasno. Można ten kod przeczytać jako: Do tego co masz do tej pory dodaj rezultat wywołania views(of:) na obecnym pod widoku.

To było gęste. Przetestujmy jak ta nowa wersja wypada w porównaniu z oryginalną:

// Some easy to do in playground tests:
    print("Test allAs have the same count:",
          assertEqual(
            allAs.count,
            viewHierarchy.views(of: A.self).count
        )
    )
// ...
// Console Output
// Test allAs have the same count: ✅
// Test allBs have the same count: ✅
// Test allLs have the same count: ✅
// Test allVs have the same count: ✅

Miodzio! Jeżeli chodzi o testy to mamy to samo zachowanie. Ale…

Czy możemy lepiej?

Zdefiniuj lepiej. W UIKit-cie jest znacznie więcej tego typu relacji. Rzuć my okiem na UIViewController. Można do niego dodawać child view controller. Czyli potraktować go właśnie tak samo jak widoki:

class AVC: UIViewController {}
class BVC: UIViewController {}
class CVC: UIViewController {}

Przepraszam za tak krótkie nazwy klas. Oszczędzi mi to trochę pisania a Tobie trochę czytania:

let controllerHierarchy: BVC = {
    let controller = BVC()
    controller
        .addChild({
            let subController = UIViewController()
                subController
                    .addChild(AVC())
            return subController
            }())
    controller
        .addChild(CVC())
    controller
        .addChild({
            let subController = UIViewController()   
            subController
                .addChild(CVC())   
            subController
                .addChild(AVC())   
            subController
                .addChild({
                    let subSubController = AVC()           
                    subSubController
                        .addChild(BVC())           
                    return subSubController
                }())   
            return subController
            }())
    return controller
}()

Jest to dokładnie ta sama hierarchia jak przy widokach tylko z zamienionymi klasami:

  • UIView -> UIViewController
  • A -> AVC
  • B -> BVC
  • UILabel -> CVC

Obrazek mógłby wyglądać mniej więcej tak:

 ┌─────┐
 │ BVC │
 └─────┘
    │   ┌──────────────────┐
    ├──▶│ UIViewController │
    │   └──────────────────┘
    │             │        ┌─────┐
    │             └───────▶│ AVC │
    │                      └─────┘
    │   ┌──────────────────┐
    ├──▶│ UIViewController │
    │   └──────────────────┘
    │             │        ┌─────┐
    │             └───────▶│ CVC │
    │                      └─────┘
    │   ┌──────────────────┐
    └──▶│ UIViewController │
        └──────────────────┘
                  │        ┌─────┐
                  ├───────▶│ CVC │
                  │        └─────┘
                  │        ┌─────┐
                  ├───────▶│ AVC │
                  │        └─────┘
                  │        ┌─────┐
                  └───────▶│ AVC │
                           └─────┘
                              │  ┌─────┐
                              └─▶│ BVC │
                                 └─────┘

Prawie jak struktura folderów… hmm… spokojnie tam nie pójdziemy. Policzmy ile czego mamy:

  • 3 instancje UIViewController — ponownie haczyk ponieważ wszystkie instancje są typu UIViewController 😉
  • 2 instancje typu CVC
  • 3 instancje typu AVC
  • 2 instancje typu BVC

Zrobimy to co każdy szanujący się developer iOS by zrobił. Skopiujemy i wkleimy sobie kawałek kodu:

extension UIViewController {
    func allChildren<T>(of type: T.Type) -> [T] {
        children
            .reduce(
                (self as? T).map{ [$0] } ?? [],
                { (acc, subController) in acc + subController.allChildren(of: type) }
        )
    }
}

Jak widać wystarczyło tylko w kilku miejscach pozmieniać nazwy i wywołać odpowiednie property i mamy kod z widoków działający dla kontrolerów. Powiedziałem, że działający? Sprawdźmy:

let allAVCs = controllerHierarchy.allChildren(of: AVC.self)
let allBVCs = controllerHierarchy.allChildren(of: BVC.self)
let allCVCs = controllerHierarchy.allChildren(of: CVC.self)
let allUIVs = controllerHierarchy.allChildren(of: UIViewController.self)

print("We have", allAVCs.count, "of type", type(of: allAVCs).Element)
print("We have", allBVCs.count, "of type", type(of: allBVCs).Element)
print("We have", allCVCs.count, "of type", type(of: allCVCs).Element)
print("We have", allUIVs.count, "of type", type(of: allUIVs).Element)

// Console Output
// We have 3 of type AVC
// We have 2 of type BVC
// We have 2 of type CVC
// We have 9 of type UIViewController

Widać jasno, że mamy tu do czynienia z jakimś wzorcem. W końcu wystarczyło skopiować i wkleić kawałek kodu dokonując w nim minimalnych zmian.

Chciałbym aby to nowe rozwiązanie było tak generyczne jak to możliwe. I chciałbym aby wyrażało mi pomysł wyciągania podrzeczy z czegoś. Utworzę do tego globalnie dostępną funkcję:

func substuff<T, W>(
    of type: T.Type,
    in what: W,
    extractStuff sub: (W) -> [W])
    -> [T] {
        sub(what)
            .reduce(
                (what as? T).map{ [$0] } ?? [],
                { (acc: [T], newWhat: W) -> [T] in
                    acc + substuff(of: type, in: newWhat, extractStuff: sub) }
        )
}

Ponownie mamy ten sam kształt zamknięty w generycznej funkcji. Ponieważ nic nie wiemy o tych generykach to nie możemy na przekazanych instancjach zawołać żadnej metody! Więc mamy czystą funkcję bez efektów ubocznych!

Pierwszy argument jaki przekazujemy to Type czyli typ tego co chcemy aby się znajdowało w zwróconej kolekcji. Drugi argument to instancja od której zaczniemy poszukiwania. Funkcja jeszcze potrzebuje informacji jak wyciągnąć tą kolekcje, która agreguje pod elementy. W kontekście rozszerzeń napisanych wcześniej to będą właśnie pod widoki i dzieci ViewControllera.

Czas użyć tej funkcji. Dodamy jeszcze raz rozszerzenia do UIView i UIViewController-a ale tym razem ciało będzie zaimplementowane za pomocą funkcji zdefiniowanej wyżej.

extension UIView {
    func viewsUsingStuff<T>(of type: T.Type) -> [T] {
        substuff(of: type, in: self, extractStuff: \.subviews)
    }
}
extension UIViewController {
    func allChildrenUsingStuff<T>(of type: T.Type) -> [T] {
        substuff(of: type, in: self, extractStuff: \.children)
    }
}

Kompiluje się! Przetestujmy czy dostaniemy te same rezultaty 😀

print("Test allAs have the same count:",
          assertEqual(
            allAs.count,
            viewHierarchy.viewsUsingStuff(of: A.self).count
        ),
          "Have the same instances:",
          assertEqual(
            allAs,
            viewHierarchy.viewsUsingStuff(of: A.self)
        )
    )
// ...
print("Test allAVCs have the same count:",
          assertEqual(
            allAVCs.count,
            controllerHierarchy.allChildrenUsingStuff(of: AVC.self).count
        ),
          "Have the same instances:",
          assertEqual(
            allAVCs,
            controllerHierarchy.allChildrenUsingStuff(of: AVC.self)
        )
    )

// Console Output
// Test allAs have the same count: ✅ Have the same instances: ✅
// Test allBs have the same count: ✅ Have the same instances: ✅
// Test allLs have the same count: ✅ Have the same instances: ✅
// Test allVs have the same count: ✅ Have the same instances: ✅
// --
// Test allAVCs have the same count: ✅ Have the same instances: ✅
// Test allBVCs have the same count: ✅ Have the same instances: ✅
// Test allCVCs have the same count: ✅ Have the same instances: ✅
// Test allUIVs have the same count: ✅ Have the same instances: ✅

Testy przechodzą, super! 😀

Można powiedzieć, że najbardziej interesującą częścią jest extractStuff. I to prawda! To jest ta część gdzie wstrzykujemy zachowanie mówiące jak wyciągnąć tą kolekcje zawierającą elementy! I możemy podnieść KeyPath do funkcji!

Podsumowanie

Wracając do PR i komentarza. Developer postanowił pozostać przy swojej oryginalnej implementacji i to jest spoko. To tylko kod 😉

Dużo ważniejsze jest to, że teraz poszliśmy o ten krok dalej. Odszukanie i zdefiniowanie tej funkcji dało nam okazje abyśmy ze sobą porozmawiali. Zobaczyliśmy co jest możliwe i kiedy powiedzieć sobie stop, już wystarczy. Dla tamtego developera to była tamta implementacja. Ja pewnie bym został przy funkcji z reduce i w razie potrzeby skopiował w inne miejsca gdzie by była potrzebna.

Dlaczego skopiował? Bo tamte implementacje jest dużo łatwiej zrozumieć. Są bliżej problemu, który rozwiązują i nie ma sensu wszystkiego za wszelką cenę robić generycznym.

Dzięki za przeczytanie tego 🙂

Lineczki

Jest taki Xcode Playground z całym kodem więc jak chcesz można się w nim pobawić 🙂

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *