1. 问题描述
今天线上监控发现一个页面泄露的问题,GC ROOT如下:
* GC ROOT android.os.HandlerThread.mLooper
* references android.os.Looper.sMainLooper
* references android.os.Looper.mQueue
* references android.os.MessageQueue.mMessages
* references android.os.Message.sPool
* references android.os.Message.target
* references android.widget.Editor$Blink.this$0
* references android.widget.Editor.mTextView
* references android.widget.EditText.mClipboardExManager
* references android.sec.clipboard.ClipboardExManager.mClipboardUIManager
* references android.sec.clipboard.ClipboardUIManager.mContext
* leaks xxxxxxx.activity.XxxxInputActivity instance
看包名都是系统的类,但是找了下系统源码,在4.0.4, 4.4.4, 5.1.1, 6.0.1里都没有出现过ClipboardUIManager.java这个类,所以应该是产商定制的类。到google里搜一下android.sec.clipboard.ClipboardUIManager.mContext
,有不少相同的问题,都是出现在三星手机上的,具体问题讨论过程看这里。
对了一下出问题的手机型号和系统:SM-N9008S、SM-N9008、SM-A7000、SM-G9008V、SM-N9006,使用的官方ROM的确都是4.4.2、5.0版本,下面详细分析这个问题。
2. 问题分析:
其实这种类型的堆栈是比较常见的,第一眼看上去就是Looper的队列里有未被消费的消息,这个消息持有了一个内部类对象(类似Editor$Blick.this$0这种类名),那这个消息对象是怎么产生的呢?
看一下Editor.java的内部类Blink,它是设置Editor光标闪烁的消息类,每隔BINK=500ms的间隔就会向主线程消息队列post一次自己,切换下一次绘制光标对应的Drawable对象(其实就是取Drawable[] mCursorDrawable里的数组成员,这个数组长度是2),关键代码如下,比较短直接贴出来:android.widget.Editor$Blink.class
public class Editor {
...
private class Blink extends Handler implements Runnable {
private boolean mCancelled;
public void run() {
if (mCancelled) {
return;
}
removeCallbacks(Blink.this); // 先从队列删除未处理的消息
if (shouldBlink()) {
// 绘制当前这个周期光标对应的Drawable
if (mTextView.getLayout() != null) {
mTextView.invalidateCursorPath();
}
// 发送下一次的消息,延时BLINK=500ms
postAtTime(this, SystemClock.uptimeMillis() + BLINK);
}
}
void cancel() {
if (!mCancelled) {
removeCallbacks(Blink.this);
mCancelled = true;
}
}
void uncancel() {
mCancelled = false;
}
}
...
}
那第一个消息是在哪里发的?退出时最后一个消息又在哪里删除的呢?看下面的代码:
public class Editor {
...
void onAttachedToWindow() {
...
resumeBlink();
}
private void resumeBlink() {
if (mBlink != null) {
mBlink.uncancel();
makeBlink();
}
}
void makeBlink() {
if (shouldBlink()) {
mShowCursor = SystemClock.uptimeMillis();
if (mBlink == null) mBlink = new Blink();
mBlink.removeCallbacks(mBlink);
mBlink.postAtTime(mBlink, mShowCursor + BLINK); // 第一个Blink消息
} else {
if (mBlink != null) mBlink.removeCallbacks(mBlink);
}
}
...
void onDetachedFromWindow() {
...
suspendBlink();
...
}
private void suspendBlink() {
if (mBlink != null) {
mBlink.cancel();
}
}
可以清楚得看到,第一个闪烁事件是在View对象的onAttachedToWindow生命周期事件里发出的,最后一个消息是在对应的onDetachedFromWindow生命周期里删除的。
分析到这里,事件的起因经过已经很清楚了:EditText在onAttachedToWindow的时候向主线程发送了一个要延迟500ms处理的消息,之后就连续按BLINK周期发送,除非onDetachedFromWindow被调用才会停止,但是因为内存泄露的原因,这个生命周期走不到,所以主线程还是会不停的接受BLINK消息对象,所以看起来是因为主线程消息队列引用造成的,而实际上却是由 android.sec.clipboard.ClipboardUIManager.java 这个单例类引起的!!
public static ClipboardUIManager getInstance(Context context) {
if (sInstance == null)
sInstance = new ClipboardUIManager(context);
return sInstance;
}
我们看一下ClipboardUIManager这个单例类的使用,入参context被 ClipboardUIManager.mContext 成员持有,所以泄露是必然,而这个 mContext 存在的目的只是为了调用getSystemService(Context.CLIPBOARD_SERVICE)方法,在这种需求下应该把Activity的Context转换为Application的Context,显然这是SAMSUNG系统的Bug。
为了验证这个这个想法,找了一台三星5.0系统的手机,把ClipboardUIManager对象打印出来,果然里面的mContext成员持有了我的Activity对象,所以真相大白了。
最后再啰嗦一句,因为Blink不是静态内部类,所以android.widget.Editor$Blink.this$0是Javac编译器为这个内部类自动生成的成员变量,通过构造方法的第一个参数传进来,用于引用外部类对象。
3. 解决方案
问题梳理清楚,剩下的就是解决方案了。唯一可行的办法是创建这个单例的时候从外面传入的Context参数就是Application的Context,可以用Activity.getApplicationContext()获取,最关键的是getInstance这个操作必须在系统调用之前由我们来触发一次,不然就没机会补救了。
View的onAttachedToWindow发生在Activity的onResume生命周期之后,所以一个页面如果包含EditText的话,ClipboardUIManage.getInstance(Context)的调用必须发生这个页面的onCreate、onStart或者onResume里。站在全局的角度看,最好放在Application的onCreate里处理掉,这样应用内所有页面就会都很安全,下面上最终的代码:
try {
if ("samsung".equalsIgnoreCase(Build.MANUFACTURER) &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT &&
Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP) {
Class cls = Class.forName("android.sec.clipboard.ClipboardUIManager");
Method m = cls.getDeclaredMethod("getInstance", Context.class);
m.setAccessible(true);
Object o = m.invoke(null, getApplicationContext());
Log.i(TAG, o.toString());
}
} catch (Throwable e) {
e.printStackTrace();
}
这里增加了准确的系统和版本判断,一方面避免其他正常的手机抛无谓的异常,另一方面解决问题就应该是针对性地处理,有错再继续改,不能怕出错就不清不楚地全部覆盖上,后面的人不知道怎么回事又不敢删。
在第一部分的问题描述里说明了,出问题手机使用的官方ROM对应的Android系统版本是4.4.2、5.0,同时 Square 的 leakcanary 也收集了一些已知系统的泄露bug,在AndroidExcludedRefs.java这个文件里,其中就包含SAMSUNG的这个系统泄露,SDK_INT >= KITKAT(19) && SDK_INT <= LOLLIPOP(21)
,对应的系统版本正好是4.4、4.4.4、5.0
CLIPBOARD_UI_MANAGER__SINSTANCE(
SAMSUNG.equals(MANUFACTURER) && SDK_INT >= KITKAT && SDK_INT <= LOLLIPOP) {
@Override void add(ExcludedRefs.Builder excluded) {
excluded.instanceField("android.sec.clipboard.ClipboardUIManager", "mContext")
.reason("ClipboardUIManager is a static singleton that leaks an activity context."
+ " Fix: trigger a call to ClipboardUIManager.getInstance() in Application.onCreate()"
+ " , so that the ClipboardUIManager instance gets cached with a reference to the"
+ " application context. Example: https://gist.github.com/cypressious/"
+ "91c4fb1455470d803a602838dfcd5774");
}
},
最后附上 Android版本、源码和API Level对应关系表
Code name | Version | API level |
---|---|---|
Marshmallow | 6.0 | API level 23 |
Lollipop MR1 | 5.1 | API level 22 |
Lollipop | 5.0 | API level 21 |
KitKat | 4.4 - 4.4.4 | API level 19 |
Jelly Bean | 4.3.x | API level 18 |
Jelly Bean | 4.2.x | API level 17 |
Jelly Bean | 4.1.x | API level 16 |
Ice Cream Sandwich | 4.0.3 - 4.0.4 | API level 15, NDK 8 |
Ice Cream Sandwich | 4.0.1 - 4.0.2 | API level 14, NDK 7 |
Honeycomb | 3.2.x | API level 13 |
Honeycomb | 3.1 | API level 12, NDK 6 |
Honeycomb | 3.0 | API level 11 |
Gingerbread | 2.3.3 - 2.3.7 | API level 10 |
Gingerbread | 2.3 - 2.3.2 | API level 9, NDK 5 |
Froyo | 2.2.x | API level 8, NDK 4 |
Eclair | 2.1 | API level 7, NDK 3 |
Eclair | 2.0.1 | API level 6 |
Eclair | 2.0 | API level 5 |
Donut | 1.6 | API level 4, NDK 2 |
Cupcake | 1.5 | API level 3, NDK 1 |
(no code name) | 1.1 | API level 2 |
(no code name) | 1.0 | API level 1 |