Wywołania asynchroniczne są jednym z elementów bardzo mocno wpływających na budowę całej aplikacji. Często jednak są traktowane jako zło konieczne lub nawet unikane, o ile to możliwe. Jednocześnie sam system iOS oraz składnia języka swift dostarczają narzędzi ułatwiających pracę w środowisku asynchronicznym.
Swift, podobnie jak JavaScript asynchroniczne wywołania opiera na callbackach. Oznacza to, że wołając funkcję asynchroniczną, jednym z argumentów jest inna funkcja (lub blok kodu), która ma zostać wywołana, kiedy praca asynchroniczna się zakończy. Mimo, że działanie jest odmienne od wywołania synchronicznego, zapis w kodzie wygląda mimo wszystko podobnie. Na przykład:
func synchroniczna() -> String {
//jakaś praca w funkcji
return "Wynik"
}
//wywołanie synchronicznej
let dane = synchroniczna()
print(dane)
func asynchroniczna(callback: @escaping (String) -> Void) {
DispatchQueue.global().async {
//jakas praca
callback("Wynik")
}
}
//wywołanie asynchronicznej
asynchroniczna() {
dane in
print (dane)
}
O ile sama budowa obu funkcji się różni, to ich użycie już nie tak bardzo. Wynik z synchronicznej pobieramy przypisując jej wywołanie do zmiennej, a wynik asynchronicznej dostajemy w bloku po wywołaniu samej funkcji. Zapis mimo wszystko wygląda dość podobnie. Oczywiście istotą asynchroniczności jest to, że wynik dostaniemy po jakimś czasie a wątek wołający funkcję nie będzie czekać na jej wykonanie, tylko zajmie się kolejnymi instrukcjami.
Sytuacja zaczyna się komplikować wraz z przyrostem wywołań naszych funkcji. Załóżmy, że potrzebujemy ściągnąć z serwera dane użytkownika, w nich jest m.in jego identyfikator, którego możemy użyć do pobrania jego projektów i czekających na niego wiadomości.
func sUser(userName: String) throws -> CustomUserData {
/// ściągamy dane
return UserData(userId: 5)
}
func sGetProjects(userId: Int) throws -> [String] {
///ściągamy dane
return ["p1", "p2", "p3"]
}
func sGetMessages(userId: Int) throws -> [String] {
return ["m1", "m2", "m3"]
}
do {
let u = try sUser("jaro")
let p = try? sGetProjects(u.userId)
let m = try? sGetMessages(u.userId)
} catch {
/// handle me!
}
Pobranie wszystkich danych wymaga trzech niezależnych wywołań. Nie można ich zrobić rownolegle bo drugie i trzecie wymaga danych zwróconych przez pierwsze. Asynchronicznie wyglądałoby to np tak:
func aUser(_ userName: String, callback: @escaping ( (UserData, Error?)) -> Void) {
callback( (UserData(userId: 5),nil))
}
func aGetProjects(_ userId: Int, callback: @escaping ( ([String],Error?)) -> Void) {
callback( (["p1", "p2", "p3"],nil))
}
func aGetMessages(_ userId: Int, callback: @escaping ( ([String],Error?)) -> Void) {
callback( (["m1", "m2", "m3"],nil))
}
var userData: UserData?
var projects: [String]?
var messages: [String]?
aUser("jaro") {
tuple in
let (user,error) = tuple
if error != nil {
//handle me
return
}
userData = user
aGetProjects(user.userId) { tuple in
let (p,e) = tuple
projects = p
//handle e
}
aGetMessages(user.userId) { tuple in
let (m,e) = tuple
messages = m
//handle e
}
}
Drugie i trzecie wywołanie nie powinno się uruchomić jeśli pierwsze zwróciło błąd. Nie mamy przecież danych potrzebnych do ich wykonania.
Poprawne wykonanie wszystkich wywołań da nam kilka zestawów danych. Powiązanych ze sobą ale jednak niezależnych obiektów. Zastanówmy się jak zwrócić takie dane. Załóżmy, że pobranie danych osoby wraz z jego projektami stanowi pewną całość, co sugeruje, że nie powinniśmy zmuszać klienta naszych funkcji do samodzielnego wywoływania każdej z nich. Spróbujmy skonstruować funkcję dającą pełny wynik.
Możemy komplet danych opakować w strukturę lub w krotkę. Dla uproszczenia w przykładzie użyję krotki.
func allUserData(name: String, callback: @escaping ( (UserData?, [String]?, [String)?, Error? ) -> Void) {
var userData: UserData?
var projects: [String]?
var messages: [String]?
aUser(name) {
tuple in
let (user,error) = tuple
if error != nil {
//handle me
callback( (nil,nil,nil, error ) )
return
}
userData = user
let d = DispatchGroup() //1
d.enter() //2
aGetProjects(user.userId) { tuple in
defer { d.leave() } //3
let (p,e) = tuple
projects = p
//handle e
}
d.enter() //2 znowu
aGetMessages(user.userId) { tuple in
defer { d.leave() } //3 znowu
let (m,e) = tuple
messages = m
//handle e
}
d.notify(queue: .global()) { //4
callback( (userData, projects, messages, nil) )
}
}
}
Funkcja nie obsługuje poprawnie błędów ale nie to jest teraz głównym zmartwieniem. Mamy sytuację w której wołamy dwie funkcje równolegle i musimy poczekać aż obie skończą, zanim zwrócimy kompletny wynik. Możemy tu się posłużyć DispatchGroup (//1). Każde wejście do grupy (//2) bilansujemy wyjściem (//3), a kiedy wszystkie zadania się zakończą, dostaniemy powiadomienie (//4), w którym możemy poskładać końcowy wynik. Według mnie takie rozwiązanie jest poprawne ponieważ wprost zaznaczamy które wywołania są od siebie niezależne, oraz nasza funkcja allUserData zwraca cały komplet danych wywołującemu.
A co, gdyby pójść inną drogą?
Powiedzmy, że funkcja aUser pobierze id usera i rozpocznie pobieranie pozostałych danych. Jeśli uznamy, że nie jest to aż tak ważne żeby pobierać je równocześnie, możemy każde kolejne wywołanie umieścić w callbacku poprzedniego i unikniemy używania konstrukcji w stylu DispatchGroup. Może to się wydać tym atrakcyjniejsze im więcej wywołań trzeba zrobić, żeby mieć komplet danych. Możemy Pójść jeszcze dalej i aUser może nie mieć swojego callbacka, zamiast tego ostatnia wywołana funkcja użyje NotificationCenter do powiadomienia klienta, że dane już są.
Mogłoby to wyglądać np. tak:
class DataManager {
var userData: UserData?
var projects: [String]?
var messages: [String]?
func loadData(user: String) {
aUser("jaro") {
user in
let (u,e) = user
self.userData = u
aGetProjects(u.userId) { tuple in
let (p,e) = tuple
self.projects = p
//handle e
aGetMessages(u.userId) { tuple in
let (m,e) = tuple
self.messages = m
//handle e
NotificationCenter.default.post(
name: NSNotification.Name("allLoaded"), object: nil)
}
}
}
}
}
// użycie:
let d = DataManager()
d.loadData(user: "jaro")
// dowolna praca
/...
@objc func allLoaded() {
print(d.userData) //...
}
Na pierwszy rzut oka ten kod nie wygląda źle. Użycie jest banalne, mamy wszystko pod kontrolą, kod klienta wolny jest od bloków callbacków. Co więcej, rozszerzanie takiego kodu jest prostsze niż poprzedniego. Gdyby trzeba było pobierać też załączniki do wiadomości, można przerobić klasę DataManager, żeby w łańcuchu wywołań pojawiło się jeszcze jedno i nie trzeba zmieniać kodu klienckiego (chyba, że do czegoś potrzebuje załączników). Czy takie rozwiązanie ma jakiekolwiek wady?
Ma.
Moim zdaniem, jeśli taki kod zamieścisz w swoim prywatnym projekcie, który robisz po godzinach, w bardzo ograniczonym wymiarze czasu i z góry wiesz, że kod nie będzie dużo większy niż to, co wstępnie planujesz, to można się zgodzić na taki kod. Ale! Jeśli projekt jest duży, ciągle rozwijany, to takie podejście bardzo łatwo może doprowadzić do sytuacji, w której ciężko zarządzać kodem. Możliwe problemy, które według mnie mogą się pojawić (w zasadzie nie tyle mogą, co pojawią się, o ile projekt będzie wystarczająco długo rozwijany):
- Jeśli czasem trzeba zawołać którąś z powyższych funkcji niezależnie od innych - może się okazać, że ciągnie za sobą inne wywołania. Może być potrzebny refaktoring żeby to poukładać.
- Jeżeli więcej niż jedno miejsce w aplikacji pobiera dane użytkownika, powiadomienie o pobraniu danych dostaną oba. Wygląda na to, że będzie trzeba refaktorować :)
- Jeśli zajdzie potrzeba zawołania funkcji, która okaże się być ostatnią funkcją z ciągu wywołań, może się okazać, że wyśle ona powiadomienie o zakończeniu ładowania danych, skutki mogą być różne. Trzeba to mieć na uwadze.
- Architektura naokoło takiego rozwiązania sprawia, że w jednym miejscu kodu mamy "jakieś" wywołanie, a w innym spodziewamy się istnienia danych w jakimś z góry ustalonym miejscu. W tym momencie sam sposób używania aplikacji, czy organizacji okienek staje się elementem logiki biznesowej. Dodatkowo nigdzie nie opisanej.
- Z tego samego powodu, co w punkcie poprzednim, testowanie również będzie utrudnione.
- Jeśli któreś z wywołań zwróci błąd, nie wiadomo jak go obsłużyć, może dodać kolejną notyfikację oznajmiającą o błędzie? Widzisz dokąd zmierzamy?
- Single Responsibility Principle zostanie wielokrotnie złamane. Jeśli nawet nie teraz, to w przyszłości. I będzie to trudne do naprawienia.
- I najgroźniejsze, moim zdaniem: Taki kod sprawia, że łatwo dodać kolejne rzeczy w DataManagerze, a trudno zrobić coś poza nim. Jeśli aplikacja będzie się rozwijać, z każdą kolejną modyfikacją będzie coraz trudniej wprowadzać zmiany poza managerem, (bo w nim jest wszystko co potrzeba i w ogóle jak to pobrać inaczej?) a jednocześnie z racji pakowania kolejnych rzeczy do managera - coraz trudniej również wprowadzać zmiany w nim.
Jak pogodzić większą łatwość używania z łatwością rozwoju aplikacji?
Proponowałbym wprowadzić dodatkową warstwę aplikacji (nazwijmy ją warstwą usługi). Może mieć jedną metodę asynchroniczną i zwracać dane w callbacku. A sama może wołać poszczególne serwisy i poskładać dane. W przypadku wystąpienia błędu może go zignorować lub przekazać w odpowiedzi, co uchroni nas od niespodzianek.
Co jeśli inna część aplikacji musi pobrać podobne dane?
Cóż. Jeśli to są te same dane - może użyć tej samej klasy zwracającej komplet.
Jeśli są nieco inne - myślę, że zdublowanie części wywołań będzie lepszym pomysłem. Przy czym zakładam tu, że samo wywołanie (które rozumiem jako funkcję przygotowującą np. Resta, wołającą go, a poźniej zwracającą jego wyniki) i tak trzymamy w kolejnej warstwie dostępu do danych, więc dublowanie, o którym piszę dotyczy wywołania funkcji niższej warstwy a nie całego kodu przygotowującego to wywołanie.
Proponuję przynajmniej mieć na uwadze te sugestie podczas pisania kodu, moim zdaniem mogą w dłuższym czasie okazać się bardzo trafioną inwestycją.
Komentarze
Prześlij komentarz