Semakin bertambahnya fitur pada aplikasi yang kita kembangkan, kita pasti akan merasakan build time yang perlahan-lahan semakin lama. Salah satu solusi yang bisa kita lakukan adalah dengan memecah aplikasi kita menjadi aplikasi-aplikasi kecil, sehingga jika kita ingin menjalankan module Payment saja misalnya, Xcode tidak perlu meng-compile semua module lain yang ada pada project kita, sehingga build time-nya akan menjadi lebih cepat.
Aplikasi-aplikasi kecil tersebut nantinya bisa kita gabungkan menjadi satu aplikasi yang utuh untuk direlease ke AppStore. Teknik ini biasa disebut dengan Modularization.
Pada artikel ini, kita akan belajar bagaimana cara membuat aplikasi iOS dengan arsitektur modular menggunakan SwiftUI dan Swift Package Manager dengan target iOS versi 13. Serta menyelesaikan beberapa problem dalam membuat aplikasi yang modular, seperti sharing image, font, dan melakukan navigasi antar module.
Berikut adalah beberapa kelebihan aplikasi dengan arsitektur yang modular daripada arsitektur monolithic:
Dengan memecah aplikasi kita menjadi beberapa module, Xcode hanya akan meng-compile module yang mengalami perubahan saja. Misalnya kita mengubah judul pada halaman pembayaran di module Payment, maka module lain yang tidak mengalami perubahan tidak akan dicompile ulang oleh Xcode. Sehingga build time-nya akan menjadi lebih cepat.
Kita juga bisa membuat Target aplikasi-aplikasi kecil khusus untuk menjalankan satu module saja. Sehingga kita bisa menjalankan suatu fitur secara independen dan terisolasi.
Modularization memungkinkan kita untuk membuat satu module yang Reusable atau bisa digunakan berulang kali, Contohnya kita bisa membuat module UI, yang berisi komponen-komponen User Interface seperti Button, TextField, dll. atau mungkin membuat module NetworkManager untuk melakukan Network Request.
Masalah yang sering kita hadapi ketika bekerja-sama dalam tim dalam 1 project adalah Git Conflict, apalagi jika conflict-nya di file "project.pbxproj" 🥲
Conflict tersebut terjadi biasanya ketika ada lebih dari 1 orang mengerjakan di module yang sama. Nah dengan Modularization, conflict tersebut lebih bisa diminimalisir karena kita memecah module besar (aplikasi kita) menjadi beberapa module kecil, sehingga setiap orang bisa mengerjakan di module yang terpisah secara terisolasi, dengan boundary dan interface yang lebih jelas.
Module yang lebih kecil, terisolasi, independen, memiliki boundary dan interface yang jelas akan lebih mudah untuk ditest, untuk memastikan bahwa perubahan pada satu module tidak akan menyebabkan masalah pada module lain.
Build time yang lebih cepat, kode yang reusable, lebih sedikit Git Conflict, dan kemudahan dalam menulis Test tentu akan meningkatkan produktivitas kita sebagai Developer. 😄
Sebelum mulai menulis kode pertama kita dalam tutorial ini, saya akan menjelaskan sedikit mengenai arsitektur dari sample aplikasi yang akan kita buat.
Jadi, kita akan membuat aplikasi sederhana bernama "Bookstore", aplikasi ini terdiri dari 4 module yaitu:
App Module, module utama dari project kita.
Product Module, kita bisa navigasi ke halaman Cart.
Cart Module, kita bisa navigasi ke halaman Product.
Shared Module, modul yang berisi komponen-komponen yang bisa dipakai berulang-kali di modul-modul lain seperti gambar, dll.
Kira-kira diagram-nya seperti ini:
Sebagai perbandingan, berikut adalah diagram dari aplikasi "Bookstore" jika dalam arsitektur Monolithic:
Bisa dilihat dari kedua diagram di atas, bahwa dalam arsitektur modular, module Product dan module Cart tidak bisa saling punya dependensi, karena bisa menyebabkan Circular Dependency.
Solusinya kita bisa buat interface di dalam module Shared, jadi, module Product dan module Cart berkomunikasi melalui interface tersebut.
Sekarang, kita akan mulai membuat aplikasi "Bookstore" kita, berdasarkan arsitektur di atas.
Pertama, kita akan membuat sebuah Workspace, Workspace sebuah file dokumen dari Xcode yang bisa kita gunakan untuk mengelompokkan beberapa project yang berbeda menjadi 1 window sehingga kita tidak perlu berpindah-pindah window.
Silakan buka Xcode, kemudian klik menu "File -> New -> Workspace", buat folder baru bernama "Bookstore", isi nama workspace-nya dengan nama "Bookstore.xcworkspace" dan taruh di dalam folder "Bookstore"
Selanjutnya kita akan membuat project iOS baru, dengan cara klik "File -> New -> Project", dan jangan lupa pilih "SwiftUI" sebagai interface-nya.
Klik "Next", dan taruh di dalam satu folder yang sama dengan "Bookstore.xcworkspace", lalu pilih "Bookstore" pada pilihan "Add to" dan "Group".
Kemudian ubah target minimum deployment menjadi iOS 13.
dan karena target kita adalah iOS 13, maka kita tidak bisa menggunakan "BookstoreApp.swift" sebagai entry point dari aplikasi kita. Sebagai gantinya kita akan menggunakan AppDelegate dan SceneDelegate.
Silakan hapus file "BookstoreApp.swift" dan "ContentView.swift", dan buatlah 3 file berikut:
AppDelegate.swift1import UIKit23@main4final class AppDelegate: NSObject, UIApplicationDelegate {56 func application(7 _ application: UIApplication,8 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil9 ) -> Bool {10 return true11 }1213 func application(14 _ application: UIApplication,15 configurationForConnecting connectingSceneSession: UISceneSession,16 options: UIScene.ConnectionOptions17 ) -> UISceneConfiguration {18 let sessionRole = connectingSceneSession.role19 let sceneConfig = UISceneConfiguration(name: nil, sessionRole: sessionRole)20 sceneConfig.delegateClass = SceneDelegate.self21 return sceneConfig22 }23}
SceneDelegate.swift1import UIKit2import SwiftUI34final class SceneDelegate: NSObject, UIWindowSceneDelegate {56 var window: UIWindow?78 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {9 guard let windowScene = (scene as? UIWindowScene) else { return }1011 window = UIWindow(windowScene: windowScene)12 window?.rootViewController = UIHostingController(rootView: HomePage())13 window?.makeKeyAndVisible()14 }15}
HomePage.swift1import SwiftUI23struct HomePage: View {4 var body: some View {5 VStack {6 Text("Bookstore")78 Image("store")9 .resizable()10 .aspectRatio(contentMode: .fill)11 .frame(height: 250)1213 Button("Navigate to Product Page") {14 // TODO15 }1617 Button("Navigate to Cart Page") {18 // TODO19 }2021 Spacer()22 }23 }24}
Kita akan membuat 3 module menggunakan Swift Package Manager, yaitu module Shared, Product dan Cart.
Module Shared akan berisi Assets seperti gambar dan font, serta sebuah class bernama Navigator untuk melakukan navigasi antar module.
Silakan klik menu "File -> New -> Package", beri nama "Shared" dan jangan lupa pilih "Bookstore" pada pilihan "Add to" dan "Group".
Kemudian kita set target platform menjadi iOS 13, lalu buka file "Package.swift" dan tambahkan code berikut:
Package.swift1// swift-tools-version: 5.82// The swift-tools-version declares the minimum version of Swift required to build this package.34import PackageDescription56let package = Package(7 name: "Shared",9 platforms: [.iOS(.v13)],10 products: [11 // Products define the executables and libraries a package produces, and make them visible to other packages.12 .library(13 name: "Shared",14 targets: ["Shared"]),15 ],16 dependencies: [17 // Dependencies declare other packages that this package depends on.18 // .package(url: /* package url */, from: "1.0.0"),19 ],20 targets: [21 // Targets are the basic building blocks of a package. A target can define a module or a test suite.22 // Targets can depend on other targets in this package, and on products in packages this package depends on.23 .target(24 name: "Shared",25 dependencies: []),26 .testTarget(27 name: "SharedTests",28 dependencies: ["Shared"]),29 ]30)
Lakukan langkah di atas untuk membuat module Product dan Cart.
Jika ketiga module tersebut telah dibuat, di target "Bookstore" pada bagian "Targets -> General -> Frameworks, Library, and Embedded Content", silakan tambahkan ketiga module tersebut.
Di module Shared, silakan buat Asset Catalog dan import file gambar kamu.
Nah, agar module lain bisa mengakses gambar yang ada di dalam module Shared, kita perlu mengisi parameter bundle menjadi .module.
Silakan buat file baru bernama "Extension+Image.swift" yang berisi code berikut:
Extension+Image.swift1import SwiftUI23public extension Image {4 init(shared name: String) {5 self.init(name, bundle: .module)6 }7}
Untuk menggunakannya, caranya seperti ini:
HomePage.swift1import SwiftUI3import Shared45struct HomePage: View {6 var body: some View {7 VStack {8 Text("Bookstore")911 Image(shared: "store")12 .resizable()13 .aspectRatio(contentMode: .fill)14 .frame(height: 250)1516 Button("Navigate to Product Page") {17 // TODO18 }1920 Button("Navigate to Cart Page") {21 // TODO22 }2324 Spacer()25 }26 }27}
Pertama, siapkan font-nya terlebih dahulu, kamu bisa mencarinya di Google Fonts, Dafont, dll. setelah dapat, silakan masukkan di folder "Fonts" pada module Shared.
Kemudian buat file bernama "Extension+Font.swift" yang berisi code berikut:
Extension+Font.swift1import SwiftUI23public extension Font {4 static func shared(_ name: Shared.Fonts, size: CGFloat) -> Font {5 let fontName = name.rawValue.split(separator: ".").first!6 return .custom("\(fontName)", size: size)7 }8}910public enum Fonts: String, CaseIterable {11 case pinokio = "Pinokio.otf"12}1314public extension Fonts {15 static func registerFonts() {16 Fonts.allCases.forEach {17 let font = $0.rawValue.split(separator: ".")18 let fontName = String(font[0])19 let fontExtension = String(font[1])20 registerFont(bundle: .module, fontName: fontName, fontExtension: fontExtension)21 }22 }2324 private static func registerFont(bundle: Bundle, fontName: String, fontExtension: String) {25 guard let fontURL = bundle.url(forResource: fontName, withExtension: fontExtension),26 let fontDataProvider = CGDataProvider(url: fontURL as CFURL),27 let font = CGFont(fontDataProvider) else {28 fatalError("Couldn't create font from filename: \(fontName) with extension \(fontExtension)")29 }3031 var error: Unmanaged<CFError>?3233 CTFontManagerRegisterGraphicsFont(font, &error)34 }35}
Silakan masukkan nama font yang ingin kamu gunakan di dalam enum Fonts.
Dan agar font kita ikut terbawa dalam bundle pada module Shared, silakan tambahkan code berikut pada file "Package.swift":
Package.swift1// swift-tools-version: 5.82// The swift-tools-version declares the minimum version of Swift required to build this package.34import PackageDescription56let package = Package(7 name: "Shared",8 platforms: [.iOS(.v13)],9 products: [10 // Products define the executables and libraries a package produces, and make them visible to other packages.11 .library(12 name: "Shared",13 targets: ["Shared"]),14 ],15 dependencies: [16 // Dependencies declare other packages that this package depends on.17 // .package(url: /* package url */, from: "1.0.0"),18 ],19 targets: [20 // Targets are the basic building blocks of a package. A target can define a module or a test suite.21 // Targets can depend on other targets in this package, and on products in packages this package depends on.22 .target(23 name: "Shared",24 dependencies: [],26 resources: [.process("Fonts")]27 ),28 .testTarget(29 name: "SharedTests",30 dependencies: ["Shared"]),31 ]32)
Nah, untuk menggunakannya, kita tidak akan menambahkannya di "Info.plist" namun kita registrasikan secara programmatically.
Silakan buka file "SceneDelegate.swift" dan tambahkan code berikut:
SceneDelegate.swift1 import Shared23 ...45 guard let windowScene = (scene as? UIWindowScene) else { return }68 Fonts.registerFonts()910 ...
Sekarang, kita sudah bisa menggunakan custom font dari module Shared, seperti ini:
1 Text("Bookstore")2 .font(.shared(.pinokio, size: 48))
Untuk melakukan navigasi secara programmatically, kita akan menggunakan UIKit, karena kita tidak bisa menggunakan NavigationStack untuk melakukan navigasi secara programmatic pada SwiftUI di iOS 13 (minimal iOS 16).
Di dalam module Shared, silakan buat file "Navigator.swift" yang berisi code berikut:
Navigator.swift1import SwiftUI23public final class Navigator: ObservableObject {45 public let navigationController: UINavigationController = UINavigationController()6 public let router: any RouterProtocol7 public let initialRoute: Route89 public init(router: some RouterProtocol, initialRoute: Route) {10 self.router = router11 self.initialRoute = initialRoute12 }1314 public func start() {15 self.navigate(to: initialRoute)16 }1718 public func navigate(to route: Route, transition: Transition = .push, animated: Bool = true) {19 let view = router.getView(for: route).erased.environmentObject(self)20 let viewController = UIHostingController(rootView: view)2122 switch transition {23 case .push:24 self.navigationController.pushViewController(viewController, animated: animated)2526 case .presentSheet:27 viewController.modalPresentationStyle = .formSheet28 self.navigationController.present(viewController, animated: animated)2930 case .presentFullscreen:31 viewController.modalPresentationStyle = .fullScreen32 self.navigationController.present(viewController, animated: animated)33 }34 }3536 public func pop(animated: Bool = true) {37 self.navigationController.popViewController(animated: animated)38 }3940 public func popToRoot(animated: Bool = true) {41 self.navigationController.popToRootViewController(animated: animated)42 }4344 public func dismiss(animated: Bool = true) {45 self.navigationController.dismiss(animated: animated)46 }47}4849extension View {50 var erased: AnyView {51 return AnyView(self)52 }53}545556public enum Transition {57 case push58 case presentSheet59 case presentFullscreen60}6162public protocol RouterProtocol {63 associatedtype V: View6465 @ViewBuilder66 func getView(for route: Route) -> V67}6869// MARK: - Route70public enum Route {71 case main(route: MainRoute)72 case product(route: ProductRoute)73 case cart(route: CartRoute)74}7576public enum MainRoute {77 case homePage78}7980public enum ProductRoute {81 case productPage82}8384public enum CartRoute {85 case cartPage86}
class Navigator conform ke protocol ObservableObject karena kita akan menggunakannya sebagai Environment Object sehingga kita tidak perlu untuk passing object Navigator secara eksplisit di tiap-tiap View.
Silakan buka file "SceneDelegate.swift" dan tambahkan code berikut:
SceneDelegate.swift1import UIKit2import SwiftUI3import Shared45final class SceneDelegate: NSObject, UIWindowSceneDelegate {67 var window: UIWindow?810 private let navigator: Navigator = .init(router: Router(), initialRoute: .main(route: .homePage))1112 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {13 guard let windowScene = (scene as? UIWindowScene) else { return }1415 Fonts.registerFonts()1617 window = UIWindow(windowScene: windowScene)19 window?.rootViewController = navigator.navigationController20 window?.makeKeyAndVisible()2123 navigator.start()24 }25}
dan berikut adalah implementasi dari RouterProtocol:
Router.swift1import SwiftUI2import Shared3import Product4import Cart56struct Router: RouterProtocol {7 @ViewBuilder8 func getView(for route: Route) -> some View {9 switch route {10 case .main(let route):11 MainRouter().getView(for: route)1213 case .product(let route):14 ProductRouter().getView(for: route)1516 case .cart(let route):17 CartRouter().getView(for: route)18 }19 }20}2122struct MainRouter {23 @ViewBuilder24 func getView(for route: MainRoute) -> some View {25 switch route {26 case .homePage:27 HomePage()28 }29 }30}3132struct ProductRouter {33 @ViewBuilder34 func getView(for route: ProductRoute) -> some View {35 switch route {36 case .productPage:37 ProductPage()38 }39 }40}414243struct CartRouter {44 @ViewBuilder45 func getView(for route: CartRoute) -> some View {46 switch route {47 case .cartPage:48 CartPage()49 }50 }51}
Sekarang, kita sudah bisa menggunakan class Navigator untuk melakukan navigasi antar module.
Sebagai contoh, kita akan melakukan navigasi dari module Product ke module Cart.
Pertama, silakan ubah isi file "Package.swift" di module Product menjadi seperti ini:
Package.swift1// swift-tools-version: 5.82// The swift-tools-version declares the minimum version of Swift required to build this package.34import PackageDescription56let package = Package(7 name: "Product",8 platforms: [.iOS(.v13)],9 products: [10 // Products define the executables and libraries a package produces, and make them visible to other packages.11 .library(12 name: "Product",13 targets: ["Product"]),14 ],15 dependencies: [16 // Dependencies declare other packages that this package depends on.17 // .package(url: /* package url */, from: "1.0.0"),19 .package(path: "../Shared")20 ],21 targets: [22 // Targets are the basic building blocks of a package. A target can define a module or a test suite.23 // Targets can depend on other targets in this package, and on products in packages this package depends on.24 .target(25 name: "Product",27 dependencies: ["Shared"]),28 .testTarget(29 name: "ProductTests",30 dependencies: ["Product"]),31 ]32)
lalu untuk navigasinya, caranya seperti ini:
ProductPage.swift1import SwiftUI2import Shared34public struct ProductPage: View {57 @EnvironmentObject var navigator: Navigator89 public init() {}1011 public var body: some View {12 Button("Navigate to Cart Page") {14 navigator.navigate(to: .cart(route: .cartPage))15 }16 .navigationBarTitle("Product Page")17 }18}
Sekarang, kita telah mengetahui apa itu Modularization, kelebihannya, serta bagaimana cara untuk membuat aplikasi iOS yang modular menggunakan SwiftUI dan Swift Package Manager. Jika kita berencana untuk mengubah aplikasi Monolith kita menjadi aplikasi yang lebih modular, ada sebuah guideline menarik dari Essential Developer dalam memecah aplikasi menjadi module-module yang lebih kecil:
Oke mungkin itu saja yang bisa saya bagikan kali ini, kalau kamu merasa artikel ini bermanfaat silakan Like & Share artikel ini ke teman-teman kamu atau jika kamu punya pertanyaan, tulis aja di kolom komentar, Thank you! 😁 🙏
Source code lengkap dari tutorial ini bisa teman-teman akses di sini: https://github.com/alfinsyahruddin/Bookstore
Referensi :
Always open to new ideas. 🕊️
Articles that you might want to read.
Otomatisasi build, testing, screenshot, dan deployment aplikasi iOS ke Testflight & AppStore.
Tutorial Image Classification menggunakan Create ML dan Vision Framework.
Otomatisasi build, testing, dan deployment aplikasi iOS ke Testflight & AppStore.