首页 » 技术文章 » Google Acra源码研究报告

Google Acra源码研究报告

 

日期:2017-03-24

一. 项目简介

ACRA是Google推出的开源Android应用Crash reports框架。本文主要针对其最新的源码(v4.9.2)进行学习研究,目的是了解在Android平台上处理未捕获异常,并在崩溃时收集各种设备及上下文信息,并生成崩溃报告,保存到本地文件,并使用合适的机制将所有的崩溃报告上报给服务器等一整套开源解决方案。

项目主页及源码:https://github.com/ACRA/acra

二. 模块及关键类

模块划分及关键类说明

package class
org.acra ACRA 入口
ErrorReporter 异常处理器
org.acra.builder ReportBuilder 异常报告构建器
ReportExecutor 异常报告执行器
org.acra.collector CrashReportDataFactory 异常报告收集数据工厂
org.acra.config ACRAConfiguration ACRA配置
org.acra.collections - 集合类
org.acra.dialog CrashReportDialog 崩溃Dialog
org.acra.file ReportLocator 报告文件定位
org.acra.http DefaultHttpRequest HTTP网络请求
org.acra.legacy LegacyFileHandler 传统旧文件升级处理器
org.acra.log ACRALog 日志接口
org.acra.model Element 日志报告字段元素
org.acra.prefs SharedPreferencesFactory Pref工厂
org.acra.security KeyStoreHelper https KeyStore帮助类
org.acra.sender ReportSender 异常报告发送器接口
org.acra.util - 工具类

三. ACRA类

首先看如何初始化ACRA:

public static void init(Application app, ACRAConfiguration config, boolean checkReportsOnApplicationStart);

参数:

  • application:应用实例,这个实例会被ACRA以静态变量hold住,看起来会有内存泄漏问题,但其实没问题。
  • config:自定义配置
  • checkReportsOnApplicationStart:是否在应用启动的时候检查并上传报告。

具体的异常处理都是由 ErrorReportor 来处理的,并以静态变量被ACRA hold住,保证其生命周期和application一致。它其实是一个 Thread.UncaughtExceptionHandler , 可以捕获到未捕捉的异常。

在初始化的时候,为了兼容旧版本的报告文件,提供了一个将旧版本文件升级到新版本文件的机制。

并且提供配置,允许在启动的时候删除旧应用版本遗留下来的旧异常报告文件。(升级app后,之前版本遗留下来的文件被视为旧文件。)

也提供选项,允许在启动的时候删除所有不合法的报告文件。

在启动的时候,也会上传所有合法的异常报告文件。注意使用了独立的线程里的Service进行发送操作,以实现和应用主进程进行隔离区分。

四. 捕捉崩溃(ErrorReporter)

ErrorReporter实现了Thread.UncaughtExceptionHandler接口,并自己注册到应用主线程上,它是一个单例对象,用于手机崩溃上下文数据,并发送崩溃报告。

当崩溃发生时,它收集崩溃时的上下文数据(例如设备,系统,崩溃stack trace等信息),并将其写入应用私有目录(/data/data/packageName) 下的一个崩溃报告文件中。

这个崩溃报告文件会被发送的时机由:

  • 如果崩溃配置为silent模式或toast模式,崩溃报告文件会立即被发送
  • 每次程序启动的时候,上一次没有被发送则被发送
  • 如果崩溃配置为notification,则会弹出对话框,用户点击发送才会被发送。

首先会收集收集的 Configuration 数据,这个数据收集起来消耗比较大,因此只有当准备report的时候才进行收集。

所有的崩溃报告都是由各种不同的收集器来收集起来的,然后通过 CrashReport DataFactory 报告工厂来生成报告。

在设置新的 Thread uncaught Exception Handler 之类,先保存旧的handler,因为handler只能设置一个,保存旧的,今后可以去调用旧的handler,让它也有可能来处理异常。

4.1 当崩溃发生时的处理工作

当崩溃发生的时候,被 ErrorReporter 的捕获到,会使用 ReportBuilder 来构建崩溃报告。其包括了崩溃的异常,线程,自定义数据等信息。

具体的崩溃处理,是交给 ReportExecutor 来处理的。

4.2 当崩溃发生后的清理工作

在崩溃发生以后,ACRA还会去帮助application正确的关闭上一个activity,清理并关闭进程。

清理工作包括:

  1. 关闭上一个activity(调用其finish方法)
  2. 关闭所有services
  3. 杀掉进程并退出应用

首先需要记录上一个activity。原理是在4.0版本以后调用 application 的方法来注册 activity 的生命周期监听函数,并使用weakReference来记录上一次启动的activity(最后一个onCreate的activity)

  public LastActivityManager(Application app) {
    application.registerActivityLifecycleCallbacks(() -> {
        void onActivityCreated(Activity act, Bundle bundle) {
          lastActivityCreated = new WeakReference<Activity>(act);
        }
    });
  }

然后需要找到当前进程里的所有service,并调用stopService来关闭。

最后kill掉线程,并退出。

android.os.Process.killProcess(android.os.Process.myPid());
System.exit(10);

参见:org.acra.builder.LastActivityManager
参见:org.acra.util.ProcessFinisher

五. 异常报告处理器(ReportExecutor)

ReportExecutor负责收集和处理异常报告。

5.1 生成崩溃报告

当崩溃发生时,崩溃的异常等信息交给 ReportExecutor,然后转交给 CrashReportDataFactory 来生成具体的崩溃报告。

5.2 保存崩溃报告到文件

之后将报告保存到本地文件中。

注意:ACRA写报告文件是用的同步操作,在当前线程进行阻塞IO操作,而不是在开启一个工作线程来写文件。TODO:为什么?

具体写文件的逻辑是有 CrashReportPersister 来实现。具体存到哪个目录是由 ReportLocator 来决定的。而具体存成什么文件名,是根据崩溃的时间戳来决定的:

/data/data/packageName/files/{UnapprovedFolder}/{Timestamp}-{IS_SILENT}.stacktrace

例如:

/data/data/test.lds.com.androidtest/app_ACRA-unapproved/2017-03-25T03:34:45.633+00:00-IS_SILENT.stacktrace

ACRA会将报告存在不同的两个目录下:

  • app_ACRA-unapproved
  • app_ACRA-approved

崩溃报告首先都会存到 unapproved 目录下,在该目录下的报告不会发送出去,而允许发送以后则会将报告移动到 approved 目录下,在该目录下的崩溃报告才会被发送出去。

5.3 崩溃报告文件格式

ACRA的崩溃报告以JSON格式保存在本地中。

报告是键值对结构,包含各种字段的信息,具体的字段可参考:

https://github.com/ACRA/acra/wiki/ReportContent

注意:需要上报哪些字段,不需要上报哪些字段,这都是可以手动配置的,避免上报的数据过多。

5.4 崩溃发送模式

  • 如果模式为silent或toast,则立即将报告发送出去。
  • 如果模式为notification,则在通知栏显示一个notification,来通知用户该应用崩溃了,点击这个通知会弹出一个对话框,用户可选择是否发送崩溃报告,如果用户选择发送,则立即将报告发送出去。
  • 如果模式为dialog,则直接显示dialog给用户选择是否发送。

六. 崩溃报告数据工厂(CrashReportDataFactory)

ACRA支持各种数据的收集,每个收集器都收集不同类型的数据,并且它支持配置需要发送什么类型的数据,而不发送什么数据。

CrashReportDataFactory负责将崩溃时的数据,以及根据配置来收集不同的上下文数据,将其封装成一个崩溃报告对象(CrashReportData)。

崩溃报告其实是一个关键对,包含各种不同字段的数据,并可以在写到文件的时候输入json格式数据。

七. 崩溃报告发送器(SenderServiceStarter)

当崩溃报告生成以后,并允许发送时,会交由 SenderServiceStarter 来负责启动 SenderServie 服务来在厚爱发送报告。

八. 后台发送崩溃报告服务(SenderService)

SenderService 负责具体的将崩溃报告发送出去的工作。

注意该Service是在另一个进程中(名为acra的进程)来和应用的主进程进行隔离。

其继承于 IntentService ,非常适合于短时间的后台操作,并在工作线程执行。每次执行完毕后则会关闭自己。

首先它会将所有 unapproved 目录下的报告都移动到 approved 目录,然后将该目录下的所有报告进行批量分发。因为可能会注册多种报告发送方法(如邮件、自定义服务器脚本等),所以具体的报告分发工作都交由 ReportDistributor 来处理。

每一次Service启动的时候,都只会最多发送 MAX_SEND_REPORTS 即5封报告来避免网络负载过大。

九. 崩溃报告分发器(ReportDistributor)

ReportDistributor 负责将某一个崩溃报告分发给所有注册的报告发送器。具体支持哪些发送器是由application上的注解来决定的。

一共有三种发送器(对应注解ReportsCrashes):

  1. 没有发送器 NullSender
  2. 邮件发送器 EmailIntentSender (对应注解 mailTo )
  3. 自定义服务器发送器 HttpSender(对应注解 fromUri )

分发的时候会将崩溃报告从文件读入到内存,即将文件里的JSON反映射为 CrashReportData 对象。然后再将其分发出去。

崩溃报告分发以后则会立即删除崩溃文件。

注意:如果在从文件解析JSON的过程中出现异常,即无法解析JSON文件,则会直接删除该报告,因为这个报告很可能是不合法的格式。而如果是分发过程中出现异常,则不会删除崩溃文件。

十. 崩溃报告发送器

ACRA支持多种发送崩溃报告的方法。发送器支持在错误情况下重试的机制 RetryPolicy

10.1 发送给自定义服务器(fromUri)

可以将崩溃报告发送给自定义服务器上的某个脚本,脚本的url在注解中的 fromUri 中配置。

支持在config中配置,是否需要使用 BasicAuth 来进行用户认证,可配置 username/password 。

还可以在config中配置http的超时时间,headers等信息。

具体的HTTP网络请求是由 DefaultHttpRequest 类来实现的。它的内部实现是使用 HttpURLConnection 来实现HTTP请求的。并实现了https,BasicAuth的支持。

崩溃报告的内容会直接以输出流的形式传给服务器。

10.2 发送给指定邮箱(mailTo)

可以将报告以邮件的形式发送给指定的邮箱地址,但是需要注意的是这里并没有实现邮件发送功能,而仅仅是使用Intent来唤起设备上直接发送邮件的邮件客户端来进行发送。即对于用户而言,发送的时候程序会跳到邮件客户端编写新邮件的页面,并且收件人和发送的内容都可以填写好了,点击发送即可。

崩溃报告会在邮件的正文(纯文本)的形式发送,每一条数据一行,以“=”进行key和value的分隔符, 例如:

APP_VERSION_CODE=1
USER_CRASH_DATE=2017-03-25T03:34:45.633+00:00
PRODUCT=sdk_google_phone_x86

十一. Collector信息收集器

ACRA可以收集各种各样的崩溃上下文数据,则从架构上它将所有信息进行分类,交给不同的收集器去收集,也支持配置去过滤需要的收集器来收集不同的信息。

11.1 应用日志收集器(LogCatCollector)

收集崩溃时最近300行的Logcat日志输出内容。可支持收集不同缓存区的日志。

缓存区:

  • main,查看主要日志缓冲区(默认值)
  • events,查看包含事件相关消息的缓冲区。
  • radio, 查看包含无线装置/电话相关消息的缓冲区。

收集这些信息需要使用执行终端命令 logcat 来实现,并支持根据当前的pid进行过滤。

List<String> commandLine = new ArrayList<>();
commandLine.add("logcat");
// ...  ​
final Process process =
  new ProcessBuilder().command(commandLine).redirectErrorStream(true).start();
// process.getInputStream()

收集到的logcat日志会以 \n 作为分隔符,拼接成一个字符串。

收集logcat信息需要权限:android.permission.READ_LOGS, 并且系统版本在Android 4.1及以上。logocat命令官方文档:https://developer.android.com/studio/command-line/logcat.html

11.2 系统日志收集器(DropBoxCollector)

Android内有一个名为DropBoxManager的Service,用于持久化的记录系统日志或程序崩溃日志。

DropBoxManager 是 Android 在 Froyo(API level 8) 引入的用来持续化存储系统数据的机制, 主要用于记录 Android 运行过程中, 内核, 系统进程, 用户进程等出现严重问题时的 log, 可以认为这是一个可持续存储的系统级别的logcat.

DropBox可以记录到如下的日志:

  • crash: 崩溃时
  • native_crash: 当调用 NativeCrashReporter.run() 时。
  • WTF:调用 Log.wtf()Log.wtfQuiet()
  • ANR:应用程序未响应时
  • watchdog: 如果WatchDog 检测到系统进程(system_server)出现问题时。
  • lowmem: 内存不足时
  • 等等

收集这些信息需要获取 DropBoxManager 的 Service 对象即可:

final DropBoxManager dropbox =
  (DropBoxManager) context.getSystemService(Context.DROPBOX_SERVICE);
// ...
DropBoxManager.Entry entry = dropbox.getNextEntry(tag, time);

当系统出现各种情况时,都会写入各种日志,日志类型清单如下(不完全):

SYSTEM_TAGS NOTE
system_app_anr 系统app无响应
system_app_wtf 系统app发生严重错误
system_app_crash 系统app崩溃
system_app_strictmode 系统app严格模式
system_server_anr system进程无响应
system_server_wtf system进程发生严重错误
system_server_crash system进程崩溃
data_app_anr 普通应用无响应
data_app_wtf 普通应用发生严重错误
data_app_creash 普通应用崩溃
data_app_strictmode 普通应用严格模式
BATTERY_DISCHARGE_INFO 系统充电日志( BatteryService )
SYSTEM_RECOVERY_LOG 系统恢复而重启( /cache/recovery/log文件存在时)
SYSTEM_BOOT 系统开机日志
SYSTEM_LAST_KMSG 内核错误( /proc/last_kmsg文件存在时)
APANIC_CONSOLE 内核错误( /data/dontpanic/apanic_console文件存在时)
APANIC_THREADS 内核错误( /data/dontpanic/apanic_threads文件存在时)
SYSTEM_RESTART 系统重启日志
SYSTEM_TOMBSTONE Native进程崩溃日志
event_data
netstats_error 网络状态异常日志( NetworkStatsService )

所有dropbox的文件都以 TAG@timestrap.txt 为名称保存在系统目录:

/data/system/dropbox

每次有新文件写入的时候,都会有系统广播:

DropBoxManager.ACTION_DROPBOX_ENTRY_ADDED

例如:data_app_crash@1490172449967.txt 文件就记录了一个普通app崩溃的日志信息:

Process: com.example.dumptestbyjava
Flags: 0xa8be46
Package: com.example.dumptestbyjava v1 (1.1)
Build: Android/sdk_google_phone_x86/generic_x86:5.1.1/LMY48X/3728910:userdebug/test-keys

java.lang.ArrayIndexOutOfBoundsException: length=3; index=10
	at com.example.dumptestbyjava.MainActivity$3.onClick(MainActivity.java:167)
	at android.view.View.performClick(View.java:4780)
	at android.view.View$PerformClick.run(View.java:19866)
	at android.os.Handler.handleCallback(Handler.java:739)
	at android.os.Handler.dispatchMessage(Handler.java:95)
	at android.os.Looper.loop(Looper.java:135)
	at android.app.ActivityThread.main(ActivityThread.java:5254)
	at java.lang.reflect.Method.invoke(Native Method)
	at java.lang.reflect.Method.invoke(Method.java:372)
	at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:903)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:698)

收集logcat信息需要权限:android.permission.READ_LOGS, 并且系统版本在Android 4.1及以上。dropBox介绍参考:http://blog.csdn.net/ljchlx/article/details/8559963
DropBoxManagerService启动说明:http://gityuan.com/2016/06/12/DropBoxManagerService/
DropBox介绍:http://www.jianshu.com/p/f9174a8d0a10

11.3 崩溃日志收集器(StacktraceCollector)

收集崩溃时引发的异常exception的stack trace。

这个比较简单,因为exception对象里包含了stack trace,直接将其转换为字符串即可。

// Throwable
public void void printStackTrace(PrintWriter err);

11.4 时间收集器(TimeCollector)

收集应用启动的时间,和崩溃发生的时间。

应用启动的时间就是调用 init 方法的时候。

崩溃的时间就是崩溃当前的时间戳。

时间格式:

yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ

11.5 键值对收集器(SimpleValuesCollector)

收集一些简单的键值对,如:

  • is_silent: 配置中的选项
  • report_id: 每个崩溃报告会生成一个报告的唯一识别码UUID UUID.randomUUID().toString()
  • installation_id: 每次安装的时候会生成一个机器的唯一识别码UUID(安装的时候会将其写入到文件)
  • package_name: 应用的包名 context.getPackageName())
  • phone_model: 手机型号 Build.MODEL
  • android_version: android版本号 Build.VERSION.RELEASE , 例如 5.1.1
  • brand: 手机品牌 Build.BRAND
  • product: 手机产品名 Build.PRODUCT
  • file_path: 应用私有目录路径: /data/data/packageName/files
  • user_ip: 用户IP , 从networkInterface中获取的当前所有ip

11.6 系统配置收集器(ConfigurationCollector)

收集的信息比较多,包括屏幕尺寸,触摸屏类型,系统语言,键盘,导航,UI模式等信息。

这些信息收集起来其实比较简单,只需要调用context的方法即可。

getContext().getResources().getConfiguration();

收集信息实例如下:

{
"compatScreenHeightDp": 511,
"compatScreenWidthDp": 319,
"compatSmallestScreenWidthDp": 320,
"densityDpi": 420,
"fontScale": 1,
"hardKeyboardHidden": "HARDKEYBOARDHIDDEN_YES",
"keyboard": "KEYBOARD_NOKEYS",
"keyboardHidden": "KEYBOARDHIDDEN_NO",
"locale": "en_US",
"mcc": 310,
"mnc": 260,
"navigation": "NAVIGATION_NONAV",
"navigationHidden": "NAVIGATIONHIDDEN_YES",
"orientation": "ORIENTATION_PORTRAIT",
"screenHeightDp": 658,
"screenLayout": "SCREENLAYOUT_SIZE_NORMAL+SCREENLAYOUT_LONG_NO+SCREENLAYOUT_LAYOUTDIR_LTR",
"screenWidthDp": 411,
"seq": 6,
"smallestScreenWidthDp": 411,
"touchscreen": "TOUCHSCREEN_FINGER",
"uiMode": "UI_MODE_TYPE_NORMAL+UI_MODE_NIGHT_NO",
"userSetLocale": false
},

具体参数含义可参考官方文档: https://developer.android.com/reference/android/content/res/Configuration.html

11.7 内存信息收集器(MemoryInfoCollector)

收集当前应用最大内存(total),可用内存(available),以及dumpsys里的内存信息。

内存信息使用的就是终端命令,来获取跟内存有关的信息。

dumpsys meninfo

注意dumpsys在很多机器上使不行的,需要android.permission.DUMP权限,但该权限只有系统应用才允许使用。

Total PSS by process:
    52818 kB: com.google.android.gms (pid 4071)
    50785 kB: system (pid 3455)
    40850 kB: com.google.android.gms.persistent (pid 3672)
    31394 kB: com.google.android.googlequicksearchbox (pid 3720 / activities)
    31273 kB: com.android.systemui (pid 3545)
    19930 kB: com.google.android.googlequicksearchbox:search (pid 3849)
    17980 kB: com.google.android.gms.unstable (pid 4354)
    15836 kB: com.android.phone (pid 3703)
    14464 kB: zygote (pid 3173)
    12383 kB: com.google.android.gms.ui (pid 4423)
     9932 kB: com.android.inputmethod.latin (pid 3618)
     7867 kB: android.process.acore (pid 3746)
     7264 kB: com.google.process.gapps (pid 3797)
     6428 kB: mediaserver (pid 3175)
     5776 kB: test.lds.com.androidtest:acra (pid 7266)
     5254 kB: com.google.android.googlequicksearchbox:interactor (pid 3598)
     4926 kB: com.android.providers.calendar (pid 4179)
     4131 kB: com.android.keychain (pid 4917)
     3638 kB: com.android.defcontainer (pid 4884)
     3166 kB: com.svox.pico (pid 4943)
     2562 kB: logd (pid 1157)
     1906 kB: surfaceflinger (pid 1162)
     1583 kB: drmserver (pid 1171)
      995 kB: vold (pid 1161)
      876 kB: netd (pid 3174)
      763 kB: keystore (pid 1174)
      742 kB: debuggerd (pid 1169)
      702 kB: rild (pid 1170)
      556 kB: adbd (pid 1167)
      456 kB: healthd (pid 1158)
      453 kB: sh (pid 8276)
      437 kB: lmkd (pid 1159)
      437 kB: sh (pid 1166)
      423 kB: sdcard (pid 1856)
      408 kB: /init (pid 1)
      406 kB: dumpsys (pid 8411)
      358 kB: logcat (pid 5268)
      320 kB: ueventd (pid 829)
      299 kB: installd (pid 1173)
      292 kB: servicemanager (pid 1160)

11.8 反射信息收集器(ReflectionCollector)

通过反射获取到一些常量信息,一般和环境有关。

  • Build 的常量信息:包括BRAND,DEVICE等。
  • BuildConfigs 的常量信息:包括debug,application_id, flavor, version_code, version_name等
  • Environment 的常量信息:包括 getExternalStorageDirectory 等环境变量(这个是通过调用其一系列get方法的获取的)

11.9 屏幕信息收集器(DisplayManagerCollector)

收集屏幕相关的数据,如sizerotationwidthheightMetricsRealMetricsrefreshRate等。

使用 DisplayManager 来获取 Display 对象(可能有多个,但一般只有一个)

11.10 自定义数据收集器(CustomDataCollector)

收集自定义数据,由用户主动插入的自定义数据。

11.11 Pref信息收集器(SharedPreferencesCollector)

收集 SharedPreferences 信息。

有两种情况:

  1. Application作为context获得的 default SharedPreferences 对象里的数据。
  2. 在配置中指定 name 的 SharedPreferences 对象里的所有键值对。

11.12 设备特性收集器(DeviceFeaturesCollector)

收集设备支持的特性,如是否有GPS,有蓝牙,有加速器,openGL es的版本号等信息。

收集方法比较简单,使用以下代码即可:

final PackageManager pm = context.getPackageManager();
final FeatureInfo[] features = pm.getSystemAvailableFeatures();​
// 获取 OpenGL Es 版本号
if (feature.name == null) {
  feature.getGlEsVersion();
}

11.13 手机设置收集器(SettingsCollector)

收集手机设置相关信息:

  • SETTINGS_GLOBAL 全局设置,如WiFi是否开启,铃声,蓝牙是否开启,GPS是否开启等手机设置。
  • SETTINGS_SYSTEM 系统设置,音量大小,是否开启震动等设置。
  • SETTINGS_SECURE 隐私设置,

这些信息也是通过反射来获取的

// SETTINGS_GLOBAL 
Global.class.getFields();
​// SETTINGS_SYSTEM 
System.class.getFields();
​// SETTINGS_SECURE 
Secure.class.getFields();

11.14 包信息收集器(PackageManagerCollector)

收集应用版本信息,如versionCode和versionName。

11.15 设备唯一标识收集器(DeviceIdCollector)

获取设备唯一识别码,如IMEI号信息

final TelephonyManager tm =
  (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
tm.getDeviceId();

需要权限:android.permission.READ_PHONE_STATE
更多设备唯一识别码获取参见:http://www.cnblogs.com/lvcha/p/3721091.html

11.16 自定义日志收集器(LogFileCollector)

收集自定义log文件,可以在配置中指定自定义个log文件路径,崩溃报告则会将其log文件的内容以字符串的形式作为报告的信息进行收集。

11.17 媒体格式收集器(MediaCodecCollector)

收集设备支持的媒体格式,如视频格式,音频格式等。

收集这些信息是使用反射 android.media.MediaCodecInfo 类两个内部类:

  • MediaCodecInfo$CodecCapabilities
  • MediaCodecInfo$CodecProfileLevel

11.18 崩溃线程信息收集器(ThreadCollector)

收集崩溃线程的信息,如 Thread 的 id, name, priority, groupName

这里 Thread 对象是由 UncaughtExceptionHandleruncaughtException 方法一路传进来的,指向引发崩溃的线程。

十二. 结语

ACRA作为一个被广泛使用的Android Crash Report系统,又是Google官方推出,因此该框架有几点优势:

  1. 经过大量真实应用长时间的使用,稳定性比较高。
  2. 因为Google对Android的了解,对于大量信息的收集比较全面,包括使用反射来收集的信息。
  3. 框架在架构上设计得比较好,值得学习。

本文作者:鼎三

原文链接:Google Acra源码研究报告,转载请注明来源!

0