当我们在开发React Native应用时,如果在调试状态下,获取错误的异常信息是非常简单的,JS异常会立即在真机上显示(或者打开调试模式在浏览器控制台中显示),原生层的java闪退异常则可以通过Android Studio的Logcat进行查看。
但是当我们将应用打包成apk包,并提交测试的时候,一旦出现异常或闪退都会比较棘手。如果复现步骤简单可能还好,我们可以尝试在开发环境下复现,可是一旦碰上小概率,且复现步骤不明确的bug就很难了,而且改bug的效率也会非常低。
这里就需要我们及时的将全局未捕获异常以文件的形式写入到手机本地存储中。接下来我将分两种情况介绍具体的处理方法:
1. JS异常
JS异常即RN层抛出的异常,如果异常不是很严重,往往会出现这种情况:APP未闪退,但是界面显示为白屏。
捕获React全局未捕获异常,需要借助componentDidCatch方法,我们可以先编写一个全局错误处理组件:
AppErrorHandler.js
import React, { Component } from 'react';
import { saveJSExceptionsToStorage } from '@nativeModules';
import { LogUtil } from '@utils';
class AppErrorHandler extends Component {
componentDidCatch(error, errorInfo) {
// 全局未捕获JS异常处理,DEBUG模式输出日志,RELEASE模式存储日志到本地
LogUtil("AppErrorHandler: ", error.stack);
if (!__DEV__) {
saveJSExceptionsToStorage(error.stack);
}
}
render() {
const { children } = this.props;
return children;
}
}
export default AppErrorHandler;
上面的saveJSExceptionsToStorage方法是我封装的将错误信息写入手机存储的原生方法,具体可以查看我的这篇博客。
error.stack是字符串形式的错误异常栈信息,这样获取到的异常信息比error.message要更加完整。
对__DEV__进行判断的作用是:只有在release模式中,才将JS异常写入本地,因为如果是调试状态,我们根本不需要存储异常信息。
然后就是在我们的根组件中:
App.js
// ......
const App = () => {
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
}
});
return (
null }}>
{AuthScreens()}
{MainScreens()}
);
};
export default App;
可以看到,我们把AppErrorHandler组件作为整个页面组件的根组件使用。这样在release包中抛出JS异常时,我们就能到
/storage/emulated/0/Android/data/${packageName}/file/jsExceptionLogs/log_yyyy_MM_dd_HH_mm_ss.txt中拿到异常信息。
注:建议将app中生成的日志文件保存在/storage/emulated/0/Android/data/${packageName}/file/路径下,这个路径的内容会在APP被卸载时清除。与他同级的还有一个目录/storage/emulated/0/Android/data/${packageName}/caches,这个目录中往往用来存放一些应用缓存的临时内容,会在用户手动清除应用缓存时一并删除。上述的两个路径应该是属于Android外部存储的私有路径,我们往这里写入文件是不需要动态申请外部存储(EXTERNAL_STORAGE)写入权限的。
2. 原生java异常
这类异常就比较严重了,一旦出现,APP会立即停止运行并闪退,造成极差的用户体验。为了捕获java中的全局未捕获异常,我们需要编写一个CrashHandler类并实现Thread.UncaughtExceptionHandler接口。代码比较简单,我就直接贴出整个类的实现吧:
CrashHandler.java
package com.smarthome;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Looper;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
import com.smarthome.utils.FileUtils;
import java.io.File;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.io.Writer;
import java.lang.reflect.Field;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
public class CrashHandler implements Thread.UncaughtExceptionHandler {
private Context mContext;
private static final String TAG = CrashHandler.class.getSimpleName();
private CrashHandler() { }
private static volatile CrashHandler instance;
static CrashHandler getInstance() {
if (instance == null) {
synchronized (CrashHandler.class) {
if (instance == null) {
instance = new CrashHandler();
}
}
}
return instance;
}
private Map crashInfos = new HashMap(); // 键值对的形式收集错误日志
void init() {
mContext = MainApplication.getAppContext();
// 设置CrashHandler为程序默认的处理器
Thread.setDefaultUncaughtExceptionHandler(this);
}
@Override
public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwable) {
handleException(throwable);
}
private void handleException(Throwable throwable) {
getPackageInfo();
String crashMsg = saveCrashInfo(throwable);
writeLogToStorage(crashMsg);
uploadCrashToServer(crashMsg);
remindUserOfCrash();
}
private void getPackageInfo() {
PackageManager packageManager = mContext.getPackageManager();
try {
PackageInfo packageInfo = packageManager.getPackageInfo(mContext.getPackageName(), PackageManager.GET_CONFIGURATIONS);
if (packageInfo != null) {
String packageName = packageInfo.packageName;
String versionName = packageInfo.versionName;
String versionCode;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
versionCode = String.valueOf(packageInfo.getLongVersionCode());
} else {
versionCode = String.valueOf(packageInfo.versionCode);
}
crashInfos.put("packageName", packageName);
crashInfos.put("versionName", versionName);
crashInfos.put("versionCode", versionCode);
}
} catch(PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
Field[] fields = Build.class.getDeclaredFields();
for (Field field : fields) {
try {
field.setAccessible(true);
crashInfos.put(field.getName(), String.valueOf(field.get(null)));
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
private String saveCrashInfo(Throwable throwable) {
StringBuilder sb = new StringBuilder();
sb.append("Crash Log start: ==========================\n");
for (Map.Entry entry : crashInfos.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
sb.append(key).append(": ").append(value).append("\n");
}
Writer writer = new StringWriter();
PrintWriter printWriter = new PrintWriter(writer);
throwable.printStackTrace(printWriter);
Throwable cause = throwable.getCause();
// 回溯异常抛出链
while (cause != null) {
cause.printStackTrace(printWriter);
cause = cause.getCause();
}
printWriter.close();
String crashResult = writer.toString();
sb.append("crashResult: ").append(crashResult);
sb.append("end========================================\n");
return sb.toString();
}
private void writeLogToStorage(String crashMsg) {
String externalFilePath = FileUtils.getExternalFileDir().getPath();
String targetDir = externalFilePath + File.separator + "crashlogs";
File log = new File(targetDir);
if (log.exists() ||
(!log.exists() && log.mkdir())) {
StringBuilder targetFilePath = new StringBuilder();
Date now = new Date();
DateFormat dateFormat = new SimpleDateFormat("yyyy_MM_dd_HH_mm_ss", Locale.getDefault());
targetFilePath.append(targetDir).append(File.separator).append("crashlog_").append(dateFormat.format(now)).append(".txt");
File targetFile = new File(targetFilePath.toString());
FileUtils.writeTextToFiles(targetFile, crashMsg);
}
}
private void uploadCrashToServer(String crashMsg) {
Log.d(TAG, crashMsg);
}
private void remindUserOfCrash() {
new Thread(() -> {
Looper.prepare();
Toast.makeText(mContext, "抱歉,程序发生异常,即将退出o(╥﹏╥)o", Toast.LENGTH_LONG).show();
Looper.loop();
}).start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(1);
}
}
上面代码中值得关注的就以下几点:
(1)需要通过Thread.setDefaultUncaughtExceptionHandler(this);方法将当前类设置为全局未捕获异常处理类;
(2)我们需要重写uncaughtException方法来自定义监听到全局未捕获异常时的处理逻辑(收集包信息以及错误信息,导出本地文件或上传服务器等)
(3)uncaughtException方法中获取到的是throwable对象,如果想要获取异常抛出的堆栈信息,我们需要回溯异常抛出链。通过throwable.getCause();能够获取到导致当前异常抛出的异常
(4)当我们的异常收集工作完成后,在停止APP进程运行前,如果我们想要尽量友好的提示用户,比如用Toast。我们需要新开一个线程去执行他,否则会抛出如下异常:
导致Toast无法正常弹出。
同时,如果在新线程中想要Toast提示信息,必须在该线程中启动事件循环,否则会收到java.lang.RuntimeException: Can't toast on a thread that has not called Looper.prepare();
所以我们需要在新线程中初始化Looper。也就是调用Looper.prepare()以及Looper.loop();。(主线程中不需要主动初始化Looper,是因为系统已经自动为我们的主线程开启了事件循环)
最后,我们只需要在入口文件MainApplication.java的onCreate方法中调用:
CrashHandler.getInstance().init();即可。
通过以上的步骤,我们基本上就构建了完备的release包异常捕获流程,当提测包出现异常时,我们只需要让测试人员把测试机上的对应时间的日志文件发给我们即可。这样就再也不用担心小概率bug始终无法复现啦~
作者:KarmaGut