Nguyên nhân gây Memory leak trong Android

Trong bài viết trước chúng ta đã hình dung ra được nguyên nhân gây ra memory leak trong java. Từ vấn đề đó mà trong java tìm ẩn vô cùng nhiều bẫy ẩn khiến cho lập trình viên bị lạc vào mê lộ Memory leak lúc nào không hay. Những tip and trick về java mình sẽ trình bày ngay sau đây thôi nhưng mục tiêu của loạt bài này là Android. Chính vì vậy chúng ta sẽ cùng tìm hiểu về cơ chế sâu xa có thể gây ra memory leak trong Android trước nhé.

I.Cơ chế Looper và Handler trong Android

1.Mesage looper trong Android

Khi một ứng dụng Android mới bắt đầu, framework khởi tạo một đối tượng Looper cho mainthread của ứng dụng. Một Looper sẽ triển khai một message queue đơn giản, xử lí đối tượng Message tuần tự trong vòng lặp. Tất cả các sự kiện chính trong framework ứng dụng (Ví dụ như Activity lifecycle method call, bấm nút…) đều được bao bọc bởi đối tượng Message. Các đối tượng này được thêm vào trong Looper message queue và được xử lí từng cái một.

2.Handler trong Android

Khi một Handler được khởi tạo trong mainthread. Chúng được liên kết với Looper message queue. Message được đưa vào trong message queue được giữ một tham chiếu đến với Handler giúp cho framework có thể call Handler#handlerMessage(Message) khi Looper hoàn thiện xử lí với message.

II.Lỗi lập trình gây leak trong Android

1.Sử dụng một đối tượng quá lớn

Quá dễ hiểu, một đối tượng quá lớn bản thân nó sẽ có một retained size lớn tương tự với việc nó sẽ chiếm một không gian lưu trữ lớn hơn và cũng lâu để được giải phóng bởi gc hơn. Điều này sẽ gây ra rằng đôi khi có một vài thuộc tính của đối tượng sẽ không cần thiết và không được sử dụng. Chúng ta đã có memory leak ngay trong đối tượng mà chúng ta tạo ra.

Để khắc phục điều này các bạn có thể nghiên cứu tới Builder pattern. Nếu có dịp tôi sẽ trình bày về design pattern này, Một design pattern khá thú vị. Ý nghĩa của nó là đưa ra đúng đối tượng phù hợp với thông số được truyền vào và như thế chúng ta có thể hạn chế được các chi tiết thừa không cần thiết được tạo mặc định trong đối tượng của chúng ta.

Tuy nhiên bạn không thể dùng được Builder với Activity hay Fragment. Vậy nên hãy tối ưu hóa chúng. Hãy dùng tới MVP. MVP là một thứ rất tuyệt vời mà sau hai năm tiếp cận với Android tôi mới cảm thấy được. Nếu có thời gian tôi sẽ viết về nó. Chỉ có điều tôi không hiểu là tại sao Android Studio không đưa nó vào thành một framework luôn trong Android nhỉ?

2.Sử dụng sai context

Loại context nào nên được sử dụng?

  • Application context?
  • Activity context?

Context nào sẽ phù hợp hơn trong hoàn cảnh này?

new ArrayAdapter(this, android.R.layout.simple_list_item_1, list);
new ArrayAdapter(getApplicationContext(), android.R.layout.simple_list_item_1, list);
new ArrayAdapter(getBaseContext(), android.R.layout.simple_list_item_1, list);

Nếu bạn chưa biết ArrayAdapter không tồn tại cùng vòng đời với Activity bằng chứng là ArrayAdapter sẽ vẫn còn khi Activity được hủy đi. Một phép thử đơn giản đó là bạn đặt một check null cho biến adapter. Khi thực hiện xoay màn hình, view được vẽ lại nhưng adapter lúc này lại hoàn toàn không bị null. Chính vì vậy mà lựa chọn tốt nhất lúc này là chúng ta phải lựa chọn đó là phương án số 2. Đến với một ví dụ nữa

new TextView(this);
new TextView(getApplicationContext());
new TextView(getBaseContext());

Khác với trong ví dụ trên, lần này chúng ta có TextView là một dạng view tồn tại cùng vòng đời đối với Activity. Chính vì vậy, context hợp lí của chúng ta phải là phương án thứ nhất.

Dù chỉ là vấn đề lựa chọn context thôi nhưng chúng ta cần có một sự tỉnh táo và hiểu biết nhất định để lựa chọn. Nên nhớ, với các context tương ứng thì chúng đều là Activity hoặc Application và cùng kế thừa lại BaseContext. Chính vì vậy việc truyền sai context sẽ khiến chúng ta vô tình keep lại activity cùng với đối tượng mà chúng ta dùng để truyền. Trong Android, Activity là đối tượng gần như nặng nề nhất do chúng phải chiếm giữ các reference tới tài nguyên (resource) của hệ thống, view hiển thị hiện thời trên màn hình cũng như các thông tin khác. Chính vì vậy lỗi truyền sai context có thể để lại những hậu quả rất nặng nề cho ứng dụng. Tuy nhiên chúng ta cũng có những mẹo sau để hạn chế vấn đề này xảy ra.

  • Cố gắng sử dụng Application Context nhiều nhất có thể.
  • Chỉ sử dụng Activity khi cần thiết. Nghĩa là đối tượng sẽ được tồn tại gắn liền với Activity hoặc là phương thức cần truyền Context mô tả việc yêu cầu sử dụng nó.
  • Không bao giờ được sử dụng getBaseContext() trừ phi bạn đang viết một thư viện hệ thống và bản thân bạn nắm rất rõ về phương thức này.

3.Không hủy dialog trong fragment

Khác với Activity, dialog không sống cùng với fragment. Đơn giản bởi vì bạn không thể truyền context của fragment cho dialog. Điều này có nghĩa là khi bạn rời khỏi framgment thì dialog nằm trong fragment cũng vẫn có thể vẫn còn tồn tại. Điều này khiến cho dialog của bạn trong nhiều trường hợp không thực sự có giá trị. Hãy tưởng tượng một dialog agePicker hiển thị trên màn hình không chứa bất kì một thành phần hiển thị tuổi nào. Thứ nhất là nó sẽ rất vô duyên, thứ nhì là khi bạn bấm ok thì những phần tử của màn hình agePickerFragment đã mất rồi và crash app hoàn toàn có thể xảy ra.

Để khắc phục điều này chúng ta sẽ phải @Override lại phương thức onDestroyView() như sau:

@Override
public void onDestroyView(){
    super.onDestroyView();
    if (progressDialog != null) {
        progressDialog.dismiss()
    }
}

Ở đây chúng ta có progressDialog là dialog chịu trách nhiệm hiển thị khi có sự call API của màn hình hiện tại. Nếu như chuyển khỏi màn hình khác thì chúng ta cũng không cần phải để nó hiện ra nữa.

Thực ra mình cũng không thích progressDialog này lắm vì nó đi ngược lại tư duy thiết kế về mặt UX của mình. Thế nhưng khách hàng thích, họ trả tiền, đành chiều.

4.Sử dụng sai static trong Android

Với đoạn code sau thì chưa bao giờ có vấn đề gì:

@Override
protected void onCreate(Bundle state) {
    super.onCreate(state);
    TextView label = new TextView(this);
    label.setText("Leaks are bad");
    setContentView(label);
}

Tuy nhiên nó sẽ trở thành một vấn đề khi đoạn code được bổ xung thêm chức năng như sau:

private static Drawable sBackground;

@Override
protected void onCreate(Bundle state) {
     super.onCreate(state);
    TextView label = new TextView(this);
    label.setText("Leaks are bad");

    if (sBackground == null) {
        sBackground = getDrawable(R.drawable.large_bitmap);
    }
    label.setBackgroundDrawable(sBackground);
    setContentView(label);

}

Có một điều mà chúng ta nên biết về Drawable trong Android. Khi một drawable được truyền vào view, nó sẽ giữ một tham chiếu tới TextView. TextView có một strong references đến Activity. Drawable không phải là một phần tử nằm trong danh sách thu hồi của GC do nó đang được khai báo là static và cũng tương tự như thế, cả TextView lẫn Activity đều không được.

Phần lớn các drawable tôi từng sử dụng thì không được gắn vào Views, nhưng một vài trong số chúng thì có, và cho đến khi nào tôi còn giữ các tham chiếu của view tới các đối tượng drawable thì khi đó chúng còn tiềm ẩn khả năng gây leak. Tại sao? Bởi vì drawable yêu cầu rằng client của nó (thường là view) sẽ phải triển khai một Drawable.Callback interface. Interface này được sử dụng để drawable thực thi anination tương ứng với nhiệm vụ. Vậy nếu như View khi được loại bỏ khỏi window/activity nhưng drawable vẫn tiếp tục giữ tham chiếu này thì View cũng sẽ vẫn giữ đến tham chiếu đó và không bao giờ bị thu hồi bởi GC.

Cách để xử lí trường hợp này là tôi tạo ra thêm một phương thức nhằm hủy callback này khi view được loại bỏ khỏi màn hình.

private static void unbindDrawable(Drawable d){
    if(d!=null){
        d.setCallback(null);
    }
}

Phương thức này sẽ được tôi gọi đến trong phương thức onDetachedFromWindow() của mỗi view mà tôi đã thực hiện tùy biến như sau:

@Override
public void onDetachedFromWindow() {
     if (AnyApplication.DEBUG)
         Log.d(TAG, "onDetachedFromWindow");
     super.onDetachedFromWindow();
     AnyApplication.getConfig().removeChangedListener(this);

     //cleaning up memory
    unbindDrawable(mPreviewPopup.getBackground());
    unbindDrawable(getBackground());
    //...
}

Với cách phía trên, chúng ta đã loại bỏ được những tham chiếu không cần thiết đến với view khi view này không còn được sử dụng nữa.

5.Tại sao nên để Handlers là static?

Chúng ta sẽ cùng đi qua một ví dụ về Handler lỗi:

class MainActivity extends Activity{
    Handler handler = new Handler(){
        @Override
        public void handleMessage(Message msg) {
             super.handleMessage(msg);
        }
    };
}

Một đoạn code tưởng chừng như vô hại thế nhưng chúng ta cần phải nhớ. Class Handler trên nên để là static hoặc sẽ xuất hiện memory leak.

Khi một class được đặt bên trong một class khác nó sẽ ngăn cản class bao bọc nó được thu hổi bởi GC. Nếu như Hanlder sử dụng Looper hoặc MessageQueue của một thread khác thread main thì sẽ không có vấn đề gì xảy ra. Tuy nhiên nếu không bạn cần phải đặt lại class này là static. Mục đích của việc này là để tạo ra một week reference tới class bên ngoài. Một cách khác là bạn cũng có thể sử dụng phương pháp là truyền vào Handler khi khởi tạo giá trị cho nó thay vì khởi tạo giá trị ngay trong class bao bọc.

6.Có nên để inner classes là static?

Vậy đâu là rủi ro khi sử dụng non-static inner class?

Trong Java, một non-static inner class hoặc một class ẩn danh giữ và ngấm ngầm tham chiếu đến outer class của nó. Static inner class mặt khác lại không làm điều này. Chính vì vậy chúng ta sẽ nên tạo một thói quen suy nghĩ trước khi tạo ta một inner class. Nếu có thời gian mình sẽ viết về inner class. Tại sao lại cần chúng.

7.Đăng kí các receiver, service hoặc các listener

Đăng kí các service và không hủy đi cũng như đăng kí bằng cách add các listener mà không hủy chúng đi là một điều rất nguy hiểm. Có rất nhiều bạn bỏ qua việc un-register các BroadcastReceiver, các listener được add vào các phần tử static… Điều này thực sự nguy hiểm khi activity được giải phóng nhưng mà tham chiếu tới chúng thì vẫn còn.

Hãy thực hiện giải phóng tất cả khi bạn rời khỏi activity. Điều này dễ dàng để hình thành thói quen thôi mà:

@Override
protected void onDestroy() {
    super.onDestroy();
    if (dialog != null)
         dialog.dismiss();
    if (progressDialog != null)
         progressDialog.dismiss();

    mNavigationManager.addTabChangeListener(this);
    mNavigationManager.terminate();
    doUnbindUploadService();
    mNetworkListener.unRegister();
}

8.Thread nguy hiểm như thế nào?

Cùng xem xét đoạn code sau:

public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle arg){
        super.onCreate(arg);
        example();
    }

    private void example(){
        new Thread(){
            @Override
            public void run(){
                while(true){
                    SystemClock.sleep(1000);
                }
            }
        }.start();
    }
}

Đoạn code trên có gì nguy hiểm? Thread có thể gây leak song song hoặc không với memory.

Tại sao lại vậy? Thread trong Java chính là GC root. Có nghĩa là Dalvik Virtual Machine (DVM) giữ một tham chiếu cứng (Strong reference) đến tất cả các thread hiện hành trong runtime system và kết quả là thread nào không còn hoạt động nữa vẫn sẽ không bao giờ bị thu hồi bởi garbage collection.

Phải làm sao đây?

  • Hãy triển khai một phương thức để hủy thread của bạn.

  • Chuyển qua sử dụng service.

  • Hoặc bạn có thể làm như thế này:

      public class MainActivity extends Activity {
          @Override
          protected void onCreate(Bundle arg) {
              super.onCreate(arg);
              example();
          }
    
          private void example() {
              new MyThread().start();
          }
          private static class MyThread extends Thread {
              @Override
              public void run() {
                  while (true) {
                      SystemClock.sleep(1000);
                  }
              }
          }
      }
    

9.Recycle bitmaps

Điều này với bản Android 3.0 trở lên đã không còn quan trọng nữa nên mình lược bỏ chỉ để lại từ khóa để các bạn tìm hiểu thôi. Dài quá cũng lười viết. Bao giờ nếu có duyên gặp trực tiếp mình giải thích thì giải thích thôi.

III.Tổng kết lại

Chúng ta có thể làm gì?

  • Đừng có tham chiếu bất kì cái gì bên ngoài phạm vi của chúng.

  • Làm cho một đối tượng tồn tại ngắn nhất có thể.

  • Đừng giả định là Java sẽ dọn dẹp thread của bạn.

  • Weak reference

  • Sử dụng càng ít bộ nhớ càng tốt. Cache lại nhiều nhiều nhất có thể dữ liệu có ích.

  • Nếu như cần nhiều memory hơn:

      <application
              android:largeHeap="true">
      </application>
    

hoặc

    ActivityManager.getLargeMemoryClass();

Thế đấy, chúng ta đã có một loại các mánh để tránh memory leak nhưng những thứ bên trên liệu đã đủ? Liệu bạn code có đủ sức nhớ hết những vấn đề kia mãi mãi? Liệu ta có phải đụng vào dự án lớn? Quả thật sẽ có rất nhiều điều xảy ra khi bạn phải check leak. Trong bài viết tiếp theo mình sẽ đề cập tới các công cụ mà Google đã làm ra để hỗ trợ các lập trình viên Android để phòng tránh hoặc dò leak. Tất nhiên là ngoài Google cũng còn khá nhiều anh bạn vui tính giúp chúng ta. Nếu đủ thời gian mình sẽ viết về các third party đó nữa.

5 Likes

Cho e hỏi trong lỗi thứ 2 sử dụng sai context. Làm sao check được thằng ArrayAdapter null để biết nó không tồn tại cùng Activity. Tại vì nếu khi xoay màn hình ArrayAdapter được khởi tạo lại thì giá trị nó sẽ luôn bằng null chứ nhỉ. Trừ khi khai báo biến là static

83% thành viên diễn đàn không hỏi bài tập, còn bạn thì sao?