**作者:**好未来-Android开发资深工程师-林玉强
崩溃率是很影响用户体验的事情,还最容易流失用户。
网校快速发展多年,增加了无数的业务,开发也使用了无数的第三方库,第三方库良莠不齐。
在19年年底的时候7.0版本的时候,崩溃率还是0.66%。过年开发8.0,年后发版8.0以后,崩溃率还是很高,在周大鑫发起下,邹昊滑栋梁的带领下,开始强攻APP崩溃,目标降到千分之一以下。在团队共同努力下,一个个的改,最终实现了用户崩溃率从千分之七到万分之八。
一.面临的挑战
由于Android碎片化严重这些问题,有些系统崩溃,手机厂商rom的崩溃非常难解。
第三方软件glide崩溃比较多,但是github上有人提过,也没有人解决。
x5浏览器崩溃,也像他们反馈过,好多都是app内存越来越多,运行环境差,内存不够引起的。
二.java常规崩溃
1.NullPointerException
比较场景且量大的崩溃,而且产生的场景很多。
(1)变量周期控制
大家一般都会注意有没有初始化,看代码逻辑没有问题。
但是在退出的时候时候置为null,收到释放,但是忽略了异步任务。
mWorkerThread.setOnEngineCreate(new CloudWorkerThreadPool.OnEngineCreate() {
@Override
public void onEngineCreate(RTCEngine mRtcEngine, String fileFullPath) {
if (mRtcEngine != null) {
}
// https://bugly.qq.com/v2/crash-reporting/crashes/a0df5ed682/1326941?pid=1
//已经stop了
if (mWorkerThread == null) {
return;
}
创建RTC,等待回调。当老师停止接麦,置为null。
public void stopRTC() {
if (mWorkerThread != null) {
mWorkerThread.exit();
mWorkerThread = null;
}
}
(2)界面跳转
不合理的界面跳转不用intent,用静态变量的, 正常使用,不会崩溃,用户从后台重启,静态变量回收,就发生崩溃。
是当时做插件化的时候不支持Parcelable,临时方案。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_correct_report);
Bundle extras = getIntent().getExtras();
scoreInfo = ExerciseSingle.getInstance().getmExerciseResult();
if (extras == null || scoreInfo == null) {
finish();
//https://bugly.qq.com/v2/crash-reporting/crashes/a0df5ed682/32238?pid=1
XrsCrashReport.postCatchedException(new Exception("" + (savedInstanceState == null)));
return;
}
虽然不发生崩溃了,但是这种写法不合法。需要改技术方案。
(3)intent传值失败
大家都知道。Activity的intent除了传递基础类型。还有Serializable和
Parcelable两种复杂类型。第一种是java的,第二种是android新加的。
(VideoLivePlayBackEntity) bundle.getSerializable(LiveVideoConfig.videoliveplayback)
Serializable有可能失败。以前是通过提示让用户重试解决。
if (mVideoEntity == null) {
XESToastUtils.showToast("参数错误,请重新进入");
activity.finish();
XrsCrashReport.postCatchedException(new Exception());
return false;
}
改成Parcelable解决问题,而且传输效率高
2.IllegalArgumentException
(1)glide加载图片
在Activity finish之后调用
统一在 ImageLoader 换成 Application,glide使用Activity会控制生命周期。所以这种写法是暂时的。需要上次调用都判断加载的时候Activity是不是finish。
public static SingleConfig.ConfigBuilder with(Context context) {
//https://bugly.qq.com/v2/crash-reporting/crashes/a0df5ed682/105?pid=1
if (context instanceof Activity) {
context = context.getApplicationContext();
}
return new SingleConfig.ConfigBuilder(context);
}
(2)Recycle Item重复添加问题。
解决这个bug。真是一个折磨。改了几个版本都改不好,最后只能换实现方式。
RecyclerView虽然作为ListView的替代者有着较好的性能提升,但是ListView常用的addHeaderView,addFooterView,在RecyclerView中没有提供这个方法。
有两种方案。一直是head和foot都是一个viewgroup。都往里面加 ,判断type。
//创建View,如果是HeaderView或者是FooterView,直接在Holder中返回
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
if(mHeaderView != null && viewType == TYPE_HEADER) {
return new ListHolder(mHeaderView);
}
if(mFooterView != null && viewType == TYPE_FOOTER){
return new ListHolder(mFooterView);
}
View layout = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item, parent, false);
return new ListHolder(layout);
}
另一种是都当一个个的item,判断是不是head和foot。
@Override
public int getItemViewType(int position) {
if (isHeader(position)) {
return ITEM_TYPE_HEADER_MIN + position;
}
if (isFooter(position)) {
return ITEM_TYPE_FOOTER_MIN + getFooterIndexInFooters(position);
}
return getDataItemType(itemPositionInData(position));
}
前一个移除。不用通知adapter,直接移除里面的View。
但是LinearLayoutManager不能使用scrollToPosition,后一个需要通知。
如果添加以后不移除,两个都没事。
但是如果有移除,view的postion会变。引起各种isAttached true的崩溃。
然后不让getItemType和postion绑定,每个view固定type,以为能好。但还是崩溃了
后来判断是不是重复添加。然后返回一个其他类型-1234。添加空的ViewHolder,当时容易复现的崩溃后来没有复现了。但是上线以后,还是有崩溃。
后来使用的技术方案是把添加的view包一层。
@Override
public int getItemLayoutId() {
//只是一个 RelativeLayout
return R.layout.ct_video_modle_item;
}
@Override
public void convert(ViewHolder holder, View view, int position) {
ViewGroup group = (ViewGroup) holder.getConvertView();
ViewGroup parent = (ViewGroup) view.getParent();
if (parent != null) {
if (parent != group) {
parent.removeView(view);
group.addView(view);
}
} else {
group.addView(view);
}
}
然后就没有再复现了。
三.native崩溃
1.硬件发生异常,即硬件(通常是CPU)检测到一个错误条件并通知Linux内核,内核处理该异常,给相应的进程发送信号。硬件异常的例子包括执行一条异常的机器语言指令,诸如,被0除,或者引用了无法访问的内存区域。大部分信号如果没有被进程处理,默认的操作就是杀死进程。SIGSEGV(段错误),SIGBUS(内存访问错误),SIGFPE(算数异常)属于这种信号。
2.进程调用的库发现错误,给自己发送中止信号,默认情况下,该信号会终止进程。在本文中,SIGABRT(中止进程)属于这种信号。
3.带源码的基本都好解决,常用的工具
arm-linux-androideabi-addr2line 可以将崩溃的pc值转化成方法和源文件的行数
arm-linux-androideabi-readelf 读取elf信息,so就是elf格式文件
arm-linux-androideabi-c
类的方法
arm-linux-androideabi-objdump 反汇编,但没有IDA好用
4.java调用native
java类中存一个c层对象的指针long变量。传到c层会强转成指针
这个操作是线程不安全的,所以使用的时候需要加同步锁或者使用只有一个线程的线程池。
(1)语音评测,初始化时间长,所以启动线程。
初始化文件下载,加了同步锁。
初始化模型,单独开了线程。没有加锁。
所以如果在初始化过程中切换语音,就容易崩溃,写代码频繁调用,可以复现。增加同步锁以后,就解决了。
new Thread("initOfflineSpeech") {
@Override
public void run() {
synchronized (eventId) {
if (language != lang) {
BuglyLog.e(TAG, "initOfflineSpeech : lang=" + lang + ",return");
return;
}
在以后的开发中,对这种问题就格外注意,在开发rtc的时候就没有发生类似问题了。
(2)glide崩溃,glide是个第三方库,没有native源码,但是好几个native的崩溃。
通过源码 RecyclableBufferedInputStream 这个初始化的时候传入AssetManager$AssetInputStream。这种输入流肯定是apk里的资源。
看堆栈,是加载的gif图片。在首页找到一个这种图片,开始的解决办法是这个图片只有在显示的时候执行,后台或者切换的时候暂停。这样崩溃就降低了不少。
通过代码查看,这个流是在 LocalUriFetcher 里加载出来的。猜测这个崩溃可能也是多线程造成的。LocalUriFetcher类中 loadData 通过子类StreamLocalUriFetcher获得流。cleanup 有个关闭流的方法。猜测AssetInputStream并不是线程安全的。
多次调用加载图片。在loadData以后起个线程,同时关闭。这两个崩溃都能复现。
第一个崩溃
第二个崩溃
为了解决AssetInputStream线程不安全。写一个 SafeInputStream 代理了它。里面在方法上加 synchronized 关键字。然后多次调用就没有复现
5.xcrah崩溃
app接入了bugly,能按版本统计用户合次数崩溃,但是拿不到原始日志。
做华佗系统,需要按用户查询,所以接入了xcrash。但是上线后发现在步步高设备上有很多崩溃
崩溃的 pc 地址 000050da 使用的aar,没有符号表。通过addr2line获得不了
arm-linux-androideabi-addr2line.exe -C -f -e libxcrash.so 000050da
JNI_OnLoad
??:?
幸好xcrash是开源的。使用ndk源码编译。
c_trace_check_address_valid
xc_trace.c:212
而且注释也写了有些时候会崩溃。
为了验证这个,通过IDA工具反编译so,通过上下文,可以看出来sub_4B0C 就是xc_trace_dumper方法。
在R0崩溃,R0是dword_E988,跳到赋值的地方
为啥新版本到这个地方崩溃的多了。看源码发现执行到这的是发生anr了。
线程 xcrash_trace_dp
崩溃的方法 xc_trace_libart_runtime_instance 是 system/lib/libart.so 的 _ZN3art7Runtime9instance_E
编译源码,使用readelf 解析elf信息
readelf -s -W …/out/target/product/generic/system/lib/libart.so | grep
使用命令 arm-linux-androideabi-c++filt.exe 可以很方便的解析出来
所以就是 art/runtime/runtime.h 中的
// A pointer to the active runtime or null.
static Runtime* instance
;
变量。
anr 以后调用 符号表 _ZN3art7Runtime14DumpForSigQuitERNSt3__113basic_ostreamIcNS1_11char_traitsIcEEEE 也就是
art::Runtime::DumpForSigQuit(std::__1::basic_ostream<char, std::__1::char_traits >&)
Runtime 的 void Runtime::DumpForSigQuit(std::ostream& os) 方法
但某些机型(多为步步高)这个有问题,可以使用 setjmp 和 longjmp 做异常处理,c语言的try catch。
调用之前使用signal捕获异常。第一次longjmp返回的res=0,崩溃以后在sighandler方法里
longjmp(envinstance,1);然后longjmp返回的res=1;不进行调用。
最后调用方法signal(SIGSEGV,oldinstance);把就的崩溃处理设置回去,这样才能让xcrash和bugly继续统计崩溃。
6.mmkv崩溃
新版本有几个mmkv的SIGBUS(BUS_ADRERR)崩溃。
SIGBUS就是内存不对齐产生的,但是Android并没有这个问题。
目前存在的基本都是mmap产生的问题。
mmap将一个文件或者其它对象映射进内存。读文件是非常高效。
产生sigbus的原因第一种是,加载到内存以后,其他地方,修改了文件,使文件大小变小发生的。
还有一直是使用memcpy扩大文件,但是磁盘已满。
尝试编译源码,始终对不上,最后在github提问题,他们把debug的so发过来,addr2line解析了一下。
arm-linux-androideabi-addr2line.exe -C -f -e libmmkv.so 00012020
MMKV::oldStyleWriteActualSize(unsigned int)
/Users/lingol/Developer/mmkv/Core/MMKV_IO.cpp:418
memcpy(m_file->getMemory(), &actualSize, Fixed32Size);
果然是memcpy方法。解决这个可以在初始化的时候判断文件剩余,弹出提示让用户清理空间,微信就是这么做的。
当发送在过程中,还是可以通过setjmp 和 longjmp 进行异常捕获,发送到上层进行提示。
四.系统崩溃
1.Toast崩溃
一般文章都会说这个崩溃,这个是系统7.0上的问题。而且数量很大,而且没有前置if判断可以解决
查看系统源码,后来的系统版本也是通过try解决的
API:25
public void handleShow(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
+ " mNextView=" + mNextView);
if (mView != mNextView) {
...
...
if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
}
}
API:26
public void handleShow(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
+ " mNextView=" + mNextView);
if (mView != mNextView) {
...
...
if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
}
}
这个Handler是TN类的一个final变量
private static class TN extends ITransientNotification.Stub {
private final WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();
private static final int SHOW = 0;
private static final int HIDE = 1;
private static final int CANCEL = 2;
final Handler mHandler;
可以通过反射改变它,然后代理它解决。
public static void hook(Toast toast) {
try {
boolean isos7 = false;
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
isos7 = true;
}
if (isos7) {
if (sField_TN == null) {
initField();
}
if (sField_TN != null) {
Object tn = sField_TN.get(toast);
Handler preHandler = (Handler) sField_TN_Handler.get(tn);
sField_TN_Handler.set(tn, new SafelyHandlerWarpper(preHandler));
}
}
} catch (Exception e) {
FrameCrashReport.postCatchedException(e);
}
}
/**
* https://www.jianshu.com/p/e0bf316045bb
*/
public static class SafelyHandlerWarpper extends Handler {
private Handler impl;
public SafelyHandlerWarpper(Handler impl) {
this.impl = impl;
}
@Override
public void handleMessage(Message msg) {
try {
impl.handleMessage(msg);//需要委托给原Handler执行
} catch (Exception e) {
Log.d("XESToastUtils", "handleMessage", e);
FrameCrashReport.postCatchedException(e);
}
}
}
所以工程统一了Toast,封装类XESToastUtils。
类似都还有一个播放器的
可以使用反射修改变量解决。
2.DeadSystemException

还是在7.0的系统上非常常见的崩溃,通过getSystemService方法得到系统服务对象。调用它方法的时候容易复现。
有替换方法的替换,没有的只能try。如果非常必须的可以延迟循环调用几次直到成功。
比如获得屏幕宽高都有崩溃,使用替代方案。
使用服务的方法
public static int getScreenHeight() {
WindowManager windowManager = (WindowManager) ContextManager.getContext().getSystemService(Context
.WINDOW_SERVICE);
DisplayMetrics dm = new DisplayMetrics();
windowManager.getDefaultDisplay().getMetrics(dm);
return dm.heightPixels;
不使用服务的方法
public static Point getScreenMetrics(Context context) {
DisplayMetrics dm = context.getResources().getDisplayMetrics();
int w_screen = dm.widthPixels;
int h_screnn = dm.heightPixels;
return new Point(w_screen, h_screnn);
}
只要Handler崩溃在handleMessage 绝大多数都可以反射解决。通过设置Handler.Callback将原来的方法代理。如果有Handler.Callback,设置新的,代理旧的。进行异常捕获。编写LooperHook类
/** 对Handler反射,崩溃中带handleMessage的都可以使用 */
public static boolean hookCall(final Handler handler, final boolean throwExce) {
android.util.Log.d(TAG, "hookCall:hand=" + handler + ",throwExce=" + throwExce);
try {
if (callbackField == null) {
callbackField = Handler.class.getDeclaredField("mCallback");
callbackField.setAccessible(true);
}
Handler.Callback callback = (Handler.Callback) callbackField.get(handler);
if (callback != null) {
//防止重复设置
if (!(callback instanceof XesHandlerCallback1) && !(callback instanceof XesHandlerCallback2)) {
XesHandlerCallback1 callback1 = new XesHandlerCallback1(handler, callback, throwExce);
callbackField.set(handler, callback1);
}
} else {
XesHandlerCallback2 callback2 = new XesHandlerCallback2(handler, throwExce);
callbackField.set(handler, callback2);
}
return true;
} catch (Exception e) {
FrameCrashReport.postCatchedException(e);
Log.e(TAG, "hookCall", e);
}
return false;
}
对android.app.ActivityThread也进行hook
private static void hookActivityThread() {
try {
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Field field = activityThreadClass.getDeclaredField("mH");
field.setAccessible(true);
Object activityThread = activityThreadClass.getMethod("currentActivityThread").invoke((Object) null);
LooperHook.hookCall((Handler) field.get(activityThread), true);
} catch (Exception e) {
Log.d(TAG, "hookActivityThread", e);
}
}
但是也不能所以移除都捕获。需要加一些白名单判断
private boolean isWhiteExce(Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (e instanceof DeadSystemException) {
return true;
}
}
//https://bugly.qq.com/v2/crash-reporting/crashes/a0df5ed682/1364347?pid=1
if (e.getClass().getName().contains("WindowManager$BadTokenException")) {
return true;
}else if (e instanceof IllegalArgumentException) {
//https://bugly.qq.com/v2/crash-reporting/crashes/a0df5ed682/1369716?pid=1
return ("" + e.getMessage()).contains("not attached to window manager");
}
return false;
}
如果有的崩溃无法反射,也可以使用epic等hook框架,后台配置hook方法,如果还是特定机型,可以加上机型控制。
五.内存问题
这半年没有专门对内存做特别的优化,但是对内存泄露做了很多处理,内存降下来,OOM也将了,引起底层内存不足的abort的崩溃也少了。
java的有专门的第三方库leakcanary可以使用,在团队集体努力下,解决了99.9%的java内存泄露。Activity如果释放,会有很大的提示。
对于android native 内存泄露,一般使用android系统8.0以后的debug_malloc进行检测。
内存监控沿用LIBC的malloc_debug模块。不使用官方方式开启该功能,比较麻烦,不利于自动化测试,离开开发工具就没法用了。可以编译一份放到自己的项目中,hook所有内存函数,跳转到malloc_debug的监控函数leak_xxx执行,这样malloc_debug就监控了所有的内存申请/释放,并进行了相应统计。
android源码中的malloc_debug放到工程,需要5个so。结合爱奇艺的xhook实现,在合适的时候调一下方法,就知道内存有没有释放。
可以监控的内存方法有malloc,free,calloc,realloc,posix_memalign,memalign,aligned_alloc,malloc_usable_size,还有c++的new,delete关键字。
首先监控了psijk的so。就发现一个指针没有释放的问题。虽然只有几十个字节
六.稳定控制
1.使用leakcanary以后,这个工具发现问题,会弹出警告,测试就会提一个jira问题,把信息放进去,相应的开发需要解决。
2.灰度发版。目前每个大版本测试都会组织内测。网校大部分开发,测试和产品都会参加,会把问题记录下来。有时候的崩溃可能没有发现。但是bugly统计的每一个崩溃,都会解决,由于内测的原因,好多解决过的bug又崩溃了。查到以后发现是前几天安装的,所以后台打包增加了编译时间放到了bugly。
发到用户那边是小渠道发放,发现崩溃就解决,继续放量。
后来又新增了自动化测试,崩溃会提到jira上。
通过两次灰度,使崩溃率基本稳定。
**作者 **
好未来-Android开发资深工程师-林玉强
招聘信息
好未来技术团队正在热招前端、算法、后台开发等各个方向高级开发工程师岗位,大家可扫描下方二维码或微信搜索“好未来技术”公众号,点击“技术招聘”栏目了解详情,欢迎感兴趣的伙伴加入我们!
也许你还想看