Bitmap và bộ nhớ

Các đối tượng bitmap thường là những thành phần đơn lẻ đóng góp nhiều nhất vào mức sử dụng bộ nhớ của một ứng dụng. Cho dù đó là biểu tượng ứng dụng, hình ảnh thông báo hay nội dung nghe nhìn, việc xử lý bitmap không hiệu quả có thể nhanh chóng dẫn đến lỗi Hết bộ nhớ (OOM) và áp lực bộ nhớ trên toàn hệ thống.

Cấu hình bitmap và dữ liệu pixel

Lượng bộ nhớ mà một bitmap tiêu thụ chủ yếu được xác định bằng kích thước (chiều rộng × chiều cao) và cấu hình (Bitmap.Config).

Cấu hình xác định số byte được dùng để biểu thị mỗi pixel:

Cấu hình Số byte trên mỗi pixel Mô tả
ALPHA_8 1 Chỉ có kênh alpha (độ trong suốt). Hữu ích cho mặt nạ.
RGB_565 2 Đỏ (5 bit), Xanh lục (6 bit), Xanh dương (5 bit). Không có phiên bản alpha. Phù hợp với hình ảnh mờ đục, trong đó độ trung thực cao về màu sắc không phải là yếu tố quan trọng.
ARGB_8888 4 Alpha, Đỏ, Lục, Lam (mỗi kênh 8 bit). Mặc định và phổ biến nhất.
RGBA_F16 8 Dấu phẩy động có độ bán chính xác. Dùng cho nội dung có gam màu rộng và HDR.
HARDWARE Không áp dụng Được lưu trữ trong bộ nhớ đồ hoạ (gralloc/DMABuf). Xem phần Bitmap phần cứng.

Công thức bộ nhớ: Memory (Bytes) = Width × Height × Bytes Per Pixel

Ví dụ: hình ảnh toàn màn hình trên thiết bị 1080p (1920x1080) trong ARGB_8888 sẽ có kích thước: 1920 × 1080 × 4 byte ≈ 8, 3 MB.

Bitmap trong vùng nhớ đệm so với bitmap dùng chung

Bitmap vùng nhớ khối xếp (vùng nhớ khối xếp gốc)

Trong Android hiện đại (8.0 trở lên), dữ liệu pixel bitmap được lưu trữ trong Vùng nhớ khối xếp gốc, trong khi chỉ có một đối tượng trình bao bọc nhỏ nằm trong vùng nhớ khối xếp Java.

Khi một ứng dụng cần trình bày hình ảnh, hình ảnh đó thường được giải mã từ một tệp hình ảnh nén thành Bitmap và được lưu trữ trong vùng nhớ heap.

Bitmap dùng chung (ashmem/memfd)

Khi một bitmap được chuyển giữa các quy trình (ví dụ: thông qua Binder đến SystemUI cho một thông báo), Android sẽ tránh sao chép dữ liệu pixel bằng cách sử dụng bộ nhớ dùng chung (ashmem hoặc memfd).

Bạn có thể sao chép một thực thể Bitmap vào bộ nhớ dùng chung một cách rõ ràng bằng cách gọi Bitmap.asShared() hoặc một cách ngầm ẩn nếu Bitmap được đặt bên trong Parcel (thường bằng cách thêm Bitmap vào Parcelable, chẳng hạn như Bundle) và gửi qua Binder IPC.

Khi một bitmap dùng chung được gửi qua Binder IPC, bản thân dữ liệu pixel sẽ không được sao chép, mà thay vào đó, một chỉ số mô tả tệp tham chiếu đến một vùng bộ nhớ dùng chung sẽ được sao chép vào quy trình nhận. Vùng nhớ cơ bản có thể được chia sẻ giữa nhiều quy trình và sẽ không được giải phóng cho đến khi tất cả các bộ mô tả tệp tham chiếu đến vùng nhớ đó đều đã đóng.

Bitmap có thể thay đổi so với bitmap không thể thay đổi

  • Bitmap có thể thay đổi: Có thể sửa đổi sau khi tạo (ví dụ: thông qua Canvas). Các bitmap này luôn yêu cầu phân bổ bộ nhớ riêng. Nếu một Bitmap có thể thay đổi được sao chép, thì bạn phải tạo một bản sao chép sâu (bản sao thứ hai của tất cả dữ liệu pixel).
  • Bitmap bất biến: Không thể thay đổi. Điều này cho phép tối ưu hoá, chẳng hạn như chia sẻ cùng một vùng đệm bộ nhớ cơ bản giữa các thực thể Bitmap khác nhau. Bitmap được tải từ tài nguyên APK (BitmapFactory) thường là bất biến.

Xử lý bitmap hiệu quả

Tập hợp và sử dụng lại bitmap

Việc phân bổ và huỷ phân bổ bitmap thường xuyên gây ra sự thay đổi phân bổ, buộc GC phải chạy liên tục. Các thư viện tải hình ảnh phổ biến sử dụng một Bitmap Pool (Nhóm Bitmap).

Google đề xuất Glide làm giải pháp cho các ứng dụng dựa trên Java và Coil cho các ứng dụng dựa trên Kotlin (đặc biệt là khi sử dụng Jetpack Compose).

Khi không cần dùng bitmap nữa, thay vì để bitmap được GC, ứng dụng sẽ gọi bitmap.recycle() hoặc trả bitmap về một nhóm. Lần tiếp theo cần dùng một bitmap có cùng kích thước và cấu hình, nhóm sẽ cung cấp vùng đệm hiện có, tránh việc phân bổ mới.

Bitmap phần cứng

Bitmap.Config.HARDWARE cho phép bạn lưu trữ dữ liệu pixel trực tiếp trong bộ nhớ đồ hoạ (DMABuf).

  • Ưu điểm:
    • Tiết kiệm bộ nhớ: Không dùng ứng dụng hoặc vùng nhớ heap gốc; dùng bộ nhớ GPU. Thường thì các Bitmap xuất hiện trong giao diện người dùng của ứng dụng vẫn cần được sao chép vào bộ nhớ GPU, vì vậy, thao tác này sẽ giúp bạn tiết kiệm thao tác sao chép và chi phí bộ nhớ phát sinh.
    • Hiệu suất: Vẽ cực kỳ nhanh vì dữ liệu đã có trên GPU.
  • Nhược điểm:
    • Không biến đổi: Bạn không thể sửa đổi bitmap phần cứng.
    • Đọc lại chậm: Việc truy cập vào các pixel từ CPU (ví dụ: getPixel()) rất tốn kém.
    • Phân bổ: Khó theo dõi hơn trong các công cụ tiêu chuẩn như AHAT (xem bên dưới).

Các lỗi thường gặp về bộ nhớ bitmap

Ngay cả khi bạn sử dụng các cấu hình bitmap hiện đại, một số mẫu định kỳ trong cách bạn giải mã và lập lịch bitmap có thể gây ra các mức tăng đột biến lớn về bộ nhớ.

Giải mã bitmap được điều chỉnh tỷ lệ quá mức

Một bức ảnh có độ phân giải đầy đủ 4000 × 3000 pixel sẽ chiếm 48 MB trong ARGB_8888. Việc giải mã toàn bộ hình ảnh đó chỉ để hiển thị trong hình thu nhỏ có kích thước 200 × 150 pixel sẽ lãng phí hơn 99% bộ đệm pixel được phân bổ.

Khi bạn giải mã trực tiếp hình ảnh bằng ImageDecoder hoặc BitmapFactory, hãy giảm tần số lấy mẫu trong quá trình giải mã để khớp với kích thước khung hiển thị mục tiêu bằng cách sử dụng ImageDecoder.setTargetSize() hoặc BitmapFactory.Options.inSampleSize. Các thư viện tải hình ảnh như Glide và Coil tự động thực hiện việc giảm tần số lấy mẫu này khi bạn cung cấp kích thước khung hiển thị mục tiêu có giới hạn.

Ví dụ: khi giải mã một bitmap bằng ImageDecoder, hãy truyền một OnHeaderDecodedListener để giảm kích thước đầu ra xuống kích thước khung hiển thị mục tiêu của bạn:

val source = ImageDecoder.createSource(resources, R.drawable.high_res_photo)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
    if (info.size.width > targetWidth || info.size.height > targetHeight) {
        decoder.setTargetSize(targetWidth, targetHeight)
    }
}

Tỷ lệ giữ chân người xem đồng thời cao nhờ tính năng giải mã song song

Ngay cả khi các bitmap riêng lẻ có kích thước phù hợp và tồn tại trong thời gian ngắn, việc giải mã nhiều hình ảnh song song có thể gây ra tình trạng tăng đột biến nghiêm trọng về bộ nhớ. Ví dụ: nếu một màn hình trình sắp xếp hoặc thư viện gửi 30 tác vụ trên một nhóm luồng không giới hạn để giải mã đồng thời các biểu tượng hoặc hình thu nhỏ, thì tất cả 30 vùng đệm pixel chưa nén và vùng đệm tạm thời của bộ giải mã sẽ chiếm bộ nhớ truy cập ngẫu nhiên (RAM) cùng một lúc.

Việc giữ lại đồng thời ở mức cao sẽ làm tăng mức sử dụng bộ nhớ heap gốc cao nhất và có thể kích hoạt lmkd huỷ trước khi lô hoàn tất. Giới hạn mức độ đồng thời giải mã bằng một nhóm luồng, semaphore hoặc trình điều phối coroutine có giới hạn, chẳng hạn như Dispatchers.IO.limitedParallelism(2) để chỉ một vài bitmap giải mã cùng lúc.

Bitmap tạm thời chưa được tái chế trong các vòng lặp xử lý khung hình

Trong Android 8.0 trở lên, đối tượng trình bao bọc Bitmap Java chỉ chiếm khoảng 56 byte trên vùng nhớ khối xếp Java, trong khi vùng đệm pixel của đối tượng này nằm trong vùng nhớ khối xếp gốc và có thể chiếm vài megabyte. Bạn có thể xác minh việc phân chia này trong Trình phân tích bộ nhớ của Android Studio hoặc AHAT, trong đó mỗi thực thể Bitmap cho thấy kích thước Java nông khoảng 56 byte cùng với kích thước gốc nhiều megabyte và trong dumpsys meminfo trong Native Allocations (Bitmap (malloced)).

Các quy trình có tần suất cao, chẳng hạn như phân tích khung hình camera, OCR hoặc các vòng lặp suy luận ML thường phân bổ một bitmap mới trên mỗi khung hình bằng cách gọi ImageProxy.toBitmap() và Bitmap.createBitmap() để xoay hoặc cắt. Việc thả các tham chiếu đến những khung hình bị thay thế mà không tái chế chúng có thể làm tăng bộ nhớ gốc. Các trình bao bọc Java nhỏ hầu như không làm tăng mức sử dụng vùng nhớ khối xếp Java, vì vậy, chúng không kích hoạt quy trình thu thập rác đủ nhanh để ngăn hàng trăm megabyte bộ đệm pixel gốc tích luỹ trước khi NativeAllocationRegistry thu hồi chúng.

Khi bạn xử lý các khung hình trong một vòng lặp chặt chẽ, hãy sử dụng lại các vùng đệm được phân bổ trước nếu có thể hoặc gọi bitmap.recycle() một cách rõ ràng trên các bitmap trung gian tạm thời ngay khi mỗi khung hình hoàn tất quá trình xử lý.

Bài tập thực hành: khám phá bitmap

Chúng ta sẽ dùng ứng dụng mẫu BitmapLab để khám phá những khái niệm này.

1. Đo lường bằng dumpsys meminfo

Khởi chạy BitmapLab rồi nhấn vào ALLOCATE 10MB ARGB_8888. Sau đó chạy:

adb shell dumpsys meminfo -s com.android.bitmaplab

Trên các phiên bản Android mới, hãy tìm mục Native Allocations (Phân bổ gốc). Những thông tin này cung cấp thông tin phân bổ tốt hơn nhiều cho bitmap so với Tóm tắt ứng dụng chung:

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240  # <--- 10MB Bitmap data!
Bitmap (nonmalloced):        0                              0
  • Bitmap (malloced): Bitmap được phân bổ trong vùng nhớ heap gốc của quy trình. Đây là nơi hầu hết các bitmap tiêu chuẩn nằm trong Android 8.0 trở lên.
  • Bitmap (nonmalloced): Bitmap sử dụng bộ nhớ chuyên dụng như Bitmap phần cứng hoặc Bitmap dùng chung (thông qua ashmem hoặc memfd).

Nếu phân bổ một Bitmap dùng chung trong BitmapLab, bạn sẽ thấy bitmap đó xuất hiện trong Bitmap (nonmalloced):

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240
Bitmap (nonmalloced):        1                          10240  # <--- Shared Bitmap!

Theo dõi bitmap dùng chung

Trên một số phiên bản Android và cấu hình kernel, dumpsys meminfo cũng cung cấp tính năng theo dõi độ phân giải cao cho các bitmap được ánh xạ vào không gian địa chỉ của quy trình thông qua các bộ mô tả tệp.

Theo mặc định, các bitmap dùng chung sẽ sử dụng một tên chung ("bitmap"). Để bật tính năng phân bổ chi tiết và theo dõi bitmap riêng biệt (xác định các bitmap dùng chung trên nhiều quy trình), bạn phải bật thuộc tính hệ thống sau:

adb shell setprop debug.hwui.bitmap_ashmem_long_name true

Khi được bật, các vùng ashmem trong /proc/<pid>/smaps sẽ có tên mô tả rõ ràng hơn. meminfo sẽ tận dụng điều đó và kết quả sẽ có dạng như sau:

 Shared Bitmaps
                         Count                       Size(KB)
                        ------                         ------
              Mapped:        1                          10240
              Unique:        1                          10240
  • Đã ánh xạ: Tổng kích thước của tất cả các ánh xạ bộ nhớ liên quan đến bitmap.
  • Riêng biệt: Kích thước của bitmap chỉ xem xét các giá trị riêng biệt (tức là hai hoặc nhiều ánh xạ của cùng một số lượng dữ liệu pixel Bitmap dùng chung cơ bản chỉ được tính một lần).

2. Bitmap trong AHAT

AHAT cung cấp khả năng trực quan hoá tuyệt vời cho Bitmap.

  1. Trong BitmapLab, hãy phân bổ một số bitmap.
  2. Chụp tệp báo lỗi bằng cờ -b (để đưa dữ liệu bitmap gốc vào):

    adb shell am dumpheap -b png com.android.bitmaplab /data/local/tmp/bitmaps.hprof
    adb pull /data/local/tmp/bitmaps.hprof .
    ahat bitmaps.hprof
    
  3. Mở localhost:7100 rồi tìm đường liên kết Bitmaps trong thanh bên hoặc tìm lớp Bitmap.

  4. AHAT sẽ kết xuất các bitmap trong trình duyệt, giúp bạn dễ dàng xác định những hình ảnh đang chiếm dụng bộ nhớ.

AHAT hiển thị các bitmap được kết xuất

3. Dấu vết bitmap trong Perfetto

Perfetto có thể theo dõi số lượng và mức phân bổ bitmap theo thời gian. Khung Android sẽ phát ra các bộ đếm này khi danh mục gfx atrace được bật cho một ứng dụng cụ thể.

  1. Bắt đầu theo dõi. Bạn phải thêm danh mục gfx và nhắm đến gói ứng dụng cụ thể bằng cờ -a:

    external/perfetto/tools/record_android_trace -o bitmaps.perfetto-trace \
        -t 15s -b 64mb view gfx dalvik am res memory -a com.android.bitmaplab
    
  2. Trong BitmapLab, hãy nhấn liên tục vào các nút Phân bổ và Xoá.

  3. Nhấn vào Parcel/Unparcel Bitmap (Phân chia/Huỷ phân chia ảnh bitmap).

  4. Phân tích dấu vết trong ui.perfetto.dev.

Trong phần quy trình cho com.android.bitmaplab, bạn sẽ thấy: * Số lượng bitmap: Bộ đếm cho biết số lượng bitmap đang hoạt động. * Bộ nhớ bitmap: Bộ đếm cho biết tổng số byte mà bitmap sử dụng.

Lát cắt cấp cao (Perfetto SDK)

BitmapLab cũng sử dụng Perfetto SDK để phát ra các lát cắt cấp cao cho các thao tác với bitmap. Tìm BitmapLab_ trong dấu vết để tìm: * BitmapLab_parcelUnparcel: Các lát cắt bao gồm logic phân chia và hợp nhất. * BitmapLab_postNotification: Các lát cắt bao gồm quy trình đăng thông báo.

Theo dõi quy trình thông báo

Khi bạn nhấn vào Đăng thông báo, ứng dụng sẽ tạo một thông báo chứa bitmap hiện tại và gửi thông báo đó đến hệ thống. Mã khung chịu trách nhiệm cho việc này sẽ phát ra các lát Perfetto với các sự kiện luồng kết nối việc phân chia (ghi bitmap vào một Parcel để gửi qua Binder IPC) và việc huỷ phân chia (đọc bitmap từ một Parcel ở đầu nhận).

Trong ảnh chụp màn hình bên dưới, bạn có thể thấy ứng dụng phân chia bitmap lớn để dùng trong một giao dịch Binder nhằm đăng thông báo và quy trình phân chia tương ứng trong quy trình system_server.

Perfetto cho thấy một luồng từ BitmapLab đến system_server thông qua Thông báo

Khi dùng Perfetto, bạn thậm chí có thể theo dõi cùng một bitmap thông báo khi bitmap này tiếp tục lan truyền trên các luồng và quy trình, chẳng hạn như từ một luồng Trình liên kết trong system_server (triển khai máy chủ Trình liên kết INotificationManager) đến các luồng worker system_server. Sau đó, các luồng này có thể chuyển tiếp cùng một bitmap đến com.android.systemui để hiển thị trong ngăn thông báo.

Thử thách dành cho ứng dụng hệ thống

Các ứng dụng hệ thống như SystemUI (Thông báo) và Trình chạy phải đối mặt với những thách thức riêng:

  1. Nội dung không giới hạn: Có thể có nhiều thông báo và tiện ích. Nếu mỗi thành phần giữ một bitmap lớn, hệ thống có thể nhanh chóng hết bộ nhớ.
  2. Sao chép: Cùng một biểu tượng ứng dụng có thể được lưu trong bộ nhớ đệm của Trình chạy, vùng thông báo của SystemUI và ứng dụng Cài đặt.
  3. Chia sẻ qua Vùng đệm phần cứng: Để giảm thiểu vấn đề này, các thành phần hệ thống đang chuyển sang một dịch vụ "tải hình ảnh xuống" tập trung, chia sẻ các thực thể HardwareBuffer trên các quy trình.
  4. Phân bổ DMABuf: Bitmap phần cứng tiết kiệm không gian heap nhưng sử dụng bộ nhớ DMABuf. Việc phân bổ bộ nhớ này cho một quy trình cụ thể trong các công cụ bộ nhớ tiêu chuẩn sẽ khó khăn hơn.

    Dùng adb shell dmabuf_dump để xem các hoạt động phân bổ DMABuf trên toàn hệ thống. Công cụ này cung cấp thông tin chi tiết về các vùng đệm theo từng quy trình:

     droid.bitmaplab:19562
                      Name              Rss              Pss         nr_procs            Inode               Exporter
                 <unknown>          3840 kB          1280 kB                3             3397              virtio_gpu
                    system            12 kB             4 kB                3             3398                  system
                 <unknown>          3840 kB          1920 kB                2             3399              virtio_gpu
                    system            12 kB             6 kB                2             3400                  system
             PROCESS TOTAL         11556 kB          5136 kB
    
    • RSS: Tổng kích thước của vùng đệm nếu vùng đệm được ánh xạ trong quy trình.
    • Pss: Kích thước theo tỷ lệ (RSS chia cho số lượng quy trình dùng chung vùng đệm). Đây là chỉ số phù hợp nhất cho việc kế toán.
    • nr_procs: Số lượng quy trình hiện đang giữ một tham chiếu đến vùng đệm này.
    • Trình xuất: Trình điều khiển đã phân bổ vùng đệm (ví dụ: virtio_gpu trên Cuttlefish hoặc một heap Ion/DMA-BUF dành riêng cho nhà cung cấp trên phần cứng).

    Bạn cũng có thể dùng adb shell dmabuf_dump -b để xem thông tin tóm tắt về tất cả các vùng đệm và tổng mức sử dụng DMA-BUF trên toàn hệ thống.


← Java | ↑ Lên | Gốc →