Thủ thuật và công cụ tối ưu ứng dụng Android

Thử thách sự kiên nhẫn của người dùng: lối tắt đến uninstallation.

Thiết bị Android giờ đây có nhiều lõi, nên viết app mượt mà trở nên quá dễ dàng đúng không? Sai rồi. Vì mọi thứ trên Android có thể được thực hiện theo nhiều cách khác nhau, việc chọn ra một cách tốt nhất không hề dễ dàng. Nếu muốn chọn được cách hiệu quả nhất, bạn cần hiểu được những gì đang thực sự diễn sâu bên trong. Và hiện có không ít công cụ giúp chúng ta biết được những thông tin này. Ứng dụng được tối ưu đúng cách không những chạy mượt mà, mà còn giúp cải thiện trải nghiệm người dùng, và ít tiêu tốn điện năng hơn.

Để thấy rõ hơn nữa tầm quan trọng của việc tối ưu, hãy xem thử những số liệu sau. Theo một bài viết trên Nimbleroid, 86% người dùng (trong đó có cả tôi) đã từng uninstall ứng dụng do hiệu năng kém. Nếu bạn phải load content, bạn phải hiển thị chúng trước mặt người dùng trong vòng ít hơn 11 giây. Ngoài khiến người dùng khó chịu và cuối cùng uninstall app, ứng dụng còn có thể nhận reviews xấu.

Điều đầu tiên mà bất cứ người dùng nào vẫn luôn chú ý đến là thời gian khởi động của ứng dụng. Theo một bài viết khác của Nimbledroid nữa, trong 100 ứng dụng hàng đầu, 40 ứng dụng mất chưa đến 2 giây để khởi động, và 70 ứng dụng khởi động trong chưa đến 3 giây. Vậy nên nếu có thể, bạn nên hiển thị một vài content ngay lập tức và delay background checks và updates một chút.

Bạn cũng phải nhớ, tối ưu “non” cũng là ngọn nguồn của thất bại. Bạn không nên tốt quá nhiều thời gian cho micro optimization. Bạn sẽ thấy lợi ích to lớn của việc chạy code tối ưu thường xuyên. Ví dụ như, onDraw() function, chạy mỗi frame, lý tưởng 60 lần một giây. Drawing là operation chậm nhất hiện nay, vì vậy bạn chỉ nên redraw nếu thật sự cần thiết.

Performance Tips

Dưới đây là một vài nội dung bạn cần quan tâm nếu bạn thực sự để ý đến hiệu suất.

1. String vs StringBuilder

Giả sử bạn có một String, và bạn muốn append thêm Strings vào đó mười nghìn lần. Đoạn code có thể như sau.

String string = "hello";
for (int i = 0; i < 10000; i++) {
    string += " world";
}

Trên Android Studio Monitors, bạn có thể thấy rõ nhiều liên kết String tỏ ta vô cùng thiếu hiệu quả. Có hàng đống Garbage Collections (GC) xuất hiện.

Operation này mất khoản 8 giây trên thiết bị (chạy Android 5.1.1) khá tốt của tôi. Ta còn có cách hiệu quả hơn nhiều cho mục tiêu này, đó là StringBuilder.

StringBuilder sb = new StringBuilder("hello");
for (int i = 0; i < 10000; i++) {
    sb.append(" world");
}
String string = sb.toString();

Cũng trên cùng một thiết bị, giải pháp này chỉ mất chưa đến 5ms. Lược đồ CPU và Memory cũng gần như bằng phẳng, như vậy bạn có thể thấy được, sự cải thiện là vô cùng rõ rệt. Dù vậy cũng nên chú ý, để có được khác biệt này, chúng ta đã phải append đến 10 nghìn Strings (việc mà bạn hiếm khi làm). Như vậy suy ra, nếu bạn thêm chỉ một vài Strings, sự cải thiện sẽ không quá rõ rệt. Bên cạnh đó, nếu bạn:

String string = "hello" + " world";

Như vậy, được chuyển sang StringBuilder, String sẽ làm việc bình thường.

Có lẽ bạn đang tự hỏi, tại sao cách nối Strings đầu tiên lại chậm như vậy? Lý do là vì Strings bất biến (một khi được tạo thì không cách nào thay đổi nữa). Ngay cả khi bạn nghĩ bạn đang thay đổi giá trị của một String, bạn thực tế đang tạo một String mới với giá trị mới. Trong ví dụ sau:

String myString = "hello";
myString += " world";

Trong memory, bạn sẽ không có 1 String “hello world”, mà thực ra có đến hai Strings. String myString sẽ chứa “hello world”, như bạn muốn. Tuy nhiên, String gốc “hello world” vẫn còn nguyên đó, mà không có reference, “nằm” đợi bị đẩy vào Garbage Collection. Đây chính là lý do bạn nên lưu trữ passwords trong char array thay vì String. Nếu bạn lưu trữ password dưới dạng String, String này sẽ ở trong memory, dạng human-readable format, đến khi GC tiếp theo cho độ dài thời gian không đoán trước được. Quay lại tính chất bất biến được nhắc đến ở trên, String sẽ ở trong memory ngay cả khi bạn chỉ định nó một giá trị khác sau khi sử dụng. Tuy nhiên, nếu bạn dọn sạch char array sau khi dùng password, password sẽ hoàn toàn biến mất.

2. Picking the Correct Data Type

Trước khi bắt đầu viết code, bạn nên quyết định sẽ dùng kiểu data nào cho collection. Ví dụ, bạn nên dùng Vector hay ArrayList? Còn phụ thuộc vào trường hợp sử dụng. Nếu bạn cần thread-safe collection (chỉ cho phép một thread một lúc), bạn nên chọn một Vector, vì lý do đồng bộ. Trong các trường hợp khác, bạn có lẽ nên trung thành với ArrayList, trừ khi bạn có một lí do cụ thể để sử dụng vectors.

Vậy trường hợp bạn muốn một collection với unique objects thì sao? Bạn có thể dùng Set. Chúng không thể chứa bản trùng lặp, vậy bạn sẽ không cần phải tự quản lý chúng. Có nhiều kiểu sets phù hợp với các mục đích khác nhau. Với nhóm unique items đơn giản, các bạn có thể dùng HashSet. Nếu bạn muốn bảo toàn thứ tự của items đúng như lúc được nhận, hãy chọn LinkedHashSet. TreeSet tự động sắp xếp items, vậy bạn sẽ không phải call bất cứ sorting methods nào lên đó. TreeSet còn sắp xếp items vô cùng hiệu quả, bạn không cần phải nghĩ thêm bất cứ thuật toán sắp xếp nào khác.

Data thống trị tất cả. Nếu bạn chọn đúng cấu trúc data và tổ chức data hợp lý, thuật toán sẽ tự động hiện ra. Như vậy, không phải thuật toán, mà cấu trúc data, mới là trung tâm của lập trình.

— Rob Pike’s 5 Rules of Programming

Sắp xếp intergers hay strings khá đơn giản. Tuy nhiên, nếu bạn muốn sếp một class theo tính chất nào đó? Giả sử bạn muốn viết danh sách bữa ăn (meal), và lưu trữ tên và mốc thời gian (timestamp). Bạn sẽ sếp bữa ăn theo mốc thời gian tăng dần như thế nào? Bạn chỉ việc implement Comparable interface vào class Meal và override function compareTo(). Để sắp xếp bữa ăn theo timestamps từ thấp đến cao, ta có thể viết:

@Override
public int compareTo(Object object) {
    Meal meal = (Meal) object;
    if (this.timestamp < meal.getTimestamp()) {
        return -1;
    } else if (this.timestamp > meal.getTimestamp()) {
        return 1;
    }
    return 0;
}

3. Location Updates

Để thu thập vị trí của người dùng, các bạn có thể dùng Google Location Service API (có chứa nhiều functions hữu ích).

Đi vào vấn đề chính, tôi muốn chỉ ra một số điểm quan trọng từ góc nhìn hiệu suất.

Trước hết, chỉ nên sử dụng địa chỉ chính xác nhất nếu cần thiết. Ví dụ như, với dự báo thời tiết, bạn không cần biết vị trí chính xác làm gì. Xác định một khu vực đại khái dựa trên network sẽ nhanh hơn và tiếp kiệm pin hơn nhiều. Để làm được như vậy, các bạn có thể set priority xuống LocationRequest.PRIORITY_LOW_POWER.

Bạn cũng có thể sử dụng function của LocationRequest setSmallestDisplacement(). Như vậy, nếu thay đổi vị trí nhỏ hơn giá trị đính trước, ứng dụng sẽ không được thông báo. Ví dụ, nếu bạn có bản đồ hiển trị nhà hàng xung quanh, và bạn đặt độ xê dịch thấp nhất là 20m, ứng dụng sẽ không yêu cầu xem thử nhà hàng nếu người dùng chỉ đi lanh quanh trong phòng. Yêu cầu sẽ trở nên vô dụng, vì dù gì cũng không có nhà hàng mới xuất hiện gần đấy.

Quy luật thứ hai là yêu cầu cập nhật vị trí với tần suất phù hợp. Quy luật này khá nhiển nhiên. Nếu bạn thực sự đang xây dựng ứng dụng dự báo thời tiết đó, bạn không cần phải yêu cầu vị trí mỗi vài giây, vì có lẽ bạn không cách nào dự báo chính xác đến cớ đó. Bạn có thể dùng function setInterval() để thiết đặt khoảng thời gian cách nhau của mỗi lần cập nhật vị trí cho ứng dụng. Nếu nhiều ứng dụng liên tục yêu cầu vị trí của người dùng, mỗi ứng dụng sẽ được thông báo mỗi khi cập nhật ví trí mới, ngay cả khi bạn thiết đặt setInterval() cao hơn đi chăng nữa. Để ứng dụng không được thông báo quá trường xuyên, luôn đặt tuần suất cập nhật nhanh nhất với setFastestInterval().

Và cuối cùng, quy luật thứ 3, chỉ yêu cầu cập nhật vị trí khi cần đến. Nếu bạn đang hiển thị các địa điểm gần đó trên map mỗi x giây và ứng dụng quay về background, bạn không cần phải biết vị trí mới. Ta không việc gì phải cập nhật map nếu người dùng không dùng thấy được. Hãy ngừng theo dõi cập nhật vị trí khi phù hợp, thường là trong lúc onPause(). Bạn sau đó có thể khôi phục cập nhật trong onResume().

4. Network Requests

Ứng dụng có khả năng rất cao đang sử dụng internet để download hay upload dữ liệu. Nếu đúng như vậy, bạn đã có lý do để chú ý đến handling network requests. Một trong số đó là dữ liệu di động, khá giới hạn với nhiều người và chúng ta không nên phí phạm quá nhiều.

Lý do thứ hai là pin. Cả WiFi lẫn mạng di động có thể ngốn rất nhiều pin nếu sử dụng quá mức. Giả sử bạn muốn tải 1 kb. Để thực hiện network request, bạn phải khởi động cellular hoặc WiFi radio, rồi mới có thể tải dữ liệu bạn cần. Tuy nhiên, radio sẽ không ngủ ngay lập tức sau khi hoạt động, mà sẽ tiếp tục hoạt động “lơ lửng” trong 20-40 giây tiếp theo, tùy vào thiết bị và nhà mạng.

Vậy ta giải quyết như thế nào? Batch. Để tránh khởi động lại radio mỗi vài giây, hãy nạp trước những dữ kiện mà người dùng có thể sẽ dùng trong những phút tiếp theo. Các batch phù hợp hoàn toàn phụ thuộc vào ứng dụng , nhưng nếu có thể, bạn nên download người dùng có thể sẽ cần trong 3-4 phút tiếp theo. Bạn cũng có thể điều chỉnh thông số batch dựa trên loại internet của người dùng, hoặc thay đổi state. Ví dụ, nếu người dùng vừa dùng WiFi vừa sạc, bạn có thể tải trước nhiều dữ liệu hơn khi người dùng dùng mạng di động với mức pin thấp. Ta khó có thể xem xét được hết các biến số có thể sảy ra. May mắn thay, ta đã có GCM Network Manager!

GCM Network Manager là một class vô cùng hữu ích với rất nhiều thông số tùy chỉnh. Bạn có thể dễ dàng lên lịch thực hiện các task lặp đi lặp lại hoặc một-lần-rồi-thôi. Với task lặp đi lặp lại, bạn có thể đặt tuần suất cao và thấp nhất. Như vậy, bạn không chỉ có thể batch request của mình, mà còn cả những requests từ các ứng dụng khác. Ta chỉ phải đánh thức radio một lần mỗi chu kỳ, và khi radio khởi động, tất cả ứng dụng trong queue sẽ download và upload những dữ liệu cần thiết. Manager này còn xác định được kiểu kết nối và trạng thái sạc của thiết bị và điều chỉnh cho phù hợp. Bạn có thể tìm thêm thông tin về GCM NM ở đây. Một số tasks minh họa:

Task task = new OneoffTask.Builder()
    .setService(CustomService.class)
    .setExecutionWindow(0, 30)
    .setTag(LogService.TAG_TASK_ONEOFF_LOG)
    .setUpdateCurrent(false)
    .setRequiredNetwork(Task.NETWORK_STATE_CONNECTED)
    .setRequiresCharging(false)
    .build();

Bên cạnh đó, kể từ Android 3.0, nếu bạn thực hiện một network request lên main thread, bạn sẽ thấy NetworkOnMainThreadException, cảnh báo bạn không tái phạm nữa.

5. Reflection

Reflection là năng lực tự theo dõi constructors, fields, methods,… của classes và objects. Reflection thường dùng cho tương thích ngược, để kiểm tra xem liệu một method nhất định có thích hợp cho phiên bản OS nào đó không. Nếu bạn phải dùng reflection cho mục đích này, hãy đảm bảo cache response, vì sử dụng reflection khá là chậm. Mội số thư viện phổ biến (như Roboguice) cũng có dùng Reflection cho dependency injection. Đó là lý do tại sao bạn nên ưu tiên Dagger 2 hơn. Để biết thêm về Reflection, đọc ở đây.

6. Autoboxing

Autoboxing và unboxing là quá trình chuyển đổi một kiểu nguyên mẫu sang kiểu Object, và ngược lại; hay trong thực tế, chuyển đối int sang interger. Để chuyển đổi, compiler sử dụng nội bộ function Integer.valueOf(). Quá trình chuyển đổi không chỉ chậm, mà Object còn chiếm nhiều bộ nhớ hơn kiểu nguyên mẫu tương ứng của chúng. Hãy xem thử.

Integer total = 0;
for (int i = 0; i < 1000000; i++) {
    total += i;
}

Cách trên trung bình mất khoảng 500ms, viết lại để tránh autoboxing sẽ cải thiện tốc độ đáng kể

int total = 0;
for (int i = 0; i < 1000000; i++) {
    total += i;
}

Giải pháp này chỉ mất khoảng 2ms, nhanh hơn đến 25 lần. Nếu bạn không tin tôi, kiểm tra thử đi. Kết quả hiển nhiên sẽ khác với mỗi thiết bị, nhưng nhìn chung vẫn nhanh hơn nhiều.

Trường hợp tạo biến của kiển Integer như trên có lẽ bạn không gặp quá nhiều. Nhưng còn những trường hợp bất khả kháng thì sao? Chẳng hạn một map, bạn phải sử dụng Objects (như Map<Integer, Integer>) ở đâu? Hãy xem thử giải pháp được nhiều người sử dụng.

Map<Integer, Integer> myMap = new HashMap<>();
for (int i = 0; i < 100000; i++) {
    myMap.put(i, random.nextInt());
}

Thêm 100k ints ngẫu nhiên vào map mất khoảng 250ms. Còn với giải pháp với SparselntArray thì sao?

SparseIntArray myArray = new SparseIntArray();
for (int i = 0; i < 100000; i++) {
    myArray.put(i, random.nextInt());
}

Nhanh hơn nhiều, mất khoảng 50ms. Đây cũng là một trong nhều cách tăng hiệu năng dễ dàng nhất, vì ta không phải làm gì phức tạp cả, và code cũng rất dễ đọc. Với một ứng dụng sạch sẽ, dùng cách thứ nhất sẽ mất đến 13MB bộ nhớ, trong khi ints nguyên mẫu chỉ mất chưa đến 7MB.

SparseIntArray một trong nhều collections “nhiệm màu” có thể giúp bạn tránh được autoboxing. Một map như Map<Integer, Long> có thể bị thay thế bởi SparseLongArray, vì giá trị của map theo kiểu Long. Nếu bạn nhìn vào source code của SparseLongArray, bạn sẽ thấy một điều khá thú vị. Về bản chất, đây chỉ là một cặp arrays thôi. Bạn cũng có thể dùng SparseBooleanArray theo cách tương tự.

Nếu bạn đọc source code, bạn chắc có để ý một note nói rằng SparseIntArray có thể chậm hơn HashMap. Nhưng qua rất nhiều lần thử nghiệm, thôi nhận thấy SparseIntArray luôn biểu hiện tốt hơn, cả về hiệu năng lẫn bộ nhớ.

7. OnDraw

Như tôi đa nói ở trên, bạn sẽ nhận thấy được lợi ích to lớn của việc tối ưu code đến hiệu năng. Một trong những functions được nhiều sự chú ý nhất là onDraw(). Có lẽ bạn sẽ không ngạc nhiên khi biết được đây là function đảm nhiệm drawing lên màn hình. Vì nhiều thiết bị thường hoạt động ở mức 60 fps, nên function cũng được chạy 60 lần một giây. Mỗi frame có khoảng 16ms để xử lý hoàn toàn (kể cả quá trình chuẩn bị lẫn drawing), nên bạn cần tránh những functions chạy chậm. Chỉ main thread có thể draw trên màn hình, vậy bạn nên tránh thực hiện các tác vụ “đắt đỏ” lên đó. Nếu bạn freeze main thread trong vài giây, bạn có thể nhận được vài thông báo Application Not Responding (ANR) tai tiếng. Với việc resize hình ảnh, hay công việc trên database,… sử dụng background thread.

Nếu bạn nghĩ rằng người dùng sẽ không để ý khoảng trống frame rate đó, bạn đã lầm rồi!

Tôi đã từng thấy nhiều người từng thử rút ngắn code, cho rằng như vậy sẽ hiệu quả hơn. Đây chưa hẳn là cách hay, vì code ngắn hơn không có nghĩa là nhanh hơn.

Một trong nhiều điều bạn nên tránh trong onDraw() là: phân phối các objects như Paint. Hãy chuẩn bị sẵn mọi thứ trước khi draw. Ngay cả khi bạn đã tối ưu onDraw(), bạn chỉ nên call ra khi thực sự cần thiết. Như vậy, không call function tối ưu thì phải làm gì mới tốt? Không call bất cứ function nào cả. Trong trường hợp phải draw text, ta có function khá gọn nhẹ drawText(), mà bạn có thể tinh chỉnh nhiều thứ như text, coordinates, hay màu text.

8. ViewHolders

Viewholder design pattern là một phương pháp giúp ta scroll list mượt mà hơn, một kiểu view cachung, có thể giảm mạnh việc call findViewById() và inflate views bằng cách lưu trữ chúng.

static class ViewHolder {
    TextView title;
    TextView text;

    public ViewHolder(View view) {
        title = (TextView) view.findViewById(R.id.title);
        text = (TextView) view.findViewById(R.id.text);
    }
}

Trong function getView() của adapter, bạn có thể kiểm tra xem đã có useable view chưa. Nếu không, cứ tạo một cái.

ViewHolder viewHolder;
if (convertView == null) {
    convertView = inflater.inflate(R.layout.list_item, viewGroup, false);
    viewHolder = new ViewHolder(convertView);
    convertView.setTag(viewHolder);
} else {
    viewHolder = (ViewHolder) convertView.getTag();
}

viewHolder.title.setText("Hello World");

9. Resizing Images

Đa phần, ứng dụng nào cũng chứa một vài hình ảnh trong đó. Khi bạn download một vài JPGs từ trên mạng, những hình ảnh này có độ phân giải rất cao, trong khi thiết bị chỉ có thể hiển thị được một phần nhỏ. Kể cả khi bạn chụp hình bằng camera của thiết bị, hình ảnh cũng cần phải downsize trước khi hiển thị vì độ phân giải của ảnh lớn hơn nhiều so với độ phân giải hiển thị. Việc Resize hình ảnh trước khi hiển thị cực kỳ quan trọng . Nếu cứ cố hiển thị ở độ phân giải tối đa, bạn sẽ hết bộ nhớ rất nhanh.

Vậy bạn có một bitmap, nhưng lại chả biết gì về nó. Bạn có thể sử dụng một flag Bitmaps tiện dụng mang tên inJustDecodeBounds, cho phép bạn tìm độ phân giải của bitmap. Giả sử bitmap bạn có là 1024×768, và ImageView được dùng để hiển thị chỉ ở 400×300. Bạn nên tiếp tục chia độ phân giải của bitmap cho 2 đến khi vừa lớn hơn ImageView. Như vậy, bitmap sẽ bị downsample theo hệ số 2, cho ra bitmap có độ phân giải 512×384. Bitmap đã downsample sẽ dùng ít bộ nhớ hơn đến 4 lần, tránh trường hợp OutOfMemory error vô cùng đáng tiếc.

Tuy nhiên, nếu ứng dụng có sử dụng nhiều hình ảnh, đây không phải là ý hay. Trường hợp này, hãy tránh xa việc resize và recycle hình ảnh bằng tay, hãy sử dụng thư viện của bên thứ ba cho việc này, một số công cụ nổi tiếng nhất là Picasso by Square, Universal Image Loader, Fresco by Facebook, hoặc công cụ ưa thích của tôi, Glide.

10. Strict Mode

Strict Mode là một công cụ phát triển khá tiện dụng, nhưng lại ít được biết đến. Công cụ thường được dùng để xác định network requests hoặc disk accesses từ main thread. Bạn có thể điều chỉnh Strict Mode tìm kiếm các sự cố xác định, và penalty được kích haotj. Ví dụ google như sau:

public void onCreate() {
    if (DEVELOPER_MODE) {
        StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
                .detectDiskReads()
                .detectDiskWrites()
                .detectNetwork()
                .penaltyLog()
                .build());
        StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
                .detectLeakedSqlLiteObjects()
                .detectLeakedClosableObjects()
                .penaltyLog()
                .penaltyDeath()
                .build());
    }
    super.onCreate();
}

Nếu bạn muốn xác định càng nhiều sự cố Strict Mode có thể, hãy dùng detectAll(). Tuy nhiên, bạn không nên cố gắng fix mọi Strict Mode reports. Chỉ nên điều tra trước, nếu bạn không rõ report có phải sự cố hay không, hãy để yên. Đồng thời, chỉ nên sử dụng Strict Mode dể debug, và luôn luôn disable khi vào production builds.

Debugging Performance: The Pro Way

Giờ hãy xem thử một số công cụ giúp bạn tìm bottlenecks (cố lọ, chỗ ùn tắc dữ liệu gây giật lag).

1. Android Monitor

Đây là công cụ được built thẳng vào Android Studio. Theo mặc định, bạn có thể tìm Android Monitor ở góc cuối bên trái màn hình. Có hai tabs: Logcat và Monitors. Phần Monitors chứa 4 lược đồ khác nhau. Network, CPU, GPU và Memory.

Phần Network hiển thị traffic (KB/s) ra và vào. Phần CPU hiển thị mức độ sử dụng (%). Màn hình GPU hiển thị thời gian dùng để render frames một UI window. Đây là phần chi tiết nhất trong 4 màn hình.

Cuối cùng, chúng ta có Memory monitor, đây có lẽ chính là phần mà chúng ta sẽ dùng nhiều nhất. Theo mặc định, phần này hiển thị lượng bộ nhớ Free và Allocated hiện có. Bạn cũng có thể chỉnh được Garbage Collection, để kiểm tra xem lượng bộ nhớ đã dùng có giảm thiểu hay không. Công cụ còn có một tính năng Dump Java Heap, giúp tạo file HPROF có thể mở bằng HPROF Viewer and Analyzer. Qua đó bạn có thể thấy được mình đã cấp phát bao nhiều objects, thứ gì chiếm bao nhiêu bộ nhớ, và có thể là objects đang gây thất thoát memory. Analyzer này không dễ sử dụng, nhưng rất đáng công sức tìm hiểu. Cũng trong Memory monitor, bạn có thể thực hiện Allocation Tracking hẹn giờ.

2. GPU Overdraw

Đây là một công cụ trợ giúp đơn giản, bạn có thể kích hoạt công cụ trong phần Tùy Chọn Phát Triển (khi đã enable developer mode trước). Khi lựa chọn Debug GPU overdraw ““Show overdraw areas””, và màn hình của bạn sẽ nhiểu thị nhiều thứ màu kỳ quái. Không sao đâu, cố ý cả đấy. Màu sắc thể hiện số lần overdraw ở một khu vực nhất định. Màu sắc không đổi đồng nghĩa khu vực đó không có overdraw (đây chính là mục tiêu ta hướng tới). Màu xanh dương là 1 overdraw, xanh lá 2, hồng 3, đỏ 4.

Bạn thường sẽ luôn thấy vài overdraw, đặc biệt quanh khu vực texts, navigation drawers, dialogs,… vậy nên bạn không cần phải tìm cách loại bỏ hết chúng đâu. Nếu ứng dụng hóa xanh (dương hoặc lá cây đều được) thì mọi chuyện còn khá ổn. Tuy nhiên nếu bạn thấy quá nhiều màu đỏ trên một số hoạt ảnh đơn giản, bạn sẽ muốn điều tra một chút đấy. Có thể là do quá nhiều fragments chồng lên nhau (nếu bạn chỉ chồng thêm mà không thay thế). Như tôi đã nói ở trên, drawing là phần chậm nhất trong ứng dụng, nên không việc gì phải draw thứ gì đó nếu có hơn 3 lớp được draw lên đó.

3. GPU Rendering

Một công cụ hay nữa từ Tùy Chọn Phát Triển, mang tên Profile GPU rendering. Khi lựa chọn công cụ, chọn tiếp “On screen as bars”. Bạn sẽ thấy một vài thanh màu hiện lên màn hình. Vì mỗi ứng dụng có nhiều thanh riêng biệt; kỳ lạ làm sao, thanh status cũng có nhiều thanh màu riêng, và nút định hướng ngay trên màn hình cũng có nhiều thanh như vậy. Nhìn chung, những thanh này cập nhật song song với thao tác của bạn lên màn hình.

Mỗi thanh có thể có 3 đến 4 màu, và theo Android docs, kích thước của mỗi thanh cũng có dụng ý riêng: càng nhỏ, càng tốt. Ở dưới, ta có màu xanh hiển thị thời gian dùng để tạo và cập nhật danh sách hiển thị của View. Nếu phần này quá dài, có nghĩa rằng rất nhiều custom view drawing tập trung ở đây, hoạc nhiều hoạt động diễn ra trong function onDraw(). Nếu thiết bị của bạn sử dụng Android 4.0 trở lên, bạn sẽ thấy thanh màu hồng chồng lên thanh màu xanh. Thanh này hiển thị thời gian dành cho việc chuyển đổi tài nguyên đến render thread. Và rồi phần màu đỏ, thể hiện thời gian 2D renderer của Android dùng cho việc cấp lệnh đến OpenGL để draw và redraw danh sách hiển thị. Trên cùng là thanh màu cam, thể hiện thời gian CPU đợi GPU hoàn thành công việc. Nếu thanh này quá dài, GPU đang hoạt động quá mức.

Còn một màu nữa bên trên màu cam. Đó là dòng xanh lá cây biểu thị ngưỡng 16ms. Vì bạn cần chạy ứng dụng ở mức 60fps, bạn có khoảng 16ms để draw mỗi frame. Nếu bạn không đáp ứng được, nhiều frame sẽ bị bỏ qua luôn, ứng dụng sẽ giật, và người dùng chắc chắn sẽ nhận ra được. Còn phải cực kỳ chú ý đến animations và scrolling, đây là nơi ta cần phải mượt mà nhất. Ngay cả khi với công cụ này, bạn xác định được một vài frames bị bỏ qua, ta vẫn chưa thực sự biết được vấn đề chính xác là gì.

4. Hierarchy Viewer

Đây là một trong những công cụ tôi thích nhất, đơn giản vì nó vô cùng mạnh mẽ. Bạn có thể khởi động từ Android Studio qua Tools -> Android -> Android Device Monitor, hoặc “monitor” trong sdk/tools folder. Bạn cũng có thể tìm hierarachyviewer đơn độc và thực thi được ở đây; nhưng nếu bị khất từ, bạn nên mở monitor. Tuy nhiên, bạn mở Android Device Monitor, hãy chuyển sang góc nhìn Hierarchy Viewer. Nếu bạn không thấy bất cứ ứng dụng nào đang chạy gắn với thiết bị, ta vẫn có vài cách xử lý. Topic này cũng có nhiều vấn đề và cách giải quyết phù hợp cho nhiều trường hợp.

Với Hierarchy Viewer, bạn có thể có được cái nhìn tổng quan về view hierarchies (hiển nhiên). Nếu bạn thấy mỗi layout với một XML riêng biệt, bạn có thể dễ dàng nhận ra views vô dụng. Tuy nhiên, nếu bạn tiếp tục kết hợp layouts, mọi thứ có thể trở nên rối tung rối mù. Công cụ như thế này giúp ta dễ nhận định hơn, ví dụ như, một số RelativeLayout, chỉ có 1 con, một RelativeLayour khác. Như vậy, một trong hai có thể bị xóa bỏ.

Không nên call requestLayout() để xem thử mỗi view lớn bao nhiều, vì có thể traverse (cắt) toàn bộ view hierarchy. Nếu có xung đột với measurement, hierachy sẽ được traverse (cắt ngang) nhiều lần. Nếu vấn đề này xảy ra trong vài animation, sẽ buộc thiết bị nhảy qua vài frames.

Góc phải bên trên chứa một nút phóng đại preview của một view cụ thể, trong một cửa sổ riêng. Bến dưới đó, bạn có thể thấy được preview thật sự của view trong ứng dụng. Mục tiếp theo là một số liệu, hiểu thị bao nhiều “con” view đó có, kể cả view đó. Nếu bạn chỉ định một node (thường là root) và nhấn “Obtain layout times” (3 vòng tròn 3 màu), bạn sẽ có thêm 3 giá trị nữa được fill, xuất hiện labelled measure, layout, và draw bên cạnh các vòng màu. Measure phase thể hiện khoảng thời gian để đo view được chỉ định. Layout phase là về thời gian render, còn drawing là thời gian thực hi draw thực tế. Những giá trị và màu sắc này đều có liên quan đến nhau. Màu xanh cho thấy view renders năm trong top 50% tất cả views trong tree. Màu vàng là chậm hơn 50%, mà màu đỏ là chậm nhất. Tất cả số liệu là so sánh tương quan, nên lúc nào cũng có màu đỏ cả.

Bên dưới giá trị ta có class name, như “TextView”, ID view nội bộ của object, và android:id của view bạn set trong files XML. Theo tôi, bạn nên tập thói quen thêm IDs vào mọi views, ngay cả khi bạn không reference chúng vào code. Như vậy việc xác định views trong Hierachy trở nên vô cùng đơn giản. Và nếu bạn có tests tự động trong project, thói quen này cũng giúp việc khoang vùng elements nhanh hơn. Cách thêm IDs vào etements được thêm vào files XML khá đơn giản. Nhưng còn element được thêm động (dynamically added elements) thì sao? Cũng đơn giản không kém. Chỉ việc tạo một file ids.xml trong values folder và gõ vào ô yêu cầu. Như sau:

<resources>
    <item name="item_title" type="id"/>
    <item name="item_body" type="id"/>
</resources>

Sau đó, trong đoạn code, bạn có thể dùng setId(R.id.item_title). Thật không thể nào đơn giản hơn nữa.

Còn một vài điều khác cần để ý khi tối ưu UI. Nhìn chung bạn nên tránh các hierarchies sâu mà tìm đến các hierarchies nông, và rộng hơn. Đừng dùng layout không cần đến. Ví dụ như, bạn có thể thay thế một nhóm LinearLayout được lồng vào nhau với RelativeLayout, hoặc TableLayout. Đừng ngại thử nghiệm với nhiều layouts khác nhau, không nên dùng mãi LinearLayoutRelativeLayout. Bạn cũng nên tạo thêm custom views khi cần thiết, như vậy có thể cải thiện hiệu suất đáng kể nếu thực hiện đúng cách. Ví dụ như, bạn có biết rằng Instagram không sử dụng TextViews để hiển thị comments?

Wrapping Up

Quá trì tối ưu Android thực sự còn phức tạp hơn thế này nhiều. Dù chỉ một khâu thôi, cũng có hằng hà vô số cách thực hiện, với ưu khuyết điểm riêng. chỉ khi đã hiểu được chuyện gì đang diễn ra bạn mới có thể biết được giải pháp nào là tốt nhất.

Techtalk via Toptal

Nguyễn Linh

Chia sẻ để cùng tiến bộ...