+8

Android App Performance - Part 1: Managing Memory

Đa số các thiết bị chạy Hệ điều hành (HĐH) Android đều là các thiết bị có bộ nhớ nhỏ hơn nhiều lần so với các thiết bị chạy HĐH nhân Linux khác, vì thế quản lý bộ nhớ khi xây dựng ứng dụng là một phần trong quá trình tối ưu hóa tốc độ, hiệu năng.

Trong phần này xin được giới thiệu sơ lược nhất về cách thức Android quản lý bộ nhớ, một vài khái niệm, chỉ dẫn giúp ích trong quá trình thiết kế cũng như triển khai ứng dụng.

I. Cách thức Android quản lý bộ nhớ

Android sử dụng nhân Linux, giống như các HĐH sử dụng nhân Linux khác, Android sử dụng bộ nhớ ảo cho phép phân trang (paging) và ánh xạ (mapping) bộ nhớ. Điểm khác biệt ở đây là Android không cung cấp bộ nhớ trao đổi (swap).

Điều này có nghĩa là mọi đối tượng được tạo ra hay vùng dữ liệu đã được phân trang đều nằm trên RAM và không tồn tại thao tác paged-in/ paged-out khi swapping bộ nhớ từ RAM ra disk.

Chính vì thế, chỉ có duy nhất một cách để giải phóng bộ nhớ trên Android đó chính là giải phóng các đối tượng thông qua việc loại bỏ tham chiếu tới chúng (strong/hard-reference), khi đó các đối tượng này sẽ được trình dọn rác (garbage collector) "dọn dẹp".

Chia sẻ bộ nhớ

Để cân bằng bộ nhớ, Android cố gắng tối ưu hóa số lượng trang bộ nhớ với số tiến trình thông qua các cách sau:

  • Tiến trình ứng dụng là một tiến trình con được phân chia (fork) từ một tiến trình có tên Zygote, hay nói cách khác Zygote là tiến trình cha của mọi tiến trình ứng dụng. Zygote được khởi chạy khi hệ thống boot. Để khởi tạo một tiến trình ứng dụng mới, hệ thống tạo một tiến trình con từ Zygote, tải và chạy code của ứng dụng. Chính nhờ đó mà các tiến trình ứng dụng sử dụng và kế thừa bộ nhớ cũng như các tài nguyên từ tiến trình Zygote.
  • Dữ liệu tĩnh, dữ liệu sử dụng chung nhất được quản lý bởi tiến trình cha, điều này cho phép dữ liệu được sử dụng và chia sẻ giữa các tiến trình con. Một số dữ liệu tĩnh như Dalvik code (file .odex), tài nguyên ứng dụng (tài nguyên trong các file .apk) hay thư viện code (file .so).
  • Android phân bổ bộ nhớ động giữa các tiến trình bằng cách chỉ định chính xác vùng bộ nhớ được chia sẻ sử dụng ashmem hoặc gralloc. Ví dụ như bộ nhớ con trỏ sử dụng phần bộ nhớ dùng chung với Content Provider hay window surfaces sử dụng phần bộ nhớ dùng chung với bộ nhớ ứng dụng.

Việc chia sẻ bộ nhớ giữa các ứng dụng được sử dụng rộng rãi trong Android, vì vậy việc theo dõi, đo đạc bộ nhớ sử dụng là hết sức cần thiết. Android SDK cung cấp công cụ cho phép thực hiện việc này như logcat, monitor tool, dumpsys. Trong phần cuối của bài viết sẽ hướng dẫn sử dụng một số công cụ này.

Zygote in Android

Chia sẻ bộ nhớ trong Android [2]

Cấp phát và Thu hồi bộ nhớ

Android sử dụng một số cách thức sau để cấp phát và thu hồi bộ nhớ:

  • Davik VM cung cấp phạm vi cho bộ nhớ heap với từng tiến trình. Bộ nhớ heap có thể tăng lên nhưng chỉ tới một giới hạn mà hệ thống cho phép.
  • Android sử dụng giá trị PSS của HĐH Linux (Proportional Set Size) để xác định số page mà tiến trình đang chiếm giữ (cả private page và share page). Thông số này giúp tính toán xem giá trị thực sự của bộ nhớ mà tiến trình chiếm giữ. Đồng nghĩa với đó, Android cũng đánh giá được khi nào thì tiến trình có thể bị kill do chiếm giữ quá nhiều bộ nhớ. [3]
  • Sau khi bộ dọn rác (GC) hoạt động Davik VM tìm các page không được sử dụng và giải phóng nó.

Hạn chế bộ nhớ

Để duy trì một môi trường đa tác vụ , Android thiết lập một giới hạn cứng về kích thước heap cho mỗi ứng dụng. Giới hạn kích thước heap chính xác khác nhau giữa các thiết bị dựa trên kích thước RAM. Nếu ứng dụng đã đạt đến giới hạn này và cố gắng yêu cầu cấp phát bộ nhớ thêm, exception OutOfMemoryError sẽ được trả về.

Để xác định chính xác giá trị heap trên thiết bị hiện tại, có thể truy vấn hệ thống bằng cách gọi getMemoryClass(), phương thức trả về số MB heap khả dụng trên thiết bị.

List Device Heap Size

Danh sách heap size của một số thiết bị

Chuyển đổi giữa các ứng dụng

Thay vì sử dụng bộ nhớ swap khi người dùng chuyển đổi giữa các ứng dụng, Android giữ các ứng dụng không được người dùng nhìn thấy trong một LRU cache. Hệ thống này giúp ứng dụng được lưu trữ, nếu người dùng quay lại ứng dụng quá trình này cho phép di chuyển qua lại giữa các ứng dụng nhanh chóng hơn.

Điều này làm giảm hiệu suất tổng thể của hệ thống, vì thế khi cần giải phóng bộ nhớ, Android sẽ tính toán và kill các ứng dụng, service thông qua một số chỉ số như: vị trí trong LRU cache, độ lớn bộ nhớ ...

Quản lý bộ nhớ trong ứng dụng

Việc quản lý bộ nhớ RAM được tiến hành ngay từ quá trình thiết kế ứng dụng, tới khi viết mã hay tối ưu mã nguồn. Có rất nhiều cách khác nhau để có thể thiết kế và viết mã hiệu quả hơn cũng như áp dụng các kĩ thuật nay đan xen với nhau.

Sử dụng hạn chế service

Các service thường được dùng để thực hiện các công việc trong background nhưng service có một số hạn chế:

  • Tiến trình Service được Android cho phép chạy nền tới khi nó tự kết thúc hay ứng dụng gọi lệnh dừng. Vì thế Service tiêu tốn tài nguyên hệ thống trong suốt quá trình hoạt động, thường là khoảng thời gian rất dài không cần thiết. Vì thế service làm chậm quá trình chuyển đổi giữa các ứng dụng.
  • Vì service làm giảm hiệu suất tổng thể nên Android sẽ ưu tiên việc kill các service so với các activity.

Vì vậy nên hạn chế sử dụng Service hoặc sử dụng IntentService thay thế để rút ngắn thời gian hoạt động.

Giải phóng tài nguyên khi hệ thống thiếu bộ nhớ

Giải phóng tài nguyên giúp tăng đáng kể khả năng hoạt động của hệ thống, tác động trực tiếp đến chất lượng của các trải nghiệm người dùng.

Với các version API < 14, Android cung cấp callback onLowMemory() trong interface ComponentCallbacks

public interface ComponentCallbacks {
    ....
    // Được gọi sau khi system kill tất cả các ứng dụng nằm trong background
    public void onLowMemory();
    ....
}

Còn với API > 14, Android cung cấp thêm interface ComponentCallbacks

public interface ComponentCallbacks2 extends ComponentCallbacks {
    ....
    // Được gọi khi system muốn ứng dụng giảm thiểu bộ nhớ
    public void onTrimMomory(int level);
    ....
}

Implement interface:

public class AlbumActivity implements ComponentCallbacks2 {
    ....
    @Override
    public void onTrimMemory(int level) {
        // Giải phóng bộ nhớ giúp tăng hiệu năng hệ thống và
        // giúp giảm thiểu khả năng bị system kill khi thiếu bộ nhớ.

        if (level >= TRIM_MEMORY_MODERATE) {
            // Ứng dụng nằm gần giữa LRU background app
            // Nên giải phóng cache
            mCache.evictAll();
        } else if (level >= TRIM_MEMORY_BACKGROUND) {
            // Ứng dụng nằm phía đầu trong LRU background app
            // Giảm thiểu size của cache
            mCache.trimToSize(mCache.size() / 2);
        }
    }
    ....
}

Tương ứng còn một số level cho thấy mức độ bộ nhớ và nguy cơ bị kill của ứng dụng như TRIM_MEMORY_RUNNING_MODERATE, TRIM_MEMORY_UI_HIDDEN, TRIM_MEMORY_COMPLETE ... Với mỗi mức độ ta nên có cách thức giải phóng bộ nhớ phù hợp để cân bằng giữa tài nguyên và trải nghiệm người dùng.

Tránh lãng phí bộ nhớ khi sử dụng bitmap

Load ảnh bitmap và giữ nó trong RAM cần hết sức lưu ý tới kích thước màn hình và độ phân giải của ảnh. Nên scale ảnh bitmap kích thước lớn xuống thay vì load ảnh trực tiếp. Với độ phân giải hay kích thước gấp đôi cần thêm bình phương bộ nhớ.

Dưới đây là ví dụ đơn giản cho việc tính toán kích thước bitmap và decode bitmap từ file. Android cung cấp thuộc tính inJustDecodeBounds trong class BitmapFactory.Options để tránh việc decode trực tiếp bitmap, khi đặt thuộc tính là true cho phép lấy về các giá trị outWidht, outHeight, outMimeType cho việc tính toán size của bitmap cần decode. [4]

    /**
     * Decode bitmap image from file.
     *
     * @param filename
     * @param reqWidth
     * @param reqHeight
     * @return
     */
    public Bitmap decodeBitmapFromFile(String filename, int reqWidth, int reqHeight) {

        // First decode with inJustDecodeBounds=true to check dimensions
        final BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeFile(filename, options);

        // Calculate inSampleSize
        options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

        // Decode bitmap with inSampleSize set
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeFile(filename, options);
    }

    /**
     * Calculate image in sample size.
     *
     * @param options
     * @param reqWidth
     * @param reqHeight
     * @return
     */
    public static int calculateInSampleSize(BitmapFactory.Options options,
                                            int reqWidth, int reqHeight) {
        // Raw height and width of image
        final int height = options.outHeight;
        final int width = options.outWidth;
        int inSampleSize = 1;

        if (height > reqHeight || width > reqWidth) {

            final int halfHeight = height / 2;
            final int halfWidth = width / 2;

            /*
            Calculate the largest inSampleSize value that is a power of 2 and keeps both
            height and width larger than the requested height and width.
            */
            while ((halfHeight / inSampleSize) > reqHeight
                    && (halfWidth / inSampleSize) > reqWidth) {
                inSampleSize *= 2;
            }

            // Anything more than 2x the requested pixels, sample down further
            inSampleSize *= 2;
        }
        return inSampleSize;
    }

Sử dụng các container tối ưu

Android cung cấp một số container được tối ưu hóa trên nền tảng này thay thế một phần cho các collection container trong Java như Array, List, Map ... , chẳng hạn như SparseArray, SparseBooleanArray, LongSparseArray.

Dưới đây là ví dụ sử dụng SparseArray thay thế cho HashMap:

Sử dụng HashMap thông thường với một giá trị key là một object Interger có thể không hiểu quả vì nó với mỗi Object value cần tương ứng một object Integer. SparseArray được thiết kế hiệu quả hơn bằng cách sử dụng giá trị int thay vì một object Integer, việc sử dụng primitive variable trong một số trường hợp SparseArray giữ Object tránh được việc Garbage Collector dọn dẹp bộ nhớ.

# HashMap
Map<Integer, Bitmap> bitmapCache = new HashMap<Integer, Bitmap>();
private void fillBitmapCache() {
     bitmapCache.put(R.drawable.icon, BitmapFactory.decodeResource(getResources(), R.drawable.icon));
     bitmapCache.put(R.drawable.abstrakt, BitmapFactory.decodeResource(getResources(), R.drawable.abstrakt));
     bitmapCache.put(R.drawable.wallpaper, BitmapFactory.decodeResource(getResources(), R.drawable.wallpaper));
     bitmapCache.put(R.drawable.scissors, BitmapFactory.decodeResource(getResources(),
 }

Bitmap bm = bitmapCache.get(R.drawable.icon);
#SparseArray
SparseArray<Bitmap> bitmapCache = new SparseArray<Bitmap>();
private void fillBitmapCache() {
     bitmapCache.put(R.drawable.icon, BitmapFactory.decodeResource(getResources(), R.drawable.icon));
     bitmapCache.put(R.drawable.abstrakt, BitmapFactory.decodeResource(getResources(), R.drawable.abstrakt));
     bitmapCache.put(R.drawable.wallpaper, BitmapFactory.decodeResource(getResources(), R.drawable.wallpaper));
     bitmapCache.put(R.drawable.scissors, BitmapFactory.decodeResource(getResources(),
 }

Bitmap bm = bitmapCache.get(R.drawable.icon);

Có ý thức về tiêu tốn bộ nhớ

Cần nắm rõ chi phí tài nguyên của ngôn ngữ cũng như các thư viện đang sử dụng, giữ ý thức về việc tiêu tốn tài nguyên ngay từ khi viết dòng lệnh đầu tiên.

Một số ví dụ cho việc tiêu tốn bộ nhớ khi sử dụng:

  • Enums cần nhiều hơn gấp đôi bộ nhớ so với biến static. Nên tránh sử dụng Enum trong Android.
  • Mỗi clss trong Java (bao gồm cả các inner class) sử dụng khoảng 500 bytes.
  • Mỗi instance tiêu tốn khoảng 12-16 bytes.
  • Thêm một phần tử vào HashMap tiêu tốn thêm 32 bytes [5]
  • Abtractions được ví như "good programming practice", thông thường developer tạo nên các lớp abtract, interface giúp việc phát triển, cải thiện, bảo trì code được dễ dàng và linh hoạt. Nhưng đi kèm với đó là sự phức tạp trong cấu trúc chương trình, liên kết giữa các lớp dẫn tới chi phí cho bộ nhớ cũng tăng lên. Cần cân bằng giữa lợi ích của trừu tượng hóa mang lại và chi phí cho tài nguyên.

Chi phí bộ nhớ sẽ tăng lên nhanh chóng khi ứng dụng được thiết kế với cấu trúc class phức tạp, các object với nhiều thuộc tính tiêu tốn bộ nhớ như bitmap, collection, ... Tiết kiệm từng chút tài nguyên sẽ không cải thiện hiệu năng tức thì của hệ thống nhưng đảm bảo được tính bền vững, cho phép ứng dụng chạy trên nhiều thiết bị khác nhau.

Sử dụng protobufs cho dữ liệu serialized

Protobufs (Protocol Buffers) là ngôn ngữ, một nền tảng được thiết kế bởi Google cho dữ liệu tuần tự hóa có cấu trúc như XML, nhưng nhỏ hơn, nhanh hơn và đơn giản hơn. Nếu sử dụng protobufs cho dữ liệu phía server, cũng cần đồng thời sử dụng protobufs nano tại phía client. Protobufs tạo ra mã cực kỳ chi tiết, giải quyết một số vấn đề trong ứng dụng: bộ nhớ RAM, kích thước file apk, ứng dụng chạy chậm, ...

Tránh sử dụng các framework Dependency injection (DI)

Sử dụng một số framework thiết kế với DI như Guice hoặc RoboGuice có thể đơn giản hóa code và cung cấp một môi trường tốt cho việc testing hay phát triển, nâng cấp ứng dụng.

Tuy nhiên, những framework này thực hiện quá trình khởi tạo bằng cách quét code và bắt lấy các annotations, điều này làm tăng đáng kể số lượng code để ánh xạ giữa các class và annotations, khi đó cũng tăng đáng kể bộ nhớ ngay cả khi không cần nó tới các ánh xạ này. Code của các annotations này được load vào bộ nhớ nhưng các page được mapping này trong bộ nhớ sẽ không được giải phóng cho đến khi các chúng không được sử dụng trong thời gian dài.

Chú ý khi sử dụng các thư viện mở rộng

Các thư viện mở rộng cho Java thường không được viết cho môi trường di động và có thể không có hiệu quả khi được sử dụng cho trên một thiết bị di động.

Ngay cả các thư viện được cho là thiết kế để sử dụng trên Android có thể nguy hiểm vì mỗi thư viện có thể làm những việc khác nhau, có thể xung đột với nhau. Ví dụ, một trong những thư viện có thể sử dụng protobufs nano trong khi thư viện khác sử dụng protobufs micro; hay như sử dụng cùng một lúc hai thư viện hỗ trợ download, cache ảnh từ internet có thể khiến bộ nh tăng lên nhiều lần hoặc xung đột giữa việc khởi tạo các thư mục lưu ảnh trên SDcard.

Cũng nên chú ý khi sử dụng thư viện khi chỉ cần dùng một vài trong hàng chục chức năng mà thư viện cung cấp, khi đó file apk lớn hơn vì phần code không cần thiết hay bộ nhớ tăng lên vì những chức năng không được dùng tới.

Sử dụng ProGuard loại bỏ code không cần thiết

ProGuard là công cụ được cung cấp kèm theo bản SDK, ProGuard cắt bớt, tối ưu hóa, và mã hóa giả code bằng cách loại bỏ mã không sử dụng, đổi tên các lớp, trường, các phương thức với tên tối nghĩa về mặt ngữ nghĩa nhưng tối ưu về mặt tài nguyên. Sử dụng ProGuard thể làm cho mã của bạn gọn gàng hơn, giảm số lượng page để mapping code trên RAM.

ProGuard được cấu hình thông qua file proguard.config trong project.

-optimizationpasses 5
-dontusemixedcaseclassnames
-dontskipnonpubliclibraryclasses
-dontpreverify
-verbose
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*

-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.app.backup.BackupAgentHelper
-keep public class * extends android.preference.Preference
-keep public class com.android.vending.licensing.ILicensingService

..................

Ví dụ về class trước và sau khi được obfuscated

// Input
public class MainActivity extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState){
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        Button button = (Button) findViewById(R.id.button);
    }

}

// Output
// Input
public class a extends ac {
    @Override
    public void c(b s){
        super.c(s);
        s(190219922015);
        bt ct = (bt) f(19692014969);
    }
}

Sử dụng đa tiến trình

Đa tiến trình - Multiple processes là một kỹ thuật tiên tiến có thể giúp tăng hiệu năng, trải nghiệm người dùng bằng cách chia các thành phần của ứng dụng vào nhiều tiến trình khác nhau.

Kỹ thuật phải được sử dụng một cách cẩn thận và hầu hết các ứng dụng không nên chạy trên nhiều tiến trình khác nhau vì nó có thể dễ dàng chiếm dụng bộ nhớ nhiều hơn nếu thực hiện không đúng. Phương pháp này hữu ích cho các ứng dụng thực thi các công việc riêng rẽ cả ở foreground cũng như background.

Một ví dụ cho việc xây dựng ứng dụng đa tiến trình là một trình chơi nhạc có thể hoạt động khi nằm trong background và hoạt động với thời gian dài. Nếu toàn bộ ứng dụng chạy trong một tiến trình, sau đó phân bổ tài nguyên để vừa đáp ứng được giao diện và tương tác người dùng, vừa chơi nhạc liên tục, ngay cả khi người dùng đang sử dụng ứng dụng khác. Một ứng dụng như vậy có thể được chia thành hai tiến trình: một cho giao diện, và một cho công việc chạy trong background.

Có thể thiết lập một tiến trình riêng biệt cho mỗi thành phần ứng dụng bằng cách định nghĩa trong Manifest file bằng thuộc tính android:process.

Ví dụ, bạn có thể định nghĩa một service sẽ chạy trong một tiến trình riêng biệt từ tiến trình chính của ứng dụng bằng cách khai báo một tiến trình mới có tên là "background":

<service android:name=".PlaybackService"
         android:process=":background" />

Trước khi quyết định để tạo ra một tiến trình mới, cần phải đánh giá được sự cần thiết khi tạo tiến trình này vì một tiến trình mới không làm bất cứ việc gì tiêu tốn thêm khoảng 1.4MB Private Drity.

MEMINFO Empty Process

Nếu chia ứng dụng thành nhiều tiến trình, chỉ nên để có một tiến trình xây dựng giao diện và tương tác với người dùng. Các tiến trình khác nên tránh các tương tác UI, vì điều này sẽ nhanh chóng tăng bộ nhớ RAM của tiến trình (đặc biệt là khi load bitmap, resource hay các tài nguyên khác).

Tương tự như vậy, khi đã chia tách ứng dụng thành nhiều tiến trình khác nhau, nên tránh sự kết nối giữa giao diện hay tài nguyên của các tiến trình này, các tiến trình nên độc lập và có giao diện riêng biệt cũng như code riêng rẽ để tránh việc một tiến trình nằm trong background liên kết tới một tiến trình trong foreground làm tăng gấp đôi lượng page mà tiến trình đó chiếm giữ trên RAM.

Tổng kết

Trong phần I đã giới thiệu một vài khái niệm, lưu ý khi nghiên cứu xây dựng, triển khai ứng dụng trên nền tảng Android.

Trong phần tiếp theo sẽ tìm hiểu cách đo đạc, phân tích bộ nhớ, một số performance tips khi coding cũng như cách thức tối ưu khi xây dựng UI.

Reference

[1] Android Developer - Managing Your App's Memory

[2] Android is NOT just ‘Java on Linux’

[3] Android Memory Management: Understanding App PSS

[4] Managing Bitmap Memory

[5] From Java code to Java heap

[6] Google I/O 2012 - Doing More With Less: Being a Good Android Citizen


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.