Section 4/202 menit

4. @MainActor

4. @MainActor

Teori: Global Actor dan Main Thread Contract

@MainActor adalah global actor — sebuah singleton actor yang merepresentasikan main thread. Ini bukan sekadar "jalankan di main thread" — ini adalah isolation domain yang di-enforce compiler.

Mengapa main thread istimewa?

UIKit dan AppKit tidak thread-safe. Mereka berkomunikasi dengan window server (daemon OS yang mengatur tampilan visual) melalui main run loop. Jika kamu update UI dari background thread, dua hal bisa terjadi:

  1. Corrupted UI state (karena concurrent writes ke UIKit internal state)
  2. Crash dengan "UI API called from background thread" di debug

@MainActor mengkodekan invariant ini ke dalam type system: "kode ini dijamin berjalan di main thread, compiler memverifikasinya."

Cara Kerja

swift
// @MainActor class: semua property dan method diisolasi ke main thread
@MainActor
class UserListViewModel: ObservableObject {
    @Published var users: [User] = []      // dijamin di main thread
    @Published var isLoading = false       // dijamin di main thread

    func loadUsers() async {
        isLoading = true  // di main thread ✓

        // await: hop ke background untuk fetch
        let fetched = await userRepository.fetchAll()

        // Kembali ke main thread setelah await
        users = fetched        // di main thread ✓
        isLoading = false      // di main thread ✓
    }
}

Memanggil @MainActor dari Background

swift
actor DataProcessor {
    weak var delegate: (any DataProcessorDelegate)?

    func process(data: Data) async {
        let result = await heavyComputation(data)

        // Crossing boundary: actor → @MainActor
        // Butuh await karena berpindah isolation domain
        await delegate?.processingCompleted(result: result)
    }
}

@MainActor
protocol DataProcessorDelegate: AnyObject {
    func processingCompleted(result: ProcessingResult)
}

MainActor.run — One-off Hop

swift
// Untuk kode yang sebagian besar di background tapi sesekali butuh main thread
actor BackgroundWorker {
    func performWork() async {
        let data = await fetchFromNetwork()
        let processed = processLocally(data)

        // One-off update ke UI tanpa harus mark seluruh fungsi @MainActor
        await MainActor.run {
            NotificationCenter.default.post(
                name: .dataProcessed,
                object: processed
            )
        }
    }
}

Kapan Menggunakan @MainActor

Gunakan @MainActor ketika:

  • ViewController, View, UIView subclass
  • ViewModel yang mengelola @Published properties
  • Delegate methods yang update UI
  • Timer atau notification handler yang update UI
  • AppDelegate, SceneDelegate

Jangan gunakan @MainActor ketika:

  • Business logic yang tidak menyentuh UI — ini akan menyebabkan UI stutter jika logic berat
  • Network layer, database layer, heavy computation
  • Fungsi yang sering dipanggil — setiap call membutuhkan hop ke main thread

Real Use Case: ViewModel dengan Loading State

swift
@MainActor
final class ArticleListViewModel: ObservableObject {
    enum ViewState {
        case idle
        case loading
        case loaded([Article])
        case error(String)
    }

    @Published private(set) var state: ViewState = .idle
    @Published private(set) var searchQuery = ""

    private let repository: ArticleRepository
    private var searchTask: Task<Void, Never>?

    init(repository: ArticleRepository) {
        self.repository = repository
    }

    func load() {
        guard case .idle = state else { return }
        fetchArticles()
    }

    func search(query: String) {
        searchQuery = query
        searchTask?.cancel()

        guard !query.isEmpty else {
            fetchArticles()
            return
        }

        // Debounce: tunggu 300ms setelah user berhenti ketik
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            await fetchArticles(query: query)
        }
    }

    private func fetchArticles(query: String? = nil) {
        state = .loading

        Task {
            do {
                let articles: [Article]
                if let q = query {
                    articles = try await repository.search(query: q)
                } else {
                    articles = try await repository.fetchAll()
                }
                // Kembali ke @MainActor setelah await — otomatis
                state = .loaded(articles)
            } catch is CancellationError {
                state = .idle
            } catch {
                state = .error(error.localizedDescription)
            }
        }
    }
}

struct Article: Sendable, Identifiable {
    let id: String
    let title: String
    let content: String
}