Chuyển đến nội dung chính
  1. Bài viết/

Cách tôi gỡ lỗi ứng dụng iOS với Xcode và Simulator

· loading · loading ·
Nhân Tài Đức
Tác giả
Nhân Tài Đức
Người dẫn dắt đội ngũ và kỹ sư phần mềm, sống tại Seoul, Hàn Quốc
Mục lục

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à 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ụ debug, 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.

Breakpoint làm được nhiều hơn bạn nghĩ
#

Đa số chúng ta chỉ click vào lề trái để đặt breakpoint rồi thôi. Nhưng thử click chuột phải vào một breakpoint 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.

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 ở breakpoint 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
}

Click chuột phải vào breakpoint, 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 ngon y như vậy.

Symbolic Breakpoint
#

Loại này dùng khi bạn không biết một method đ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 load? Đặt symbol 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 project:

  • UIViewAlertForUnsatisfiableConstraints bắt các xung đột Auto Layout ngay khi chúng xảy ra
  • objc_exception_throw dừng ở mọi exception Objective-C, trước khi ứng dụng kịp crash
  • malloc_error_break giúp tìm ra các lỗi cấp phát bộ nhớ

Exception Breakpoint
#

Đây nên là thứ đầu tiên bạn thiết lập khi tạo project mới. Thêm một cái (breakpoint navigator, + → Exception Breakpoint) là Xcode sẽ dừng ngay lúc có exception được throw, 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 unwrap 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ụ crash khó hiểu trong console.

Breakpoint Action
#

Breakpoint không nhất thiết phải dừng chương trình. Sửa một breakpoint, 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)

Tick thêm “Automatically continue after evaluating actions” là breakpoint biến thành một công cụ ghi 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 ở breakpoint, cái console ở dưới cùng không chỉ để đọ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: "john@example.com"

po in 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 local cùng lúc:

(lldb) frame variable

Thay đổi giá trị lúc runtime
#

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 compile lại.

(lldb) expr user.name = "Jane Doe"
(lldb) expr index = 0

Tìm ra lỗi rồi và muốn thử cách sửa mà không phải build 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 edge case.

Gọi method 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

Watchpoint
#

Muốn biết khi nào một biến bị thay đổi? Đặt watchpoint:

(lldb) watchpoint set variable user.isLoggedIn

Từ 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 state 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ứ: system log, 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 log đó:

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:

  1. Chọn simulator hoặc thiết bị đang kết nối
  2. Lọc theo subsystem của bạn: subsystem:com.example.app
  3. Lọc theo level: 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 filter predicate. Tôi có một cái cho network request, 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 error với warning.

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. Log có cấu trúc nên lọc được theo level và category. Nó đủ nhanh để việc ghi log không làm ứng dụng chậm đi. Trên production, dữ liệu nhạy cảm được tự động ẩn đi, và log vẫn còn nguyên kể cả khi ứng dụng crash.

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ợ truy vấn predicate đầy đủ. Đâ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ể export log ra để đính kèm vào bug report, tiện hơn nhiều so với copy-paste 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 debug của Xcode (hoặc nhấn Cmd+Shift+D). Xcode sẽ hiện toàn bộ 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ị render 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 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 tap, những label bị render ở 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 view hierarchy, biểu tượng cảnh báo màu tím nghĩa là đang có xung đột constraint. Click vào đó, Xcode sẽ chỉ rõ những constraint nào đang “đánh nhau”.

Hãy đặt identifier cho constraint:

heightConstraint.identifier = "ProfileImageHeight"

Khi constraint bị vỡ, thông báo lỗi sẽ hiện "ProfileImageHeight" thay vì một địa chỉ bộ nhớ, và lúc đó log mới thực sự đọc được.

Bạn cũng có thể in cây constraint ra từ LLDB:

(lldb) po view.hasAmbiguousLayout
(lldb) po view._autolayoutTrace()

Những tính năng của simulator giúp tiết kiệm thời gian
#

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 transition thực sự đang xảy ra chuyện gì, hoặc tìm hiểu vì sao một animation 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 cache ảnh theo cách này: mọi thứ vẫn chạy ổn, cho tới khi cảnh báo được bắn 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 API request chạy ngon lành trên WiFi nhưng lại timeout khi dùng 4G, thì đây là cách để tái hiện lỗi đó.

Tôi luôn để sẵn một profile 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 timeout và lỗi ở trạng thái loading, 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 đè status bar
#

Click chuột phải vào status bar trong simulator 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 screenshot đồ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 object đang nằm trong bộ nhớ, quan hệ giữa chúng, và phần bạn thực sự quan tâm: object nào đang bị leak.

Dấu chấm than màu tím nghĩa là có leak. Click vào là bạn thấy ngay retain cycle.

Đây là kiểu leak 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ớ một cách nghiêm túc, hãy dùng Instruments (Product → Profile, hoặc Cmd+I).

Instrument Allocations cho bạn thấy từng lần cấp phát object, bộ nhớ tăng lên theo thời gian ra sao, class 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à leak), những lần cấp phát lớn bất thường (mình vừa load cả 1000 tấm ảnh một lúc à?), và những object 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 call tree.

Sắp xếp theo “Self Weight” để tìm 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 parse JSON bị gọi tới 1000 lần mỗi giây. Chuyển nó sang 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ừ 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"  // ✓
    }
}

Runtime Diagnostics
#

Vào Edit Scheme → Run → Diagnostics. Ở đây có một loạt checkbox, mỗi cá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, buffer overflow, memory leak. 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ụ crash lúc có lúc không.

Thread Sanitizer
#

Thread Sanitizer bắt data race và các lỗi liên quan đến 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ó 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ụ crash 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 message được gửi tới object đã 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 object 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 crash ngay khi mở
#

Trước hết hãy thêm exception breakpoint; 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 key trong Info.plist, force-unwrap optional trong phần mã khởi tạo, hoặ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:

  1. Việc cập nhật không chạy trên main thread
  2. Outlet chưa được nối
  3. View thực ra không hiển thị (kiểm tra bằng po view.window trong LLDB; nếu kết quả là nil thì view không nằm trong hierarchy)

Còn nếu là table view hay collection view: bạn quên gọi reloadData() rồi.

Ứng dụng crash 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 cache mà không bao giờ được nhả ra, view controller không chịu giải phóng (retain cycle), hoặc một array hay dictionary nào đó cứ phình to mãi.

Hãy giả lập cảnh báo bộ nhớ trong simulator để kiểm tra phần mã dọn dẹp của bạn.

Những vụ crash khó hiểu
#

Bật sanitizer lên. Crash lúc có lúc không thường là do thread, nên hãy chạy với Thread Sanitizer. Còn crash 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 OTA update trong simulator
#

Ứng dụng của tôi có phát hành bản cập nhật over-the-air (OTA), và simulator 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 network traffic. Mở Console.app, lọc theo subsystem của ứng dụng, bạn sẽ thấy các request và response của manifest theo thời gian thực: 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 loading indicator không? Có chuyển sang phương án dự phòng một cách êm ái không?

Quanh bước kiểm tra cập nhật, tôi ghi log 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 bundle mới được load.

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 header 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 client.

Công cụ bên thứ ba
#

Charles Proxy / Proxyman
#

Hai công cụ này cho bạn xem toàn bộ network traffic, sửa request và response, 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 layout ngay trên máy của tester.

Gỡ lỗi trên máy thật
#

Debug 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, tick “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ứ: system log, log của ứng dụng, crash report. Chi tiết hơn console của Xcode rất nhiều.

Mỗi khi tester báo “ứng dụng bị crash”, tôi luôn nhờ họ cắm máy vào để lấy console log. 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
#

  1. Thêm exception breakpoint nếu chưa có
  2. Tái hiện lỗi
  3. Đặt breakpoint gần chỗ tôi nghi là đang sai
  4. Kiểm tra giá trị bằng po trong LLDB
  5. Sửa giá trị biến để thử cách sửa mà không cần compile lại
  6. Xem view hierarchy nếu lỗi liên quan đến giao diện
  7. Profile bằng Instruments nếu lỗi liên quan đến hiệu năng
  8. Bật sanitizer nếu lỗi liên quan đến bộ nhớ hoặc thread

Bí quyết là đi ngược từ triệu chứng. Crash? Exception breakpoint. 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 biến thành bug:

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 câu print đi, hoặc ít nhất bọc chúng trong #if DEBUG. Log debug 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 build lại ứng dụng hai mươi lần.

Và nhớ rằng simulator 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 thread hoặc bộ nhớ. Hoặc do hiệu năng, vì simulator 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 subsystem của ứng dụng
  • Proxyman để gỡ lỗi network
  • 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 memory leak, 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 retain cycle đủ 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.