Dalam aplikasi apa pun yang terdiri lebih dari satu layar, ada kebutuhan untuk mengimplementasikan navigasi di antara komponen-komponennya. Tampaknya ini seharusnya tidak menjadi masalah, karena di UIKit ada komponen kontainer yang cukup nyaman seperti UINavigationController dan UITabBarController, serta metode tampilan modal yang fleksibel: cukup gunakan navigasi yang tepat pada waktu yang tepat.

Namun, segera setelah aplikasi beralih ke layar menggunakan push notification atau tautan, semuanya menjadi sedikit lebih rumit. Segera ada banyak pertanyaan:
- Apa yang harus dilakukan dengan pengontrol tampilan, yang sekarang ada di layar?
- cara beralih konteks (mis. tab aktif di UITabBarController)?
- Apakah tumpukan navigasi saat ini memiliki layar yang tepat?
- kapan navigasi harus diabaikan?

Dalam pengembangan iOS, kami di Badoo menemukan semua masalah ini. Sebagai hasilnya, kami memformalkan metode solusi kami menjadi pustaka komponen untuk navigasi, yang kami gunakan di semua produk baru. Pada artikel ini saya akan berbicara tentang pendekatan kami secara lebih rinci. Contoh penerapan praktik yang dijelaskan dapat dilihat dalam
proyek demo kecil.
Masalah kita
Seringkali masalah navigasi diselesaikan dengan menambahkan komponen global yang mengetahui struktur layar dalam aplikasi dan memutuskan apa yang harus dilakukan dalam kasus tertentu. Struktur layar berarti informasi tentang keberadaan wadah dalam hierarki pengendali dan bagian aplikasi saat ini.
Badoo memiliki komponen yang serupa. Ini bekerja dengan cara yang mirip dengan perpustakaan yang agak lama dari Facebook, yang sekarang tidak lagi dapat ditemukan di repositori publiknya. Navigasi didasarkan pada URL yang terkait dengan layar aplikasi. Pada dasarnya, semua logika terkandung dalam satu kelas, yang terkait dengan keberadaan bilah tab dan beberapa fungsi khusus untuk Badoo. Kompleksitas dan konektivitas komponen ini sangat tinggi sehingga penyelesaian tugas yang membutuhkan perubahan dalam logika navigasi bisa memakan waktu beberapa kali lebih lama dari yang direncanakan. Testabilitas kelas ini juga menimbulkan pertanyaan besar.
Komponen ini dibuat ketika kami hanya memiliki satu aplikasi. Kita tidak dapat membayangkan bahwa di masa depan kita akan mengembangkan beberapa produk yang sangat berbeda satu sama lain (
Bumble ,
Lumen dan lain-lain). Karena alasan ini, navigator dari aplikasi kami yang paling dewasa - Badoo - tidak mungkin digunakan dalam produk lain dan setiap tim harus membuat sesuatu yang baru.
Sayangnya, pendekatan baru juga dipertajam untuk aplikasi tertentu. Dengan meningkatnya jumlah proyek, masalahnya menjadi jelas dan muncul ide untuk membuat perpustakaan yang akan menyediakan serangkaian komponen tertentu, termasuk logika navigasi universal. Ini akan membantu meminimalkan waktu implementasi fungsionalitas serupa di produk baru.
Kami menerapkan router universal
Tugas utama yang diselesaikan oleh navigator global tidak begitu banyak:
- Temukan layar aktif saat ini.
- Entah bagaimana membandingkan jenis layar aktif dan isinya dengan apa yang perlu ditampilkan.
- Lakukan transisi seperlunya (urutan transisi).
Mungkin perumusan tugas terlihat agak abstrak, tetapi abstraksi inilah yang memungkinkan untuk melakukan universalisasi logika.
1. Pencarian layar aktif
Tugas pertama tampaknya cukup sederhana: Anda hanya harus melalui seluruh hierarki layar dan menemukan
UIViewController teratas.

Antarmuka objek kita mungkin terlihat seperti ini:
protocol TopViewControllerProvider { var topViewController: UIViewController? { get } }
Namun, tidak jelas bagaimana menentukan elemen root dari hierarki dan apa yang harus dilakukan dengan layar kontainer seperti UIPageViewController dan wadah khusus aplikasi.
Opsi termudah untuk menentukan elemen root adalah dengan mengambil pengontrol root dari layar aktif:
UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController
Pendekatan ini mungkin tidak selalu berfungsi dengan aplikasi di mana ada banyak jendela. Tetapi ini adalah kasus yang agak jarang, dan masalahnya dapat diselesaikan dengan secara eksplisit melewatkan jendela yang diinginkan sebagai parameter.
Masalah dengan layar kontainer dapat diselesaikan dengan membuat protokol khusus untuk mereka, yang akan berisi metode untuk mendapatkan layar aktif, atau Anda dapat menggunakan protokol yang diumumkan di atas. Semua pengontrol wadah yang digunakan dalam aplikasi harus mengimplementasikan protokol ini. Misalnya, untuk
UITabBarController, implementasi mungkin terlihat seperti ini:
extension UITabBarController: TopViewControllerProvider { var topViewController: UIViewController? { return self.selectedViewController } }
Tetap hanya melalui seluruh hierarki dan mendapatkan layar teratas. Jika pengontrol berikutnya mengimplementasikan TopViewControllerProvider, kita akan mendapatkan layar yang ditunjukkan padanya melalui metode yang dideklarasikan. Jika tidak, pengontrol yang ditunjukkan padanya akan diperiksa secara moderat (jika ada).
2. Konteks saat ini
Tugas menentukan konteks saat ini terlihat jauh lebih rumit. Kami ingin menentukan jenis layar dan, mungkin, informasi yang ditampilkan di sana. Tampaknya logis untuk membuat struktur yang mengandung informasi ini.
Tetapi tipe apa yang harus memiliki properti objek? Tujuan utama kami adalah membandingkan konteks dengan apa yang perlu ditampilkan, sehingga mereka harus mengimplementasikan protokol yang
setara . Ini dapat diimplementasikan melalui tipe generik:
struct ViewControllerContext<ScreenType: Equatable, InfoType: Equatable>: Equatable { let screenType: ScreenType let info: InfoType? }
Namun, karena spesifikasi Swift, ini memberlakukan batasan tertentu pada penggunaan jenis ini. Untuk menghindari masalah, struktur ini dalam aplikasi kami memiliki tampilan yang sedikit berbeda:
protocol ViewControllerContextInfo { func isEqual(to info: ViewControllerContextInfo?) -> Bool } struct ViewControllerContext: Equatable { public let screenType: String public let info: ViewControllerContextInfo? }
Opsi lain adalah memanfaatkan fitur Swift baru,
Tipe Buram , tetapi hanya tersedia mulai dengan iOS 13, yang masih tidak dapat diterima untuk banyak produk.
Implementasi perbandingan konteks cukup jelas. Agar tidak menulis fungsi isEqual untuk tipe yang sudah menerapkan Equatable, Anda bisa melakukan trik sederhana, kali ini menggunakan keunggulan Swift:
extension ViewControllerContextInfo where Self: Equatable { func isEqual(to info: ViewControllerContextInfo?) -> Bool { guard let info = info as? Self else { return false } return self == info } }
Hebat, kami memiliki objek untuk dibandingkan. Tetapi bagaimana Anda bisa menghubungkannya dengan
UIViewController ? Salah satu caranya adalah dengan menggunakan
objek terkait , fitur yang berguna dari bahasa Objective C. dalam beberapa kasus, tetapi pertama, itu tidak terlalu eksplisit, dan kedua, kami biasanya ingin membandingkan konteks hanya beberapa layar aplikasi. Karena itu, membuat protokol tampak ide yang bagus:
protocol ViewControllerContextHolder { var currentContext: ViewControllerContext? { get } }
dan implementasinya hanya di layar yang diperlukan. Jika layar aktif tidak menerapkan protokol ini, maka isinya dapat dianggap tidak signifikan dan tidak diperhitungkan saat menampilkan yang baru.
3. Eksekusi transisi
Mari kita lihat apa yang sudah kita miliki. Kemampuan setiap saat untuk mendapatkan informasi tentang layar aktif dalam bentuk struktur data tertentu. Informasi yang diterima secara eksternal melalui URL terbuka, pemberitahuan push, atau cara lain untuk memulai navigasi, yang dapat dikonversi menjadi struktur dengan tipe yang sama dan berfungsi sebagai maksud navigasi. Jika layar atas sudah menunjukkan informasi yang diperlukan, maka Anda dapat mengabaikan navigasi atau memperbarui konten layar.

Tetapi bagaimana dengan transisi itu sendiri?
Adalah logis untuk membuat komponen (sebut saja
router ) yang akan mengambil apa yang Anda perlu tunjukkan pada input, membandingkannya dengan apa yang telah ditampilkan, dan melakukan transisi atau urutan transisi. Selain itu, router mungkin berisi logika umum untuk memproses dan memvalidasi informasi dan status aplikasi. Yang utama adalah Anda tidak harus memasukkan logika khusus untuk fungsi domain atau aplikasi dalam komponen ini. Jika Anda mematuhi aturan ini, itu akan tetap dapat digunakan kembali untuk berbagai aplikasi dan mudah dipelihara.
Deklarasi antarmuka dasar protokol semacam itu terlihat seperti ini:
protocol ViewControllerContextRouterProtocol { func navigateToContext(_ context: ViewControllerContext, animated: Bool) }
Anda dapat menggeneralisasi fungsi di atas dengan melewati urutan konteks. Ini tidak akan berdampak signifikan pada implementasi.
Cukup jelas bahwa router akan memerlukan pabrik pengontrol, karena hanya data navigasi yang diterima pada inputnya. Penting untuk membuat layar terpisah di dalam pabrik, dan mungkin bahkan seluruh modul berdasarkan konteks yang ditransfer. Dari bidang
screenType ,
Anda dapat menentukan layar mana yang ingin Anda buat, dari bidang
info - dengan data apa yang perlu Anda isi sebelumnya:
protocol ViewControllersByContextFactory { func viewController(for context: ViewControllerContext) -> UIViewController? }
Jika aplikasi ini bukan klon Snapchat, maka kemungkinan besar metode yang digunakan untuk menampilkan pengontrol baru akan kecil. Oleh karena itu, untuk sebagian besar aplikasi, memperbarui tumpukan
UINavigationController dan menampilkan layar modal sudah cukup. Dalam hal ini, Anda dapat menentukan enum dengan tipe yang mungkin, misalnya:
enum NavigationType { case modal case navigationStack case rootScreen }
Jenis layar tergantung pada bagaimana itu ditampilkan. Jika ini adalah notifikasi pemblokiran, maka itu perlu ditampilkan secara moderen. Layar lain mungkin perlu ditambahkan ke tumpukan navigasi yang ada melalui
UINavigationController .
Memutuskan cara menampilkan layar tertentu lebih baik tidak di router itu sendiri. Jika kita menambahkan ketergantungan router di bawah protokol
ViewControllerNavigationTypeProvider dan mengimplementasikan seperangkat metode yang diinginkan khusus untuk setiap aplikasi, maka kita akan mencapai tujuan ini:
protocol ViewControllerNavigationTypeProvider { func navigationType(for context: ViewControllerContext) -> NavigationType }
Tetapi bagaimana jika kita ingin memperkenalkan jenis navigasi baru di salah satu aplikasi? Perlu menambahkan opsi baru ke enum, dan semua aplikasi lain akan mengetahuinya? Mungkin, dalam beberapa kasus inilah yang kami perjuangkan, tetapi jika Anda berpegang pada prinsip
terbuka-tertutup , maka untuk fleksibilitas yang lebih besar Anda dapat memasukkan protokol objek yang dapat melakukan transisi:
protocol ViewControllerContextTransition { func navigate(from source: UIViewController?, to destination: UIViewController, animated: Bool) }
Kemudian
ViewControllerNavigationTypeProvider akan berubah menjadi ini:
protocol ViewControllerContextTransitionProvider { func transition(for context: ViewControllerContext) -> ViewControllerContextTransition }
Sekarang kita tidak terbatas pada set tipe tampilan layar yang tetap dan kita dapat memperluas kemampuan navigasi tanpa perubahan pada router itu sendiri.
Terkadang Anda tidak perlu membuat
UIViewController baru untuk beralih ke beberapa layar - cukup beralih ke yang sudah ada. Contoh yang paling jelas adalah berpindah tab di
UITabBarController . Contoh lain adalah transisi ke elemen yang ada di tumpukan pengontrol yang ditampilkan alih-alih membuat layar baru dengan konten yang sama. Untuk melakukan ini, di router, sebelum membuat
UIViewController baru
, Anda dapat terlebih dahulu memeriksa apakah konteksnya dapat diaktifkan.
Bagaimana cara mengatasi masalah ini? Lebih banyak abstraksi!
protocol ViewControllerContextSwitcher { func canSwitch(to context: ViewControllerContext) -> Bool func switchContext(to context: ViewControllerContext, animated: Bool) }
Dalam hal tab, protokol ini dapat diimplementasikan oleh komponen yang tahu apa yang terkandung dalam
UITabBarViewController dan dapat memetakan
ViewControllerContext ke tab tertentu dan beralih tab.

Seperangkat objek tersebut dapat diteruskan ke router sebagai ketergantungan.
Untuk meringkas, algoritma pemrosesan konteks akan terlihat seperti ini:
func navigateToContext(_ context: ViewControllerContext, animated: Bool) { let topViewController = self.topViewControllerProvider.topViewController if let contextHolder = topViewController as? ViewControllerContextHolder, contextHolder.currentContext == context { return } if let switcher = self.contextSwitchers.first(where: { $0.canSwitch(to: context) }) { switcher.switchContext(to: context, animated: animated) return } guard let viewController = self.viewControllersFactory.viewController(for: context) else { return } let navigation = self.transitionProvider.navigation(for: context) navigation.navigate(from: self.topViewControllerProvider.topViewController, to: viewController, animated: true) }
Lebih mudah untuk menampilkan diagram ketergantungan router dalam bentuk diagram UML:

Router yang dihasilkan dapat digunakan untuk transisi yang dimulai secara otomatis atau melalui tindakan pengguna. Dalam produk kami, jika navigasi tidak terjadi secara otomatis, fungsi sistem standar digunakan, dan sebagian besar modul tidak menyadari keberadaan router global. Penting untuk diingat tentang implementasi protokol
ViewControllerContextHolder jika diperlukan agar router selalu dapat mengetahui informasi yang dilihat pengguna pada waktu saat ini.
Keuntungan dan kerugian
Baru-baru ini, kami mulai memperkenalkan metode manajemen navigasi yang dijelaskan ke dalam produk Badoo. Terlepas dari kenyataan bahwa implementasinya ternyata sedikit lebih rumit daripada opsi yang disajikan dalam
proyek demo , kami senang dengan hasilnya. Mari kita mengevaluasi kelebihan dan kekurangan dari pendekatan yang dijelaskan.
Dari manfaatnya antara lain:
- universalitas
- relatif mudahnya implementasi, bila dibandingkan dengan opsi yang disajikan di bagian alternatif,
- kurangnya batasan pada arsitektur aplikasi dan implementasi navigasi konvensional antar layar.
Kerugiannya sebagian merupakan konsekuensi dari keuntungan.
- Pengendali perlu mengetahui informasi apa yang ditampilkan. Jika kita mempertimbangkan arsitektur aplikasi, UIViewController harus ditugaskan ke lapisan tampilan, dan logika bisnis tidak boleh disimpan di lapisan ini. Struktur data yang mengandung konteks navigasi harus diimplementasikan di sana dari lapisan logika bisnis, tetapi tetap saja pengendali akan menyimpan informasi ini, yang tidak terlalu benar.
- Sumber kebenaran tentang keadaan aplikasi adalah hierarki layar yang ditampilkan, yang dalam beberapa kasus mungkin menjadi batasan.
Alternatif
Alternatif untuk pendekatan ini bisa dengan membangun hierarki modul aktif secara manual. Contoh dari solusi semacam itu adalah
penerapan pola Koordinator, di mana koordinator membentuk struktur pohon yang berfungsi sebagai sumber kebenaran untuk menentukan layar aktif, dan logika keputusan untuk menunjukkan layar ini atau itu atau tidak itu terdapat di dalam koordinator itu sendiri.
Gagasan serupa dapat ditemukan dalam arsitektur
RIB , yang
digunakan oleh tim Android kami.
Alternatif semacam itu memberikan abstraksi yang lebih fleksibel, tetapi membutuhkan keseragaman dalam arsitektur dan bisa terlalu rumit untuk banyak aplikasi.
Jika Anda mengambil pendekatan berbeda untuk menyelesaikan masalah seperti itu, jangan ragu untuk membicarakannya di komentar.