
Beberapa tahun yang lalu, kami di Badoo mulai menggunakan pendekatan MVI untuk pengembangan Android. Itu dimaksudkan untuk menyederhanakan basis kode yang kompleks dan menghindari masalah status yang salah: dalam skenario sederhana itu mudah, tetapi semakin kompleks sistem, semakin sulit untuk mempertahankannya dalam bentuk yang benar dan semakin mudah untuk melewatkan bug.
Di Badoo, semua aplikasi tidak sinkron - tidak hanya karena fungsionalitas luas yang tersedia bagi pengguna melalui UI, tetapi juga karena kemungkinan pengiriman data satu arah oleh server. Dengan menggunakan pendekatan lama dalam modul obrolan kami, kami menemukan beberapa bug aneh yang sulit direproduksi, yang harus kami habiskan cukup banyak waktu untuk menghilangkannya.
Rekan kami Zsolt Kocsi (
Medium ,
Twitter ) dari kantor London memberi tahu bagaimana menggunakan MVI kami membangun komponen independen yang mudah digunakan kembali, keuntungan apa yang kami dapatkan dan kerugian apa yang kami temui saat menggunakan pendekatan ini.
Ini adalah artikel ketiga dari serangkaian artikel tentang arsitektur Android Badoo. Tautan ke dua yang pertama:
- Arsitektur MVI modern berdasarkan pada Kotlin .
- Membangun sistem komponen reaktif dengan Kotlin .
Jangan memikirkan komponen yang tidak terhubung dengan baik.
Konektivitas yang lemah dianggap lebih baik daripada kuat. Jika Anda hanya mengandalkan antarmuka dan bukan pada implementasi spesifik, maka akan lebih mudah bagi Anda untuk mengganti komponen, lebih mudah untuk beralih ke implementasi lain tanpa menulis ulang sebagian besar kode, yang menyederhanakan termasuk pengujian unit.
Kami biasanya berakhir di sini dan mengatakan bahwa kami telah melakukan segala hal yang mungkin dalam hal konektivitas.
Namun, pendekatan ini tidak optimal. Misalkan Anda memiliki kelas A yang perlu menggunakan kemampuan tiga kelas lain: B, C dan D. Bahkan jika Anda merujuk mereka melalui antarmuka, kelas A semakin sulit dengan masing-masing kelas ini:
- dia tahu semua metode di semua antarmuka, nama dan tipe pengembaliannya, bahkan jika dia tidak menggunakannya;
- saat menguji A, Anda perlu mengonfigurasi lebih banyak tiruan ( objek tiruan );
- lebih sulit untuk menggunakan A berulang kali dalam konteks lain di mana kita tidak memiliki atau tidak ingin memiliki B, C, dan D.
Tentu saja, justru kelas A yang harus menentukan set minimum antarmuka yang diperlukan untuk itu (prinsip pemisahan antarmuka dari
SOLID ). Namun, dalam praktiknya, kita semua harus berurusan dengan situasi di mana, demi kenyamanan, pendekatan yang berbeda diambil: kami mengambil kelas yang sudah ada yang mengimplementasikan beberapa fungsi, mengekstraksi semua metode publiknya ke dalam antarmuka, dan kemudian menggunakan antarmuka ini di mana kelas yang disebutkan diperlukan. Yaitu, antarmuka digunakan bukan atas dasar apa yang diperlukan komponen ini, tetapi atas dasar apa yang dapat ditawarkan komponen lain.
Dengan pendekatan ini, situasi semakin memburuk dari waktu ke waktu. Setiap kali kami menambahkan fungsionalitas baru, kelas kami ditautkan dalam web antarmuka baru yang perlu mereka ketahui. Kelas tumbuh dalam ukuran, dan pengujian menjadi semakin sulit.
Akibatnya, ketika Anda perlu menggunakannya dalam konteks yang berbeda, hampir tidak mungkin untuk memindahkan mereka tanpa semua kusut yang dengannya mereka terhubung, bahkan melalui antarmuka. Anda dapat menggambar analogi: Anda ingin menggunakan pisang, dan itu ada di tangan monyet yang tergantung di pohon, sehingga sebagai hasilnya, di beban di pisang Anda akan mendapatkan seluruh bagian dari hutan. Singkatnya, proses transfer membutuhkan banyak waktu, dan segera Anda mulai bertanya pada diri sendiri mengapa dalam praktiknya sangat sulit untuk menggunakan kembali kode.
Komponen Kotak Hitam
Jika kita ingin komponennya mudah dan dapat digunakan kembali, maka untuk ini kita tidak perlu tahu tentang dua hal:
- tentang di mana lagi itu digunakan;
- tentang komponen lain yang tidak terkait dengan implementasi internal.
Alasannya jelas: jika Anda tidak tahu tentang dunia luar, maka Anda tidak akan terhubung dengannya.
Apa yang benar-benar kita inginkan dari komponen:
- mendefinisikan input (input) dan output (output) data sendiri;
- Jangan pikirkan dari mana data ini berasal atau ke mana ia pergi;
- itu harus swasembada sehingga kita tidak perlu tahu struktur internal komponen untuk penggunaannya.
Anda dapat mempertimbangkan komponen kotak hitam, atau sirkuit terpadu. Dia memiliki kontak input dan output. Anda menyoldernya - dan sirkuit mikro menjadi bagian dari sistem yang tidak diketahui olehnya.

Sampai sekarang, diasumsikan bahwa kita berbicara tentang aliran data dua arah: jika kelas A membutuhkan sesuatu, ia mengekstrak metode melalui antarmuka B dan menerima hasilnya dalam bentuk nilai yang dikembalikan oleh fungsi.

Tapi kemudian A tahu tentang B, dan kami ingin menghindari ini.
Tentu saja, skema semacam itu masuk akal untuk fitur implementasi tingkat rendah. Tetapi jika kita membutuhkan komponen yang dapat digunakan kembali yang bekerja seperti kotak hitam mandiri, kita perlu memastikan bahwa itu tidak tahu apa-apa tentang antarmuka eksternal, nama metode, atau nilai pengembalian.
Kami beralih ke searah
Tetapi tanpa nama dan metode antarmuka, kami tidak dapat memanggil apa pun! Yang tersisa adalah menggunakan aliran data searah, di mana kita hanya mendapatkan input dan menghasilkan output:

Pada awalnya, ini mungkin terlihat seperti batasan, tetapi solusi seperti itu memiliki banyak keuntungan, yang akan dibahas di bawah ini.
Dari
artikel pertama kita tahu bahwa fitur (Fitur) mendefinisikan data input mereka sendiri (Keinginan) dan data output mereka sendiri (Negara). Oleh karena itu, tidak masalah bagi mereka dari mana Wish berasal atau ke mana Negara pergi.

Itu yang kita butuhkan! Fitur dapat digunakan di mana pun Anda bisa memberi mereka input, dan dengan output Anda dapat melakukan apa pun yang Anda inginkan. Dan karena fitur tidak berkomunikasi langsung dengan komponen lain, mereka adalah modul mandiri dan tidak terkait.
Sekarang ambil View dan rancanglah sehingga itu juga merupakan modul mandiri.
Pertama, View harus sesederhana mungkin sehingga hanya bisa menangani tugas internalnya.
Tugas macam apa? Ada dua di antaranya:
- rendering ViewModel (input);
- memicu ViewEvents tergantung pada tindakan pengguna (output).
Mengapa menggunakan ViewModel? Mengapa tidak langsung menggambarkan kondisi fitur tersebut?
- (Non) menampilkan fitur di layar bukan bagian dari implementasi. Tampilan harus dapat merender sendiri jika data berasal dari beberapa sumber.
- Tidak perlu mencerminkan kompleksitas negara dalam Tampilan. ViewModel harus hanya berisi informasi siap-pakai yang diperlukan untuk membuatnya tetap sederhana.
Juga, View tidak boleh tertarik pada yang berikut:
- dari mana semua ViewModels ini berasal;
- apa yang terjadi ketika ViewEvent dipicu;
- logika bisnis apa pun;
- pelacakan analitik;
- penjurnalan
- tugas lain.
Semua ini adalah tugas eksternal, dan Lihat tidak boleh dihubungkan dengan mereka. Mari kita berhenti dan merangkum kesederhanaan Tampilan:
interface FooView : Consumer<ViewModel>, ObservableSource<Event> { data class ViewModel( val title: String, val bgColor: Int ) sealed class Event { object ButtonClicked : Event() data class TextFocusChanged(val hasFocus: Boolean) : Event() } }
Implementasi Android harus:
- Temukan tampilan Android dengan ID mereka.
- Menerapkan metode penerimaan antarmuka konsumen dengan menetapkan nilai dari ViewModel.
- Setel pendengar (ClickListeners) untuk berinteraksi dengan UI untuk menghasilkan acara tertentu.
Contoh:
class FooViewImpl @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, private val events: PublishRelay<Event> = PublishRelay.create<Event>() ) : LinearLayout(context, attrs, defStyle), FooView,
Jika tidak terbatas pada Fitur dan Tampilan, berikut adalah komponen apa yang akan terlihat dengan pendekatan ini:
interface GenericBlackBoxComponent : Consumer<Input>, ObservableSource<Output> { sealed class Input sealed class Output }
Sekarang semuanya jelas dengan polanya!

Bersatu, bersatu, bersatu!
Tetapi bagaimana jika kita memiliki komponen yang berbeda dan masing-masing memiliki input dan output sendiri? Kami akan menghubungkan mereka!
Untungnya, ini dapat dengan mudah dilakukan menggunakan Binder, yang juga membantu menciptakan ruang lingkup yang tepat, seperti yang kita ketahui dari
artikel kedua :
Keuntungan pertama: mudah diperluas tanpa modifikasi
Penggunaan komponen yang tidak terkait dalam bentuk kotak hitam yang hanya terhubung sementara memungkinkan kita untuk menambahkan fungsionalitas baru tanpa memodifikasi komponen yang ada.
Ambil contoh sederhana:

Di sini, fitur (F) dan Tampilan (V) hanya terhubung satu sama lain.
Bindings yang sesuai adalah:
bind(feature to view using stateToViewModelTransformer) bind(view to feature using uiEventToWishTransformer)
Misalkan kita ingin menambahkan pelacakan beberapa acara UI ke sistem ini.
internal object AnalyticsTracker : Consumer<AnalyticsTracker.Event> { sealed class Event { object ProfileImageClicked: Event() object EditButtonClicked : Event() } override fun accept(event: AnalyticsTracker.Event) {
Berita baiknya adalah kita bisa melakukan ini hanya dengan menggunakan kembali saluran tampilan keluaran yang ada:

Dalam kode, tampilannya seperti ini:
bind(feature to view using stateToViewModelTransformer) bind(view to feature using uiEventToWishTransformer)
Fungsionalitas baru dapat ditambahkan hanya dengan satu baris penjilidan tambahan. Sekarang, kita tidak hanya tidak dapat mengubah satu baris kode tunggal, tetapi bahkan tidak tahu bahwa output digunakan untuk memecahkan masalah baru.
Jelas, sekarang lebih mudah bagi kita untuk menghindari kekhawatiran tambahan dan komponen yang tidak perlu rumit. Mereka tetap sederhana. Anda dapat menambahkan fungsionalitas ke sistem dengan hanya menghubungkan komponen ke yang sudah ada.
Keuntungan kedua: mudah digunakan berulang kali
Dengan menggunakan contoh Fitur dan Tampilan, dapat dilihat dengan jelas bahwa kita dapat menambahkan sumber input baru atau konsumen data output hanya dengan satu baris dengan pengikatan. Ini sangat memudahkan penggunaan kembali komponen di berbagai bagian aplikasi.
Namun, pendekatan ini tidak terbatas pada kelas. Cara menggunakan antarmuka ini memungkinkan kita untuk menggambarkan komponen reaktif yang lengkap dari berbagai ukuran.
Dengan membatasi diri pada data input dan output tertentu, kami menghilangkan kebutuhan untuk mengetahui cara kerja semuanya di bawah tenda, dan oleh karena itu kami dengan mudah menghindari secara tidak sengaja menghubungkan internal komponen dengan bagian lain dari sistem. Dan tanpa mengikat, Anda dapat dengan mudah dan mudah menggunakan komponen berulang kali.
Kami akan kembali ke ini di salah satu artikel berikut dan mempertimbangkan contoh penggunaan teknik ini untuk menghubungkan komponen tingkat yang lebih tinggi.
Pertanyaan pertama: di mana menempatkan binding?
- Pilih tingkat abstraksi. Bergantung pada arsitekturnya, ini mungkin sebuah Activity, sebuah fragmen, atau beberapa ViewController. Saya harap Anda masih memiliki beberapa tingkat abstraksi di bagian-bagian di mana tidak ada UI. Misalnya, dalam beberapa lingkup pohon konteks DI.
- Buat kelas terpisah untuk pengikatan di tingkat yang sama dengan bagian UI ini. Jika itu FooActivity, FooFragment, atau FooViewController, maka Anda dapat meletakkan FooBindings di sebelahnya.
- Pastikan Anda menanamkan FooBindings dalam instance komponen yang sama yang Anda gunakan dalam Activity, fragmen, dll.
- Untuk membentuk ruang lingkup binding, gunakan siklus aktivitas atau fragmen. Jika loop ini tidak terkait dengan Android, maka Anda dapat membuat pemicu secara manual, misalnya, saat membuat atau menghancurkan lingkup DI. Contoh lain dari ruang lingkup dijelaskan dalam artikel kedua .
Pertanyaan kedua: pengujian
Karena komponen kami tidak tahu apa-apa tentang orang lain, kami biasanya tidak perlu bertopik. Pengujian disederhanakan untuk memverifikasi respons komponen yang benar terhadap input data dan menghasilkan hasil yang diharapkan.
Dalam hal Fitur, ini berarti:
- kemampuan untuk menguji apakah input data tertentu menghasilkan keadaan yang diharapkan (output).
Dan dalam hal Tampilan:
- kita dapat menguji apakah suatu ViewModel (input) tertentu mengarah ke keadaan UI yang diharapkan;
- kita dapat menguji apakah simulasi interaksi dengan UI mengarah ke inisialisasi pada ViewEvent (output) yang diharapkan.
Tentu saja, interaksi antar komponen tidak hilang secara ajaib. Kami baru saja mengekstrak tugas-tugas ini dari komponen itu sendiri. Mereka masih perlu diuji. Tapi dimana?
Dalam kasus kami, Binder bertanggung jawab untuk menghubungkan komponen:
Tes kami harus mengkonfirmasi hal berikut:
1. Transformer (pemetaan).
Beberapa koneksi memiliki pemetaan, dan Anda perlu memastikan bahwa mereka benar mengkonversi elemen. Dalam kebanyakan kasus, unit test yang sangat sederhana sudah cukup untuk ini, karena pemetaan biasanya juga sangat sederhana:
@Test fun testCase1() { val transformer = Transformer() val testInput = TODO() val actualOutput = transformer.invoke(testInput) val expectedOutput = TODO() assertEquals(expectedOutput, actualOutput) }
2. Komunikasi.
Anda perlu memastikan bahwa koneksi telah dikonfigurasi dengan benar. Apa gunanya pekerjaan komponen individu dan pemetaan, jika karena alasan tertentu hubungan di antara mereka tidak terjalin? Semua ini dapat diuji dengan mengatur lingkungan yang mengikat dengan bertopik, sumber inisialisasi dan memeriksa apakah hasil yang diharapkan diterima di sisi klien:
class BindingEnvironmentTest { lateinit var component1: ObservableSource<Component1.Output> lateinit var component2: Consumer<Component2.Input> lateinit var bindings: BindingEnvironment @Before fun setUp() { val component1 = PublishRelay.create() val component2 = mock() val bindings = BindingEnvironment(component1, component2) } @Test fun testBindings() { val simulatedOutputOnLeftSide = TODO() val expectedInputOnRightSide = TODO() component1.accept(simulatedOutputOnLeftSide) verify(component2).accept(expectedInputOnRightSide) } }
Dan meskipun untuk pengujian Anda harus menulis tentang jumlah kode yang sama dengan pendekatan lain, namun komponen swasembada membuatnya lebih mudah untuk menguji masing-masing bagian, karena tugas-tugasnya jelas dipisahkan.
Makanan untuk dipikirkan
Meskipun deskripsi sistem kami dalam bentuk grafik kotak hitam baik untuk pemahaman umum, ini hanya berfungsi selama ukuran sistem relatif kecil.
Lima hingga delapan garis mengikat dapat diterima. Tetapi, setelah lebih terhubung, akan agak sulit untuk memahami apa yang terjadi:


Kami dihadapkan dengan fakta bahwa dengan peningkatan jumlah tautan (bahkan ada lebih banyak daripada dalam fragmen kode yang disajikan) situasinya menjadi lebih rumit. Alasannya tidak hanya dalam jumlah garis - semacam ikatan dapat dikelompokkan dan diekstraksi untuk metode yang berbeda - tetapi juga karena semakin sulit untuk menjaga segala sesuatu yang terlihat. Dan ini selalu pertanda buruk. Jika lusinan komponen berbeda terletak pada level yang sama, maka tidak mungkin membayangkan semua interaksi yang mungkin.
Alasannya adalah penggunaan komponen - kotak hitam atau yang lainnya?
Jelas, jika ruang lingkup yang Anda gambarkan pada awalnya rumit, maka tidak ada pendekatan yang akan menyelamatkan Anda dari masalah yang disebutkan sampai Anda membagi sistem menjadi bagian-bagian yang lebih kecil. Ini akan rumit bahkan tanpa daftar besar ikatan, itu tidak akan begitu jelas. Selain itu, jauh lebih baik jika kompleksitasnya diekspresikan secara eksplisit dan tidak disembunyikan. Lebih baik melihat daftar gabungan baris tunggal yang mengingatkan Anda tentang berapa banyak komponen individual yang Anda ketahui tentang tautan yang tersembunyi di dalam kelas dalam panggilan metode yang berbeda.
Karena komponen itu sendiri sederhana (mereka adalah kotak hitam dan proses tambahan tidak mengalir ke dalamnya), lebih mudah untuk memisahkan mereka, yang berarti bahwa ini adalah langkah ke arah yang benar. Kami memindahkan kesulitan ke satu tempat - ke daftar ikatan, sekilas yang memungkinkan Anda untuk mengevaluasi situasi umum dan mulai berpikir tentang cara keluar dari kekacauan ini.
Pencarian solusi membutuhkan waktu lama, dan ini masih berlangsung. Kami berencana untuk berbicara tentang cara mengatasi masalah ini di artikel berikut. Tetap berkomunikasi!