- Marcio Granzotto Rodrigues
- 4 lata temu
- Kategorie:Opinia, Techniczne
- Tagi:Android, Architecture, development, iOS, kotiln, Mobile, VIPER
Zaczynając jako developer Androida, a później pracując również z iOS, miałem styczność z kilkoma różnymi architekturami projektów – niektórymi dobrymi, a niektórymi złymi.
Z radością używałem architektury MVP dla Androida, dopóki nie spotkałem – i pracowałem osiem miesięcy z – architekturą VIPER w projekcie iOS. Kiedy wróciłem do Androida, zdecydowałem się zaadaptować i wdrożyć VIPER na nim, mimo że niektórzy inni devs sugerowali, że nie ma sensu używać architektury iOS na Androidzie. Biorąc pod uwagę fundamentalną różnicę pomiędzy frameworkami Androida i iOS, miałem kilka pytań dotyczących tego, jak użyteczny byłby VIPER dla Androida. Czy byłoby to wykonalne i warte wysiłku? Zacznijmy od podstaw.
Co to jest VIPER?
VIPER to czysta architektura używana głównie w tworzeniu aplikacji na iOS. Pomaga ona utrzymać kod w czystości i organizacji, unikając sytuacji Massive-View-Controller.
VIPER to skrót od View Interactor Presenter Entity Router, czyli klas, które mają dobrze zdefiniowaną odpowiedzialność, zgodnie z zasadą Single Responsibility Principle. Możesz przeczytać więcej o tym w tym doskonałym artykule.
Androidowe architektury
Istnieje już kilka bardzo dobrych architektur dla Androida. Bardziej znane to Model-View-ViewModel (MVVM) i Model-View-Presenter (MVP).
MVVM ma dużo sensu, jeśli używasz go wraz z wiązaniem danych, a ponieważ nie podoba mi się zbytnio idea wiązania danych, zawsze używałem MVP w projektach, nad którymi pracowałem. Jednak w miarę rozwoju projektu, prezenter może stać się ogromną klasą z wieloma metodami, co czyni go trudnym do utrzymania i zrozumienia. Dzieje się tak, ponieważ jest ona odpowiedzialna za wiele rzeczy: musi obsługiwać zdarzenia UI, logikę UI, logikę biznesową, sieci i zapytania do bazy danych. To narusza zasadę pojedynczej odpowiedzialności, coś, co VIPER może naprawić.
Naprawmy to!
Mając na uwadze te problemy, rozpocząłem nowy projekt Android i zdecydowałem się użyć MVP + Interactor (lub VIPE, jeśli wolisz). To pozwoliło mi przenieść część odpowiedzialności z prezentera do Interactora. Pozostawienie prezentera z obsługą zdarzeń UI i przygotowaniem danych, które pochodzą z Interactora, do wyświetlenia na Widoku. Następnie, Interactor jest odpowiedzialny tylko za logikę biznesową i pobieranie danych z DB lub API.
Zacząłem również używać interfejsów do łączenia modułów razem. W ten sposób nie mogą one uzyskać dostępu do metod innych niż te zadeklarowane w interfejsie. Chroni to strukturę i pomaga zdefiniować jasną odpowiedzialność za każdy moduł, unikając błędów programistów, takich jak umieszczanie logiki w niewłaściwym miejscu. Oto jak wyglądają interfejsy:
class LoginContracts { interface View { fun goToHomeScreen(user: User) fun showError(message: String) } interface Presenter { fun onDestroy() fun onLoginButtonPressed(username: String, password: String) } interface Interactor { fun login(username: String, password: String) } interface InteractorOutput { fun onLoginSuccess(user: User) fun onLoginError(message: String) } }
A oto kod ilustrujący klasy, które implementują te interfejsy (jest w Kotlinie, ale w Javie powinno być tak samo).
class LoginActivity: BaseActivity, LoginContracts.View { var presenter: LoginContracts.Presenter? = LoginPresenter(this) override fun onCreate() { //... loginButton.setOnClickListener { onLoginButtonClicked() } } override fun onDestroy() { presenter?.onDestroy() presenter = null super.onDestroy() } private fun onLoginButtonClicked() { presenter?.onLoginButtonClicked(usernameEditText.text, passwordEditText.text) } fun goToHomeScreen(user: User) { val intent = Intent(view, HomeActivity::class.java) intent.putExtra(Constants.IntentExtras.USER, user) startActivity(intent) } fun showError(message: String) { //shows the error on a dialog } } class LoginPresenter(var view: LoginContracts.View?): LoginContracts.Presenter, LoginContracts.InteractorOutput { var interactor: LoginContracts.Interactor? = LoginInteractor(this) fun onDestroy() { view = null interactor = null } fun onLoginButtonPressed(username: String, password: String) { interactor?.login(username, password) } fun onLoginSuccess(user: User) { view?.goToNextScreen(user) } fun onLoginError(message: String) { view?.showError(message) } } class LoginInteractor(var output: LoginContracts.InteractorOutput?): LoginContracts.Interactor { fun login(username: String, password: String) { LoginApiManager.login(username, password) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe({ //does something with the user, like saving it or the token output?.onLoginSuccess(it) }, { output?.onLoginError(it.message ?: "Error!") }) } }
Pełny kod jest dostępny na tym Gist.
Możesz zobaczyć, że moduły są tworzone i łączone razem podczas uruchamiania. Kiedy tworzona jest aktywność, inicjalizuje ona Prezentera, przekazując siebie jako Widok w konstruktorze. Prezenter następnie inicjalizuje Interactor przekazując siebie jako InteractorOutput
.
W projekcie VIPER na iOS byłoby to obsługiwane przez Router, tworząc UIViewController
, lub pobierając go ze Storyboard, a następnie łącząc wszystkie moduły razem. Ale na Androidzie nie tworzymy aktywności samodzielnie: musimy używać Intents, i nie mamy dostępu do nowo utworzonej aktywności z poprzedniej. Pomaga to zapobiegać wyciekom pamięci, ale może sprawiać ból, jeśli chcemy po prostu przekazać dane do nowego modułu. Nie możemy również umieścić Prezentera na dodatkach Intenta, ponieważ musiałby on być Parcelable
lub Serializable
. Jest to po prostu nie do zrobienia.
Dlatego w tym projekcie pominąłem Routera. Ale czy to jest idealny przypadek?
VIPE + Router
Powyższa implementacja VIPE rozwiązała większość problemów MVP, rozdzielając obowiązki Prezentera z Interaktorem.
Jednakże Widok nie jest tak pasywny jak Widok VIPERA w iOS. Musi on obsługiwać wszystkie regularne responsywności widoku plus routing do innych modułów. To NIE powinno być jego odpowiedzialnością i możemy zrobić to lepiej. Wprowadź Router.
Tutaj są różnice pomiędzy „VIPE” i VIPER:
class LoginContracts { interface View { fun showError(message: String) //fun goToHomeScreen(user: User) //This is no longer a part of the View's responsibilities } interface Router { fun goToHomeScreen(user: User) // Now the router handles it } } class LoginPresenter(var view: LoginContracts.View?): LoginContracts.Presenter, LoginContracts.InteractorOutput { //now the presenter has a instance of the Router and passes the Activity to it on the constructor var router: LoginContracts.Router? = LoginRouter(view as? Activity) //... fun onLoginSuccess(user: User) { router?.goToNextScreen(user) } //... } class LoginRouter(var activity: Activity?): LoginContracts.Router { fun goToHomeScreen(user: User) { val intent = Intent(view, HomeActivity::class.java) intent.putExtra(Constants.IntentExtras.USER, user) activity?.startActivity(intent) } }
Pełny kod dostępny tutaj.
Teraz przenieśliśmy logikę routingu widoku do Routera. Potrzebuje on tylko instancji aktywności, aby mógł wywołać metodę startActivity
. To nadal nie łączy wszystkiego razem jak VIPER iOS, ale przynajmniej respektuje zasadę pojedynczej odpowiedzialności.
Podsumowanie
Po stworzeniu projektu z MVP + Interactor i pomagając współpracownikowi w stworzeniu pełnego projektu VIPER Android, mogę śmiało powiedzieć, że architektura działa na Androidzie i jest tego warta. Klasy stają się mniejsze i bardziej możliwe do utrzymania. Prowadzi to również proces rozwoju, ponieważ architektura sprawia, że jasne jest, gdzie kod powinien być napisany.
Tutaj w Cheesecake Labs planujemy używać VIPER w większości nowych projektów, więc możemy mieć lepsze możliwości utrzymania i bardziej przejrzysty kod. Ułatwia to również przeskok z projektu iOS do projektu Android i vice-versa. Oczywiście jest to ewoluująca adaptacja, więc nic tu nie jest wyryte w kamieniu. Chętnie docenimy jakieś opinie na ten temat!