MainActor và điều gì xảy ra với UI trên Main Thread
iOS & Swift . TutorialsContents
Chào mừng bạn đến với Fx Studio. Hành trình của chúng ta để khám phá về thế giới Concurrency vẫn còn khá là dài. Và bài viết này sẽ tiếp tục đưa tới cho bạn một khái niệm mới trong Swift 5.5, đó là MainActor. Bên cạnh đó, bài viết là sự kết hợp với vấn đề kinh điển khi tương tác với UI trên Main Thread.
Nếu bạn chưa biết về MainActor cũng không sao. Nhưng bạn cần phải biết về Actor, một khái niệm mới trong Swift 5.5 trước đã. Bạn có thể truy cập link dưới đây để đọc.
Còn nếu mọi thứ đã ổn rồi, thì …
Bắt đầu thôi!
Chuẩn bị
Sự kiện lớn nhất của Apple vào đầu tháng 6/2021 vừa qua là WWDC 21. Đã có rất nhiều thứ mới mẻ được giới thiệu trong Swift 5.5. Chúng ta đã tìm hiểu về Async/Await, sau đó là Actor. Và bây giờ, chúng ta sẽ tiếp tục tìm hiểu khái niệm mới MainActor.
Do đó, bạn cần phải chuẩn bị như sau:
-
- Swift 5.5
- Xcode 13 (beta)
Thật là khó khi Xcode 13 khá là nặng, nên có thể Macbook của bạn sẽ chạy không nỗi. Nên bạn cũng phải cân nhắc khi cập nhật macOS và Xcode 13 nhoé.
Về mặt lý thuyết, những gì mình trình bày ở dưới đây sẽ liên quan tới 2 khái niệm trình bày 2 vấn đề gặp phải khi lập trình bất đồng bộ.
Về mặt demo, ta sẽ demo với một Project iOS nào đó cũng được. Giao diện cực kì đơn giản thôi. Bạn không cần lo lắng về nó quá.
Main Thread và UI
Main Thread là gì?
Đây là một câu hỏi khá kinh điển. Cũng là một vấn đề khá cơ bản đối với một dev bất kì. Nhưng đại đa số dev lại không hiểu về nó. Dẫn tới nhiều sai lầm thuộc dạng kinh điển. Để bắt đầu, chúng ta sẽ bổ túc kiến thức một tí nhoé.
Trong ứng dụng iOS hay bất cứ ứng dụng nào, khi chương trình được khởi chạy, hệ thống sẽ khởi động một Thread ban đầu cùng với một tiến trình thực thi một tác vụ nào đó. Thì Thread đó chính là Main Thread.
Khi tiến trình thực hiện xong nhiệm vụ của nó thì Main Thread sẽ kết thúc. Cũng như chương trình của chúng ta sẽ kết thúc theo. Đó là lý do, mà bạn lúc học C/C++ mãi không hình dung ra được. Cho tới khi, bạn bắt đầu nhúng tay vào việc tạo các ứng dụng có giao diện.
Đối với ứng dụng iOS nói riêng, nó thuộc hệ ứng dụng với giao điện đồ hoạ người dùng. Do đó, Main Thread sẽ chịu trách nhiệm xử lý giao diện. Nên nhiều lúc, chúng ta còn bắt gặp khái niệm tương tự là Main UI, UI Thread … hoặc khi lười thì nói chữ Main là ám chỉ cho toàn bộ khái niệm ở trên.
DispatchQueue Main
DispatchQueue.main.async { ... }
Chắc chắn cho dù bạn là dev iOS mới vào nghề hay lâu năm, thì đây là câu thần chú của bạn khi cần giải quyết một vấn đề gì đó trên Main Thread. Tâm lý của bạn lúc đó sẽ là …
Cho nó chắc chắn thôi!
Ta hãy thử một ví dụ cực kì kinh điển nhoé. Bạn hãy xem qua đoạn code sau:
class HomeViewController: UIViewController { // MARK: Properties @IBOutlet weak var imageView: UIImageView! // MARK: Life cycle override func viewDidLoad() { super.viewDidLoad() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) DispatchQueue.global(qos: .userInteractive).async { print("🔵 - MainThread is \(OperationQueue.mainQueueChecker())") self.changeImageBackground(.blue, title: "🔵") } } // MARK: Actions @IBAction func start(_ sender: Any) { } // MARK: Function func changeImageBackground(_ color: UIColor, title: String) { print("\(title) - MainThread is \(OperationQueue.mainQueueChecker())") imageView.backgroundColor = color } } // Check MainThread extension OperationQueue { static func mainQueueChecker() -> String { return Self.current == Self.main ? "✅" : "❌" } }
Trong đó, nhiệm vụ của đoạn code sẽ là cố gắng thay đổi màu nền của UIImageView tại function viewWillAppear
của ViewController. Điểm chú ý, chính là tao tác của chúng ta không ở trên Main Thread. Bạn hãy thử build ứng dụng và xem kết quả nhoé.
Thông điệp Xcode đưa cho chúng ta khá đơn giản, ngắn gọn và dễ hiểu.
must be used from main thread only
Lỗi ở trên sẽ có thể xảy ra khi bạn tương tác với UI ở một Thread khác. Và bạn rất có thể gặp phải vấn đề này khi làm …
- Tương tác với API
- Query Database
- Đọc ghi file
- Tương tác với các thiết bị ngoại vi
- …
Do đó, điều đầu tiên bạn cần nhớ là: “chỉ dùng Main Thread khi tiến hành cập nhật giao diện. Và chỉ có Main Thread mà thôi”. Để giải quyết vấn đề này lại là khá đơn giản, triệu hồi câu thần chú ở trên nhoé.
func changeImageBackground(_ color: UIColor, title: String) { DispatchQueue.main.async { print("\(title) - MainThread is \(OperationQueue.mainQueueChecker())") self.imageView.backgroundColor = color } }
Multi Threads
Bây giờ, ta thử nâng độ khó của vấn đề lên với việc kết hợp như sau:
- Cho cùng một lúc từ nhiều Thread triệu hồi việc cập nhật giao diện
- Function xử lý cập nhật giao diện vẫn chạy ở Main Thread
Bạn tham khảo đoạn code sau nhoé.
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) DispatchQueue.concurrentPerform(iterations: 10) { i in if i % 2 == 0 { print("🔵 #\(i) - MainThread is \(OperationQueue.mainQueueChecker())") changeImageBackground(.blue, title: "🔵") } else { print("🔴 #\(i) - MainThread is \(OperationQueue.mainQueueChecker())") changeImageBackground(.red, title: "🔴") } } }
Trong đó,
.concurrentPerform
được dùng để triệu hồi các Thread cùng một lúc với nhau. Số lượng hay số lần triệu hồi sẽ tuỳ thuộc hệ thống quyết định.- Theo chẳn/lẽ mà ta sẽ thay đổi màn nền theo xanh hoặc đỏ.
- Function
changeImageBackground
vẫn triệu hồiDispatchQueue.main
bên trong đó.
Bạn hãy thực thi project nhiều lần và cảm nhận kết quả nha. Trong đó, bạn sẽ bắt gặp một lần như thế này.
Vấn đề trên sẽ là:
- Nhiều luồng chạy đồng thời với nhau, sẽ có Thread Main và khác Main được triệu gọi.
- Sẽ có các độ ưu tiên giữa các Thread khác nhau.
- Thời điểm cập nhật UI cũng khác nhau.
- Có thể triệu hồi chạy trước, nhưng lại về sau.
Tất cả dẫn đến việc bạn khó lòng kiểm soát được kết quả như ý đồ ban đầu của mình. Ví dụ trên đã mình hoạ cho vấn đề này. Mặc dù, chương trình không crash
. Tuy nhiên, ta không đảm bảo 100% là kết quả hiển thị ra đúng.
Người ta sẽ gọi vấn đề đó với cái tên đơn giản hơn, là …
BUG
Data Race trên Main Thread
Kết quả phần ở trên, chính là Data Race trên Main Thread. Khi phần dữ liệu bị lôi vào cuộc chay đua và nó lại chính là giao diện của bạn. Cũng theo kinh nghiệm đúc kết từ nhiều bài trước. Chúng ta phải giải quyết bài toán này bằng những gì có được. Hãy bắt đầu với …
Sync
Đồng bộ là phương pháp đầu tiên mà bạn sẽ nghĩ tới. Code tham khảo như sau:
func changeImageBackground(_ color: UIColor, title: String) { DispatchQueue.main.sync { print("\(title) - MainThread is \(OperationQueue.mainQueueChecker())") self.imageView.backgroundColor = color } }
Với suy nghĩ rằng: “chúng ta sẽ thực hiện công việc một cách đồng bộ để đảm bảo thứ tự cập nhật UI“. Nhưng với kiểu dùng như trên thì bạn đang block
chính Main Thread bằng chính Main Thread. Chương trình của bạn sẽ crash
ngay.
Nghe qua thì thốn quá. Và hậu quả cũng rất thốn.
Ta lại sử dụng phương pháp Lock Queue với một Serial Queue xem sao. Tham khảo code ví dụ tiếp nha.
let serialQueue = DispatchQueue(label: "serialQueue") func changeImageBackground(_ color: UIColor, title: String) { DispatchQueue.main.async { self.serialQueue.sync { print("\(title) - MainThread is \(OperationQueue.mainQueueChecker())") self.imageView.backgroundColor = color } } }
Trong đó:
- Sử dụng một Serial Queue để block Thread lại và thực hiện tiến trình trong đó
- Main Thread vẫn tương tác với kiểu bất đồng bộ
.async
Nhưng mà bạn có ngờ đâu, rằng
Main Thread cũng chính là một Serial Queue.
Đắng cay phải không nào. Với 2 Serial Queue thì
async
chạy trongasync
thì sẽ bịcrash
hoặc kết quả không đúng mong đợi.sync
chạy trongasync
thì không khác đểmain.async
chạy một mình.
Đúng là …
ĐỜI THẬT!
Async/Await
Thất bại với đồng bộ, ta sẽ áp dụng tinh hoa của Swift 5.5, chính là async/await. Để triệu tiêu vấn đề này. Nhưng mà vì sử dụng async/await, nên bạn cần phải cập nhật lại function tương tác với UI một chút. Thêm async
vào khai báo function, để giúp đó có thể tương tác trên bất đồng bộ.
func changeImageBackground(_ color: UIColor, title: String) async { DispatchQueue.main.async { print("\(title) - MainThread is \(OperationQueue.mainQueueChecker())") self.imageView.backgroundColor = color } }
Sau đó, bạn sẽ triệu hồi cú pháp async { ... }
với mục đích chạy các tác vụ cập nhật UI một cách bất đồng bộ. Xem code ví dụ nha.
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) async { for i in 0..<10 { print("🔵 #\(i) - MainThread is \(OperationQueue.mainQueueChecker())") await changeImageBackground(.blue, title: "🔵") } } async { for i in 0..<10 { print("🔴 #\(i) - MainThread is \(OperationQueue.mainQueueChecker())") await changeImageBackground(.red, title: "🔴") } } }
Build và cảm nhật kết quả nha.
Mặc dù, kết quả chạy nhiều lần và đúng với ý đồ của chúng ta. Nhưng mà tất cả đều chạy ở Main Thread. Bạn xem qua console thì cũng thấy được.
Nguyên nhân,
- Khi dùng
async
thì nó sẽ chạy bất đồng bộ tại Thread nó đang đứng. - Với triệu hồi
await
thì nó sẽ dùng chờ tác vụ đó hoàn thành. Sau đó, mới chạy tiếp vòng lặp khác.
Về bản chất, với cách dùng này sẽ đưa mọi thứ về đồng bộ & cùng trên Main Thread. Tuy nhiên, khác với ý đồ ban đầu của mình là chạy với nhiều thứ Thread khác nhau. Lại nhưng …
Có một điều có ý nghĩa rất lớn.
Ta có thể loại bỏ DispatchQueue.main
ở function cập nhật UI. Bạn không phải lo lắng về việc quên sử dụng DispatchQueue.main
khi muốn cập nhật UI trong làm việc bất đồng bộ.
Detach
Tiếp theo mình sẽ giới thiệu thêm cho bạn một cú pháp. Đó là
detach { ... }
Nó giúp cho bạn tách ra một phần nhỏ với tiến trình đang chạy, với tác vụ được thực hiện trong closure của nó. Nôm na, bạn từ Main Thread với sử dụng detach
, thì bạn có thể triệu hồi được Thread khác.
Và muốn gọi function khác trong detach
, bạn hãy dùng thêm cú pháp await
ở trước. Mặc định tất cả chúng nó là bất đồng bộ.
Ngoài ra, bạn có thể thêm các thứ tự ưu tiên như của DispatchQueue. Để quyết định detach
sẽ chạy như thế nào đồi với Thread hiện tại.
Bạn xem lại ví dụ sau nha
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) detach { [self] in print("🔵 #\(0) - MainThread is \(OperationQueue.mainQueueChecker())") await changeImageBackground(.blue, title: "🔵") } detach { [self] in print("🔴 #\(0) - MainThread is \(OperationQueue.mainQueueChecker())") await changeImageBackground(.red, title: "🔴") } } func changeImageBackground(_ color: UIColor, title: String) async { print("\(title) - MainThread is \(OperationQueue.mainQueueChecker())") self.imageView.backgroundColor = color }
Trong đó,
- Tách từ Main Thread với cú pháp
detach
và để độ ưu tiên là mặc định. - Nếu bạn muốn thay đổi độ ưu tiên, thì hãy thêm tham số
priority
vàodetach
nha. - Function cập nhật giao diện sẽ có
async
Build project và chạy thử, bạn sẽ thấy lại vấn đề gặp phải khi chạy ở nhiều Thread khác Main để cập nhật giao diện.
- Nếu function cập nhật UI có
async
. Xác định rằng nó sẽ thực hiện tiến trình bất đồng bộ trên Thread hiện tại, với Thread khác Main thì nó sẽ chạy ở Thread khác Main. (lỗi) - Nếu function cập nhật UI không có
async
. Vớiawait
triệu hồi nó, tức bạn sẽ chờ function cập nhật UI thực hiện ở Main Thread tại Thread khác Main.
Bạn hãy thử bỏ đi async
trong khai báo function cập nhật UI đó. Build lại project và cảm nhận kết quả nhoé. Bạn sẽ thấy điều kì diệu xảy ra. Nhưng mà …
Loạn não cmnr!
MainActor
Nếu tới được đây, mình chúc mừng bạn đã sống sót qua Main Thread với đủ thứ xảy ra. Và đủ thứ điều mà lập trình viên có thể làm việc với nó. Khá là hại não và đau đầu khi chúng ta tương tác với giao diện theo cách bất đồng bộ.
Khái niệm
Nhưng, Apple đã không phụ lòng chúng ta. Với Swift 5.5 sinh ra đủ thứ hại não. Để giảm bớt cơn đau đầu của dev, ta lại có thêm một khái niệm là MainActor. Hay còn gọi là nhân vật chính, lúc này nó mới xuất hiện.
Về khái niệm, MainActor là một tác nhân toàn cục (global actor). Nó giúp chúng ta an toàn trong việc tương tác với các biến cục bộ trong tương tác bất đồng bộ. Các tác nhân này sẽ chỉ được truy cập và thay đổi giá trị trạng thái tại Main Thread hay Thread UI.
MainActor cũng là giải pháp loại bỏ đi Data Race cho biến toàn cục hay biến tĩnh. Cho phép truy cập tới các biến đó một cách an toàn.
Về cách sử dụng thì khá đơn giản. Bạn chỉ cần thêm từ khoá @MainActor
vào trước khai báo class / property / function / variable … là được. Chương trình sẽ xác nhận tụi nó chỉ được phép cập nhật giá trị tại Main Thread.
Tóm tắt lại:
- Cách hiểu đơn giản nhất là đồng chí MainActor như là một Singleton thứ thiệt.
- Cho phép bạn thêm vào các method hay properties
- Tụi nó chỉ được truy cập ở Main Thread
- Cách sử dụng:
- Thêm từ khoá @MainActor vào trước thứ bạn cần thêm
- Nó là một wrapper properties cho struct MainActor
Cập nhật giao diện trên nhiều thread
Quay về ví dụ và ý đồ của chúng ta từ đầu bài viết tới chừ. Ta sẽ giải quyết chúng nó trong vòng 1 nốt nhạc. Bạn hãy thêm từ khoá @MainActor
vào trước function cập nhật UI. Và vẫn giữa nguyên detach
để có được đa luồng từ Main Thread.
Xem ví dụ code như sau:
@MainActor func changeImageBackground(_ color: UIColor, title: String) { print("\(title) - MainThread is \(OperationQueue.mainQueueChecker())") self.imageView.backgroundColor = color }
Trong đó:
- Bạn bỏ đi
async
trong khai báo function. Vì MainActor cũng là một Actor, nó xác định function đó sẽ là chạy bất đồng bộ rồi.
Build lại chương trình vào bạn sẽ thấy kết quả lúc này khá đơn giản. Hoặc bạn có thể nâng cấp lên việc triệu hồi đồng thời các Thread để kiểu tra xem giao diện có bị Data Race hay không.
DispatchQueue.concurrentPerform(iterations: 100) { i in detach { if i % 2 == 0 { print("🔵 #\(i) - MainThread is \(OperationQueue.mainQueueChecker())") await self.changeImageBackground(.blue, title: "🔵") } else { print("🔴 #\(i) - MainThread is \(OperationQueue.mainQueueChecker())") await self.changeImageBackground(.red, title: "🔴") } } }
Bạn hãy buid lại project và chill cùng nó nhoé!. Chúc bạn thành công.
Tạm kết
- Biết được những gì xảy ra khi cập nhật giao hiện tại Main Thread
- Vấn đề Data Race & hành trình xử lý nó tại Main Thread
- Khái niệm về MainActor và cách dùng cơ bản đầu tiên với @MainActor
Mặc dù, chúng ta đã giải quyết vấn đề Data Race xảy ra với UI trên Main Thread bằng MainActor. Tất nhiên, chúng ta còn nhiều điều cần khám thêm về MainActor nữa. Và bài viết sau, mình sẽ nói nhiều hơn về những gì xảy ra với Data trên Main Thread và giải pháp mà MainActor đem lại cho bạn.
Okay! Tới đây, mình xin kết thúc bài viết giới thiệu về MainActor trong Swift 5.5 . Nếu có gì thắc mắc hay góp ý cho mình thì bạn có thể để lại bình luận hoặc gởi email theo trang Contact.
- Bài viết tiếp theo tại đây.
- Bạn có thể checkout code tại đây. (file
HomeViewController.swift
nha bạn)
Cảm ơn bạn đã đọc bài viết này!
Written by chuotfx
Hãy ngồi xuống, uống miếng bánh và ăn miếng trà. Chúng ta cùng nhau đàm đạo về đời, về code nhóe!
Leave a Reply Cancel reply
Fan page
Tags
Recent Posts
- Charles Proxy – Phần 1 : Giới thiệu, cài đặt và cấu hình
- Complete Concurrency với Swift 6
- 300 Bài code thiếu nhi bằng Python – Ebook
- Builder Pattern trong 10 phút
- Observer Pattern trong 10 phút
- Memento Pattern trong 10 phút
- Strategy Pattern trong 10 phút
- Automatic Reference Counting (ARC) trong 10 phút
- Autoresizing Masks trong 10 phút
- Regular Expression (Regex) trong Swift
Archives
- September 2024 (1)
- July 2024 (1)
- June 2024 (1)
- May 2024 (4)
- April 2024 (2)
- March 2024 (5)
- January 2024 (4)
- February 2023 (1)
- January 2023 (2)
- November 2022 (2)
- October 2022 (1)
- September 2022 (5)
- August 2022 (6)
- July 2022 (7)
- June 2022 (8)
- May 2022 (5)
- April 2022 (1)
- March 2022 (3)
- February 2022 (5)
- January 2022 (4)
- December 2021 (6)
- November 2021 (8)
- October 2021 (8)
- September 2021 (8)
- August 2021 (8)
- July 2021 (9)
- June 2021 (8)
- May 2021 (7)
- April 2021 (11)
- March 2021 (12)
- February 2021 (3)
- January 2021 (3)
- December 2020 (3)
- November 2020 (9)
- October 2020 (7)
- September 2020 (17)
- August 2020 (1)
- July 2020 (3)
- June 2020 (1)
- May 2020 (2)
- April 2020 (3)
- March 2020 (20)
- February 2020 (5)
- January 2020 (2)
- December 2019 (12)
- November 2019 (12)
- October 2019 (19)
- September 2019 (17)
- August 2019 (10)