Navigasi dalam aplikasi Android menggunakan koordinator

Selama beberapa tahun terakhir, kami telah mengembangkan pendekatan umum untuk membuat aplikasi Android. Arsitektur murni, pola arsitektur (MVC, MVP, MVVM, MVI), pola repositori, dan lainnya. Namun, masih belum ada pendekatan yang diterima secara umum untuk mengatur navigasi dalam aplikasi. Hari ini saya ingin berbicara dengan Anda tentang template "koordinator" dan kemungkinan penerapannya dalam pengembangan aplikasi Android.
Pola koordinator sering digunakan dalam aplikasi iOS dan diperkenalkan oleh Soroush Khanlou untuk menyederhanakan navigasi aplikasi. Diyakini bahwa pekerjaan Sorush didasarkan pada pendekatan Pengontrol Aplikasi yang dijelaskan dalam Pola Arsitektur Aplikasi Perusahaan oleh Martin Fowler.
Template "koordinator" dirancang untuk menyelesaikan tugas-tugas berikut:

  • bergumul dengan masalah Massive View Controller (masalahnya sudah ditulis di Habré - kira-kira penerjemah), yang sering memanifestasikan dirinya dengan munculnya Kegiatan-Tuhan (aktivitas dengan banyak tanggung jawab).
  • pemisahan logika navigasi menjadi entitas yang terpisah
  • penggunaan kembali layar aplikasi (aktivitas / fragmen) karena koneksi lemah dengan logika navigasi

Tapi, sebelum Anda mulai membiasakan diri dengan template dan mencoba menerapkannya, mari kita lihat implementasi navigasi yang digunakan dalam aplikasi Android.

Logika navigasi dijelaskan dalam aktivitas / fragmen


Karena Android SDK memerlukan Konteks untuk membuka aktivitas baru (atau FragmentManager untuk menambahkan fragmen ke aktivitas), cukup sering logika navigasi dijelaskan langsung dalam aktivitas / fragmen. Bahkan contoh-contoh dalam dokumentasi untuk Android SDK menggunakan pendekatan ini.

class ShoppingCartActivity : Activity() { override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { val intent = Intent(this, CheckoutActivity::class.java) startActivity(intent) } } } 

Dalam contoh di atas, navigasi terkait erat dengan aktivitas. Apakah nyaman untuk menguji kode seperti itu? Orang dapat berargumen bahwa kita dapat memisahkan navigasi menjadi entitas yang terpisah dan menamainya, misalnya Navigator, yang dapat diimplementasikan. Mari kita lihat:

 class ShoppingCartActivity : Activity() { @Inject lateinit var navigator : Navigator override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { navigator.showCheckout(this) } } } class Navigator { fun showCheckout(activity : Activity){ val intent = Intent(activity, CheckoutActivity::class.java) activity.startActivity(intent) } } 

Ternyata tidak buruk, tetapi saya ingin lebih.

Navigasi dengan MVVM / MVP


Saya akan mulai dengan pertanyaan: di mana Anda akan menempatkan logika navigasi saat menggunakan MVVM / MVP?

Pada lapisan di bawah presenter (sebut saja logika bisnis)? Bukan ide yang baik, karena kemungkinan besar Anda akan menggunakan kembali logika bisnis Anda dalam model presentasi atau presenter lainnya.

Di lapisan tampilan? Anda yakin ingin mengadakan acara antara presentasi dan model presentasi / presentasi? Mari kita lihat sebuah contoh:

 class ShoppingCartActivity : ShoppingCartView, Activity() { @Inject lateinit var navigator : Navigator @Inject lateinit var presenter : ShoppingCartPresenter override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { presenter.checkoutClicked() } } override fun navigateToCheckout(){ navigator.showCheckout(this) } } class ShoppingCartPresenter : Presenter<ShoppingCartView> { ... override fun checkoutClicked(){ view?.navigateToCheckout(this) } } 

Atau jika Anda lebih suka MVVM, Anda bisa menggunakan SingleLiveEvents atau EventObserver

 class ShoppingCartActivity : ShoppingCartView, Activity() { @Inject lateinit var navigator : Navigator @Inject lateinit var viewModel : ViewModel override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { viewModel.checkoutClicked() } viewModel.navigateToCheckout.observe(this, Observer { navigator.showCheckout(this) }) } } class ShoppingCartViewModel : ViewModel() { val navigateToCheckout = MutableLiveData<Event<Unit>> fun checkoutClicked(){ navigateToCheckout.value = Event(Unit) // Trigger the event by setting a new Event as a new value } } 

Atau mari kita letakkan navigator dalam model tampilan alih-alih menggunakan EventObserver seperti yang ditunjukkan pada contoh sebelumnya

 class ShoppingCartViewModel @Inject constructor(val navigator : Navigator) : ViewModel() { fun checkoutClicked(){ navigator.showCheckout() } } 

Harap dicatat bahwa pendekatan ini dapat diterapkan pada presenter. Kami juga mengabaikan kemungkinan kebocoran memori di navigator jika ada tautan ke aktivator.

Koordinator


Jadi di mana kita menempatkan logika navigasi? Logika bisnis? Sebelumnya, kami sudah mempertimbangkan opsi ini dan sampai pada kesimpulan bahwa ini bukan solusi terbaik. Melempar peristiwa antara tampilan dan model tampilan mungkin berhasil, tetapi tidak terlihat seperti solusi yang elegan. Selain itu, tampilan masih bertanggung jawab atas logika navigasi, meskipun kami membawanya ke navigator. Mengikuti metode pengecualian, kami masih memiliki opsi untuk menempatkan logika navigasi dalam model presentasi, dan opsi ini tampaknya menjanjikan. Tetapi haruskah model tampilan peduli dengan navigasi? Bukankah itu hanya lapisan antara tampilan dan model? Itu sebabnya kami sampai pada gagasan koordinator.

"Mengapa kita membutuhkan tingkat abstraksi lain?" - kamu bertanya. Apakah sebanding dengan kompleksitas sistem? Dalam proyek-proyek kecil, abstraksi dapat benar-benar berubah demi abstraksi, namun, dalam aplikasi yang kompleks atau dalam kasus menggunakan tes A / B, koordinator mungkin berguna. Misalkan pengguna dapat membuat akun dan masuk. Kami sudah memiliki beberapa logika, di mana kami harus memeriksa apakah pengguna telah masuk dan menampilkan layar masuk atau layar utama aplikasi. Koordinator dapat membantu dengan contoh yang diberikan. Perhatikan bahwa koordinator tidak membantu menulis lebih sedikit kode, tetapi membantu mendapatkan kode logika navigasi dari tampilan atau model tampilan.

Gagasan koordinator sangat sederhana. Dia hanya tahu layar aplikasi mana yang akan dibuka selanjutnya. Misalnya, ketika pengguna mengklik tombol pembayaran untuk pesanan, koordinator menerima acara yang sesuai dan tahu bahwa langkah selanjutnya adalah membuka layar pembayaran. Di iOS, koordinator digunakan sebagai pencari lokasi untuk membuat ViewControllers dan mengontrol tumpukan belakang. Ini cukup untuk koordinator (ingat prinsip tanggung jawab tunggal). Dalam aplikasi Android, sistem menciptakan kegiatan, kami memiliki banyak alat untuk mengimplementasikan dependensi dan ada backstack untuk kegiatan dan fragmen. Sekarang mari kita kembali ke ide asli koordinator: koordinator hanya tahu layar mana yang akan berikutnya.

Contoh: Aplikasi berita menggunakan koordinator


Akhirnya mari kita bicara langsung tentang template. Bayangkan kita perlu membuat aplikasi berita sederhana. Aplikasi ini memiliki 2 layar: "daftar artikel" dan "teks artikel", yang dibuka dengan mengklik pada item daftar.



 class NewsFlowCoordinator (val navigator : Navigator) { fun start(){ navigator.showNewsList() } fun readNewsArticle(id : Int){ navigator.showNewsArticle(id) } } 

Sebuah skrip (Aliran) berisi satu atau beberapa layar. Dalam contoh kami, skenario berita terdiri dari 2 layar: "daftar artikel" dan "teks artikel". Koordinator itu sangat sederhana. Saat aplikasi dimulai, kami memanggil NewsFlowCoordinator # start () untuk menampilkan daftar artikel. Ketika pengguna mengklik pada item daftar, metode NewsFlowCoordinator # readNewsArticle (id) dipanggil dan layar dengan teks lengkap artikel ditampilkan. Kami masih bekerja dengan navigator (kami akan membicarakannya nanti), di mana kami mendelegasikan pembukaan layar. Koordinator tidak memiliki status, tidak bergantung pada implementasi back-end dan hanya mengimplementasikan satu fungsi: ia menentukan ke mana harus pergi berikutnya.

Tetapi bagaimana menghubungkan koordinator dengan model presentasi kami? Kami akan mengikuti prinsip inversi dependensi: kami akan meneruskan lambda ke model tampilan, yang akan dipanggil ketika pengguna mengetuk artikel tersebut.

 class NewsListViewModel( newsRepository : NewsRepository, var onNewsItemClicked: ( (Int) -> Unit )? ) : ViewModel() { val newsArticles = MutableLiveData<List<News>> private val disposable = newsRepository.getNewsArticles().subscribe { newsArticles.value = it } fun newsArticleClicked(id : Int){ onNewsItemClicked!!(id) // call the lambda } override fun onCleared() { disposable.dispose() onNewsItemClicked = null // to avoid memory leaks } } 

onNewsItemClicked: (Int) -> Unit adalah lambda yang memiliki satu argumen integer dan mengembalikan Unit. Harap dicatat bahwa lambda mungkin nol, ini akan memungkinkan kami untuk menghapus tautan untuk menghindari kebocoran memori. Pembuat model tampilan (misalnya, belati) harus melewati tautan ke metode koordinator:

 return NewsListViewModel( newsRepository = newsRepository, onNewsItemClicked = newsFlowCoordinator::readNewsArticle ) 

Sebelumnya, kami menyebutkan navigator, yang melakukan perubahan layar. Implementasi navigator adalah kebijaksanaan Anda, karena itu tergantung pada pendekatan spesifik Anda dan preferensi pribadi. Dalam contoh kami, kami menggunakan satu aktivitas dengan beberapa fragmen (satu layar - satu fragmen dengan model presentasi sendiri). Saya memberikan implementasi navigator yang naif:

 class Navigator{ var activity : FragmentActivity? = null fun showNewsList(){ activty!!.supportFragmentManager .beginTransaction() .replace(R.id.fragmentContainer, NewsListFragment()) .commit() } fun showNewsDetails(newsId: Int) { activty!!.supportFragmentManager .beginTransaction() .replace(R.id.fragmentContainer, NewsDetailFragment.newInstance(newsId)) .addToBackStack("NewsDetail") .commit() } } class MainActivity : AppCompatActivity() { @Inject lateinit var navigator : Navigator override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) navigator.activty = this } override fun onDestroy() { super.onDestroy() navigator.activty = null // Avoid memory leaks } } 

Implementasi navigator di atas tidak ideal, tetapi ide utama dari posting ini adalah untuk memperkenalkan koordinator ke dalam pola. Perlu dicatat bahwa karena navigator dan koordinator tidak memiliki status, mereka dapat dideklarasikan dalam aplikasi (misalnya, Singleton menggunakan belati) dan dapat digunakan di Aplikasi # onCreate ().

Mari tambahkan otorisasi ke aplikasi kita. Kami akan menentukan layar login baru (LoginFragment + LoginViewModel, untuk kesederhanaan kami akan menghilangkan pemulihan dan pendaftaran kata sandi) dan LoginFlowCoordinator. Mengapa tidak menambahkan fungsionalitas baru ke NewsFlowCoordinator? Kami tidak ingin mendapatkan Koordinator Dewa yang akan bertanggung jawab untuk semua navigasi di aplikasi? Juga, skrip otorisasi tidak berlaku untuk skenario pembaca berita, bukan?

 class LoginFlowCoordinator( val navigator: Navigator ) { fun start(){ navigator.showLogin() } fun registerNewUser(){ navigator.showRegistration() } fun forgotPassword(){ navigator.showRecoverPassword() } } class LoginViewModel( val usermanager: Usermanager, var onSignUpClicked: ( () -> Unit )?, var onForgotPasswordClicked: ( () -> Unit )? ) { fun login(username : String, password : String){ usermanager.login(username, password) ... } ... } 

Di sini kita melihat bahwa untuk setiap acara UI ada lambda yang sesuai, namun tidak ada lambda untuk panggilan balik dari login yang berhasil. Ini juga merupakan detail implementasi dan Anda dapat menambahkan lambda yang sesuai, namun saya punya ide yang lebih baik. Mari menambahkan RootFlowCoordinator dan berlangganan perubahan model.

 class RootFlowCoordinator( val usermanager: Usermanager, val loginFlowCoordinator: LoginFlowCoordinator, val newsFlowCoordinator: NewsFlowCoordinator, val onboardingFlowCoordinator: OnboardingFlowCoordinator ) { init { usermanager.currentUser.subscribe { user -> when (user){ is NotAuthenticatedUser -> loginFlowCoordinator.start() is AuthenticatedUser -> if (user.onBoardingCompleted) newsFlowCoordinator.start() else onboardingFlowCoordinator.start() } } } fun onboardingCompleted(){ newsFlowCoordinator.start() } } 

Dengan demikian, RootFlowCoordinator akan menjadi titik masuk navigasi kami alih-alih NewsFlowCoordinator. Mari kita fokus pada RootFlowCoordinator. Jika pengguna masuk, maka kami memeriksa apakah ia telah menyelesaikan onboarding (lebih lanjut tentang ini nanti) dan memulai skrip untuk berita atau onboarding. Harap dicatat bahwa LoginViewModel tidak terlibat dalam logika ini. Kami menggambarkan skenario orientasi.



 class OnboardingFlowCoordinator( val navigator: Navigator, val onboardingFinished: () -> Unit // this is RootFlowCoordinator.onboardingCompleted() ) { fun start(){ navigator.showOnboardingWelcome() } fun welcomeShown(){ navigator.showOnboardingPersonalInterestChooser() } fun onboardingCompleted(){ onboardingFinished() } } 

Onboarding dimulai dengan memanggil OnboardingFlowCoordinator # start (), yang menampilkan WelcomeFragment (WelcomeViewModel). Setelah mengklik tombol "selanjutnya", metode OnboardingFlowCoordinator # welcomeShown () dipanggil. Yang menampilkan layar berikut ini PersonalInterestFragment + PersonalInterestViewModel, di mana pengguna memilih kategori berita menarik. Setelah memilih kategori, pengguna mengetuk tombol "berikutnya" dan metode OnboardingFlowCoordinator # onboardingCompleted () dipanggil, yang mem-proksi panggilan ke RootFlowCoordinator # onboardingCompleted (), yang meluncurkan NewsFlowCoordinator.
Mari kita lihat bagaimana koordinator dapat menyederhanakan pekerjaan dengan tes A / B. Saya akan menambahkan layar dengan tawaran untuk melakukan pembelian dalam aplikasi dan akan menunjukkannya kepada beberapa pengguna.



 class NewsFlowCoordinator ( val navigator : Navigator, val abTest : AbTest ) { fun start(){ navigator.showNewsList() } fun readNewsArticle(id : Int){ navigator.showNewsArticle(id) } fun closeNews(){ if (abTest.isB){ navigator.showInAppPurchases() } else { navigator.closeNews() } } } 

Sekali lagi, kami belum menambahkan logika apa pun pada tampilan atau modelnya. Sudahkah Anda memutuskan untuk menambahkan InAppPurchaseFragment ke orientasi? Untuk melakukan ini, Anda hanya perlu mengubah koordinator onboarding, karena fragmen belanja dan model tampilan sepenuhnya independen dari fragmen lain dan kami dapat dengan bebas menggunakannya kembali dalam skenario lain. Koordinator juga akan membantu mengimplementasikan tes A / B, yang membandingkan dua skenario onboarding.

Sumber lengkap dapat ditemukan di github , dan untuk pemalas saya telah menyiapkan demo video


Saran yang berguna: menggunakan kotlin Anda dapat membuat dsl yang nyaman untuk menggambarkan koordinator dalam bentuk grafik navigasi.

 newsFlowCoordinator(navigator, abTest) { start { navigator.showNewsList() } readNewsArticle { id -> navigator.showNewsArticle(id) } closeNews { if (abTest.isB){ navigator.showInAppPurchases() } else { navigator.closeNews() } } } 

Ringkasan:


Koordinator akan membantu membawa logika navigasi ke komponen yang digabungkan dengan longgar. Saat ini tidak ada perpustakaan yang siap produksi, saya hanya menjelaskan konsep pemecahan masalah. Apakah koordinator berlaku untuk aplikasi Anda? Saya tidak tahu, itu tergantung pada kebutuhan Anda dan betapa mudahnya mengintegrasikannya ke dalam arsitektur yang ada. Mungkin bermanfaat untuk menulis aplikasi kecil menggunakan koordinator.

Faq:

Artikel itu tidak menyebutkan penggunaan koordinator dengan pola MVI. Apakah mungkin menggunakan koordinator dengan arsitektur ini? Ya, saya punya artikel terpisah .

Google baru-baru ini memperkenalkan Pengontrol Navigasi sebagai bagian dari Android Jetpack. Apa kaitan koordinator dengan navigasi Google? Anda dapat menggunakan Pengontrol Navigasi baru alih-alih navigator di koordinator atau langsung di navigator alih-alih secara manual membuat transaksi fragmen.

Dan jika saya tidak ingin menggunakan fragmen / aktivitas dan saya ingin menulis back-end saya sendiri untuk mengelola tampilan - apakah saya dapat menggunakan koordinator dalam kasus saya? Saya juga memikirkan hal ini dan sedang mengerjakan prototipe. Saya akan menulis tentang ini di blog saya. Sepertinya saya bahwa mesin negara akan sangat menyederhanakan tugas.

Apakah koordinator terikat pada pendekatan aplikasi aktivitas tunggal? Tidak, Anda bisa menggunakannya dalam berbagai skenario. Implementasi transisi antara layar tersembunyi di navigator.

Dengan pendekatan yang dijelaskan, Anda mendapatkan navigator yang sangat besar. Kami agak berusaha menjauh dari God-Object'a? Kami tidak diharuskan untuk menggambarkan navigator dalam satu kelas. Buat beberapa navigator kecil yang didukung, misalnya, navigator terpisah untuk setiap skenario pengguna.

Bagaimana cara bekerja dengan animasi transisi berkelanjutan? Jelaskan animasi transisi di navigator, maka aktivitas / fragmen tidak akan tahu apa-apa tentang layar sebelumnya / berikutnya. Bagaimana navigator tahu kapan memulai animasi? Misalkan kita ingin menampilkan animasi transisi antara fragmen A dan B. Kita dapat berlangganan acara onFragmentViewCreated (v: View) menggunakan FragmentLifecycleCallback dan ketika acara ini terjadi, kita dapat bekerja dengan animasi dengan cara yang sama seperti yang kita lakukan secara langsung dalam fragmen: tambahkan OnPreDrawListener untuk menunggu sampai siap dan panggil startPostponedEnterTransition (). Dengan cara yang kira-kira sama, Anda dapat menerapkan transisi animasi antara aktivitas menggunakan ActivityLifecycleCallbacks atau antara ViewGroup menggunakan OnHierarchyChangeListener. Jangan lupa untuk berhenti berlangganan dari acara nanti untuk menghindari kebocoran memori.

Source: https://habr.com/ru/post/id416301/


All Articles