Android 平台 Native 代码的崩溃捕获机制及实现
yuyutoo 2024-10-11 21:39 9 浏览 0 评论
进行更多扩展操作,如:
打印logcat和应用日志
上报crash次数
对不同的crash做不同的恢复措施
可以针对业务不断改进和适应
二、现有的方案
其实3个方案在Android平台的实现原理都是基本一致的,综合考虑,可以基于coffeecatch改进。
三、信号机制
1.程序奔溃
在Unix-like系统中,所有的崩溃都是编程错误或者硬件错误相关的,系统遇到不可恢复的错误时会触发崩溃机制让程序退出,如除零、段地址错误等。
异常发生时,CPU通过异常中断的方式,触发异常处理流程。不同的处理器,有不同的异常中断类型和中断处理方式。
linux把这些中断处理,统一为信号量,可以注册信号量向量进行处理。
信号机制是进程之间相互传递消息的一种方法,信号全称为软中断信号。
2.信号机制
函数运行在用户态,当遇到系统调用、中断或是异常的情况时,程序会进入内核态。信号涉及到了这两种状态之间的转换。
(1) 信号的接收
接收信号的任务是由内核代理的,当内核接收到信号后,会将其放到对应进程的信号队列中,同时向进程发送一个中断,使其陷入内核态。注意,此时信号还只是在队列中,对进程来说暂时是不知道有信号到来的。
(2) 信号的检测
进程陷入内核态后,有两种场景会对信号进行检测:
进程从内核态返回到用户态前进行信号检测
进程在内核态中,从睡眠状态被唤醒的时候进行信号检测
当发现有新信号时,便会进入下一步,信号的处理。
(3) 信号的处理
信号处理函数是运行在用户态的,调用处理函数前,内核会将当前内核栈的内容备份拷贝到用户栈上,并且修改指令寄存器(eip)将其指向信号处理函数。
接下来进程返回到用户态中,执行相应的信号处理函数。
信号处理函数执行完成后,还需要返回内核态,检查是否还有其它信号未处理。如果所有信号都处理完成,就会将内核栈恢复(从用户栈的备份拷贝回来),同时恢复指令寄存器(eip)将其指向中断前的运行位置,最后回到用户态继续执行进程。
至此,一个完整的信号处理流程便结束了,如果同时有多个信号到达,上面的处理流程会在第2步和第3步骤间重复进行。
(4) 常见信号量类型
四、捕捉native crash
1.注册信号处理函数
第一步就是要用信号处理函数捕获到native crash(SIGSEGV, SIGBUS等)。在posix系统,可以用sigaction():
#include <signal.h>
signum:代表信号编码,可以是除SIGKILL及SIGSTOP外的任何一个特定有效的信号,如果为这两个信号定义自己的处理函数,将导致信号安装错误。
act:指向结构体sigaction的一个实例的指针,该实例指定了对特定信号的处理,如果设置为空,进程会执行默认处理。
oldact:和参数act类似,只不过保存的是原来对相应信号的处理,也可设置为NULL。
struct sigaction sa_old; memset(&sa, 0, sizeof(sa)); sigemptyset(&sa.sa_mask); sa.sa_sigaction = my_handler; sa.sa_flags = SA_SIGINFO;if (sigaction(sig, &sa, &sa_old) == 0) { ... }
2.设置额外栈空间
#include <signal.h>int sigaltstack(const stack_t *ss, stack_t *oss);
SIGSEGV很有可能是栈溢出引起的,如果在默认的栈上运行很有可能会破坏程序运行的现场,无法获取到正确的上下文。而且当栈满了(太多次递归,栈上太多对象),系统会在同一个已经满了的栈上调用SIGSEGV的信号处理函数,又再一次引起同样的信号。
我们应该开辟一块新的空间作为运行信号处理函数的栈。可以使用sigaltstack在任意线程注册一个可选的栈,保留一下在紧急情况下使用的空间。(系统会在危险情况下把栈指针指向这个地方,使得可以在一个新的栈上运行信号处理函数)
stack_t stack; memset(&stack, 0, sizeof(stack));/* Reserver the system default stack size. We don't need that much by the way. */stack.ss_size = SIGSTKSZ; stack.ss_sp = malloc(stack.ss_size); stack.ss_flags = 0;/* Install alternate stack size. Be sure the memory region is valid until you revert it. */if (stack.ss_sp != NULL && sigaltstack(&stack, NULL) == 0) { ... }
3.兼容其他signal处理
static void my_handler(const int code, siginfo_t *const si, void *const sc) {
某些信号可能在之前已经被安装过信号处理函数,而sigaction一个信号量只能注册一个处理函数,这意味着我们的处理函数会覆盖其他人的处理信号
保存旧的处理函数,在处理完我们的信号处理函数后,在重新运行老的处理函数就能完成兼容。
五、注意事项
1.防止死锁或者死循环
首先我们要了解async-signal-safe和可重入函数概念:
A signal handler function must be very careful, since processing elsewhere may be interrupted at some arbitrary point in the execution of the program.
POSIX has the concept of “safe function”. If a signal interrupts the execution of an unsafe function, and handler either calls an unsafe function or handler terminates via a call to longjmp() or siglongjmp() and the program subsequently calls an unsafe function, then the behavior of the program is undefined.
回想下在“信号机制”一节中的图示,进程捕捉到信号并对其进行处理时,进程正在执行的正常指令序列就被信号处理程序临时中断,它首先执行该信号处理程序中的指令(类似发生硬件中断)。但在信号处理程序中,不能判断捕捉到信号时进程执行到何处。如果进程正在执行malloc,在其堆中分配另外的存储空间,而此时由于捕捉到信号而插入执行该信号处理程序,其中又调用malloc,这时会发生什么?这可能会对进程造成破坏,因为malloc通常为它所分配的存储区维护一个链表,而插入执行信号处理程序时,进程可能正在更改此链表。(参考《UNIX环境高级编程》)
Single UNIX Specification说明了在信号处理程序中保证调用安全的函数。这些函数是可重入的并被称为是异步信号安全(async-signal-safe)。除了可重入以外,在信号处理操作期间,它会阻塞任何会引起不一致的信号发送。下面是这些异步信号安全函数:
但即使我们自己在信号处理程序中不使用不可重入的函数,也无法保证保存的旧的信号处理程序中不会有非异步信号安全的函数。所以要使用alarm保证信号处理程序不会陷入死锁或者死循环的状态。
static void signal_handler(const int code, siginfo_t *const si,void *const sc) {/* Ensure we do not deadlock. Default of ALRM is to die. * (signal() and alarm() are signal-safe) */ signal(code, SIG_DFL); signal(SIGALRM, SIG_DFL); /* Ensure we do not deadlock. Default of ALRM is to die. * (signal() and alarm() are signal-safe) */ (void) alarm(8); .... }
2.在哪里打印堆栈
(1) 子进程
考虑到信号处理程序中的诸多限制,一般会clone一个新的进程,在其中完成解析堆栈等任务。
下面是Google Breakpad的流程图,在新的进程中DoDump,使用ptrace解析crash进程的堆栈,同时信号处理程序等待子进程完成任务后,再调用旧的信号处理函数。父子进程使用管道通信。
(2) 子线程
在我的实验中,在子进程或者信号处理函数中,经常无法回调给java层。于是我选择了在初始化的时候就建立了子线程并一直等待,等到捕捉到crash信号时,唤醒这条线程dump出crash堆栈,并把crash堆栈回调给java。
static void nativeInit(JNIEnv* env, jclass javaClass, jstring packageNameStr, jstring tombstoneFilePathStr, jobject obj) { ... initCondition(); pthread_t thd;int ret = pthread_create(&thd, NULL, DumpThreadEntry, NULL); if(ret) { qmlog("%s", "pthread_create error"); } }void* DumpThreadEntry(void *argv) { JNIEnv* env = NULL;if((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != JNI_OK) { LOGE("AttachCurrentThread() failed"); estatus = 0;return &estatus; }while (true) {//等待信号处理函数唤醒 waitForSignal();//回调native异常堆栈给java层 throw_exception(env);//告诉信号处理函数已经处理完了 notifyThrowException(); }if((*g_jvm)->DetachCurrentThread(g_jvm) != JNI_OK) { LOGE("DetachCurrentThread() failed"); estatus = 0;return &estatus; } return &estatus; }
六、收集native crash原因
信号处理函数的入参中有丰富的错误信息,下面我们来一一分析。
/*信号处理函数*/void (*sa_sigaction)(const int code, siginfo_t *const si, void * const sc) siginfo_t { int si_signo; /* Signal number 信号量 */ int si_errno; /* An errno value */ int si_code; /* Signal code 错误码 */ }
1.code
发生native crash之后,logcat中会打出如下一句信息:
signal 11 (SIGSEGV), code 0 (SI_USER), fault addr 0x0
根据code去查表,其实就可以知道发生native crash的大致原因:
代码的一部分如下,其实就是根据不同的code,输出不同信息,这些都是固定的。
case SIGFPE:switch(code) {case FPE_INTDIV:return "Integer divide by zero";case FPE_INTOVF:return "Integer overflow";case FPE_FLTDIV:return "Floating-point divide by zero";case FPE_FLTOVF:return "Floating-point overflow";case FPE_FLTUND:return "Floating-point underflow";case FPE_FLTRES:return "Floating-point inexact result";case FPE_FLTINV:return "Invalid floating-point operation";case FPE_FLTSUB:return "Subscript out of range";default:return "Floating-point"; }break;case SIGSEGV:switch(code) {case SEGV_MAPERR:return "Address not mapped to object";case SEGV_ACCERR:return "Invalid permissions for mapped object";default:return "Segmentation violation"; } break;
2.pc值
信号处理函数中的第三个入参sc是uc_mcontext的结构体,是cpu相关的上下文,包括当前线程的寄存器信息和奔溃时的pc值。能够知道崩溃时的pc,就能知道崩溃时执行的是那条指令。
不过这个结构体的定义是平台相关,不同平台、不同cpu架构中的定义都不一样:
x86-64架构:uc_mcontext.gregs[REG_RIP]
arm架构:uc_mcontext.arm_pc
3.共享库名字和相对偏移地址
(1) dladdr()
pc值是程序加载到内存中的绝对地址,我们需要拿到奔溃代码相对于共享库的相对偏移地址,才能使用addr2line分析出是哪一行代码。通过dladdr()可以获得共享库加载到内存的起始地址,和pc值相减就可以获得相对偏移地址,并且可以获得共享库的名字。
Dl_info info; if (dladdr(addr, &info) != 0 && info.dli_fname != NULL) { void * const nearest = info.dli_saddr; //相对偏移地址 const uintptr_t addr_relative = ((uintptr_t) addr - (uintptr_t) info.dli_fbase); ... }
作为有追求的我们,肯定不满足于仅仅通过一个函数就获得答案。我们尝试下如何手工分析出相对地址。首先要了解下进程的地址空间布局。
(2) Linux下进程的地址空间布局
任何一个程序通常都包括代码段和数据段,这些代码和数据本身都是静态的。程序要想运行,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间中为其代码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程中还要有其动态环境,其中最重要的就是堆栈。
上图中Random stack offset和Random mmap offset等随机值意在防止恶意程序。Linux通过对栈、内存映射段、堆的起始地址加上随机偏移量来打乱布局,以免恶意程序通过计算访问栈、库函数等地址。
栈(stack),作为进程的临时数据区,增长方向是从高地址到低地址。
(3) /proc/self/maps:检查各个模块加载在内存的地址范围
在Linux系统中,/proc/self/maps保存了各个程序段在内存中的加载地址范围,grep出共享库的名字,就可以知道共享库的加载基值是多少。
得到相对偏移地址之后,使用readelf查看共享库的符号表,就可以知道是哪个函数crash了。
七、获取堆栈
1.原理
在前一步,我们获取了奔溃时的pc值和各个寄存器的内容,通过SP和FP所限定的stack frame,就可以得到母函数的SP和FP,从而得到母函数的stack frame(PC,LR,SP,FP会在函数调用的第一时间压栈),以此追溯,即可得到所有函数的调用顺序。
2.实现
在4.1.1以上,5.0以下:使用安卓系统自带的libcorkscrew.so
5.0以上:安卓系统中没有了libcorkscrew.so,使用自己编译的libunwind
#ifdef USE_UNWIND/* Frame buffer initial position. */ t->frames_size = 0;/* Skip us and the caller. */ t->frames_skip = 0;/* 使用libcorkscrew解堆栈 */#ifdef USE_CORKSCREW t->frames_size = backtrace_signal(si, sc, t->frames, 0, BACKTRACE_FRAMES_MAX); #else /* Unwind frames (equivalent to backtrace()) */ _Unwind_Backtrace(coffeecatch_unwind_callback, t); #endif/* 如果无法加载libcorkscrew,则使用自己编译的libunwind解堆栈 */#ifdef USE_LIBUNWINDif (t->frames_size == 0) { size_t i; t->frames_size = unwind_signal(si, sc, t->uframes, 0,BACKTRACE_FRAMES_MAX); for(i = 0 ; i < t->frames_size ; i++) { t->frames[i].absolute_pc = (uintptr_t) t->uframes[i]; t->frames[i].stack_top = 0; t->frames[i].stack_size = 0; __android_log_print(ANDROID_LOG_DEBUG, TAG, "absolute_pc:%x", t->frames[i].absolute_pc); } } #endif
libunwind是一个独立的开源库,高版本的安卓源码中也使用了libunwind作为解堆栈的工具,并针对安卓做了一些适配。下面是使用libunwind解堆栈的主循环,每次循环解一层堆栈。
static ALWAYS_INLINE intslow_backtrace (void **buffer, int size, unw_context_t *uc){ unw_cursor_t cursor; unw_word_t ip;int n = 0;if (unlikely (unw_init_local (&cursor, uc) < 0))return 0;while (unw_step (&cursor) > 0) {if (n >= size)return n;if (unw_get_reg (&cursor, UNW_REG_IP, &ip) < 0)return n; buffer[n++] = (void *) (uintptr_t) ip; } return n; }
八、获取函数符号
(1) libcorkscrew
可以通过libcorkscrew中的get_backtrace_symbols函数获得函数符号。
/* * Describes the symbols associated with a backtrace frame. */typedef struct { uintptr_t relative_pc; uintptr_t relative_symbol_addr;char* map_name;char* symbol_name;char* demangled_name; } backtrace_symbol_t;/* * Gets the symbols for each frame of a backtrace. * The symbols array must be big enough to hold one symbol record per frame. * The symbols must later be freed using free_backtrace_symbols. */void get_backtrace_symbols(const backtrace_frame_t* backtrace, size_t frames, backtrace_symbol_t* backtrace_symbols);
(2) dladdr
更通用的方法是通过dladdr获得函数名字。
int dladdr(void *addr, Dl_info *info); typedef struct {const char *dli_fname; /* Pathname of shared object that contains address */ void *dli_fbase; /* Base address at which shared object is loaded */ const char *dli_sname; /* Name of symbol whose definition overlaps addr */ void *dli_saddr; /* Exact address of symbol named in dli_sname */} Dl_info;
传入每一层堆栈的相对偏移地址,就可以从dli_fname中获得函数名字。
九、获得java堆栈
如何获得native crash所对应的java层堆栈,这个问题曾经困扰了我一段时间。这里有一个前提:我们认为crash线程就是捕获到信号的线程,虽然这在SIGABRT下不一定可靠。有了这个认知,接下来就好办了。在信号处理函数中获得当前线程的名字,然后把crash线程的名字传给java层,在java里dump出这个线程的堆栈,就是crash所对应的java层堆栈了。
在c中获得线程名字:
char* getThreadName(pid_t tid) {if (tid <= 1) {return NULL; }char* path = (char *) calloc(1, 80);char* line = (char *) calloc(1, THREAD_NAME_LENGTH); snprintf(path, PATH_MAX, "proc/%d/comm", tid); FILE* commFile = NULL;if (commFile = fopen(path, "r")) { fgets(line, THREAD_NAME_LENGTH, commFile); fclose(commFile); } free(path);if (line) {int length = strlen(line);if (line[length - 1] == '\n') { line[length - 1] = '\0'; } } return line; }
然后传给java层:
/** * 根据线程名获得线程对象,native层会调用该方法,不能混淆 * @param threadName * @return */ @Keep public static Thread getThreadByName(String threadName) {if (TextUtils.isEmpty(threadName)) {return null; } Set<Thread> threadSet = Thread.getAllStackTraces().keySet(); Thread[] threadArray = threadSet.toArray(new Thread[threadSet.size()]); Thread theThread = null;for(Thread thread : threadArray) {if (thread.getName().equals(threadName)) { theThread = thread; } } Log.d(TAG, "threadName: " + threadName + ", thread: " + theThread); return theThread; }
十、 结果展示
经过诸多探索,终于得到了完美的堆栈:
java.lang.Error: signal 11 (Address not mapped to object) at address 0x0
在native层构造了一个Error传给java,所以在java层可以很轻松地根据堆栈进行业务上的处理。
public interface CrashHandleListener { @Keep void onCrash(int id, Error e); }
另外初始化时就建立等待回调线程的方式,提供了稳定的给java层的回调。在回调中我们打印了app的状态信息,包括activity的堆栈、app是否在前台等,以及打印crash前的logcat日志和把应用日志flush进文件。针对某些具体的native crash还做了业务上的处理,例如遇到热补丁框架相关的crash时就回滚补丁。
在用户环境中的很多native crash单靠堆栈是解决不了的,logcat是非常重要的补充。好几例webview crash都是通过发生crash时的logcat定位的。比如我们曾经遇到过的一个的webview crash:
#00 pc 00039874 /system/lib/libc.so (tgkill+12)
单凭堆栈根本看不出来是什么问题,但是在logcat中却看到这样一个warning log:
05-21 15:09:28.423 W/System.err(16811): java.lang.NullPointerException: Attempt to get length of null array05-21 15:09:28.424 W/System.err(16811): at java.io.ByteArrayInputStream.<init>(ByteArrayInputStream.java:60)05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.fetcher.HttpImageFetcher.fetchFromNetwork(HttpImageFetcher.java:86)05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.fetcher.BaseFetcher.fetch(BaseFetcher.java:24)05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.delaystream.DelayInputStream.read(DelayInputStream.java:36)05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.delaystream.DelayHttpInputStream.read(DelayHttpInputStream.java:12)05-21 15:09:28.424 W/System.err(16811): at java.io.InputStream.read(InputStream.java:181)05-21 15:09:28.424 W/System.err(16811): at org.chromium.android_webview.InputStreamUtil.read(InputStreamUtil.java:54)
查代码发现是我们在WebViewClient的shouldInterceptRequest接口中的业务代码发生了NullPointerException, 传进去WebView内部变成了natvie crash,问题解决。
相关推荐
- VBA中利用Instr函数(vba int函数)
-
【分享成果,随喜正能量】每一个在你的生命里出现的人,都有原因,喜欢你的人给了你温暖和勇气,你喜欢的人让你学会了爱和自持,你不喜欢的人教会你宽容与尊重,不喜欢你的人让你自省与成长。。...
- Insta360 Link体验:支持4K画质,一款使用场景丰富的AI云台摄像头
-
记者|王公逸伴随直播、线上会议需求的兴起,网络直播的需求愈发增大,8月2日,影石Insta360正式推出全新产品:Insta360Link,这是一款AI智能云台摄像头。从产品形态来说,Insta3...
- VBA技术资料MF299:利用Instr进行文本查找
-
我给VBA的定义:VBA是个人小型自动化处理的有效工具。利用好了,可以大大提高自己的工作效率,而且可以提高数据的准确度。“VBA语言専攻”提供的教程一共九套,分为初级、中级、高级三大部分,教程是对VB...
- Fabric.js 拖放元素进画布 - 掘金
-
本文简介点赞+关注+收藏=学会了学习Fabric.js,我的建议是看文档不如看demo。本文实现的功能:将元素拖进到画布中并生成对应的图形或图片。效果如下图所示:...
- Vue3为什么推荐使用ref而不是reactive
-
为什么推荐使用ref而不是reactivereactive本身具有很大局限性导致使用过程需要额外注意,如果忽视这些问题将对开发造成不小的麻烦;ref更像是vue2时代optionapi的data的替...
- Fabric.js 样式不更新怎么办?(js更改样式)
-
本文简介带尬猴,我嗨德育处主任不知道你有没有遇到过在使用Fabric.js时无意中一些骚操作修改了元素的样式,但刷新画布却没更新元素样式?如果你也遇到同样的问题的话,可以尝试使用本文的方法。...
- Fabric.js 修改画布交互方式到底有什么用?
-
本文简介点赞+关注+收藏=学会了fabric.js为我们提供了很多厉害的方法。今天要搞明白的一个东西是canvas.interactive。官方文档对canvas.interact...
- Rust Web编程:第五章 在浏览器上显示内容
-
我们现在正处于可以构建一个Web应用程序的阶段,该应用程序可以使用不同的方法和数据管理一系列HTTP请求。这很有用,特别是当我们为微服务构建服务器时。然而,我们也希望非程序员能够与我们的应...
- Fabric.js 自由绘制椭圆 - 掘金(canvas画椭圆)
-
本文简介点赞+关注+收藏=学会了本文讲解在Fabric.js中如何自由绘制椭圆形,如果你还不了解Fabric.js,可以查阅《Fabric.js从入门到精通》。效果如下图所示...
- 手把手教你实现JS手搓"防抖"优化代码——专业的事用专业的方法!
-
前言在我们前端编程中,假如我们要给后端发送请求,万一手抖多点了几次,多发送了几遍怎么办?解决方案:防抖!这种事就要交给我们专业的“防抖”先生来处理!今天,我们就来教大家手搓“防抖”...
- 详解虚拟DOM与Diff算法(虚拟dom一定比实际dom快吗)
-
vue的虚拟DOM,Diff算法,其中一些关键的地方从别处搬运了一些图进行说明(感谢制图的大佬),也包含比较详细的源码解读。...
- 走进 React Fiber 的世界(我走进你的世界手势舞视频)
-
文/阿里淘系F(x)Team-冷卉Fiber设计思想Fiber是对React核心算法的重构,facebook团队使用两年多的时间去重构React的核心算法,在React16以上...
- 前端新一代框架 Svelte 火了!十个场景带你简单认识它!
-
近几年听到的主流框架都是Vue、React、Angular,但其实有一个框架在国外非常火,用起来也是很方便,那就是...
- 借助DeepSeek实现了一个PDF阅读器
-
1、简介使用pdf.js库加载和显示PDF文件。实现了翻页、缩放功能。提供了基本的错误处理。功能特点:支持选择本地PDF文件。可以逐页查看PDF内容。支持放大缩小功能。界面简洁,易于使...
- DeepSeek代码之旅1:卫星地图标记方法之——html语言的实现
-
最近遇到一个任务,具体功能如下:1、调用高德地图API,图层为卫星图层,根据需要标记兴趣点;2、标记完成后可以保存兴趣点,便于下次加载历史兴趣点。...
你 发表评论:
欢迎- 一周热门
-
-
前端面试:iframe 的优缺点? iframe有那些缺点
-
带斜线的表头制作好了,如何填充内容?这几种方法你更喜欢哪个?
-
漫学笔记之PHP.ini常用的配置信息
-
其实模版网站在开发工作中很重要,推荐几个参考站给大家
-
推荐7个模板代码和其他游戏源码下载的网址
-
[干货] JAVA - JVM - 2 内存两分 [干货]+java+-+jvm+-+2+内存两分吗
-
正在学习使用python搭建自动化测试框架?这个系统包你可能会用到
-
织梦(Dedecms)建站教程 织梦建站详细步骤
-
【开源分享】2024PHP在线客服系统源码(搭建教程+终身使用)
-
2024PHP在线客服系统源码+完全开源 带详细搭建教程
-
- 最近发表
- 标签列表
-
- mybatis plus (70)
- scheduledtask (71)
- css滚动条 (60)
- java学生成绩管理系统 (59)
- 结构体数组 (69)
- databasemetadata (64)
- javastatic (68)
- jsp实用教程 (53)
- fontawesome (57)
- widget开发 (57)
- vb net教程 (62)
- hibernate 教程 (63)
- case语句 (57)
- svn连接 (74)
- directoryindex (69)
- session timeout (58)
- textbox换行 (67)
- extension_dir (64)
- linearlayout (58)
- vba高级教程 (75)
- iframe用法 (58)
- sqlparameter (59)
- trim函数 (59)
- flex布局 (63)
- contextloaderlistener (56)