Tìm một vòng giữ tham chiếu bằng Memory Graph Debugger
Triệu chứng là bộ nhớ tăng đều khi người dùng điều hướng: khoảng 4MB mỗi lần đẩy một màn hình chi tiết, không bao giờ được giải phóng khi quay lại. Hai mươi lần đẩy là ứng dụng bị hệ thống đá ra.
Tôi biết đó là một vòng giữ tham chiếu. Việc tìm ra cái nào mất năm phút với đúng công cụ, còn nếu ngồi đọc code thì đã mất cả buổi chiều.
Công cụ
Chạy ứng dụng, đi vào trạng thái bị rò, rồi bấm nút Debug Memory Graph trên thanh debug — cái biểu tượng trông như ba nút nối với nhau.
Xcode tạm dừng ứng dụng và dựng một đồ thị mọi đối tượng đang sống cùng thứ đang giữ chúng sống. Cột bên trái liệt kê các thực thể theo class; chọn một cái sẽ vẽ ra các quan hệ giữ tham chiếu của nó.
Hai thiết lập làm cho việc này dùng được, cả hai nằm trong phần diagnostics của scheme:
- Malloc Stack Logging — bật, đặt thành “Live Allocations Only”. Không có nó thì bạn có đồ thị nhưng không có stack trace cho biết mỗi đối tượng được cấp phát ở đâu, mà stack trace chiếm một nửa giá trị.
- Zombie Objects — tắt trong lúc làm việc này. Nó gây nhiễu.
Quy trình
1. Đi vào màn hình, quay ra, rồi chụp đồ thị.
Nếu cái view controller bạn vừa đóng vẫn còn trong cột bên trái thì nó bị rò. Đó là toàn bộ phép thử, và nó mất mười giây.
2. Lọc theo tên class của bạn.
Cột bên trái có ô lọc ở dưới cùng. Gõ DetailViewController. Nếu bạn thấy nhiều hơn một thực thể sau
khi quay ra — hoặc thấy bất kỳ thực thể nào, trong khi lẽ ra phải là không có — thì bạn đã tìm ra
chỗ rò.
3. Chọn nó rồi đọc đồ thị.
Đồ thị hiện các mũi tên trỏ vào đối tượng đang chọn. Mỗi mũi tên là một thứ đang giữ tham chiếu mạnh. Xcode đánh dấu các vòng bằng một huy hiệu chấm than màu tím, thứ thường chỉ thẳng vào câu trả lời.
Mẹo
Phần hữu ích nhất của đồ thị không phải cái dấu vòng — mà là nhãn trên các mũi tên. Xcode chú
thích mỗi mũi tên bằng tên thuộc tính đang giữ tham chiếu. Đọc thấy “closure captured variable
self” hay “viewModel.onUpdate” là biết chính xác dòng nào cần xem.
Chỗ rò của tôi là gì
final class DetailViewModel {
var onUpdate: (() -> Void)?
func start() {
service.subscribe { [weak self] value in
self?.value = value
self?.onUpdate?()
}
}
}
final class DetailViewController: UIViewController {
let viewModel = DetailViewModel()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.onUpdate = {
self.render() // ← bắt self một cách mạnh
}
}
}
Cái [weak self] trong view model là đúng và chẳng liên quan. Cái vòng nằm ở chỗ kia:
DetailViewController → viewModel → closure onUpdate → DetailViewController
Tôi đã cẩn thận với cái closure trông có vẻ nguy hiểm — cái đi sang một service — và hoàn toàn bất cẩn với cái nằm cách đúng ba dòng so với thuộc tính mà nó được gán vào.
Cách chữa là một từ:
viewModel.onUpdate = { [weak self] in
self?.render()
}
4MB mỗi lần đẩy trở thành không.
Phép kiểm tra bắt được những thứ này sớm hơn
Hãy thêm một deinit có in ra, vào mọi view controller và view model, ít nhất là trong lúc phát
triển:
#if DEBUG
deinit { print("deinit \(type(of: self))") }
#endif
Cách đó thô thiển và nó hiệu quả hơn mọi thứ khác tôi từng thử. Đi vào rồi đi ra một màn hình; nếu không có gì in ra thì có thứ gì đó bị rò, và bạn biết trong vài giây thay vì biết khi các cảnh báo bộ nhớ bắt đầu nổ.
Giờ tôi coi một dòng deinit không xuất hiện y như coi một bài test fail.
Bốn hình dạng gây ra gần như tất cả
Một thuộc tính closure bắt lấy chủ của nó. Cái ở trên. Bất cứ thứ gì được gán vào một thuộc tính closure lưu trữ trên một đối tượng mà closure đó cũng tham chiếu tới.
Một delegate khai báo strong. var delegate: SomeDelegate? thay vì
weak var delegate: (any SomeDelegate)?. Hiếm trong code bạn viết, phổ biến trong code bạn thừa kế.
Một sink của Combine bắt self, được lưu trong self. self → cancellables → subscription → closure → self. Cách chữa là [weak self] hoặc assign(to: &$published).
Một Timer hay observer NotificationCenter có target mạnh. Timer.scheduledTimer(target: self,…) giữ tham chiếu tới target, và cái timer thì được run loop giữ. Chẳng gì được giải phóng cho
tới khi bạn vô hiệu hóa nó — và deinit sẽ không bao giờ chạy, nên bạn không vô hiệu hóa nó ở đó
được.
Cái cuối là khó chịu nhất, vì cách chữa thông thường không dùng được: bạn không thể vô hiệu hóa một
timer trong deinit nếu chính cái timer là thứ đang ngăn deinit xảy ra. Nó phải được vô hiệu hóa
trong viewWillDisappear hoặc trong một hàm dọn dẹp tường minh.
Vì sao đồ thị ăn đứt việc đọc code
Đọc code để tìm vòng giữ tham chiếu nghĩa là giữ một sơ đồ sở hữu trong đầu trong khi kiểm tra ngữ nghĩa bắt biến của từng closure, và sai đúng một lần là đủ để bỏ sót. Tôi đã làm thế suốt một tiếng trước khi mở đồ thị lên, và tôi đã nhìn thẳng vào cái closure bị rò mà không thấy — vì nó trông chẳng nguy hiểm gì.
Đồ thị thì không có quan điểm gì về việc closure nào trông nguy hiểm. Nó chỉ cho bạn thấy các mũi tên.