iOS系统崩溃的捕获

相信大家在开发iOS程序的时候肯定写过各种Bug,而其中最为严重的Bug就是会导致崩溃的Bug(一般来说妥妥的P1级)。在应用软件大大小小的各种异常中,崩溃确实是最让人难以接受的行为。毕竟崩溃意味着用户将丢失应用程序运行中的所有上下文环境,丢失其所有未保存的数据,会带给用户最糟糕的使用体验。

所以在应用的开发阶段,我们一定要杜绝此类可能造成应用程序无法使用的崩溃。但是很多崩溃并不是自己在开发阶段就能预料到的,此时就需要一种能够线上获取崩溃日志并且上报的机制,这就是所谓的崩溃捕获和上报体系。

今天我们不研究SuperApp中的崩溃上报,主要研究一下崩溃捕获是如何实现的。

iOS系统中如何捕获崩溃

首先,iOS系统中,并没有通用的能够捕获所有崩溃的处理函数。捕获崩溃主要有以下三种方式:

  • NSSetUncaughtExceptionHandler
  • Unix Signal捕获函数
  • Mach(读音为[mʌk])异常捕获函数

关于如何用上述的方式捕获崩溃,不是本次分享的重点,大家可以自行查阅博客中的代码。我们主要需要理解的是这三者各自的原理和应用场景。

NSSetUncaughtExceptionHandler

首先我们写一个会导致崩溃的Objective-C代码片段:

NSDictionary *userinfo = @{
@"username": @"TP-LINK",
@"email": @"admin@tp-link.com.cn",
@"tel": @"15015001500"
};
NSMutableArray<NSDictionary *> *memberarray = [NSMutableArray arrayWithArray:@[userinfo]];
for (NSDictionary *dic in memberarray) {
if ([[dic valueForKey:@"username"] isEqualToString:@"TP-LINK"]) {
[memberarray removeObject:dic];
}
}

运行程序,不出意外的话,程序在执行到片段的时候就会立刻崩溃,然后我们会在控制台里面看到如下打印:

*** Terminating app due to uncaught exception 'NSGenericException', reason: '*** Collection <__NSArrayM: 0xb550c30> was mutated while being enumerated.'

相信不少同学都会这串提示很熟悉,从字面上来看,程序崩溃是因为有个异常没有被捕获到,异常的类型是NSGenericException,导致异常的原因则是因为在遍历集合的时候尝试去修改里面的元素。

NSGenericException是一个继承自NSException的类,表示触发的是一种通用的异常,除了这个类,还有很多其他的子类,像是NSRangExceptionNSInvalidArgumentException等,基本上只要看到名称就知道异常大致是什么原因导致的。

NSException是可以被我们手动捕获的,例如:

@try {
for (NSDictionary *dic in memberarray) {
if ([[dic valueForKey:@"username"] isEqualToString:@"TP-LINK"]) {
[memberarray removeObject:dic];
}
}
@catch (NSException *exception) {
NSLog(@"Caught %@: %@", [exception name], [exception reason]);
}

但是在写实际项目的时候,我们通常不会手动写这类处理异常的代码,Objective-C也并没有强制要求我们写此类的异常处理程序。

其实,这主要是因为异常代表的通常是我们编写的程序存在逻辑错误,通常不可恢复,需要我们在发布给用户使用之前由开发者进行处理,所以NSException又被称为应用级异常。NSSetUncaughtExceptionHandler实际上是给我们提供了一个手段,对这些我们未捕获的异常进行一个最终的处理,但如果这些错误是在用户使用的时候发生的,我们也无法立刻进行处理。

或许也是因为这个原因,Swift语言抛弃了NSException,而只保留了Error。

由于NSSetUncaughtExceptionHandler不是万能的,比如我们写一段Swift的强制解包代码:

var userName= fetchUserName()
printUserName(userName!)

上述代码假设fetchUserName()函数返回nil,并且printUserName()函数只接受非空参数,那么在程序运行时,由于强制解包失败,应用程序会崩溃并且NSSetUncaughtExceptionHandler也无法捕获此类崩溃,这时候就需要其他的机制来捕获此类异常。

Mach异常

要了解Mach异常,首先要了解什么是Mach!首先上一张mac OS X的架构图:

mac OS X的核心操作系统被称为“Darwin”,其由系统组件和内核构成。其中内核被称为"xnu",他是一个混合型的内核,包括了Mach和BSD两个部分,其中BSD实现了文件系统、网络、NKE(Network Kernel Extension,实现注入通信加密、虚拟网络接口等网络方面的扩展功能)、POSIX接口等功能,而Mach则实现了I/O组件和驱动程序。xnu内核是开源的。

从图里面可以看到,内核的下面就是硬件,所以由Mach内核抛出的异常也被称为是最底层的异常,造成异常的原因通常是硬件导致的异常,比如:

  • 试图访问不存在的内存
  • 试图访问违反地址空间保护的内存
  • 由于非法或未定义的操作代码或操作数而无法执行指令
  • 产生算术错误,例如被零除、上溢、或者下溢
  • ……

关于Mach抛出异常的流程,我们可以结合以下图来理解:

如果出错的线程触发了一个硬件级别的错误,处于内核的陷阱处理程序就会调用exception_deliver()函数依次尝试将异常投递到thread、task和host。

这里插入一个小话题,在Mach内核中,为了和thread、task和host打交道,或者他们互相之间打交道,提供了一种基于端口的IPC手段,这个手段在Cocoa上层也有对应的抽象,就是NSMachPort。这个mach port大家可能听说过,不知大家是否有印象?

当异常发生的时候,一条包含异常的mach message,例如异常类型、发生异常的线程等等,都会被发送到一个异常端口。而thread、task、host都会维护一组异常端口,当Mach Exception机制传递异常消息的时候,它会按照thread → task → host 的顺序传递异常消息。这是通过上面的mach_exc_raise()类函数来实现的。

如果thread、task都没有处理异常,那么就会由host也就是操作系统内核来处理异常,操作系统处理异常的方式就是上图Exception Handler中的流程,可以看到,handler是一个循环处理消息的机制,mach_msg_receive()函数负责接受消息;mach_exc_server()函数内有catch_mach_exception_raise()函数,这个函数通过ux_exception()将mach异常转换为Unix的Signal,并通过threadsignal()将其发送到对应的线程上去。

这一系列过程中,我们可控的部分是thread,我们可以新建一条thread并且通过mach port监听异常端口来实现崩溃的捕获。

有时候,Debugger会在程序崩溃的时候,给出Mach异常的类型:

上述代码试图给一个assign类型的property赋值,由于引用计数为0,对象在赋值之后就被立刻释放了,所以这行代码就崩溃了

Debugger给出的标红信息,可以这么理解:

一些其他常见的Mach异常类型及其对应的原因如下表:

Exception Notes
EXC_BAD_ACCESS 访问了不该访问的内存
EXC_BAD_INSTRUCTION 线程执行非法指令
EXC_ARITHMETIC 算术异常
EXC_SOFTWARE 软件生成的异常
EXC_BREAKPOINT 跟踪或者断点

关于code大家可能会存在疑惑,它代表的其实是内核函数的返回值,其中,code=1代表的是地址不可用,其定义如下:

#define KERN_INVALID_ADDRESS            1

由于code的种类有很多,其他code对应的含义,可以翻阅kern_return.h头文件进行查阅

以下为苹果的崩溃日志,里面也包含类似信息:

Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x00000000000000b8

将两者进行结合,一般就可以判断崩溃的原因究竟是什么。了解以上知识相信会对大家日后解决Bug带来一定的帮助。

Unix Signal

Signal是Unix、类Unix以及其他POSIX兼容的操作系统中进程间通讯的一种有限制的方式。它是一种异步的通知机制,用来提醒进程一个事件已经发生。信号的作用有很多,比如可以用来进程间通信(IPC)、用于Debugger调试等,当然也可以用来报告异常。

既然Mach已经实现了硬件导致的异常,为什么还需要将其转化为Unix Signal,继续报告一次呢?

原因很简单,因为xnu包含了BSD和Mach,为了实现POSIX兼容,让用户可以使用BSD提供的POSIX API,就需要做这样一层转换。

Mach异常和Unix Signal两者的转换关系如下表:

Mach 异常 Unix Signal 原因
EXC_BAD_INSTRUCTION SIGILL 非法指令,比如数组越界,强制解包可选形等等
EXC_BAD_ACCESS SIGSEVG、SIGBUS SIGSEVG、SIGBUS两者都是错误内存访问,但是两者之间是有区别的:SIGBUS(总线错误)是内存映射有效,但是不允许被访问,比如访问一个结构体但是起始地址有误; SIGSEVG(段地址错误)是内存地址映射都失效,比如野指针
EXC_ARIHMETIC SIGFPE 运算错误,比如浮点数运算异常
EXC_BREAKPOINT SIGTRAP trace、breakpoint等等,比如说使用Xcode的断点
EXC_SOFTWARE SIGABRT、SIGPIPE、SIGSYS、SIGKILL 软件错误,其中SIGABRT最为常见。

问1:既然Mach异常可以转换为unix异常,而signal也是可以由我们自由处理的,那是否可以不处理Mach异常,只处理unix的signal就可以了?

答案是不行,因为某些异常,比如EXC_GUARD 异常(这是一种违反了受保护资源的防护而导致的异常,比如访问SQLite文件的时候关闭了它的文件描述符),是没有映射到Unix Signal的,这种异常就没法通过signal处理。

问2:那是不是处理了Mach异常,就不需要处理signal异常了呢?

答案是也不行,因为如果底层有些异常类型只能通过signal处理,比如直接调用了 __pthread_kill函数直接向某条线程发送了SIGABRT这个signal,这类异常不能被Mach所捕获

为什么没有通用的异常处理函数

现在我们可以回答这个问题了。总结一下,iOS系统中,崩溃有可能是以下两种方式产生的:

  • 应用级异常,比如NSException
  • 硬件级异常,比如野指针访问

对于前者,我们只能使用NSSetUncaughtExceptionHandler进行捕获,对于后者,我们需要使用以下机制:

  • Mach异常处理机制
  • Unix Signal异常处理机制

因为以上两者作用域也无法互相覆盖,所以以上两者也需要结合使用。

正是因为这三种处理机制覆盖了不同的领域,并且处理机制也不尽相同,因此iOS中没有通用的异常处理函数。

然而,事情没有那么简单

上述三个函数的功能十分强大,但是实际上设计一个崩溃捕获系统没有那么容易。一般来说,捕获系统除了捕获崩溃,还需要记录崩溃时的现场信息,比如崩溃时的iOS系统版本、应用版本、崩溃时间、异常信息、程序堆栈等等:

{"app_name":"TP-LINK物联","timestamp":"2023-02-16 15:40:40.00 +0800","app_version":"4.12.1","slice_uuid":"d146125f-f904-3e39-940a-0f7dd32d6071","adam_id":0,"build_version":"41201","platform":2,"bundleID":"net.tplink.surveillancesystem","share_with_app_devs":0,"is_first_party":0,"bug_type":"109","os_version":"iPhone OS 14.0.1 (18A393)","incident_id":"70E8ABFF-6F0F-4094-BF31-EE929EFA78DD","name":"TP-LINK物联"}
Incident Identifier: 70E8ABFF-6F0F-4094-BF31-EE929EFA78DD
CrashReporter Key: 8c905de38d4cd4ff6ad692cc4ca4f6b1f41a50af
Hardware Model: iPhone12,8
Process: TP-LINK物联 [2002]
Path: /private/var/containers/Bundle/Application/F17C1188-8ED4-4C72-8E46-FE7ABE28DDA1/TP-LINK物联.app/TP-LINK物联
Identifier: net.tplink.surveillancesystem
Version: 41201 (4.12.1)
Code Type: ARM-64 (Native)
Role: Foreground
Parent Process: launchd [1]
Coalition: net.tplink.surveillancesystem [564] Date/Time: 2023-02-16 15:40:39.7011 +0800
Launch Time: 2023-02-16 15:37:15.6262 +0800
OS Version: iPhone OS 14.0.1 (18A393)
Release Type: User
Baseband Version: 2.00.01
Report Version: 104 Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x00000000000000b8
VM Region Info: 0xb8 is not in any region. Bytes before following region: 4375183176
REGION TYPE START - END [ VSIZE] PRT/MAX SHRMOD REGION DETAIL
UNUSED SPACE AT START
--->
__TEXT 104c80000-1077ac000 [ 43.2M] r-x/r-x SM=COW ...app/TP-LINK物联 Termination Signal: Segmentation fault: 11
Termination Reason: Namespace SIGNAL, Code 0xb
Terminating Process: exc handler [2002]
Triggered by Thread: 15 Thread 0 name: Dispatch queue: com.apple.main-thread
Thread 0:
0 libsystem_kernel.dylib 0x00000001d0bbfdd0 0x1d0bbc000 + 15824
1 libsystem_kernel.dylib 0x00000001d0bbf184 0x1d0bbc000 + 12676
2 CoreFoundation 0x00000001a4bb6cf8 0x1a4b19000 + 646392
3 CoreFoundation 0x00000001a4bb0ea8 0x1a4b19000 + 622248
4 CoreFoundation 0x00000001a4bb04bc 0x1a4b19000 + 619708
5 GraphicsServices 0x00000001bb635820 0x1bb632000 + 14368
6 UIKitCore 0x00000001a7554734 0x1a69d7000 + 12048180
7 UIKitCore 0x00000001a7559e10 0x1a69d7000 + 12070416
8 TP-LINK物联 0x0000000104c89ff0 0x104c80000 + 40944
9 libdyld.dylib 0x00000001a4877e60 0x1a4877000 + 3680
……

在iOS系统中,如果直接在上述的崩溃处理函数中进行这些信息的记录,并不安全,这主要是因为iOS中App被限制在一个进程中运行,如果应用崩溃,那崩溃的线程将会立刻暂停执行,那就会导致如下问题:

  • 内存可能被破坏(比如某些数值溢出导致的崩溃,内存会被溢出的数据覆盖)
  • 锁可能正在被暂停执行的线程持有着
  • 数据结构可能只更新一半

这样的不稳定环境,大部分函数都不能保证能够正确运行,导致崩溃处理程序能够调用的库函数非常有限,你将无法做到:

  • 通过malloc等函数分配堆内存
  • 通过backtrace函数获取调用栈信息

如果破解这些限制?我们不妨研究下SuperApp中集成的Breakpad是怎么操作的。

Breakpad的整体构成

如上图所示,Breakpad主要由三部分构成:

  • symbol dumper:符号提取器。应用程序在构建的时候会包含debug相关的信息,它能够提取这些信息并生成专属的符号文件。
  • client:客户端是一种包含在你应用程序里面的第三方库,它能够捕获当前各线程的状态以及当前加载的共享库等信息,将其写入minidump文件中。
  • processor:处理器主要用来读取minidump文件和符号文件,将其翻译为人类可读的格式

符号文件是程序编译的产物,里面会包含函数或数据的名称、地址、大小、类型等。由于Breakpad是一个跨平台的方案,因此没有采用XCode编译产生的符号表文件,而是使用了自定义的格式。minidump则是一种微软开发的文件格式,它被用在微软的崩溃上传体系中,包含了可执行文件和共享库的列表、进程中的各线程列表信息、调用栈信息等。

Breakpad如何解决上述问题

1.如何安全分配内存

以下为Breakpad启动代码:

ProtectedMemoryAllocator* gMasterAllocator = NULL;
ProtectedMemoryAllocator* gKeyValueAllocator = NULL;
ProtectedMemoryAllocator* gBreakpadAllocator = NULL; BreakpadRef BreakpadCreate(NSDictionary* parameters) {
try {
gMasterAllocator =
new ProtectedMemoryAllocator(sizeof(ProtectedMemoryAllocator) * 2); gKeyValueAllocator =
new (gMasterAllocator->Allocate(sizeof(ProtectedMemoryAllocator)))
ProtectedMemoryAllocator(sizeof(LongStringDictionary)); int mutexResult = pthread_mutex_init(&gDictionaryMutex, NULL);
if (mutexResult == 0) {
int breakpad_pool_size = 4096; gBreakpadAllocator =
new (gMasterAllocator->Allocate(sizeof(ProtectedMemoryAllocator)))
ProtectedMemoryAllocator(breakpad_pool_size); NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init];
Breakpad* breakpad = Breakpad::Create(parameters); if (breakpad) {
gMasterAllocator->Protect();
gKeyValueAllocator->Protect();
gBreakpadAllocator->Protect(); [pool release];
return (BreakpadRef)breakpad;
} [pool release];
}
} catch(...) {
fprintf(stderr, "BreakpadCreate() : error\n");
}
...
}

上述代码片段已经包含了对内存分配问题的解决,其 核心思路是:既然崩溃时无法分配内存,那么只要在崩溃前提前分配好崩溃处理程序所需的内存并将其保护起来避免被崩溃所破坏即可

ProtectedMemoryAllocator这个类相当于一个内存池,它允许分配内存,但是分配内存无法被回收。此外,它还提供了一个Protect()方法用于将内存池设置为只读,这样一来这块内存就不会在崩溃发生的时候被各种原因覆盖。

通过源码,我们可以一窥其实现的原理,首先是构造函数:

ProtectedMemoryAllocator::ProtectedMemoryAllocator(vm_size_t pool_size)
: pool_size_(pool_size),
next_alloc_offset_(0),
valid_(false) { kern_return_t result = vm_allocate(mach_task_self(),
&base_address_,
pool_size,
TRUE
); valid_ = (result == KERN_SUCCESS);
assert(valid_);
}

vm_allocate是一个内核函数,用于申请虚拟内存,由于Breakpad需要直接申请一块较大的内存,用于整个模块的内存使用,因此它直接使用了该函数,而不是malloc。该类申请的内存大小是由参数pool_size决定的,内存分配之后,base_address_指向内存池的起始地址。

再看看Protect()方法的实现:

kern_return_t  ProtectedMemoryAllocator::Protect() {
kern_return_t result = vm_protect(mach_task_self(),
base_address_,
pool_size_,
FALSE,
VM_PROT_READ); return result;
}

其同样调用了内核函数vm_protect,将申请的虚拟内存设置为只读,这样就实现了内存的保护。

2.如何获取调用栈信息

Breakpad内部有一个MinidumpGenerator类专门用于写入minidump,其中包括了我们关心的线程调用栈信息。由于涉及到minidump格式问题,我们不深入分析这个类,只是简单介绍下原理。

首先,我们需要理解线程调用栈的结构:

线程的调用栈分为若干栈帧(stack frame),每个栈帧对应一个函数调用。上图包含了两个栈帧,DrawLine和DrawSquare。

栈帧主要由三部分组成:函数参数、返回地址、帧内的本地变量。上述DrawSquare函数调用DrawLine函数的时候,首先函数的参数入栈,然后把返回地址入栈,最后是函数内部本地变量。

这里要注意的是,有两个特殊的指针:Stack Pointer指向了调用栈的栈顶,Frame Pointer则指向了当前栈帧。

此外,我们还需要了解一下iOS系统中虚拟内存的相关知识:

我们知道,操作系统会对虚拟内存进行分页。在iOS系统中,为了更好的管理内存页,系统会将一组连续的内存页关联到一个VMObject上,也称为VM Region。我们可以通过XCode的Instruments工具,查看当前App的虚拟内存分配情况,其中就包含了VM Region的相关信息:

可以看到,VM Region被分为不同的Category,其中有一种Category叫做VM Stack,其包含的就是线程调用栈的信息。

为了获取VM Stack中的信息,Breakpad大致做了以下操作:

  1. 通过内核函数task_threads获取当前进程的所有线程

  2. 通过内核函数thread_get_state获取目标线程的thread_state_t,这个结构中包含了该线程调用栈的栈顶指针Stack Point

    _STRUCT_ARM_THREAD_STATE64
    {
    __uint64_t __x[29]; /* General purpose registers x0-x28 */
    __uint64_t __fp; /* Frame pointer x29 */
    __uint64_t __lr; /* Link register x30 */
    __uint64_t __sp; /* Stack pointer x31 */
    __uint64_t __pc; /* Program counter */
    __uint32_t __cpsr; /* Current program status register */
    __uint32_t __pad; /* Same size for 32-bit or 64-bit clients */
    };
  3. 由Stack Pointer的地址作为起始地址,获取下一个VM Region,如果其Category为VM Stack,将此块内存的信息记录下来,写入minidump

iOS系统崩溃的捕获的更多相关文章

  1. iOS系统app崩溃日志手动符号化

    iOS系统app崩溃日志手动符号化步骤: 1.在桌面建立一个crash文件夹,将symbolicatecrash工具..crash文件..dSYM文件放到该文件夹中 a.如何查询symbolicate ...

  2. iOS crash 崩溃问题的追踪方法

    http://www.cnblogs.com/easonoutlook/archive/2012/12/27/2835884.html iOS crash 崩溃问题的追踪方法 在调试程序的时候,总是碰 ...

  3. 有关iOS系统中调用相机设备实现二维码扫描功能的注意点(3/3)

    今天我们接着聊聊iOS系统实现二维码扫描的其他注意点. 大家还记得前面我们用到的输出数据的类对象吗?AVCaptureMetadataOutput,就是它!如果我们需要实现目前主流APP扫描二维码的功 ...

  4. 基于H5的移动端开发,window.location.href在IOS系统无法触发问题

    最近负责公司的微信公众号开发项目,基于H5进行开发,某些页面window.location.href在Android机上能正常运行而IOS系统上无法运行,导致无法重定向到指定页面,查了好久终于找到方法 ...

  5. iOS 系统架构

    https://developer.apple.com/library/ios/documentation/Miscellaneous/Conceptual/iPhoneOSTechOverview/ ...

  6. 超强教程:如何搭建一个 iOS 系统的视频直播 App?

    现今,直播市场热火朝天,不少人喜欢在手机端安装各类直播 App,便于随时随地观看直播或者自己当主播.作为开发者来说,搭建一个稳定性强.延迟率低.可用性强的直播平台,需要考虑到部署视频源.搭建聊天室.优 ...

  7. iOS系统架构

    1.iOS系统架构 iOS的系统架构分为四个层次 核心操作系统层 (Core OS) 它包括 内存管理 , 文件系统 , 电源管理以及一些其他的操作系统任务, 它可以直接和硬件设备进行交互 核心服务层 ...

  8. 在MacOS和iOS系统中使用OpenCV

    在MacOS和iOS系统中使用OpenCV 前言 OpenCV 是一个开源的跨平台计算机视觉库,实现了图像处理和计算机视觉方面的很多通用算法. 最近试着在 MacOS 和 iOS 上使用 OpenCV ...

  9. 深入了解ios系统机制

    1.什么叫ios?        ios一般指ios(Apple公司的移动操作系统) .        苹果iOS是由苹果公司开发的移动操作系统.苹果公司最早于2007年1月9日的Macworld大会 ...

  10. iOS系统提供开发环境下命令行编译工具:xcodebuild

    iOS系统提供开发环境下命令行编译工具:xcodebuild[3] xcodebuild 在介绍xcodebuild之前,需要先弄清楚一些在XCode环境下的一些概念[4]: Workspace:简单 ...

随机推荐

  1. 借助 Terraform 功能协调部署 CI/CD 流水线-Part 1

    在当今快节奏的开发环境中,实现无缝.稳健的 CI/CD 流水线对于交付高质量软件至关重要.在本文中,我们将向您介绍使用 Bitbucket Pipeline.ArgoCD GitOps 和 AWS E ...

  2. 1、dubbo的简介

    Dubbo是一个分布式服务框架,致力于提供高性能和透明化的RPC远程服务调用方案,以及SOA服务治理方案.简单的说,dubbo就是个服务框架,如果没有分布式的需求,其实是不需要用的,只有在分布式的时候 ...

  3. 使用 ASP.NET Core MVC 创建 Web API 系列文章目录

    使用 ASP.NET Core MVC 创建 Web API(一) 使用 ASP.NET Core MVC 创建 Web API(二) 使用 ASP.NET Core MVC 创建 Web API(三 ...

  4. 将本地文件上传到github仓库

    将本地文件上传到github空仓库 本地使用git上传文件: 第一步:在需要的文件夹(文件夹里已经放了需要提交的内容)右击git bash,输入git init 第二步:将本地文件上传到本地git仓库 ...

  5. MAKEFILE的学习

    Makefile/cmake/configure 重点学习Cmake 首先是简单的MakeFile入门 1.1 简单Makefile 范例1.1 all: @echo "Hello all& ...

  6. electron程序运行在某些 windows 上白屏

    现象: 打包后的 electron 程序 运行在某些 windows 上白屏 项目情况: vue3.0  项目使用 vue-cli 创建 使用 vue add electron-builder 添加打 ...

  7. day03-分析SpringBoot底层机制

    分析SpringBoot底层机制 Tomcat启动分析,Spring容器初始化,Tomcat如何关联Spring容器? 1.创建SpringBoot环境 (1)创建Maven程序,创建SpringBo ...

  8. Google Chart API学习(三)

    书接上回: maps-charts: <html> <head> <script type="text/javascript" src="h ...

  9. AOSP下载且编译

    一.简介 AOSP:Android Open Source Project 二.环境要求 我们可以先了解官网(https://source.android.com/docs/setup/start/r ...

  10. C# MySQL导出表结构到Excel

    软件如图,输入基础信息,点击"测试登录" 连接MySQL需要安装驱动,如下图 连接成功如下图 登录成功后,自动获取所有表信息 双击表名称,右侧查看表结构信息 导出表结构效果如下图 ...