背景
最近在准备面试,结合之前的工作经验和近期在网上收集的一些面试资料,准备将Android开发岗位的知识点做一个系统的梳理,整理成一个系列:Android应用开发岗 面试汇总。本系列将分为以下几个大模块:
Java基础篇、Java进阶篇、常见设计模式
Android基础篇、Android进阶篇、性能优化
网络相关、数据结构与算法
常用开源库、Kotlin、Jetpack
注1:以上文章将陆续更新,直到我找到满意的工作为止,有跳转链接的表示已发表的文章。
注2:该系列属于个人的总结和网上东拼西凑的结果,每个知识点的内容并不一定完整,有不正确的地方欢迎批评指正。
注3:部分摘抄较多的段落或有注明出处。如有侵权,请联系本人进行删除。
1、Handler原理
1、使用
一般用来在子线程中做耗时操作,执行完后通过Handler来发送执行结果到主线程。
即:在主线程中实例化一个Handler,拿到引用后,在子线程中通过这个引用发送消息到主线程。
//一般在Activity中实例化Handler,等同于在主线程中声明Handler,new Handler(Looper.getLooper())
private Handler handler = new Handler(){
@Override
public void handleMessage(Message msg) {
switch (msg.what){
case UPDATE:
tv.setText(String.valueOf(msg.arg1));
break;
}
}
};
public void begin(){
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);//休眠1秒,模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
Message msg = new Message();
msg.what = UPDATE;
msg.arg1 = i;
handler.sendMessage(msg);
}
}).start();
}
2、Handler初始化流程
构造方法的参数
- Callback 传入该实例,实现handleMessage(Message msg)方法,可不传。等价于上面的handleMessage方法。
- Looper 传入该实例,则会替换掉默认的Looper.myLooper()参数,即指定哪个线程的Looper。
- async 很少使用到,默认是false。
Handler在构造方法中初始化Looper和MessageQueue
- Looper如果未在构造方法中指定,则拿到的是Handler被实例化那个线程的Looper。通过Looper.myLooper()方法拿到,该方法中调用ThreadLocal<Looper> sThreadLocal 的get方法拿到:mLooper
- MessageQueue为消息队列,通过mQueue = mLooper.mQueue拿到,即mQueue实际是在Looper类中被实例化的。
ActivityThread与Looper 的调用
- 一般Android中,是由ActivityThread类的main方法创建Looper对象,即应用的主线程
- 其中main方法中会初始化MainLooper、ActivityThread、mMainHandler
- main方法中调用Looper.prepareMainLooper()、Looper.loop()
- 以上是安卓主线程初始化的流程
- 自定义使用Handler,流程如下:(跟主线程使用方式一样)
class LooperThread extends Thread {
* public Handler mHandler;
*
* public void run() {
* Looper.prepare();
*
* mHandler = new Handler() {
* public void handleMessage(Message msg) {
* // process incoming messages here
* }
* };
*
* Looper.loop();
* }
总结(UI线程中的Handler)
ActivityThread类中的main方法,调用
--1、Looper.prepareMainLooper()
--2、prepareMainLooper 方法中调用prepare方法初始化Looper对象(主线程初始化,则为MainLooper),将该对象塞入ThreadLocal<Looper> sThreadLocal变量中
--3、初始化主线程的Handler,该Handler会放入到线程池中
--4、调用Looper.loop(),开始消息循环
Handler的工作流程
1、某个线程的handler实例(一般为UI线程)调用handler.sendMessage(msg)(任意线程中调用),将Message对象加入到Looper的MessageQueue中(MessageQueue的实例在Looper中,Handler中拿到的是它的引用),Looper的loop方法是一个死循环(for(;;)),会不停的消费掉msg对象,消费掉一个,就会调用msg.target.dispatchMessage(msg),同时将msg移出队列。target即为handler的引用。
2、Handler的dispatchMessage方法中,调用了handleMessage的回调方法,供外部处理线程中生成的msg。
面试题1:子线程中怎么使用 Handler?(即自定义使用Handler,参考上面↑)
即在子线程中通过Looper.prepare()拿到一个ThreadLocal<Looper> sThreadLocal的Looper实例,再创建一个Handler,再开启死循环Looper.loop()面试题2:MessageQueue 如何等待消息?
通过 Looper.loop 方法,MessageQueue.next() 来获取消息的,如果没有消息,那就会阻塞在这里,MessageQueue.next 是怎么等待的呢?next方法最终调用了native的epoll_wait 来进行等待面试题3:线程和 Handler Looper MessageQueue 的关系?
一个线程对应一个 Looper 对应一个 MessageQueue 对应多个 Handler面试题4:多个线程给 MessageQueue 发消息,如何保证线程安全
既然一个线程对应一个 MessageQueue,那多个线程给 MessageQueue 发消息时是如何保证线程安全的呢?答:加了锁 synchronized
// MessageQueue.java
boolean enqueueMessage(Message msg, long when) {
synchronized (this) {
// ...
}
}
- 面试题5:Handler 消息延迟是怎么处理的?
// Handler.java
public final boolean postDelayed(Runnable r, long delayMillis) {
return sendMessageDelayed(getPostMessage(r), delayMillis);
}
public final boolean sendMessageDelayed(Message msg, long delayMillis) {
// 传入的 time 是 uptimeMillis + delayMillis
return sendMessageAtTime(msg, SystemClock.uptimeMillis() + delayMillis);
}
public boolean sendMessageAtTime(Message msg, long uptimeMillis) {
// ...
return enqueueMessage(queue, msg, uptimeMillis);
}
private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) {
// 调用 MessageQueue.enqueueMessage
return queue.enqueueMessage(msg, uptimeMillis);
}
最终是通过MessageQueue的next方法来实现的,等待时间调的是native的epoll_wait 来进行等待
1.将我们传入的延迟时间转化成距离开机时间的毫秒数
2.MessageQueue 中根据上一步转化的时间进行顺序排序
3.在 MessageQueue.next 获取消息时,对比当前时间(now)和第一步转化的时间(when),如果 now < when,则通过 epoll_wait 的 timeout 进行等待
4.如果该消息需要等待,会进行 idel handlers 的执行,执行完以后会再去检查此消息是否可以执行
- 面试题6:View.post 和 Handler.post 的区别
View.post 最终也是通过 Handler.post 来执行消息的
2、Binder相关
Android 是基于Linux系统来实现的,因此,我们有必要来了解一下,为什么Android 不使用Linux本身有的进程通信机制,而是要自己撸一个Binder 这玩意来实现进程间通信。
首先,需要简要的了解下Linux 进程间通信的几种方式
1、Linux IPC机制
1、管道通信的特性
管道通信是一种1v1 的通信方式,相对来说比较安全的。缺点是:数据只能单向传输。
- 打开管道必须由两端(两个进程)同时打开一个管道。分别为读(r)和写(w);读取端负责从管道中读取数据,写入端负责向管道中写入数据。
- 数据一旦被读走,管道中便不存在,不可重复读取
- 由于管道采用半双工通信方式,因此,数据只能再一个方向上流动
- 只能在有公共祖先的进程中实现通信
2、共享内存的特性
共享内存就是允许两个或多个进程共享一定的存储区。当共享的这块存储区中的数据发生改变,所有共享这块存储区的进程都会察觉数据的改变,因为数据不需要在客户端和服务端进行数据拷贝,数据直接写到内存,不用若干次数据拷贝,所以这是最快的IPC。
- 因为两个进程通过地址映射到同一片物理地址,所以,进程可以给那一片物理空间中写入数据,也可以读取数据----进程间双向通信。
- 客户端和服务端都是从内存中直接读取、写入数据的,没有涉及到数据的拷贝,因此,速度最快。
- 生命周期随内核,不会随着服务端或者客户端的断开而销毁。所有访问共享内存对象的进程都结束了,共享内存区域对象依然存在。
- 共享内存并未实现同步机制:多个进程可以同时写入数据,这就会造成数据的混乱。
3、Socket 通信的特性
Socket通信是双向的通信,客户端与服务端要建立连接,然后读写数据,相当于在两个进程间各自拷贝数据,然后传输数据,这个效率是很慢的。
- Socket 是一种双向多对多模式的IPC机制之一,但数据发生了两次拷贝,因此效率比较低。但是比较安全的。
2、Android IPC通信:Binder
1、内核空间和用户空间
内核空间(内核进程):操作系统所占用的内存区域 -------只有一份
用户空间(用户进程):用户进程所在的内存区域 ---------多份
Q:为什么要这么划分?
A:使用内核空间和用户空间这种分开来划分,可以做到,每个APP(用户空间)不会影响其他APP,也不会造成系统的崩溃。
2、物理地址和虚拟地址
-
虚拟内存
实际上我们写的程序,都是面向虚拟内存的,我们在程序中写的变量的地址,实际上是虚拟内存中的地址,当CPU想要访问该地址的时候,内存管理单元MMU就会将虚拟地址翻译成物理地址。然后,CPU就可以从真实的物理地址处获取到数据。 -
MMU:内存管理单元
它是一个硬件,不是软件。它用于将虚拟地址翻译成实际的物理内存地址,同时它还可以将特定的内存块设置成不同的读写属性,进而实现了内存保护。注意,MMU是硬件管理,不是软件实现内存管理。
这里只要知道MMU是将虚拟地址转换成物理地址的过程是MMU的核心,MMU通过页和页表来实现内存映射(mmap):从虚拟内存找到物理内存所对应的地址编码,从而获取到物理内存中所对应的数据
3、Binder IPC 通信模型
以下是Binder IPC 通信模型的经典图,原本两个进程间数据的交互需要两次拷贝(发送数据方将数据拷贝到内核空间,然后内核空间再将数据拷贝到接收方),由于使用了mmap(内存映射)就减少了一次数据接收进程的数据拷贝(也可以说是服务端)。这样Binder只需要在数据发送进程(客户端)实现一次拷贝数据到内核空间即可。
参考链接
4、Android中跨进程通信的几种方式
四大组件:Activity、广播、ContentProvider、Service(AIDL)
本地文件的读写,两个不同程序对同一个文件进行读写
MVC、MVP、MVVM的特性:
MVC:
Android提供的Activity、xml就是经典的MVC模式
- Model: 负责管理业务数据逻辑,如网络请求、数据库处理;
- View: Layout XML 文件;
- Controller: Activity 负责处理表现逻辑,如获取用户输入的文字、向Model发送数据请求。
缺点:Activity不可避免需要处理一些View层的逻辑,如显示控件的数据,这就造成了V和C层耦合。
MVP:
- Model: 负责管理业务数据逻辑,如网络请求、数据库处理;
- View: Activity 和 Layout XML 文件;
- Presenter: 负责处理表现逻辑。
与MVC类似,差别不大,只是view层不能直接调用M层,而是通过P层去转发,将数据的业务处理逻辑放在了P层。但MVC中V层和C层存在一定的耦合
以上两种模式的优点:
- 按代码功能分层,将某一类代码模块抽取出来,如网络请求相关、数据库读取相关。使功能更加单一,便于维护和复用。比如某个列表数据需要在不同的页面展示,可直接复用同一个M
- 按业务功能分层,将某一类业务的模块分出来,如同一个页面需要展示天气相关、路况相关、历史出行相关的业务,则可以根据不同的业务将业务逻辑层P层、M层分成多个P层和M层。这样其他页面需要展示天气相关的内容时,可复用P层和M层
- 便于单元测试,以上两种分层后,更便于进行单元测试,但实际开发中,进行单元测试的公司不多。
- Activity(V层)只处理生命周期的任务,业务性的代码和Android原生功能的代码分离,便于排查问题,且代码变得更加简洁
- 把业务逻辑抽到Presenter中去,避免后台线程引用着Activity导致Activity的资源无法被系统回收从而引起内存泄露和OOM
缺点:
- MVP模式会增加额外的类/接口,①在功能简单的业务场景下滥用该模式,是完全没必要且多余的,增加了维护和开发的工作量。
- V层和P层通过接口来通信,因此如果V层的业务发生变更,接口也需要变化,额外增加了接口定义的工作量。而在实际开发中,V层会进行频繁迭代。V和P是强关联的,需要手动维护。
- 在实际的开发中,应该合理使用设计模式。或不使用,或单独使用,或者混合起来使用(如MVP+MVVM)。
MVVM:
MVVM 模式改动在于中间的 Presenter 改为 ViewModel,MVVM 同样将代码划分为三个部分:
- Model: 负责管理业务数据逻辑,如网络请求、数据库处理,与 MVP 中 Model 的概念相同;
- View: Activity 和 Layout XML 文件,与 MVP 中 View 的概念相同;
- ViewModel: 存储视图状态,负责处理表现逻辑,并将数据设置给可观察数据容器。
在实现细节上,View 和 Presenter 从双向依赖变成 View 可以向 ViewModel 发指令,但 ViewModel 不会直接向 View 回调,而是让 View 通过观察者的模式去监听数据的变化,有效规避了 MVP 双向依赖的缺点。但 MVVM 本身也存在一些缺点:
- 多数据流: View 与 ViewModel 的交互分散,缺少唯一修改源,不易于追踪;
- LiveData 膨胀: 复杂的页面需要定义多个 MutableLiveData,并且都需要暴露为不可变的 LiveData。
DataBinding、ViewModel 和 LiveData 等组件是 Google 为了帮助我们实现 MVVM 模式提供的架构组件,它们并不是 MVVM 的本质,只是实现上的工具。
Lifecycle: 生命周期状态回调;
LiveData: 可观察的数据存储类;
databinding: 可以自动同步 UI 和 data,不用再 findviewById();
ViewModel: 存储界面相关的数据,这些数据不会在手机旋转等配置改变时丢失。
App启动流程
启动的起点发生在Launcher的Activity中,启动一个app说简单点就是启动一个Activity,那么我们说过所有组件的启动,切换,调度都由AMS来负责的,所以第一步就是Launcher响应了用户的点击事件,然后通知AMS(ActivityManagerService );
AMS得到Launcher的通知,就需要响应这个通知,主要就是新建一个Task去准备启动Activity,并且告诉Launcher你可以休息了(Paused);
Launcher得到AMS让自己“休息”的消息,那么就直接挂起,并告诉AMS我已经Paused了;
AMS知道了Launcher已经挂起之后,就可以放心的为新的Activity准备启动工作了,首先,APP肯定需要一个新的进程去进行运行,所以需要创建一个新进程,这个过程是需要Zygote参与的,AMS通过Socket去和Zygote协商,如果需要创建进程,那么就会fork自身,创建一个新进程,新的进程会导入ActivityThread类,这就是每一个应用程序都有一个ActivityThread与之对应的原因;
在 ActivityThread 的 main 方法中会去创建 Looper 并且执行 loop 方法以及调用了 attach 方法。attach 方法中调用 ActivityManagerService 的 attachApplication 方法。
ActivityManagerService 的 attachApplication 中首先会调用 ApplicationThread 的 bindApplication 方法。ApplicationThread 是 ActivityThread 的一个内部类。在 bindApplication 中发送消息给 ActivityThread 的 H 类,调用 handleBindApplication 方法。
handleBindApplication 方法创建 application,并且调用 callApplicationOnCreate,执行 Application 的 onCreate 方法。
ActivityManagerService 中会调用 ActivityTaskManagerInternal 的 attachApplication,最终调用的是 ActivityTaskManagerService 的内部类 LocalService 的 attachApplication。
-
接着调用到 ActivityStackSupervisor 的 realStartActivityLocked,经过一系列的调用后执行到 ActivityThread 的 handleLaunchActivity,最终调用到 Activity 的 onCreate 方法。
原文链接:https://blog.csdn.net/zzw0221/article/details/106716620
启动模式相关(launchMode)
Q:当手机执行菜单键,查看最近任务时,会出现多个正在运行的应用列表,此时这个列表里显示的是什么?
A:显示的是一个个的Task,用户可见的是Task栈顶的Activity截图。
-
Standard:
①同一个Task中:在同一个Task中打开多次同一个Activity,Activity会被创建多个实例,放进同一个Task中
②当不同的Task中:当在不同的Task中打开同一个Activity,Activity会被创建多个实例,分别放进每个Task中,这是Android默认的规则。 -
SingleTop:
在该模式下,如果栈顶Activity为我们要新建的Activity(目标Activity),那么就不会重复创建新的Activity,当启动这个Activity时,不会调用onCreate,而是调用onNewIntent方法。 -
SingleTask:
①同一个Task中:在同一个Task中创建Activity时,如果栈内存在该Activity的实例,则:1.将task内的对应Activity实例之上的所有Activity弹出栈;2.将对应Activity置于栈顶,获得焦点。
②当不同的Task中:在A应用中(A Task),启动B应用中(B Task)的Activity BB,BB的launchMode被标记为SingleTask,此时,BB先在自己的Task B中创建实例,再从B应用切换到A应用。此时按返回键,需要将B应用所有页面出栈后,才可看到A应用。即被SingleTask标记的Activity,始终只会创建在自己的Task中,不像Standard和SingleTop(跟随启动它的task) -
SingleInstance:
在该模式下,我们需要为目标Activity分配一个新的affinity,并创建一个新的Task栈,将目标Activity放入新的Task,并让目标Activity获得焦点。新的Task中有且只有这一个Activity实例。如果已经创建过目标Activity实例,则不会创建新的Task,而是将以前创建过的Activity唤醒(对应Task设为Foreground状态)
事件传递机制
事件在Android中的传递顺序
-
事件传递会经过 Activity--> Window-->DecorView --> 布局View
事件的分发是:
由Activity的dispatchTouchEvnent方法开始
–>>然后调用PhoneWindow的superDispatchTouchEvent方法
–>>接着调用DecorView的superDispatchTouchEvent方法
–>>最后还是调用ViewGroup的dispatchTouchEvent方法
事件的传递规则
一个点击事件,或者说触摸事件,被封装为了一个MotionEvent。事件的分发主要由三个重要的方法来完成:
1、分发:dispatchTouchEvent;
2、拦截:onInterceptTouchEvent;
3、处理:onTouchEvent;
如果是ViewGroup容器类view,则以上三个方法都会用到。但是如果是View类型的,不能包含子view,那就没有第二个拦截方法,因为没有子view的话,拦截方法的就是多余的,只有ViewGroup才会有拦截。
public boolean dispatchTouchEvent(MotionEvent ev)
此方法用来处理事件的分发,当事件传递给当前view时,首先就是通过调用此方法来进行传递的,如果当前view锁包含的子view的dispatchTouchEvent方法或者当前view的onTouchEvent处理了事件, 通常返回true, 表示事件已消费。如果没有处理则返回false。public boolean onInterceptTouchEvent(MotionEvent ev)
此方法用来判断某个事件是否需要拦截,如果某个view拦截了此事件,那么同一个事件序列中,此方法不会被再次调用,因为会把当前view赋值给mFirstTouchTarget对象(原本为null),后续父view判断mFirstTouchTarget != null时,就会去调用它的onTouchEvent方法,交给mFirstTouchTarget处理事件。-
public boolean onTouchEvent(MotionEvent ev)
用来处理事件,如果事件被消耗了,通常就返回true, 如果不做处理,则发挥false,并且在同一个时间序列中,当前view不会再接受到事件。
完整流程如下:
参考链接
View的绘制流程
Android 中 Activity 是作为应用程序的载体存在,代表着一个完整的用户界面,提供了一个窗口来绘制各种视图,当 Activity 启动时,我们会通过 setContentView 方法来设置一个内容视图,这个内容视图就是用户看到的界面。
- PhoneWindow 是 Android 系统中最基本的窗口系统,每个 Activity 会创建一个。PhoneWindow 是 Activity 和 View 系统交互的接口。
- DecorView 本质上是一个 FrameLayout,是 Activity 中所有 View 的祖先。
整体流程
当一个应用启动时,会启动一个主 Activity,Android 系统会根据 Activity 的布局来对它进行绘制。绘制会从根视图 ViewRoot 的 performTraversals() 方法开始,从上到下遍历整个视图树,每个 View 控制负责绘制自己,而 ViewGroup 还需要负责通知自己的子 View 进行绘制操作。视图操作的过程可以分为三个步骤,分别是测量(Measure)、布局(Layout)和绘制(Draw)。
Measure
- 用来计算 View 的实际大小。页面的测量流程从 performMeasure 方法开始。
- 具体操作是分发给 ViewGroup 的,由 ViewGroup 在它的 measureChild 方法中传递给子 View。ViewGroup 通过遍历自身所有的子 View,并逐个调用子 View 的 measure 方法实现测量操作。
- View (ViewGroup) 的 Measure 方法,最终的测量是通过回调 onMeasure 方法实现的,这个通常由 View 的特定子类自己实现,可以通过重写这个方法实现自定义 View。
Layout
- 该过程用来确定 View 在父容器的布局位置,他是父容器获取子 View 的位置参数后,调用子 View 的 layout 方法并将位置参数传入实现的。
Draw
- 该操作用来将控件绘制出来,绘制的流程从 performDraw 方法开始。performDraw 方法在类 ViewRootImpl 内。
- 最终调用到每个 View 的 draw 方法绘制每个具体的 View,绘制基本上可以分为六个步骤:
public void draw(Canvas canvas) {
...
// Step 1, draw the background, if needed
if (!dirtyOpaque) {
drawBackground(canvas);
}
...
// Step 2, save the canvas' layers
saveCount = canvas.getSaveCount();
...
// Step 3, draw the content
if (!dirtyOpaque) onDraw(canvas);
// Step 4, draw the children
dispatchDraw(canvas);
// Step 5, draw the fade effect and restore layers
canvas.drawRect(left, top, right, top + length, p);
...
canvas.restoreToCount(saveCount);
...
// Step 6, draw decorations (foreground, scrollbars)
onDrawForeground(canvas);
}
链接:https://www.jianshu.com/p/c151efe22d0d
解决滑动冲突的方式
事件分发机制:Android中是从外向内分发,从内向外消耗的,记住这一点滑动冲突就很好解决。
- 要求外部父控件滑动
外部解决滑动冲突的方式就是当我们viewGrop分发事件的时候判断是否拦截,因为事件的分发机制就是从外向内分发,那么我们在viewGrop分发的时候就判断是否需要拦截就可以解决滑动冲突。 - 要求内部子控件滑动
内部解决滑动冲突的方式是首先让父view不拦截事件,然后在子view中判断是父view拦截呢还是子view拦截。
自定义view
概述
- 自定义绘制的方式是重写绘制方法,其中最常用的是 onDraw()
- 绘制的关键是 Canvas 的使用:
①Canvas 的绘制类方法: drawXXX() (关键参数:Paint)
②Canvas 的辅助类方法:范围裁切和几何变换 - 可以使用不同的绘制方法来控制遮盖关系
自定义绘制知识的4个级别
-
1.Canvas 的 drawXXX() 系列方法及 Paint 最常见的使用
Canvas.drawXXX() 是自定义绘制最基本的操作。掌握了这些方法,你才知道怎么绘制内容,例如怎么画圆、怎么画方、怎么画图像和文字。组合绘制这些内容,再配合上 Paint 的一些常见方法来对绘制内容的颜色和风格进行简单的配置,就能够应付大部分的绘制需求了。 -
2.Paint 的使用
Paint 可以做的事,不只是设置颜色,也不只是实心空心、线条粗细、有没有阴影、拐角的形状、开不开双线性过滤、 -
3.Canvas 对绘制的辅助
范围裁切、几何变换 -
4.使用不同的绘制方法来控制绘制顺序
控制绘制顺序解决的并不是「做不到」的问题,而是性能问题。同样的一种效果,你不用绘制顺序的控制往往也能做到,但需要用多个 View 甚至是多层 View 才能拼凑出来,因此代价是 UI 的性能;而使用绘制顺序的控制的话,一个 View 就全部搞定了。
APK打包流程
1. APK包中有些什么
- classes.dex:java源文件经过编译和转换后生成的二进制的字节码文件
- resource.arsc:经过 aapt 编译后的二进制的资源文件
- AndroidManifest.xml:经过 aapt 编译后的二进制的 xml 文件
- res:除图片和 res/raw 文件夹下的文件外,其余的 xml 文件都被 aapt 编译成二进制的 xml 文件
- assets(可选):存放不进行编译的原生文件,可以是一些图片,或者是html、js、css文件
- lib(可选):存放应用程序依赖的 native 库文件,一般是用 c/c++ 编写,如 so 文件
- META-INF:存放签名信息以及每一个文件的哈希值(BASE64)
2. 打包用到的工具
- aapt:Android资源打包工具
- aidl:Android接口描述语言转化为.java文件的工具
- javac:Java Compiler
- dex:转化.class文件为Davik VM能识别的.dex文件
- apkbuilder:生成apk包
- jarsigner:.jar文件的签名工具
- zipalign:字节码对齐工具
3. 打包的具体过程
原文链接:https://blog.csdn.net/kimi985566/article/details/80064578