Quản lý và chẩn đoán bộ nhớ WebView

WebView chạy mã gốc trên nhiều quy trình để hiển thị nội dung trên web trong ứng dụng Android của bạn. Việc không quản lý các thực thể WebView có thể dẫn đến rò rỉ bộ nhớ, sự cố hết bộ nhớ (OOM) và hiệu suất của ứng dụng bị giảm sút.

Tài liệu này giải thích mô hình bộ nhớ đa quy trình của WebView, mô tả cách quản lý vòng đời của mô hình này đúng cách để ngăn chặn rò rỉ, đồng thời cung cấp quy trình thực tế để chẩn đoán các vấn đề về bộ nhớ.

Tìm hiểu cấu trúc bộ nhớ của WebView

Để quản lý hiệu quả bộ nhớ của WebView, hãy tìm hiểu cách Android phân bổ tài nguyên cho nội dung trên web:

  • Thực thi nhiều quy trình: Trên Android 8.0 (cấp độ API 26) trở lên, WebView tách nội dung trên web khỏi các chức năng cốt lõi của ứng dụng trên nhiều quy trình (trên các thiết bị có RAM thấp, có thể quay lại một quy trình):

    • Quy trình lưu trữ (Trình duyệt): Quy trình chính của ứng dụng, nơi mã Activity và Java hoặc Kotlin của bạn chạy.
    • Quy trình trình kết xuất riêng biệt: Một quy trình hộp cát riêng biệt (SandboxedProcessService) phân tích cú pháp HTML và CSS, thực thi JavaScript và kết xuất trang web.
  • Mức sử dụng bộ nhớ gốc: Hầu hết bộ nhớ WebView, bao gồm cả đồ hoạ được kết xuất, cây DOM và bộ nhớ thời gian chạy JavaScript, đều được phân bổ trong bộ nhớ gốc, chứ không phải trên vùng nhớ khối xếp Java. Tệp báo lỗi vùng nhớ khối xếp Java (.hprof) chỉ cho thấy một đối tượng trình bao bọc Java gọn nhẹ và không ghi lại bộ nhớ thực mà nội dung trên web sử dụng.

  • Tác động của bộ nhớ gốc đối với hệ thống: Không giống như các lượt phân bổ vùng nhớ khối xếp Java (bị giới hạn bởi giới hạn maxHeap của ứng dụng và nhanh chóng không thành công kèm theo OutOfMemoryError), bộ nhớ gốc có thể tăng lên đến hàng gigabyte mà không có cảnh báo. Khi bộ nhớ gốc chưa được giải phóng lấp đầy RAM vật lý và không gian hoán đổi (zRAM), Trình loại bỏ bộ nhớ thấp (LMK) của Android sẽ bắt đầu chấm dứt các tiến trình trong nền để lấy lại bộ nhớ. Điều này làm giảm khả năng đa nhiệm tổng thể của thiết bị trước khi buộc dừng ứng dụng trên nền trước.

Quản lý vòng đời của WebView

Quản lý vòng đời đúng cách là điều quan trọng để ngăn chặn rò rỉ bộ nhớ. Một lỗi phổ biến là giả định rằng việc xoá WebView khỏi bố cục hoặc để Activity hoàn tất sẽ tự động giải phóng bộ nhớ.

Để đảm bảo dọn dẹp hoàn toàn cả tệp tham chiếu ngữ cảnh Java và tài nguyên kết xuất gốc, bạn phải điều phối rõ ràng trình tự chia nhỏ trong vòng đời của thành phần lưu trữ (chẳng hạn như onDestroy()), dừng thực thi trang đang hoạt động, tách khung hiển thị khỏi vùng chứa và giải phóng các liên kết gốc.

Dọn dẹp các thực thể WebView

Để đảm bảo tắt sạch và giải phóng tài nguyên khi Activity hoặc Fragment của bạn bị huỷ, hãy làm như sau:

  1. Xoá WebView khỏi vùng chứa mẹ (ViewGroup).
  2. Dừng tải và xoá nhật ký điều hướng.
  3. Gọi destroy().
  4. Xoá tệp đối chiếu cho null.

Ví dụ sau đây minh hoạ cách dọn dẹp WebView đúng cách:

Kotlin

override fun onDestroy() {
    myWebView?.let {
        // Remove the WebView from its parent ViewGroup.
        (it.parent as? ViewGroup)?.removeView(it)
        // Stop active loading and clear history.
        it.stopLoading()
        it.clearHistory()
        // Destroy the instance.
        it.destroy()
    }
    myWebView = null
    super.onDestroy()
}

Java

@Override
protected void onDestroy() {
    if (myWebView != null) {
        // Remove the WebView from its parent ViewGroup.
        if (myWebView.getParent() instanceof ViewGroup) {
            ((ViewGroup) myWebView.getParent()).removeView(myWebView);
        }
        // Stop active loading and clear history.
        myWebView.stopLoading();
        myWebView.clearHistory();
        // Destroy the instance.
        myWebView.destroy();
    }
    myWebView = null;
    super.onDestroy();
}

Tìm hiểu về bộ nhớ sau khi huỷ

Khi bạn gọi destroy(), hệ thống sẽ giải phóng bối cảnh Activity, dọn dẹp hệ phân cấp view và dừng hoạt động nền trên web. Tuy nhiên, bạn có thể nhận thấy rằng bộ nhớ thực của quy trình (Dung lượng bộ nhớ RAM bị chiếm dụng) không giảm ngay lập tức xuống mức cơ sở trước WebView.

Đây là hành vi bình thường. Bộ nhớ đệm thời gian chạy gốc, thư viện dùng chung và các trang bộ nhớ được phân bổ vẫn nằm trong quy trình cho đến khi hệ điều hành thu hồi hoặc quy trình chấm dứt. Mục tiêu chính của destroy() là ngăn chặn tình trạng rò rỉ bộ nhớ Activity tích luỹ khi người dùng di chuyển vào và ra khỏi các màn hình dựa trên web.

Các chỉ số gỡ lỗi chính

Khi phân tích mức tiêu thụ bộ nhớ của WebView, hãy tập trung vào các chỉ số sau:

  • Kích thước cài đặt thường trú (RSS): Tổng RAM thực tế được liên kết với quy trình, bao gồm cả mã và thư viện dùng chung (được gắn nhãn là Tổng trong Trình phân tích tài nguyên của Android Studio).

  • RSS ẩn danh (RssAnon): Bộ nhớ do quy trình phân bổ trực tiếp mà không được tệp trên ổ đĩa hỗ trợ (chẳng hạn như vùng nhớ khối xếp gốc và mức phân bổ thời gian chạy JavaScript). Đây là mức sử dụng bộ nhớ chính của nội dung trên web (được gắn nhãn là Allocated (Đã phân bổ) trong Trình phân tích tài nguyên của Android Studio).

  • Mức sử dụng bộ nhớ riêng tư (PMF): Tổng của RSS ẩn danh và không gian hoán đổi (zRAM). PMF phản ánh mức tải bộ nhớ thực tế không thể loại bỏ mà ứng dụng của bạn áp đặt lên hệ thống.

  • PMF của trình duyệt so với PMF của trình kết xuất: Bộ nhớ mà quy trình chính của ứng dụng sử dụng so với bộ nhớ mà quy trình kết xuất riêng biệt sử dụng. Nội dung trên web nặng chủ yếu gây ra mức tăng đột biến trong quá trình kết xuất.

  • Số lượng đối tượng đang hoạt động (WebViews, Activities, Views): Số lượng thực thể giao diện người dùng, Ngữ cảnh và WebView đang hoạt động được lưu giữ trong bộ nhớ. Việc theo dõi các đối tượng này giúp xác định xem mức tăng bộ nhớ là do các tệp tham chiếu Java được giữ lại hay do các lượt phân bổ chỉ dành cho mã gốc.

  • Bộ nhớ riêng tư khác và Vùng nhớ khối xếp gốc: Trong dumpsys meminfo, các lượt phân bổ C/C++ gốc và ánh xạ bộ nhớ tuỳ chỉnh (chẳng hạn như PartitionAlloc Chromium hoặc vùng nhớ khối xếp thời gian chạy JavaScript được nhúng) sẽ xuất hiện trong Vùng nhớ khối xếp gốc và Bộ nhớ riêng tư khác thay vì Vùng nhớ khối xếp Java.

Để biết thêm thông tin về bộ đếm bộ nhớ của quy trình và các danh mục của bộ đếm, hãy xem Bảng chú giải thuật ngữ về bộ nhớ của quy trình.

Quy trình chẩn đoán thực tế

Vì WebView hoạt động trên nhiều quy trình và phân bổ bộ nhớ gốc, nên hãy sử dụng các công cụ và kỹ thuật sau để kiểm tra mức sử dụng bộ nhớ của thư viện này:

Công cụ phân tích tài nguyên và chẩn đoán

Để kiểm tra mức phân bổ bộ nhớ và chẩn đoán rò rỉ, hãy sử dụng các công cụ sau:

  • Trình phân tích bộ nhớ của Android Studio: Sử dụng Trình phân tích bộ nhớ để trực quan hoá các lượt phân bổ gốc, theo dõi các danh mục bộ nhớ theo thời gian và phát hiện tình trạng rò rỉ Activity trong quá trình chuyển đổi màn hình.

  • Theo dõi bộ nhớ bằng Perfetto: Sử dụng Perfetto để ghi lại bộ đếm bộ nhớ ở cấp hệ thống (chẳng hạn như RSS và RSS ẩn danh) để quan sát mức tăng trưởng tổng thể của bộ nhớ. Xin lưu ý rằng WebView lượt phân bổ công cụ gốc không tạo ra ngăn xếp lệnh gọi trong công cụ lập hồ sơ ảnh chụp nhanh của vùng nhớ khối xếp của Perfetto. Sử dụng Chrome DevTools để kiểm tra snapshot của vùng nhớ heap JavaScript và quá trình phân bổ DOM bên trong nội dung trên web.

Kiểm tra số lượng đối tượng đang hoạt động

Để xác định xem mức tăng bộ nhớ có phải do các đối tượng khung Java được giữ lại (chẳng hạn như thành phần giao diện người dùng) hay do các lượt phân bổ gốc hay không, hãy kiểm tra phần Objects của dumpsys meminfo:

adb shell dumpsys meminfo -a <var>PACKAGE_NAME</var> | grep -A 10 "Objects"

Kết quả cho thấy số lượng đối tượng đang hoạt động:

 Objects
               Views:     142         ViewRootImpl:        1
         AppContexts:       3           Activities:        1
              Assets:      12        AssetManagers:        0
       Local Binders:      32        Proxy Binders:       45
       Parcel memory:      15         Parcel count:       30
    Death Recipients:       2             WebViews:        1

Phần này cho thấy số lượng đối tượng khung, tay điều khiển IPC và lượt phân bổ Parcel đang hoạt động. Đối với thông tin chẩn đoán về WebView, hãy tập trung chủ yếu vào Activities và WebViews.

Thực hiện thao tác tương tác của người dùng mục tiêu (chẳng hạn như mở và đóng màn hình web) nhiều lần và so sánh số lượng:

  • Rò rỉ thực thể: Nếu WebViews hoặc Activities tăng dần trên mỗi thao tác điều hướng và không trở về giá trị cơ sở, thì ứng dụng của bạn đang rò rỉ thực thể Java WebView hoặc máy chủ lưu trữ Activity (ví dụ: do thiếu ViewGroup.removeView() hoặc tham chiếu trình nghe được giữ lại). Vì Activity bị rò rỉ sẽ ghim toàn bộ cây khung hiển thị và tài nguyên hình ảnh đã giải mã trong bộ nhớ, nên việc truy cập nhiều lần sẽ nhanh chóng làm cạn kiệt vùng nhớ khối xếp Java và gây ra sự cố OutOfMemoryError.

  • Rò rỉ gốc hoặc DOM: Nếu WebViews và Activities không đổi trong khi tổng RSS của quy trình và Private Other (Khác, riêng tư) tiếp tục tăng, thì rò rỉ bắt nguồn từ các tài nguyên gốc chưa được phát hành, phần tử DOM hoặc liên kết công cụ JavaScript. Vì các lượt phân bổ này nằm trong bộ nhớ gốc và bỏ qua trình thu gom rác ART, nên các công cụ phát hiện rò rỉ Java tiêu chuẩn không thể nhìn thấy chúng và chúng tiếp tục tích luỹ cho đến khi hệ điều hành chấm dứt ứng dụng.

Phân tích tiến trình kết xuất riêng biệt bằng CLI

Khi chạy dumpsys meminfo với tên gói của ứng dụng, bạn chỉ nhận được kết quả về bộ nhớ cho quy trình lưu trữ chính. Cách kiểm tra quy trình kết xuất tách biệt nơi trang web được kết xuất:

  1. Tìm mã định danh tiến trình (PID) của dịch vụ trình kết xuất riêng biệt:

    adb shell dumpsys activity processes <var>PACKAGE_NAME</var> | grep "Isolated.*SandboxedProcessService"

    Kết quả sẽ hiển thị bản ghi quy trình tách biệt và PID của quy trình đó RENDERER_PID (ví dụ: 22155):

    Isolated #5: ProcessRecord{... 22155:com.google.android.webview.debug:sandboxed_process0:...}
    
  2. Kiểm tra thông tin chi tiết về bộ nhớ của quy trình trình kết xuất bằng PID của quy trình đó:

    adb shell dumpsys meminfo <var>RENDERER_PID</var>
  3. Kiểm tra quy trình của ứng dụng lưu trữ để đánh giá mức sử dụng tài nguyên phía trình duyệt:

    adb shell dumpsys meminfo <var>PACKAGE_NAME</var>

Kiểm tra bản đồ và mức phân bổ bộ nhớ

Để xem hệ thống con gốc hoặc trình phân bổ nào chiếm bộ nhớ ẩn danh, hãy kiểm tra bản đồ bộ nhớ của quy trình:

adb shell "cat /proc/$(pidof <var>PACKAGE_NAME</var>)/maps" | grep "anon:"

Bảng sau đây liệt kê các thẻ bộ nhớ ẩn danh phổ biến và mức độ liên quan của các thẻ này đến mức tăng bộ nhớ:

Thẻ bộ nhớ OOB Mức độ liên quan đến nội dung trên ứng dụng và web Nguyên nhân phổ biến khiến mức sử dụng bộ nhớ tăng?
[anon:partition_alloc] Chromium PartitionAlloc Mức phân bổ cho cây DOM, vùng đệm kết xuất, vùng nhớ khối xếp JavaScript V8 và quá trình thực thi WebAssembly trong WebView. Có (Cao): Việc tải các trang web nặng, DOM có nhiều nội dung nghe nhìn hoặc không gọi được destroy() trên các thực thể WebView bị loại bỏ sẽ trực tiếp làm tăng mức sử dụng của thẻ này.
[anon:scudo...] hoặc [anon:libc_malloc] Trình phân bổ vùng nhớ khối xếp gốc của Android (Scudo / jemalloc) Các lượt phân bổ gốc C/C++ chung mà thư viện NDK, cầu nối JNI và quy trình đồ hoạ gốc sử dụng. Có (Trung bình đến Cao): Mức tăng xảy ra khi trình bao bọc JNI gốc hoặc phần phụ thuộc C++ của bên thứ ba giữ lại các lượt phân bổ chưa được giải phóng trong quá trình điều hướng.
[anon:...] (ví dụ: [anon:quickjs_heap...]) Tập lệnh tuỳ chỉnh hoặc thời gian chạy gốc Công cụ JavaScript được nhúng, thời gian chạy WebAssembly tuỳ chỉnh hoặc vùng đệm gốc tuỳ chỉnh. Có (Tuỳ thuộc vào bối cảnh): Thường gặp trong các ứng dụng kết hợp thực thi công cụ tập lệnh cùng với chế độ xem gốc và không dọn dẹp được các liên kết thời gian chạy.

Giới hạn của API bộ nhớ trong ứng dụng

API bộ nhớ trong ứng dụng (chẳng hạn như Debug.getMemoryInfo hoặc ActivityManager.getProcessMemoryInfo) chỉ đo lường quy trình gọi. Ở chế độ nhiều quy trình, các API này không thể thu thập bộ nhớ mà quy trình trình kết xuất riêng biệt sử dụng. Để đánh giá chính xác tổng bộ nhớ, hãy dựa vào các công cụ hệ thống như dumpsys meminfo, Perfetto hoặc Trình phân tích tài nguyên trong Android Studio.

Sắp xếp mức sử dụng bộ nhớ cao trong ứng dụng kết hợp

Khi chẩn đoán mức tăng bộ nhớ không rõ nguyên nhân trong các hoạt động tương tác định kỳ WebView (chẳng hạn như mở đường liên kết trang web hoặc di chuyển trong nguồn cấp dữ liệu dựa trên web), hãy sử dụng quy trình phân loại sau để tách biệt xem rò rỉ bắt nguồn từ lớp Java hay công cụ gốc:

  1. Xác định loại rò rỉ (Java so với mã gốc): Chạy dumpsys meminfo -a <var>PACKAGE_NAME</var> | grep -A 10 "Objects" trước và sau khi người dùng chuyển đổi lặp lại (chẳng hạn như mở và đóng các bài viết trên web hoặc vuốt qua các nguồn cấp dữ liệu).

    • Quan sát: Nếu số lượng Activities và WebViews vẫn ổn định (ví dụ: 1-2 thực thể đang hoạt động), thì ứng dụng không bị rò rỉ ngữ cảnh Activity hoặc thực thể Java WebView.
  2. Đo lường mức chênh lệch bộ nhớ giữa các lượt tương tác (Theo dõi chuỗi thời gian): Chụp ảnh nhanh dumpsys meminfo qua nhiều lượt tương tác của người dùng để tính toán tỷ lệ phân bổ cho mỗi lượt chuyển đổi:

    • Quan sát: Bộ nhớ khối xếp Java vẫn được giới hạn và hoạt động tốt (tăng đột biến trong quá trình sử dụng và giảm sau khi thu gom rác), nhưng Private Other (Riêng tư khác) và Native Heap (Bộ nhớ khối xếp gốc) tăng đều đặn vài megabyte cho mỗi lần chuyển đổi. Điều này chứng minh rằng lỗi rò rỉ hoàn toàn nằm trong bộ nhớ gốc bên ngoài thời gian chạy ART. Tệp báo lỗi vùng nhớ Java chuẩn (.hprof) sẽ không cho thấy bất kỳ vấn đề nào.
  3. Kiểm tra bản đồ bộ nhớ ẩn danh: Kiểm tra bản đồ bộ nhớ của quy trình bằng ADB (xem phần Kiểm tra bản đồ bộ nhớ và mức phân bổ):

    adb shell "cat /proc/$(pidof <var>PACKAGE_NAME</var>)/maps" | grep "anon:"
    • Quan sát: Mức tăng bộ nhớ tập trung vào vùng nhớ khối xếp của công cụ tập lệnh nhúng hoặc [anon:partition_alloc], kèm theo mức tăng chậm trong các tham chiếu toàn cục JNI. Điều này cho thấy rằng mặc dù các khung hiển thị Java đã được thay thế, nhưng các đối tượng trang gốc hoặc liên kết JavaScript cơ bản vẫn chưa được phát hành.
  4. Biện pháp khắc phục:

    • Đảm bảo rằng mọi WebView được tái chế hoặc loại bỏ đều dừng rõ ràng các tập lệnh đang hoạt động (stopLoading()), xoá nhật ký và gọi destroy().
    • Xoá các lệnh gọi lại cầu nối JavaScript tuỳ chỉnh hoặc các tệp đối chiếu toàn cục JNI liên kết với các thành phần hiển thị đã bị loại bỏ.
    • Xác nhận rằng Private Other và RSS của quy trình ổn định sau khi chuyển đổi điều hướng.

Tài nguyên khác

Để tìm hiểu thêm về cách gỡ lỗi và phân tích bộ nhớ cũng như hiệu suất của WebView, hãy xem các tài nguyên sau: