Wzorzec koordynatora w Swift
Model-View-Controller jest bardzo popularnym i często używanym wzorcem podczas tworzenia aplikacji. Aplikacje iOS koncentrują się wokół tego wzorca, wiele frameworków od Apple również go wykorzystuje. Wzorzec sprawdza się świetnie w przypadku implementacji małych aplikacji. Problemy pojawiają się jednak, gdy nasz projekt staje się bardziej rozbudowany. Tak też było w Comarchu.
Znaczna część tworzonego kodu nie należy do warstwy widoku ani modelu. Taki kod często ląduje w kontrolerze. Efektem jest wielki view controller, który oprócz tego czym powinien się zajmować (konfiguracja widoku i wyświetlanie danych) obsługuje również nawigowanie i logikę modyfikującą model. Prowadzi to do nieczytelnych, złożonych i najczęściej zależnych od siebie kontrolerów.
Rozwiązaniem problemów z nadwagą kontrolerów jest coraz popularniejszy wzorzec MVVM. Definiuje on czwarty komponent - ViewModel. Dzięki niemu wydzielimy logikę i przekazywanie danych z modelu do widoku.
No dobra, ale co z nawigowaniem i konfiguracją takich kontrolerów? Tym właśnie zajmie się koordynator, pozwoli nam wydzielić logikę nawigacji, ułatwi zarządzanie i reużywalność widoków. A w przypadku MVVM, doda również odpowiedni ViewModel dla kontrolera widoku.
Koordynator
Koncepcja koordynatora została zaprezentowana na konferencji NNSpain przez Soroush Khanlou.
Głównym założeniem jest stworzenie obiektu koordynatora, który kontroluje proces prezentowania widoków. Koordynator nie wie nic o swoim macierzystym koordynatorze, może jednak zaprezentować inne koordynatory. Dzięki temu kontrolery widoku są wyizolowane i mogą być łatwo użyte ponownie. Sposób działania koordynatorów prezentuje poniższy schemat
Każdy z koordynatorów posiada referencje do koordynatorów, które zaprezentował. Schemat działania jest podobny do UINavigationController z tą różnicą że natywna kontrolka przechowuje referencje do kontrolerów widoku (tablica viewControllers
).
Stwórzmy więc protokół, który zdefiniuje nam podstawową formę koordynatora:
protocol Coordinator: AnyObject {
var navigationController: UINavigationController {get set}
var childCoordinators: [Coordinator] {get set}
func start()
}
extension Coordinator {
func presentCoordinator(coordinator: Coordinator) {
coordinator.start()
childCoordinators.append(coordinator)
navigationController.present(coordinator.navigationController, animated: true)
}
func dismissCoordinator(coordinator: Coordinator) {
childCoordinators = childCoordinators.filter {$0 !== coordinator}
navigationController.dismiss(animated: true)
}
}
Każdy z koordynatorów posiada własny navigationController do zarządzania widokami prezentowanymi we własnym zakresie. Aby wyświetlić nowy widok, należy użyć metody pushViewController
dostarczonej przez UINavigationController
.
Metoda start konfiguruje nowy koordynator. To w niej definiujemy domyślny widok, który zostanie zaprezentowany jako pierwszy. Metodę start należy użyć zaraz po stworzeniu nowego koordynatora, jeszcze przed jego zaprezentowaniem.
Do prezentacji innych koordynatorów służy metoda presentCoordinator. Dodaje ona nową instancję do childCoordinators
i prezentuje ją modalnie.
Dobrą praktyką jest dzielenie funkcjonalności na koordynatory podrzędne. Przykładowo jeden odpowiada za logowanie, drugi za wyświetlanie głównego widoku, a trzeci za dodawanie nowego produktu. Wszystkie nowo powstałe koordynatory po zakończeniu swojego procesu zgłaszają się do koordynatora nadrzędnego, tak aby ten mógł dalej kontrolować flow aplikacji. Efektem jest podział skomplikowanej funkcjonalności na mniejsze fragmenty. Zdecydowanie łatwiej się nimi zarządza i można je ponownie wykorzystywać.
Kolekcja childCoordinators zawiera wszystkie koordynatory zaprezentowane przez dany koordynator.
Po zakończeniu danego procesu należy usunąć prezentowany koordynator z childCoordinators
i zamknąć go używając dismissCorrdinator
.
Tworzenie i uruchamianie
Załóżmy że tworzymy aplikację, która wymaga aby użytkownik zalogował się na swoje konto.
W takim przypadku pierwszym ekranem po włączeniu aplikacji powinien być ekran logowania. Stwórzmy więc nowy koordynator o nazwie LoginCoordinator. Przejmie on kontrolę nad aplikacją zaraz po jej uruchomieniu i będzie zarządzał nawigowaniem w procesie logowania, rejestracji i prezentowaniem głównego widoku.
class LoginCoordinator: Coordinator {
var navigationController: UINavigationController
var childCoordinators: [Coordinator]
init(navigationController: UINavigationController) {
self.navigationController = navigationController
childCoordinators = []
}
func start() {
let loginVC = LoginViewController.init(coordinator: self)
navigationController.setNavigationBarHidden(true, animated: false)
navigationController.pushViewController(loginVC, animated: false)
}
func presentMainView() {
let mainMenuCoordinator = MainMenuCoordinator(navigationController: UINavigationController())
presentCoordinator(coordinator: mainMenuCoordinator)
}
func presentRegisterView() {
let registerVC = RegisterViewController(coordinator: self)
navigationController.pushViewController(registerVC, animated: true)
}
}
W powyższym przykładzie definiujemy metody z których mogą korzystać prezentowane widoki:
presentMainView()
- Prezentuje główny koordynator aplikacji, dostępny tylko dla zalogowanych użytkowników.presentRegisterView()
- Prezentuje widok rejestracji nowego użytkownika, dodając go do navagationController'a.
Każdy z widoków powinien posiadać referencję do koordynatora który go prezentuje.
Konfiguracja projektu
Aby uruchomić główny koordynator aplikacji, należy zmodyfikować metodę didFinishLaunchingWithOptions
. Znajduje się ona w pliku AppDelegate
.
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
loginCoordinator = LoginCoordinator(navigationController: UINavigationController())
loginCoordinator?.start()
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = appCoordinator.navigationController
window?.makeKeyAndVisible()
return true
}
Po uruchomieniu aplikacji, na ekranie pojawi się widok logowania, zwrócony przez LoginCoordinator
.
Przekazywanie danych pomiędzy kontrolerami
Przekazywanie danych pomiędzy kontrolerami może wydawać się trudniejsze podczas korzystania z koordynatora. W rzeczywistości jest to bardzo dobry sposób na pozbycie się sztywnych połączeń pomiędzy kontrolerami. Załóżmy że chcemy aby LoginCoordinator
przekazał widokowi obsługującemu rejestrację typ tworzonego konta. W tym celu dodajmy metodę do LoginCoordinator
:
func presentRegisterView(_ accountType: AccountType) {
let registerVC = RegisterViewController(coordinator: self)
registerVC.accountType = accountType
navigationController.pushViewController(registerVC, animated: true)
}
Od tej pory aby otworzyć widok rejestracji z okna logowania, należy wywołać metodę presentRegisterView(_ accountType: AccountType)
i przekazać odpowiedni typ.
func openRegisterViewWithAccountType(_ accountType: AccountType) {
coordinator?.presentRegisterView(accountType)
}
Podsumowanie
Na pierwszy rzut oka wydaje się, że aby korzystać z koordynatorów musimy najpierw poświęcić sporo czasu na ich implementację. W praktyce okazuje się, że spora część napisanego kodu nadaje się również do wykorzystania w innych projektach.
Dzięki koordynatorom usuwamy logikę nawigacji z kontrolera widoku. Nasz kod staje się przejrzysty, mniejszy, testowalny i łatwiejszy do rozwijania.