oynix

于无声处听惊雷,于无色处见繁花

Android之从桌面启动应用全过程

这篇来学习一下从按下桌面应用图标、到应用完成启动,运行至前台,这个过程中系统都做了哪些事。

1. 引言

为了搞明白,网上的文章没少看,书也翻了不少,这些内容的相同点是,写的都很多,过程可谓相当之繁琐。不同点是,各说各的,没有哪两个人说的是一样的,这个人说这里是这样的,那个人又说这里是这样的,众说纷纭,搞得我这种站在中间本想来学知识,倒成了看热闹的,真想介绍他们互相认识,然后再看着他们讨论。也有可能是版本的原因吧,年代久远带着丝丝尘土味道的文章必然看的是老版本,而越热乎冒着热气的文章定是接近新的系统版本。我已经不想再花更多的时间来验证每篇文章,看看到底哪里对哪里不对,干脆就自己来,自己动手,丰衣足食。

重要的不是版本,而是学习方法,系统版本在不断的升级换代,死记代码实为下下策,只有掌握了学习源码的方法,才是以不变应万变的良策。

以下的方式很简单,别光看,东西很多,记不住的,跟着一起做,习得方法方为本源呐。

2. 版本

我用的SDK 30,在AndroidStudio/Preferences/Android SDK里,下载一下源码,或者用sdkmanager也可以,然后就可以看到源码了,具体路径在这

1
~/Library/Android/sdk/sources/android-30

3. 如何搜索源码

在AndroidStudio中,双击Shift,多数文件,都可以在这里面通过文件名搜到。能搜到是能搜到,但也只是能搜到而已,里面的引用关系却不都是可以关联,到处都是被@Hide@NotsupportedAppUsage等注解修饰的,所以任意打开个文件,满篇的红色报错所烘托出的喜庆氛围,时时刻刻让你有种在过年的错觉。所以,对于那些找不到的方法,找不到的类型,就只能手动去搜索,看看它到底是啥,看看这方法里面到底干了点啥。

  • 搜索文件,一种是在Android Studio里按文件名搜索,另一种是在shell里搜索,是用find命令,其中FileName可以用*占位
    1
    2
    3
    4
    5
    # 切换到源码路径下
    cd ~/Library/Android/sdk/sources/android-30

    # find,查找当前目录下,以及所有子目录下,所有文件名符合条件的文件
    find . -name "FileName"
  • 搜索方法,如果知道在哪个文件,那就直接用Android Studio里的搜索功能,在哪嗖的一下就搜出来了。但是,有时方法属于某个内部类的,所以文件的名字就不是类的名字,这样就搜不到文件,这个时候就直接用grep命令,在大杀器面前,没有什么可以藏起来。
    1
    2
    3
    4
    cd ~/Library/Android/sdk/sources/android-30

    # r:recursive 查找当前目录,及其所有子目录下的所有文件中,包含关键字的行,支持占位符,支持正则,搜索神器
    grep -r "keyWord" .

以上这些,是帮助学习过程能顺利向前推进的方式,有了动力,下面就说方向。

4. Launcher

首先明确的是,桌面Launcher也是应用,是一个有些特殊的应用,它在系统启动过程中启动的,Android系统启动流程之前简单写过,点进去可以再看一遍。桌面上看到的应用图标,点击之后的打开,长按之后的小浮窗,图标分组管理,拖拽调整位置,卸载时的动画,等等,这些都是Launcher的功能,各大厂商就是修改了Launcher里面的代码后,实现了自己独特风格的桌面。抛开其他的不谈,这里只看点击Launcher上的应用图标后,到应用完全打开,这其中的全过程。

我们上面刚刚下载的,是SDK的源码,这些SDK是供我们在开发APP过程调用的,而Launcher本身就是个APP,是系统自带的APP,它不属于SDK的源码,所以不管用哪种方式,在刚下载的源码中都是搜不到的蛛丝马迹的。这个时候怎么办?对,如果不想去找framework的源码,那就靠推测。

既然Launcher是个APP,那最终就是要继承自Activity的,点击图标就是启动Activity,那就直接看Activity里的startActivity就可以了。

5. Activity#startActivity

搜索打开Activity.java,定位startActivity方法,发现它有几个重载的方法(重载和重写的区别在于,重写是换核不换壳,重载是换参数换返回值),但是不管哪个最终都走到了startActivityForResult方法里,其内又调用了Instrumentation的execStartActivity,Instrumentaion是一个管理Activity和Application生命周期的变量,在Activity搜索这个变量的赋值,发现是在attach方法里初始化,下一步跟进它的execStartActivity方法。

6. Instrumentation#execStartActivity

搜索Instrumentation,定位execStartActivity方法,发现里面内容很少,最终调用的是

1
ActivityTaskManager.getService().startActivity

跳到ActivityTaskManager里,找到getService方法,发现返回的是个IActivityTaskManager类型的实例,不管用哪种方式搜都是搜不到对应类文件的,这时就要搜关键词了,会发现有个类继承自IActivityTaskManager.Stub,看这熟悉的味道,AIDL不就来了吗,既然来了,那就插播一条Binder。

7. Binder

这里只简单的说用法,以便于能继续把代码看下去,等会单独写一篇较为详细的说。

Android系统中,同一个进程间,资源共享,不同进程间,资源不共享,无法直接通信。Binder是Android系统中进程之间通信的方式。进程间通信,无非就是调用方法,一个进程提供方法,另一个进程调用这些方法。当然,直接调用是不可能的,中间要通过Binder。所以,提供方进程要以Binder规定的形式定义方法,调用方进程也用Binder规定的方式调用,这样就可以通信了,至于跨进程的的事交给Binder来做。

拿上面的例子来说,IActivityTaskManager中,就是提供方进程所提供的所有方法,同时,提供方要实现这些方法,通过继承IActivityTaskManager.Stub的方式,Stub中默认写了一些Binder需要用到的方法。而调用方,直接调用IActivityManager中定义的方法,即可。继续跟进代码。

8. ActivityTaskManagerService#startActivity

上面调用了IActivityTaskManager的startActivity方法,实际提供功能的是实现类ActivityTaskManagerService,搜索这个类并定位startActivity方法,这个时候,就不在Launcher的进程了,而是来到了ActivityTaskManagerService所在的进程,这个是系统进程,是在系统初始化期间启动起来的。

接下来,即将上演的就是一场足球大赛了,你会看到这个Launcher发来的启动Activity请求,在这个进程间被大家踢来踢去,你传给我,我又传给他,他又回传给我,紧张激烈,眼花缭乱,精彩至极。

startActivity方法几经转发,最终调用的是其内部的startActivityAsUser方法,这方法里面关键的代码是这句

1
2
// ActivityTaskManagerService#startActivityAsUser
getActivityStartController().obtainStarter(intent, "startActivityAsUser").set.set.set.set.execute()

getActivityStartController()获取的是一个ActivityStarterController,obtainStarter获取到的是一个ActivityStarter实例。Controller里对ActivityStarter实例进行了复用管理,这和Handler中用的Message.obtain很像,所以可以推断出,ActivityStart也是个频繁使用的对象,所以才需要增加复用的管理,以优化内存。下面看看ActivityStarter选手如何带球过人。

9. ActivityStarter

可以看到的是,在获取到ActivityStarter的实例后,进行了一些列的字段赋值操作,这里类比Message即可,最终调用了execute方法,所以它的流程是,获取对象后初始化各个字段,最后执行,搜索ActivityStarter,定位execute方法。

可以看到的是,execute方法内先是对之前的参数做了一些校验,随后便调用了executeRequest方法,execute一脚将球踢出,我们的目光随着球来到了executeRequest方法。

这个方法主要在做启动前的检查,里面有着不少的注释,解释了每一步在在检查什么,比如状态,比如权限。做完自己想做的事后,并没有实际启动Activity,而是再一次将球传出,转眼球便来到了startActivityUnchecked的脚下,startActivityUnchecked看着脚下的脚,迅速反应。

startActivityUnchecked可能不在状态,早晨吃冰了有点拉肚子,几个垫步,就将球传给了startActivityInner,看着远去的球,邪魅一笑:嘿嘿,又是划水的一天呢。

startActivityInner中依然是检查,继续着前人没有完成的工作,同时也对task stack的focusable做了一些检查,最后调用了RootWindowContainer的resumeFocusedStacksTopActivities方法,然后自己就下班了。

伪球赛直播好累,好好说话。

10. RootWindowContainer

想要启动一个Activity,势必要将其显示到屏幕上,RootWindowContainer就是管理屏幕窗口的,这里涉及到了另一个大类,WindowManagerService,WMS,这里只需要知道它将Activity显示了出来,

搜索RootWindowContainer,定位resumeFocusedStacksTopActivities,可以看到,在这里它调用了它其中维护了这个窗口对应的ActivityStack的方法,resumeTopActivityUncheckedLocked

11. ActivityStack

搜到进入到ActivityStack,发现这个文件好长,有3000多行,定位到resumeTopActivityUncheckedLocked,方法内有一大段说明注释

1
2
3
4
5
6
7
// When resuming the top activity, it may be necessary to pause the top activity (for
// example, returning to the lock screen. We suppress the normal pause logic in
// {@link #resumeTopActivityUncheckedLocked}, since the top activity is resumed at the
// end. We call the {@link ActivityStackSupervisor#checkReadyForSleepLocked} again here
// to ensure any necessary pause logic occurs. In the case where the Activity will be
// shown regardless of the lock screen, the call to
// {@link ActivityStackSupervisor#checkReadyForSleepLocked} is skipped.

意思大概是,启动一个Activity时,可能需要暂停上一个Activity。所以这个方法里做了两件事,一件是启动Activity,另一件是暂停Activity。启动Activity调用的是resumeTopActivityInnerLocked,暂停Activity调用的是checkReadyForSleep。

这里需要暂停的就是Launcher,在启动完其他Activity后,Launcher就不处于前台了,自然是要被暂停。checkReadyForSleep里调用的是ActivityStackSupervisor的checkReadyForSleepLocked方法,这个方法里又调用了RootWindowContainer的putStacksToSleep,启动Activity和暂停Activity都免不了和Window打交道,毕竟启动和暂停给用户的直观感受就是能看到,和看不到了,其他的细节就不多说了。

回过头来看resumeTopActivityInnerLocked,走了这么远,感觉希望就在眼前了。这个方法内先是为Activity创建了一个新的进程

1
2
3
4
5
6
7
8
9
10
11
12
13
ActivityRecord next = ....;
if (next.attachedToProcess()) {
next.app.updateProcessInfo(false /* updateServiceConnectionActivities */,
true /* activityChange */, false /* updateOomAdj */,
false /* addPendingTopUid */);
} else if (!next.isProcessRunning()) {
// Since the start-process is asynchronous, if we already know the process of next
// activity isn't running, we can start the process earlier to save the time to wait
// for the current activity to be paused.
final boolean isTop = this == taskDisplayArea.getFocusedStack();
mAtmService.startProcessAsync(next, false /* knownToBeDead */, isTop,
isTop ? "pre-top-activity" : "pre-activity");
}

next的类型是ActivityRecord,TaskRecord对应一个Activity栈,启动Activity时向栈内压入,销毁Activity时从栈顶弹出,栈里面存储的结构是ActivityRecord,每个ActivityRecord对应一个Activity。这里的next,指的是要启动的Activity,attachedToProcess检查的是是否存在一个进程,这里是没有,所以进入到else,else里做的事情就是为这个ActivityRecord创建一个进程。mAtmService.startProcessAsync便是创建进程的操作。

mAtmService的类型是ActivityTaskManagerService,也就是上面提到过的唯一继承了IActivityTaskManager.Stub的类

12. 为Activity启动一个进程

上面的mAtmService.startProcessAsync将会进入开启进程的任务。搜索ActivityTaskManagerService,定位到startProcessAsync方法

1
2
3
4
5
6
// Post message to start process to avoid possible deadlock of calling into AMS with the
// ATMS lock held.
final Message m = PooledLambda.obtainMessage(ActivityManagerInternal::startProcess,
mAmInternal, activity.processName, activity.info.applicationInfo, knownToBeDead,
isTop, hostingType, activity.intent.getComponent());
mH.sendMessage(m);

方法里面有一句注释,说的是为了避免死锁,所以使用消息的方式创建,mH的类型就是Handler,PooledLambda我还没看,但是可以确定的是,发送消息之后,第一参数lambda就会执行,也就是会调用ActivityManagerInternal的startProcess方法,ActivityManagerInternal是个抽象类,全局搜索”extends ActivityManagerInternal”后,可以看到LocalService是它的唯一实现类。

LocalService是AMS的内部类,打开AMS,定位到startProcess方法,里面没有过多操作,直接调用了外部类AMS的startProcessLocked方法,startProcessLocked中直接调用了ProcessList的startProcessLocked方法。

ProcessList里面的startProcessLocked一定有着俄罗斯套娃的血统,一路走过4个重载方法,才看到了底,然后又调用了startProcess方法,在这个方法里面,总算是调用Process的start的方法,Process中Zygote通过fork自己,创建出一个新的进程,在进程中执行了android.app.ActivityThread的main方法。

至此,新的进程启动完成。

13. ActivityThread与AMS绑定

这时,我们已经不在系统服务的进程中了,而是来到了刚刚系统进程为Activity新创建的进程。

ActivityThread在新的进程中被加载后,会调用它的main函数,我们来看看它在main函数中做了什么事情

1
2
3
4
5
6
public static void main(String[] args) {
Looper.prepareMainLooper();
ActivityThread thread = new ActivityThread();
thread.attach(false, startSeq);
Looper.loop();
}

很清晰,只做了两件事,一个是创建一个ActivityThread实例并attach,另一个是开始消息循环Looper,毕竟Android是个消息驱动的机制,所以进程启动先开启Looper也是必须的。然后再来看看attach中做了什么事情

1
2
3
4
5
6
7
8
final ApplicationThread mAppThread = new ApplicationThread();

final IActivityManager mgr = ActivityManager.getService();
try {
mgr.attachApplication(mAppThread, startSeq);
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}

获取IActivityManager实例,然后调用它的attachApplication方法。熟悉吗,上面是不是提到过?Launcher启动Activity时,最终调用的是不是就是这个

1
ActivityTaskManager.getService().startActivity

这里也是一样,Launcher是在和ActivityTaskManager跨进程通信,而这里是在和ActivityManagerService通信。这么一调用,才来到这个进程没多久,转眼就要回到系统服务进程中。

ActivityManagerService是IActivityManager的唯一实现类,所以上面的调用就会走到AMS的attachApplication中,这个方法里没有做太多事,直接调用了attachApplicationLocked方法,

1
2
3
4
5
6
7
8
9
10
11
12
thread.bindApplication(processName, appInfo, providerList,
instr2.mClass,
profilerInfo, instr2.mArguments,
instr2.mWatcher,
instr2.mUiAutomationConnection, testMode,
mBinderTransactionTrackingEnabled, enableTrackAllocation,
isRestrictedBackupMode || !normalMode, app.isPersistent(),
new Configuration(app.getWindowProcessController().getConfiguration()),
app.compat, getCommonServicesLocked(app.isolated),
mCoreSettingsObserver.getCoreSettingsLocked(),
buildSerial, autofillOptions, contentCaptureOptions,
app.mDisabledCompatChanges);

attachApplicationLocked先是调用了传进来的thread的bindApplication方法,thread的类型是ApplicationThread,是Activity进程调用attach方法时的第一个参数,Activity进程是刚刚创建启动,其中还没有相关的数据,通过这个方法将系统服务进程的参数发送到Activity进程。之后,attachApplicationLocked继续发扬踢皮球的好传统,将球传给了ActivityTaskManagerInternal的attachApplication,这是个抽象方法,具体实现类在ActivityTaskManagerService中的attachApplication方法,定位到这个方法后,可以看到里面再次传球,调用了RootWindowContainer的attachApplication,然后又调用了startActivityForAttachedApplicationIfNeeded方法,这个方法又调用了ActivityStackSupervisor的realStartActivityLocker方法,到这里,终于看到了曙光,这个方法里的关键代码是这些

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 // line838
// Create activity launch transaction.
final ClientTransaction clientTransaction = ClientTransaction.obtain(proc.getThread(), r.appToken);

clientTransaction.addCallback(LaunchActivityItem.obtain(new Intent(r.intent),
System.identityHashCode(r), r.info,
// TODO: Have this take the merged configuration instead of separate global
// and override configs.
mergedConfiguration.getGlobalConfiguration(),
mergedConfiguration.getOverrideConfiguration(), r.compat,
r.launchedFromPackage, task.voiceInteractor, proc.getReportedProcState(),
r.getSavedState(), r.getPersistentSavedState(), results, newIntents,
dc.isNextTransitionForward(), proc.createProfilerInfoIfNeeded(),
r.assistToken, r.createFixedRotationAdjustmentsIfNeeded()));

// Set desired final state.
final ActivityLifecycleItem lifecycleItem;
if (andResume) {
lifecycleItem = ResumeActivityItem.obtain(dc.isNextTransitionForward());
} else {
lifecycleItem = PauseActivityItem.obtain();
}
clientTransaction.setLifecycleStateRequest(lifecycleItem);

// Schedule transaction.
mService.getLifecycleManager().scheduleTransaction(clientTransaction);

Transaction,事务,是Binder通信中的一个概念,里面是用自定义的协议,每次发送协议进行数据传递,都称为一个事务。

先是获取到了一个clientTransaction实例,然后向其中添加了一个LaunchActivityItem,然后又设置了ResumeActivityItem,最终传给了scheduleTransaction。最终会调用IApplicationThread的scheduleTransaction方法,也就是说,系统服务进程会调用Activity进程的中ApplicationTHread的scheduleTransaction方法,这个事务中的LaunchActivityItem,最终会调用ActivityThread的handleLaunchActivity,而ResumeActivityItem会调用ActivityThread的handleResumeActivity方法。Activity的onResume方法执行完,就意味着这个页面已经展示在了屏幕上。

现在想一下,为什么要这么做呢?我们通过ActivityTaskManager.getService可以调用ActivityTaskManagerService中的方法,通过ActivityManager.getService可以调用ActivityManagerService中的方法,这是我们的Activity进程调用系统进程服务的方法,那么系统服进程中的服务若是需要调用Activity进程中的方法,那么它该需要通过什么方式来获取具有可调用方法的实例呢?答案是没有,系统服务进城是开机时创建的,它可以提供接口供后来者调用来获取实例来和它通信,但是我们的Activity进程是在用户的使用过程中创建和销毁的,所以,要想让系统服务进程调用我们Activity进程的方法,那么就需要在Activity进程启动的时候,主动将包含方法的实例告诉系统服务进程,也就是上面所说的绑定操作。而这里所谓的包含方法的实例,则叫做代理。Activity进程通过系统服务进程的代理,调用系统服务进程的方法;系统服务进程则是通过Activity进程的代理,来调用Activity进程的方法。

也就是说,IApplicationThread是Activity进程在系统服务进程的代理,系统服务进程都会通过IApplicationThread来调用Activity进程的方法。

IApplicationThread.java
IApplicationThread.aidl

至此,启动流程完成。

14. Transaction

上面的方法里用到了Transaction,LaunchActivityItem是怎么调用ActivityThread的handleLaunhActivity方法的呢?ResumeActivityItem又是怎么调用ActivityThread的handleResumeActivity的呢?下面来说一说这个。

首先,我们知道的是,系统服务进程中的Transaction是通过这样发送出去的

1
mService.getLifecycleManager().scheduleTransaction(clientTransaction);

mService的类型是ActivityTaskManagerService,定位到它的getLifecycleManager方法,发现返回类型是ClientLifecycleManager,也就是说,最终transaction是传给了ClientLifecycleManager的scheduleTransaction方法

1
2
3
4
5
6
7
8
9
10
11
// ClientLifecycleManager.java
void scheduleTransaction(ClientTransaction transaction) throws RemoteException {
final IApplicationThread client = transaction.getClient();
transaction.schedule();
if (!(client instanceof Binder)) {
// If client is not an instance of Binder - it's a remote call and at this point it is
// safe to recycle the object. All objects used for local calls will be recycled after
// the transaction is executed on client in ActivityThread.
transaction.recycle();
}
}

这个方法里面,直接调用了ClientTransaction的schedule方法

1
2
3
4
// ClientTransaction.java
public void schedule() throws RemoteException {
mClient.scheduleTransaction(this);
}

这个方法又调用了mClient的scheduleTransaction方法,mClient的类型是IApplicationThread,意思就是说,这个时候就把ClientTransaction发送给了ActivityThread的内部类ApplicationThread处理了。启动流程说完了,耐心一下子就上来,来,一行一行往下说,看看ApplicationThread中是怎么处理Transaction的

1
2
3
4
// ApplicationThread.java
public void scheduleTransaction(ClientTransaction transaction) throws RemoteException {
ActivityThread.this.scheduleTransaction(transaction);
}

又很简单,又什么都没干,交给了外部类ActivityThread,而scheduleTransaction方法不是ActivityThread自己的,是它继承自父类ClientTransactionHandler的,它并没有重写,所以,还要到父类ClientTransactionHandler中看看这个方法

1
2
3
4
5
6
7
// ClientTransactionHandler.java
void scheduleTransaction(ClientTransaction transaction) {
transaction.preExecute(this);
sendMessage(ActivityThread.H.EXECUTE_TRANSACTION, transaction);
}

abstract void sendMessage(int what, Object obj);

看到这,就明白了吧,父类处理ClienTransaction的方式就是,调用sendMessage方法,ActivityThread作为子类,实现了sendMessage方法,所以它能接收到这个message,它处理message的方式是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void handleMessage(Message msg) {
switch (msg.what) {
case EXECUTE_TRANSACTION:
final ClientTransaction transaction = (ClientTransaction) msg.obj;
mTransactionExecutor.execute(transaction);
if (isSystem()) {
// Client transactions inside system process are recycled on the client side
// instead of ClientLifecycleManager to avoid being cleared before this
// message is handled.
transaction.recycle();
}
// TODO(lifecycler): Recycle locally scheduled transactions.
break;
}
}

再次传球,交给了mTransactionExecutor处理,它是ActivityThread中的一个变量,类型是TransactionExecutor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// TransactionExecutor.java
public void execute(ClientTransaction transaction) {
executeCallbacks(transaction);

executeLifecycleState(transaction);
}

public void executeCallbacks(ClientTransaction transaction) {
final int size = callbacks.size();
for (int i = 0; i < size; ++i) {
item.execute(mTransactionHandler, token, mPendingActions);
item.postExecute(mTransactionHandler, token, mPendingActions);
}
}

private void executeLifecycleState(ClientTransaction transaction) {
final ActivityLifecycleItem lifecycleItem = transaction.getLifecycleStateRequest();
lifecycleItem.execute(mTransactionHandler, token, mPendingActions);
lifecycleItem.postExecute(mTransactionHandler, token, mPendingActions);
}

TransactionExecutor执行execute时,先调用callback,再调用lifecyclestate,完事。而它们的execute和postEcexute方法,都是写在自身里面的,LaunchActivityItem写的就是handleLaunchActivity,ResumeActivityItem写的就是handleResumeActivity

1
2
3
4
5
6
7
8
9
10
11
12
13
// ResumeActivityItem.java
public void execute(ClientTransactionHandler client, IBinder token, PendingTransactionActions pendingActions) {
client.handleResumeActivity(token, true /* finalStateRequest */, mIsForward, "RESUME_ACTIVITY");
}

// LaunchActivityItem.java
public void execute(ClientTransactionHandler client, IBinder token, PendingTransactionActions pendingActions) {
ActivityClientRecord r = new ActivityClientRecord(token, mIntent, mIdent, mInfo,
mOverrideConfig, mCompatInfo, mReferrer, mVoiceInteractor, mState, mPersistentState,
mPendingResults, mPendingNewIntents, mIsForward,
mProfilerInfo, client, mAssistToken, mFixedRotationAdjustments);
client.handleLaunchActivity(r, pendingActions, null /* customIntent */);
}

15. 总结

这个过程,其实用几句话就能说完,Launcher进程通过Binder向系统服务进程发送启动请求,系统服务进程做一些准备后,创建一个新进程,新进程通过Binder和系统服务进程绑定,然后系统服务进程通过Binder调用这个新进程的生命周期方法。

看着简单的流程中,其实充满了各种各种的情况,所以要做很多检查,验证,因此,一个启动Activity的请求,就可以让系统像个足球场般热闹起来。

------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2022/04/1b49758d3a97/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道