logo
hero image

SwiftUI Modularization

Sharing image, font, serta cara navigasi pada arsitektur modular di SwiftUI menggunakan Swift Package Manager. (iOS 13)
28 August 2023 · 13 Minutes

Apa itu Modularization?

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.

Kelebihan Modularization

Berikut adalah beberapa kelebihan aplikasi dengan arsitektur yang modular daripada arsitektur monolithic:

1. Build Time lebih cepat ⚡️

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.

2. Code Reusability

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.

3. Kolaborasi Tim

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.

4. Mudah untuk ditest

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.

5. Meningkatkan Produktivitas Developer 🚀

Build time yang lebih cepat, kode yang reusable, lebih sedikit Git Conflict, dan kemudahan dalam menulis Test tentu akan meningkatkan produktivitas kita sebagai Developer. 😄

Architecture Overview

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:

  1. App Module, module utama dari project kita.

  2. Product Module, kita bisa navigasi ke halaman Cart.

  3. Cart Module, kita bisa navigasi ke halaman Product.

  4. 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:

Modular ArchitectureModular Architecture

Sebagai perbandingan, berikut adalah diagram dari aplikasi "Bookstore" jika dalam arsitektur Monolithic:

Monolithic ArchitectureMonolithic Architecture

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.

Circular DependencyCircular Dependency

Setup Project

Sekarang, kita akan mulai membuat aplikasi "Bookstore" kita, berdasarkan arsitektur di atas.

Membuat Workspace

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"

Membuat Project

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".

Membuat ProjectMembuat Project

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.swift
icon
1import UIKit
2
3@main
4final class AppDelegate: NSObject, UIApplicationDelegate {
5
6 func application(
7 _ application: UIApplication,
8 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
9 ) -> Bool {
10 return true
11 }
12
13 func application(
14 _ application: UIApplication,
15 configurationForConnecting connectingSceneSession: UISceneSession,
16 options: UIScene.ConnectionOptions
17 ) -> UISceneConfiguration {
18 let sessionRole = connectingSceneSession.role
19 let sceneConfig = UISceneConfiguration(name: nil, sessionRole: sessionRole)
20 sceneConfig.delegateClass = SceneDelegate.self
21 return sceneConfig
22 }
23}
SceneDelegate.swift
icon
1import UIKit
2import SwiftUI
3
4final class SceneDelegate: NSObject, UIWindowSceneDelegate {
5
6 var window: UIWindow?
7
8 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
9 guard let windowScene = (scene as? UIWindowScene) else { return }
10
11 window = UIWindow(windowScene: windowScene)
12 window?.rootViewController = UIHostingController(rootView: HomePage())
13 window?.makeKeyAndVisible()
14 }
15}
HomePage.swift
icon
1import SwiftUI
2
3struct HomePage: View {
4 var body: some View {
5 VStack {
6 Text("Bookstore")
7
8 Image("store")
9 .resizable()
10 .aspectRatio(contentMode: .fill)
11 .frame(height: 250)
12
13 Button("Navigate to Product Page") {
14 // TODO
15 }
16
17 Button("Navigate to Cart Page") {
18 // TODO
19 }
20
21 Spacer()
22 }
23 }
24}

Membuat Modules

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.swift
icon
1// swift-tools-version: 5.8
2// The swift-tools-version declares the minimum version of Swift required to build this package.
3
4import PackageDescription
5
6let 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.

Frameworks, Library, and Embedded ContentFrameworks, Library, and Embedded Content

Sharing Image

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.swift
icon
1import SwiftUI
2
3public extension Image {
4 init(shared name: String) {
5 self.init(name, bundle: .module)
6 }
7}

Untuk menggunakannya, caranya seperti ini:

HomePage.swift
icon
1import SwiftUI
3import Shared
4
5struct HomePage: View {
6 var body: some View {
7 VStack {
8 Text("Bookstore")
9
11 Image(shared: "store")
12 .resizable()
13 .aspectRatio(contentMode: .fill)
14 .frame(height: 250)
15
16 Button("Navigate to Product Page") {
17 // TODO
18 }
19
20 Button("Navigate to Cart Page") {
21 // TODO
22 }
23
24 Spacer()
25 }
26 }
27}

Sharing Font

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.swift
icon
1import SwiftUI
2
3public 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}
9
10public enum Fonts: String, CaseIterable {
11 case pinokio = "Pinokio.otf"
12}
13
14public 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 }
23
24 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 }
30
31 var error: Unmanaged<CFError>?
32
33 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.swift
icon
1// swift-tools-version: 5.8
2// The swift-tools-version declares the minimum version of Swift required to build this package.
3
4import PackageDescription
5
6let 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.swift
icon
1 import Shared
2
3 ...
4
5 guard let windowScene = (scene as? UIWindowScene) else { return }
6
8 Fonts.registerFonts()
9
10 ...

Sekarang, kita sudah bisa menggunakan custom font dari module Shared, seperti ini:

icon
1 Text("Bookstore")
2 .font(.shared(.pinokio, size: 48))

Navigasi antar module

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.swift
icon
1import SwiftUI
2
3public final class Navigator: ObservableObject {
4
5 public let navigationController: UINavigationController = UINavigationController()
6 public let router: any RouterProtocol
7 public let initialRoute: Route
8
9 public init(router: some RouterProtocol, initialRoute: Route) {
10 self.router = router
11 self.initialRoute = initialRoute
12 }
13
14 public func start() {
15 self.navigate(to: initialRoute)
16 }
17
18 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)
21
22 switch transition {
23 case .push:
24 self.navigationController.pushViewController(viewController, animated: animated)
25
26 case .presentSheet:
27 viewController.modalPresentationStyle = .formSheet
28 self.navigationController.present(viewController, animated: animated)
29
30 case .presentFullscreen:
31 viewController.modalPresentationStyle = .fullScreen
32 self.navigationController.present(viewController, animated: animated)
33 }
34 }
35
36 public func pop(animated: Bool = true) {
37 self.navigationController.popViewController(animated: animated)
38 }
39
40 public func popToRoot(animated: Bool = true) {
41 self.navigationController.popToRootViewController(animated: animated)
42 }
43
44 public func dismiss(animated: Bool = true) {
45 self.navigationController.dismiss(animated: animated)
46 }
47}
48
49extension View {
50 var erased: AnyView {
51 return AnyView(self)
52 }
53}
54
55
56public enum Transition {
57 case push
58 case presentSheet
59 case presentFullscreen
60}
61
62public protocol RouterProtocol {
63 associatedtype V: View
64
65 @ViewBuilder
66 func getView(for route: Route) -> V
67}
68
69// MARK: - Route
70public enum Route {
71 case main(route: MainRoute)
72 case product(route: ProductRoute)
73 case cart(route: CartRoute)
74}
75
76public enum MainRoute {
77 case homePage
78}
79
80public enum ProductRoute {
81 case productPage
82}
83
84public enum CartRoute {
85 case cartPage
86}

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.swift
icon
1import UIKit
2import SwiftUI
3import Shared
4
5final class SceneDelegate: NSObject, UIWindowSceneDelegate {
6
7 var window: UIWindow?
8
10 private let navigator: Navigator = .init(router: Router(), initialRoute: .main(route: .homePage))
11
12 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
13 guard let windowScene = (scene as? UIWindowScene) else { return }
14
15 Fonts.registerFonts()
16
17 window = UIWindow(windowScene: windowScene)
19 window?.rootViewController = navigator.navigationController
20 window?.makeKeyAndVisible()
21
23 navigator.start()
24 }
25}

dan berikut adalah implementasi dari RouterProtocol:

Router.swift
icon
1import SwiftUI
2import Shared
3import Product
4import Cart
5
6struct Router: RouterProtocol {
7 @ViewBuilder
8 func getView(for route: Route) -> some View {
9 switch route {
10 case .main(let route):
11 MainRouter().getView(for: route)
12
13 case .product(let route):
14 ProductRouter().getView(for: route)
15
16 case .cart(let route):
17 CartRouter().getView(for: route)
18 }
19 }
20}
21
22struct MainRouter {
23 @ViewBuilder
24 func getView(for route: MainRoute) -> some View {
25 switch route {
26 case .homePage:
27 HomePage()
28 }
29 }
30}
31
32struct ProductRouter {
33 @ViewBuilder
34 func getView(for route: ProductRoute) -> some View {
35 switch route {
36 case .productPage:
37 ProductPage()
38 }
39 }
40}
41
42
43struct CartRouter {
44 @ViewBuilder
45 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.swift
icon
1// swift-tools-version: 5.8
2// The swift-tools-version declares the minimum version of Swift required to build this package.
3
4import PackageDescription
5
6let 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.swift
icon
1import SwiftUI
2import Shared
3
4public struct ProductPage: View {
5
7 @EnvironmentObject var navigator: Navigator
8
9 public init() {}
10
11 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}

Bookstore AppBookstore App


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:

Keep together what changes together. Separate what doesn't change together.
Essential Developer

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 :

iOS Development
SwiftUI
Modularization
SPM

Written by :
Alfin Syahruddin
Developer · Stock Trader · Libertarian · Freethinker

Always open to new ideas. 🕊️

Loading...

Related articles

Articles that you might want to read.

hero image
CI/CD aplikasi iOS dengan Fastlane dan Github Actions

Otomatisasi build, testing, screenshot, dan deployment aplikasi iOS ke Testflight & AppStore.

24 September 2023 · 9 Minutes
iOS Development
CI/CD
Fastlane
Github Actions
hero image
Image Classification pada iOS

Tutorial Image Classification menggunakan Create ML dan Vision Framework.

18 June 2023 · 7 Minutes
iOS Development
Swift
Vision
Create ML
Artificial Intelligence
hero image
CI/CD aplikasi iOS dengan Xcode Cloud

Otomatisasi build, testing, dan deployment aplikasi iOS ke Testflight & AppStore.

27 January 2024 · 8 Minutes
iOS Development
CI/CD
Xcode Cloud
All rights reserved © Alfin Syahruddin · 2019
RSS Feed