Thời gian tôi dành cho việc gỡ lỗi nhiều đến mức khó tin. Mà lập trình viên nào chẳng thế. Dạo này phần lớn thời gian đó tôi ngồi trong Xcode và trình giả lập (Simulator), lần theo lỗi trong chính ứng dụng của mình, và có một điều tôi cứ thấy lạ mãi: Xcode đi kèm cả một kho công cụ gỡ lỗi, vậy mà gần như chẳng ai đụng tới. Dưới đây là quy trình tôi đúc kết qua nhiều năm, chỉ gồm những phần thực sự giúp tiết kiệm thời gian mỗi khi có gì đó hỏng.
Điểm dừng làm được nhiều hơn bạn nghĩ#
Đa số chúng ta chỉ nhấp vào lề trái để đặt điểm dừng (breakpoint) rồi thôi. Nhưng thử nhấp chuột phải vào một điểm dừng xem, bạn sẽ thấy cả một menu tùy chọn, và nó thay đổi hẳn cách bạn gỡ lỗi.
Điểm dừng có điều kiện (Conditional Breakpoint)#
Giả sử bạn có một vòng lặp xử lý 1000 phần tử, và chỉ riêng phần tử thứ 347 là trục trặc. Nếu phải dừng ở điểm dừng 346 lần trước khi tới được nó thì coi như mất toi một buổi chiều.
for i in 0..<users.count {
processUser(users[i]) // Set breakpoint here with condition: i == 347
}Nhấp chuột phải vào điểm dừng, thêm điều kiện i == 347, thế là Xcode chỉ dừng khi điều kiện đó đúng. Mấy điều kiện kiểu userId == "abc123" hay error != nil cũng dùng tốt y như vậy.
Điểm dừng theo ký hiệu (Symbolic Breakpoint)#
Loại này dùng khi bạn không biết một phương thức đang được gọi từ đâu. Mở Breakpoint Navigator (Cmd+8), bấm nút +, rồi chọn “Symbolic Breakpoint”.
Muốn biết mỗi lần có view controller nào được nạp? Đặt ký hiệu là viewDidLoad, Xcode sẽ dừng lại ở từng cái một. Thấy nhiều quá? Thì thu hẹp lại: MyViewController.viewDidLoad.
Có mấy cái tôi để cố định trong mọi dự án:
UIViewAlertForUnsatisfiableConstraintsbắt các xung đột Auto Layout ngay khi chúng xảy raobjc_exception_throwdừng ở mọi ngoại lệ (exception) Objective-C, trước khi ứng dụng kịp bị sập (crash)malloc_error_breakgiúp tìm ra các lỗi cấp phát bộ nhớ
Điểm dừng ngoại lệ (Exception Breakpoint)#
Đây nên là thứ đầu tiên bạn thiết lập khi tạo dự án mới. Thêm một cái (Breakpoint Navigator, + → Exception Breakpoint) là Xcode sẽ dừng ngay lúc có ngoại lệ được ném ra, chứ không phải đợi đến khi ứng dụng đã chết hẳn.
Nhờ nó mà tôi bắt được không biết bao nhiêu lỗi mở gói (unwrap) giá trị nil, nhiều hơn mức tôi muốn thừa nhận. Không có nó thì mấy lỗi này chỉ hiện ra dưới dạng những vụ sập khó hiểu trong console.
Hành động của điểm dừng (Breakpoint Action)#
Điểm dừng không nhất thiết phải dừng chương trình. Sửa một điểm dừng, bấm “Add Action”, và bạn có thể cho nó:
- In giá trị biến ra mà không dừng:
"User count: @(users.count)@" - Tự động chạy lệnh LLDB
- Chạy shell script
- Phát âm thanh (tôi hay dùng cho những nhánh mã hiếm khi chạy tới, để biết ngay khi nó xảy ra)
Đánh dấu thêm “Automatically continue after evaluating actions” là điểm dừng biến thành một công cụ ghi nhật ký (log), không bao giờ làm gián đoạn bạn.
LLDB: những lệnh console bạn sẽ dùng thật#
Khi chương trình dừng ở điểm dừng, cái console ở dưới cùng không chỉ để đọc nhật ký sự cố (crash log). Đó chính là LLDB, và nó hữu ích hơn bạn tưởng nhiều.
Cơ bản#
(lldb) po user
▿ User
- id: "123"
- name: "John Doe"
- email: "[email protected]"po in đối tượng (object) ra ở dạng dễ đọc. Riêng lệnh này đã chiếm tới 90% số lần tôi dùng LLDB.
Cần chi tiết hơn thì dùng p:
(lldb) p user.name
(String) $R0 = "John Doe"Còn muốn xem toàn bộ biến cục bộ cùng lúc:
(lldb) frame variableThay đổi giá trị khi chương trình đang chạy#
LLDB thực sự đáng đồng tiền ở chỗ này: bạn sửa được giá trị biến mà không cần biên dịch lại.
(lldb) expr user.name = "Jane Doe"
(lldb) expr index = 0Tìm ra lỗi rồi và muốn thử cách sửa mà không phải biên dịch lại? Cứ đổi giá trị biến rồi cho chương trình chạy tiếp. Tôi dùng cách này liên tục mỗi khi cần khoanh vùng các trường hợp biên (edge case).
Gọi phương thức cũng được luôn:
(lldb) po self.refreshUI()
(lldb) expr navigationController?.popViewController(animated: true)Di chuyển trong chương trình#
(lldb) bt # Show the call stack
(lldb) frame select 3 # Jump to a different stack frame
(lldb) continue # Keep running (or just 'c')
(lldb) n # Step over (next line)
(lldb) s # Step into functionĐiểm theo dõi (Watchpoint)#
Muốn biết khi nào một biến bị thay đổi? Đặt điểm theo dõi:
(lldb) watchpoint set variable user.isLoggedInTừ giờ, cứ mỗi lần giá trị đó thay đổi là chương trình dừng lại, bất kể dòng mã nào gây ra. Khi phải truy lùng những lần trạng thái bị thay đổi ngoài ý muốn thì công cụ này quý như vàng.
Console.app: công cụ gỡ lỗi ít người để ý#
Console của Xcode dùng tạm thì ổn, cho đến khi bạn cần xem mọi thứ: nhật ký hệ thống, báo cáo sự cố (crash report), đủ cả. Những lúc đó tôi mở Console.app.
Nếu bạn đang dùng OSLog (và bạn nên dùng), Console.app cho phép tìm kiếm và lọc các nhật ký đó:
import os.log
let logger = Logger(subsystem: "com.example.app", category: "networking")
logger.info("Starting API request")
logger.debug("Request URL: \(url.absoluteString)")
logger.error("Failed to decode: \(error.localizedDescription)")Sau đó, trong Console.app:
- Chọn trình giả lập hoặc thiết bị đang kết nối
- Lọc theo phân hệ (subsystem) của bạn:
subsystem:com.example.app - Lọc theo mức độ:
subsystem:com.example.app AND level:error
Với những kiểu gỡ lỗi hay làm, bạn có thể lưu sẵn biểu thức lọc (predicate). Tôi có một cái cho yêu cầu mạng, một cái cho các thao tác với cơ sở dữ liệu, và một cái chỉ hiện lỗi với cảnh báo.
Sao lại dùng OSLog thay vì print()?#
So với việc rải print() khắp nơi thì OSLog hơn hẳn. Nhật ký có cấu trúc nên lọc được theo mức độ và danh mục. Nó đủ nhanh để việc ghi nhật ký không làm ứng dụng chậm đi. Trên môi trường production, dữ liệu nhạy cảm được tự động ẩn đi, và nhật ký vẫn còn nguyên kể cả khi ứng dụng bị sập.
print() vẫn ổn cho mấy lần kiểm tra nhanh rồi xóa. Còn thứ gì sau này bạn có thể cần xem lại thì nên ghi bằng OSLog.
Lọc nâng cao trong Console.app#
Console.app hỗ trợ đầy đủ truy vấn bằng biểu thức lọc. Đây là mấy câu tôi dùng thường xuyên:
# All errors and faults in your app
subsystem:com.example.app AND (level:error OR level:fault)
# Network requests that failed
subsystem:com.example.app AND category:networking AND eventMessage CONTAINS "failed"
# Everything from the last 5 minutes
subsystem:com.example.app AND timestamp >= now(-5m)Bạn cũng có thể xuất nhật ký ra để đính kèm vào báo cáo lỗi, tiện hơn nhiều so với sao chép thủ công từ console của Xcode.
Gỡ lỗi giao diện với View Debugger#
Khi giao diện bị vỡ mà bạn không hiểu tại sao, View Debugger là cách nhanh nhất để tìm ra nguyên nhân.
Chạy ứng dụng, mở tới màn hình bị lỗi, rồi bấm nút “Debug View Hierarchy” trên thanh gỡ lỗi của Xcode (hoặc nhấn Cmd+Shift+D). Xcode sẽ hiện toàn bộ cây phân cấp view (view hierarchy) dưới dạng 3D, tách ra từng lớp.
Xoay qua xoay lại một chút là vấn đề lộ ra ngay:
- View bị vẽ nằm phía sau view khác
- View nằm tít ngoài màn hình
- View có kích thước bằng 0
- Toàn bộ chuỗi ràng buộc (constraint) của view bạn đang chọn
Tôi từng nhờ nó mà tìm ra những cái nút vô hình nuốt mất sự kiện chạm, những nhãn bị vẽ ở kích thước 0x0, và có lần là một tấm ảnh lỡ tay rộng tới 10,000 pixel.
Gỡ lỗi Auto Layout#
Trong cây phân cấp view, biểu tượng cảnh báo màu tím nghĩa là đang có xung đột ràng buộc. Nhấp vào đó, Xcode sẽ chỉ rõ những ràng buộc nào đang “đánh nhau”.
Hãy đặt định danh (identifier) cho ràng buộc:
heightConstraint.identifier = "ProfileImageHeight"Khi ràng buộc bị vỡ, thông báo lỗi sẽ hiện "ProfileImageHeight" thay vì một địa chỉ bộ nhớ, và lúc đó nhật ký mới thực sự đọc được.
Bạn cũng có thể in cây ràng buộc ra từ LLDB:
(lldb) po view.hasAmbiguousLayout
(lldb) po view._autolayoutTrace()Những tính năng của trình giả lập giúp tiết kiệm thời gian#
Làm chậm hoạt ảnh (Slow Animations)#
Debug → Slow Animations làm mọi thứ chạy chậm lại còn 1/10. Rất hợp để xem trong một hiệu ứng chuyển cảnh thực sự đang xảy ra chuyện gì, hoặc tìm hiểu vì sao một hoạt ảnh trông sai sai.
Giả lập cảnh báo bộ nhớ#
Debug → Simulate Memory Warning giúp bạn kiểm tra xem ứng dụng xoay xở ra sao khi thiếu bộ nhớ. Tôi đã tìm ra khá nhiều lỗi liên quan đến bộ nhớ đệm (cache) ảnh theo cách này: mọi thứ vẫn chạy ổn, cho tới khi cảnh báo được phát ra, và tự dưng toàn bộ ảnh biến mất sạch.
Network Link Conditioner#
Xcode → Open Developer Tools → Network Link Conditioner
Công cụ này giả lập mạng 3G, LTE, mạng mất gói nhiều, hoặc mất kết nối hoàn toàn. Nếu bạn từng gặp một yêu cầu API chạy ngon lành trên WiFi nhưng lại hết thời gian chờ (timeout) khi dùng 4G, thì đây là cách để tái hiện lỗi đó.
Tôi luôn để sẵn một cấu hình tên “Bad Network”, với độ trễ 500ms và tỉ lệ mất gói 10%. Nhờ nó mà lộ ra những lỗi hết thời gian chờ và lỗi ở trạng thái đang tải, loại lỗi bạn sẽ không bao giờ thấy khi ngồi dùng WiFi tốc độ cao ở văn phòng.
Ghi đè thanh trạng thái#
Nhấp chuột phải vào thanh trạng thái (status bar) trong trình giả lập là bạn ghi đè được giờ, mức pin, cường độ sóng và tên nhà mạng. Rất tiện khi cần chụp ảnh màn hình đồng nhất, hoặc muốn kiểm tra giao diện trông thế nào ở các mức pin khác nhau.
Giả lập vị trí#
Debug → Location cho phép giả lập các vị trí khác nhau mà không cần rời khỏi bàn làm việc: tọa độ tùy chọn, đi bộ trong thành phố, hay lái xe trên cao tốc. Rất hữu ích khi làm các tính năng dựa trên vị trí, hoặc khi gặp những lỗi chỉ xuất hiện ở một số khu vực nhất định.
Gỡ lỗi bộ nhớ#
Debug Memory Graph#
Bấm nút Debug Memory Graph (Cmd+Shift+M), Xcode sẽ hiện ra mọi đối tượng đang nằm trong bộ nhớ, quan hệ giữa chúng, và phần bạn thực sự quan tâm: đối tượng nào đang bị rò rỉ bộ nhớ (memory leak).
Dấu chấm than màu tím nghĩa là có rò rỉ. Nhấp vào là bạn thấy ngay vòng tham chiếu (retain cycle).
Đây là kiểu rò rỉ tôi gặp nhiều nhất:
class ViewController: UIViewController {
var onComplete: (() -> Void)?
func setupHandler() {
onComplete = {
self.dismiss(animated: true) // ❌ Captures self strongly
}
}
}Cách sửa là dùng weak self:
onComplete = { [weak self] in
self?.dismiss(animated: true) // ✓ No retain cycle
}Delegate cũng phải khai báo weak:
weak var delegate: ManagerDelegate? // Not just 'var'Instruments#
Khi cần điều tra bộ nhớ nghiêm túc, hãy dùng Instruments (Product → Profile, hoặc Cmd+I).
Công cụ Allocations trong Instruments cho bạn thấy từng lần cấp phát đối tượng, bộ nhớ tăng lên theo thời gian ra sao, lớp nào đang ngốn nhiều bộ nhớ nhất, kèm stack trace cho biết mỗi lần cấp phát xảy ra ở đâu.
Tôi thường tìm ba thứ: bộ nhớ tăng đều theo thời gian (nhiều khả năng là rò rỉ), những lần cấp phát lớn bất thường (mình vừa nạp cả 1000 tấm ảnh một lúc à?), và những đối tượng lẽ ra đã phải được giải phóng nhưng vẫn còn đó.
Bấm đánh dấu generation (nút hình lá cờ nhỏ) trước và sau một thao tác để xem thứ gì vẫn còn nằm lại trong bộ nhớ khi đáng lẽ phải được giải phóng.
Đo hiệu năng#
Time Profiler#
Product → Profile → Time Profiler. Bấm ghi, thực hiện đúng thao tác đang chậm trong ứng dụng, rồi đọc cây lời gọi hàm (call tree).
Sắp xếp theo “Self Weight” để tìm điểm nghẽn (bottleneck). Hàm nào có self weight tới 40% thì đó chính là chỗ thời gian của bạn đang bị ngốn.
Có lần tôi phát hiện một hàm phân tích JSON bị gọi tới 1000 lần mỗi giây. Chuyển nó sang hàng đợi chạy nền (background queue) xong là giao diện hết giật ngay.
Main Thread Checker#
Xcode tự động phát hiện những chỗ cập nhật giao diện từ luồng nền (background thread). Nếu bạn thấy dòng này:
Main Thread Checker: UI API called on a background thread: -[UILabel setText:]thì nghĩa là bạn đã viết kiểu như thế này:
URLSession.shared.dataTask(with: url) { data, response, error in
self.label.text = "Loaded" // ❌ Crash!
}Sửa lại như sau:
URLSession.shared.dataTask(with: url) { data, response, error in
DispatchQueue.main.async {
self.label.text = "Loaded" // ✓
}
}Chẩn đoán khi chạy (Runtime Diagnostics)#
Vào Edit Scheme → Run → Diagnostics. Ở đây có một loạt ô đánh dấu, mỗi ô bắt được những lỗi mà tự tay bạn sẽ không bao giờ tìm ra.
Address Sanitizer#
Công cụ này tìm lỗi hỏng bộ nhớ: use-after-free, tràn bộ đệm (buffer overflow), rò rỉ bộ nhớ. Nó làm ứng dụng chậm đi 2-3 lần, nhưng bù lại bắt được những lỗi gần như không thể lần ra bằng cách nào khác. Tôi bật nó mỗi khi đi săn những vụ sập lúc có lúc không.
Thread Sanitizer#
Thread Sanitizer bắt lỗi tranh chấp dữ liệu (data race) và các lỗi liên quan đến luồng (thread). Bạn chỉ cần bật lên, chạy ứng dụng và dùng như bình thường; nếu trong mã có tình trạng tranh chấp (race condition), nó sẽ tìm ra.
Không thể chạy Address Sanitizer và Thread Sanitizer cùng lúc, nên khi gặp một vụ sập có biểu hiện kỳ quặc, tôi bật luân phiên từng cái một.
Zombie Objects#
Tùy chọn này bắt các thông điệp (message) được gửi tới đối tượng đã bị giải phóng. Khi bật lên, Xcode sẽ báo chính xác lúc nào bạn đang đụng vào vùng nhớ đã được giải phóng:
*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000Đừng để nó bật thường trực. Zombie Objects ngăn đối tượng bị giải phóng, nên bộ nhớ cứ thế tăng mãi. Nhưng để khoanh vùng một lỗi use-after-free cụ thể thì nó rất tuyệt.
Những tình huống hay gặp#
Ứng dụng bị sập ngay khi mở#
Trước hết hãy thêm điểm dừng ngoại lệ; thường thì thế là bắt được luôn.
Nếu vẫn chưa ra, mấy “nghi phạm quen mặt” là: thiếu khóa (key) trong Info.plist, ép mở gói (force-unwrap) optional trong phần mã khởi tạo, hoặc cơ chế tiêm phụ thuộc (dependency injection) bị lỗi.
Giao diện không cập nhật#
Lần. Nào. Cũng. Vậy. Nguyên nhân chỉ nằm trong mấy trường hợp sau:
- Việc cập nhật không chạy trên luồng chính (main thread)
- Outlet chưa được nối
- View thực ra không hiển thị (kiểm tra bằng
po view.windowtrong LLDB; nếu kết quả là nil thì view không nằm trong cây phân cấp)
Còn nếu là table view hay collection view: bạn quên gọi reloadData() rồi.
Ứng dụng bị sập vì cảnh báo bộ nhớ#
Dùng Allocations trong Instruments để xem thứ gì đang giữ bộ nhớ. Thường là ảnh nằm trong bộ nhớ đệm mà không bao giờ được nhả ra, view controller không chịu giải phóng (vòng tham chiếu), hoặc một mảng hay dictionary nào đó cứ phình to mãi.
Hãy giả lập cảnh báo bộ nhớ trong trình giả lập để kiểm tra phần mã dọn dẹp của bạn.
Những vụ sập khó hiểu#
Bật Sanitizer lên. Sập lúc có lúc không thường là do luồng, nên hãy chạy với Thread Sanitizer. Còn sập bên trong framework của hệ thống thì thường là do hỏng bộ nhớ, nên chạy với Address Sanitizer.
Gỡ lỗi bản cập nhật OTA trong trình giả lập#
Ứng dụng của tôi có phát hành bản cập nhật qua mạng (OTA), và trình giả lập là nơi dễ nhất để gỡ lỗi toàn bộ luồng cập nhật đó.
Việc đầu tiên tôi làm là theo dõi lưu lượng mạng. Mở Console.app, lọc theo phân hệ của ứng dụng, bạn sẽ thấy các yêu cầu và phản hồi của tệp manifest theo thời gian thực: tiêu đề (header) nào được gửi đi, máy chủ trả về cái gì, rõ ràng từng thứ.
Tiếp theo, tôi cố tình làm cho mạng tệ đi. Network Link Conditioner cho bạn thấy luồng cập nhật xoay xở ra sao khi kết nối chậm và mất gói. Ứng dụng có bị treo không? Có hiện biểu tượng đang tải không? Có chuyển sang phương án dự phòng êm ái không?
Quanh bước kiểm tra cập nhật, tôi ghi nhật ký mọi thứ: lúc bắt đầu kiểm tra, lúc phát hiện có bản mới, lúc bắt đầu tải xuống, và lúc gói (bundle) mới được nạp.
import os.log
let logger = Logger(subsystem: "com.example.app", category: "updates")
logger.info("Checking for updates...")
let update = try await Updates.checkForUpdateAsync()
logger.info("Update available: \(update.isAvailable)")Cuối cùng, bạn nên kiểm tra riêng endpoint manifest. Dùng curl gọi nó với đúng những tiêu đề mà ứng dụng gửi đi:
curl -H "expo-protocol-version: 1" \
-H "expo-platform: ios" \
-H "expo-runtime-version: 1.0.0" \
https://your-server.com/api/expo-updates/manifest/Làm vậy để chắc chắn máy chủ đang trả về đúng dữ liệu, trước khi bạn lao vào đào bới phía máy khách.
Công cụ bên thứ ba#
Charles Proxy / Proxyman#
Hai công cụ này cho bạn xem toàn bộ lưu lượng mạng, sửa yêu cầu và phản hồi, và thử các tình huống lỗi. Gỡ lỗi API mà thiếu chúng thì rất khổ.
Proxyman có giao diện đẹp hơn Charles và dùng trên macOS thấy tự nhiên hơn. Tôi chuyển sang từ năm ngoái và chưa một lần muốn quay lại.
Reveal#
Reveal giống View Debugger của Xcode nhưng mạnh hơn. Nó chạy được trên máy thật mà không cần kết nối với Xcode, nên rất hợp để truy lỗi bố cục ngay trên máy của người kiểm thử (tester).
Gỡ lỗi trên máy thật#
Gỡ lỗi không dây#
Cắm máy vào Mac qua cáp USB một lần, vào Window → Devices and Simulators, đánh dấu “Connect via network”, rồi rút cáp ra. Chỉ cần máy và Mac dùng chung mạng WiFi là máy vẫn hiện trong Xcode.
Device Console#
Window → Devices and Simulators → Open Console. Ở đây hiện đủ mọi thứ: nhật ký hệ thống, nhật ký của ứng dụng, báo cáo sự cố. Chi tiết hơn console của Xcode rất nhiều.
Mỗi khi người kiểm thử báo “ứng dụng bị sập”, tôi luôn nhờ họ cắm máy vào để lấy nhật ký console. Rất hay gặp trường hợp có sẵn một lỗi ở cấp hệ thống nằm ngay đó, giải thích được toàn bộ câu chuyện.
Tôi thực sự làm gì khi gỡ lỗi#
- Thêm điểm dừng ngoại lệ nếu chưa có
- Tái hiện lỗi
- Đặt điểm dừng gần chỗ tôi nghi là đang sai
- Kiểm tra giá trị bằng
potrong LLDB - Sửa giá trị biến để thử cách sửa mà không cần biên dịch lại
- Xem cây phân cấp view nếu lỗi liên quan đến giao diện
- Phân tích hiệu năng (profile) bằng Instruments nếu lỗi liên quan đến hiệu năng
- Bật Sanitizer nếu lỗi liên quan đến bộ nhớ hoặc luồng
Bí quyết là đi ngược từ triệu chứng. Sập? Điểm dừng ngoại lệ. Chậm? Time Profiler. Bộ nhớ? Instruments. Giao diện kỳ cục? View Debugger.
Những mẹo tôi ước mình biết sớm hơn#
Hãy dùng assertion. Chúng bắt được lỗi logic trước khi lỗi đó kịp gây hậu quả thật:
assert(users.count > 0, "Users array should never be empty here")Commit trước khi bắt đầu gỡ lỗi. Một khi đã sửa lung tung để thử các giả thuyết, bạn sẽ cần một điểm sạch sẽ để quay về.
Gỡ lỗi xong thì xóa mấy lệnh print đi, hoặc ít nhất bọc chúng trong #if DEBUG. Nhật ký gỡ lỗi cũ để lại chỉ làm cho lỗi tiếp theo khó thấy hơn.
Học LLDB cho tử tế. Nhanh hơn nhiều so với việc biên dịch lại ứng dụng hai mươi lần.
Và nhớ rằng trình giả lập không phải điện thoại thật. Lỗi chỉ xuất hiện trên máy thật thì gần như lúc nào cũng là do luồng hoặc bộ nhớ. Hoặc do hiệu năng, vì trình giả lập chạy bằng CPU của máy Mac chứ không phải chip iPhone thật.
Những công cụ tôi luôn để mở#
- Xcode, tất nhiên rồi
- Console.app, lọc sẵn theo phân hệ của ứng dụng
- Proxyman để gỡ lỗi mạng
- Instruments, khi mọi chuyện bắt đầu nghiêm trọng
Không có công cụ nào lo được hết mọi thứ. View Debugger không tìm ra rò rỉ bộ nhớ, Instruments cũng không gỡ được xung đột Auto Layout, nên phần lớn kỹ năng nằm ở chỗ chọn đúng công cụ cho đúng triệu chứng. Cái này thì chỉ có làm nhiều mới quen: dính vòng tham chiếu đủ nhiều lần rồi, chỉ cần liếc stack trace là bạn đã “ngửi” ra nó. Gỡ lỗi chẳng bao giờ thực sự vui, nhưng ít ra nó sẽ không còn là cực hình nữa.
