在上篇文章 Android 註解系列之 EventBus3 原理(四)中咱們講解了 EventBus3 的內部原理,在該篇文章中咱們將講解 EventBus3 中的 「加速引擎」
---索引類。閱讀該篇文章咱們可以學到以下知識點。html
對 APT 技術不熟悉的小夥伴,能夠查看文章 Android-註解系列之APT工具(三)java
在 Android 註解系列之 EventBus3 原理(四)中,咱們特別指出在 EventBus3 中優化了 SubscriberMethodFinder
獲取類中包含 @Subscribe
註解的訂閱方法的流程。使其能在 EventBus.register()
方法調用以前就能知道相關訂閱事件的方法,這樣就減小了程序在運行期間使用反射遍歷獲取方法所帶來的時間消耗。優化點以下圖中 紅色虛線框
所示:android
EventBus 做者 Markus Junginger 也給出了使用索引類先後 EventBus 的效率對比,以下圖所示:git
從上圖中,咱們可使用索引類後,EventBus 的效率有着明顯的提高,而效率提高的背後,正是使用了 APT
技術所建立的索引類
。那麼接下來咱們就來看一看 EventBus3 中是如何結合 APT
技術來進行優化的。github
閱讀過 EventBus3 源碼的小夥伴應該都知道,在 EventBus3 中獲取類中包含 @Subscribe
註解的訂閱方法有兩種方式。數組
而使用索引類的關鍵代碼爲 SubscriberMethodFinder
中的 getSubscriberInfo() 方法與 findUsingInfo() 方法 。 咱們分別來看這兩個方法。性能優化
private List<SubscriberMethod> findUsingInfo(Class<?> subscriberClass) {
FindState findState = prepareFindState();
findState.initForSubscriber(subscriberClass);
while (findState.clazz != null) {
//👇關鍵代碼,從索引類中獲取 SubscriberInfo
findState.subscriberInfo = getSubscriberInfo(findState);
//方式1:若是 subscriberInfo 不爲空,則從該對象中獲取 SubscriberMethod 對象
if (findState.subscriberInfo != null) {
SubscriberMethod[] array = findState.subscriberInfo.getSubscriberMethods();
for (SubscriberMethod subscriberMethod : array) {
if (findState.checkAdd(subscriberMethod.method, subscriberMethod.eventType)) {
findState.subscriberMethods.add(subscriberMethod);
}
}
} else {
//方式2:若是 subscriberInfo 爲空,那麼直接經過反射獲取
findUsingReflectionInSingleClass(findState);
}
findState.moveToSuperclass();
}
return getMethodsAndRelease(findState);
}
複製代碼
咱們能從該方法中得到如下信息:app
SubscriberMethod
數組。反射
去獲取 SubscriberMethod
集合信息。SubscriberMethod 類中含有
@Subscribe
註解的方法信息封裝(優先級,是否粘性,線程模式,訂閱的事件),以及當前方法的 Method 對象(java.lang.reflect
包下的對象)。ide
也就說 EventBus 是否經過反射獲取信息,是由 getSubscriberInfo()方法來決定,那麼咱們查看該方法。函數
private SubscriberInfo getSubscriberInfo(FindState findState) {
if (findState.subscriberInfo != null && findState.subscriberInfo.getSuperSubscriberInfo() != null) {
SubscriberInfo superclassInfo = findState.subscriberInfo.getSuperSubscriberInfo();
if (findState.clazz == superclassInfo.getSubscriberClass()) {
return superclassInfo;
}
}
//👇這裏是EventBus3中優化的關鍵,索引類
if (subscriberInfoIndexes != null) {
for (SubscriberInfoIndex index : subscriberInfoIndexes) {
SubscriberInfo info = index.getSubscriberInfo(findState.clazz);
if (info != null) {
return info;
}
}
}
return null;
}
複製代碼
從代碼邏輯中咱們能得出,若是 subscriberInfoIndexes
集合不爲空的話,那麼就會從 SubscriberInfoIndex(索引類)
中去獲取 SubscriberInfo
對象信息。該方法的邏輯並不複雜,惟一的疑惑就是這個 SubscriberInfoIndex(索引類) 對象是從何而來的呢?
聰明的小夥伴們已經想到了。對!!!就是經過 APT 技術自動生成的類。那麼咱們怎麼使用 EventBus3 中的索引類?以及 EventBus3 中是如何生成的索引類的呢? 不急不急,咱們一個一個的解決問題。咱們先來看看如何使用索引類。
若是須要使用 EventBus3 中的索引類,咱們能夠在 App 的 build.gradle
中添加以下配置:
android {
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
// 根據項目實際狀況,指定索引類的名稱和包名
arguments = [ eventBusIndex : 'com.eventbus.project.EventBusIndex’ ]
}
}
}
}
dependencies {
implementation 'org.greenrobot:eventbus:3.1.1’
// 引入註解處理器
annotationProcessor 'org.greenrobot:eventbus-annotation-processor:3.1.1’
}
複製代碼
若是有小夥伴不熟悉 gradle 配置,能夠查看 AnnotationProcessorOptions
在上述配置中,咱們須要注意以下幾點:
annotationProcessorOptions
參數中的值。也沒有必要引入 EventBus 的註解處理器。No option eventBusIndex passed to annotation processor
。\ProjectName\app\build\generated\source\apt\你設置的包名
。當咱們的索引類生成後,咱們還須要在初始化 EventBus 時應用咱們生成的索引類,代碼以下所示:
EventBus.builder().addIndex(new EventBusIndex()).installDefaultEventBus();
複製代碼
之因此要配置索引類,是由於咱們須要將咱們生成的索引類添加到 subscriberInfoIndexes
集合中,這樣咱們才能從以前講解的 getSubscriberInfo()
找到咱們配置的索引類。addIndex()
代碼以下所示:
public EventBusBuilder addIndex(SubscriberInfoIndex index) {
if (subscriberInfoIndexes == null) {
subscriberInfoIndexes = new ArrayList<>();
}
//👇這裏添加索引類到 subscriberInfoIndexes 集合中
subscriberInfoIndexes.add(index);
return this;
}
複製代碼
若是你已經配置好了索引類,那麼咱們看下面的例子,這裏我配置的索引類爲 EventBusIndex
對應包名爲: 'com.eventbus.project'
。我在 EventBusDemo.java 中聲明瞭以下方法:
public class EventBusDemo {
@Subscribe(threadMode = ThreadMode.MAIN)
public void onMessageEventOne(MessageEvent event) {
System.out.println("hello」);
}
@Subscribe(threadMode = ThreadMode.MAIN)
public void onMessageEventTwo(MessageEvent event) {
System.out.println("world」);
}
}
複製代碼
自動生成的索引類,以下所示:
public class EventBusIndex implements SubscriberInfoIndex {
private static final Map<Class<?>, SubscriberInfo> SUBSCRIBER_INDEX;
static {
SUBSCRIBER_INDEX = new HashMap<Class<?>, SubscriberInfo>();
putIndex(new SimpleSubscriberInfo(EventBusDemo.class, true, new SubscriberMethodInfo[] {
new SubscriberMethodInfo("onMessageEventOne", MessageEvent.class, ThreadMode.MAIN),
new SubscriberMethodInfo("onMessageEventTwo", MessageEvent.class, ThreadMode.MAIN),
}));
}
private static void putIndex(SubscriberInfo info) {
SUBSCRIBER_INDEX.put(info.getSubscriberClass(), info);
}
@Override
public SubscriberInfo getSubscriberInfo(Class<?> subscriberClass) {
SubscriberInfo info = SUBSCRIBER_INDEX.get(subscriberClass);
if (info != null) {
return info;
} else {
return null;
}
}
}
複製代碼
在生成的索引類中咱們能夠看出:
SimpleSubscriberInfo
的 HashMap。SimpleSubscriberInfo
類中維護了當前訂閱者的 class 對象與 SubscriberMethodInfo[] 數組
。SubscriberMethodInfo 類中含有
@Subscribe
註解的方法信息封裝(優先級,是否粘性,線程模式,訂閱的事件),以及當前方法的名稱
。
到如今,咱們已經知道了咱們索引類中的內容,那麼如今在回到 findUsingInfo()
方法:
private List<SubscriberMethod> findUsingInfo(Class<?> subscriberClass) {
//省略部分代碼
while (findState.clazz != null) {
findState.subscriberInfo = getSubscriberInfo(findState);
if (findState.subscriberInfo != null) {
//👇關鍵代碼,從索引類中獲取 SubscriberMethod
SubscriberMethod[] array = findState.subscriberInfo.getSubscriberMethods();
for (SubscriberMethod subscriberMethod : array) {
if (findState.checkAdd(subscriberMethod.method, subscriberMethod.eventType)) {
findState.subscriberMethods.add(subscriberMethod);
}
}
}
//省略部分代碼
}
}
複製代碼
當 subscriberInfo
不爲空時,會經過 getSubscriberMethods()
方法,去獲取索引類中 SubscriberMethod[]數組
信息。由於索引類使用的是 SimpleSubscriberInfo
類,咱們查看該類中該方法的實現:
@Override
public synchronized SubscriberMethod[] getSubscriberMethods() {
int length = methodInfos.length;
SubscriberMethod[] methods = new SubscriberMethod[length];
for (int i = 0; i < length; i++) {
SubscriberMethodInfo info = methodInfos[i];
methods[i] = createSubscriberMethod(info.methodName, info.eventType, info.threadMode,
info.priority, info.sticky);
}
return methods;
}
複製代碼
觀察該代碼,咱們發現 SubscriberMethod 對象的建立是經過 createSubscriberMethod
方法建立的,咱們繼續跟蹤。
protected SubscriberMethod createSubscriberMethod(String methodName, Class<?> eventType, ThreadMode threadMode, int priority, boolean sticky) {
try {
Method method = subscriberClass.getDeclaredMethod(methodName, eventType);
return new SubscriberMethod(method, eventType, threadMode, priority, sticky);
} catch (NoSuchMethodException e) {
throw new EventBusException("Could not find subscriber method in " + subscriberClass +
". Maybe a missing ProGuard rule?", e);
}
}
複製代碼
從上述代碼中,咱們能夠看出 SubscriberMethod
中的 Method
對象,實際上是調用訂閱者的 class 對象並使用 getDeclaredMethod()
方法找到的。
如今爲止咱們已經基本瞭解,索引類之因此相比傳統的經過反射遍歷去獲取訂閱方法效率要更高。是由於在自動生成的索引類中,已經包含了相關訂閱者中的訂閱方法的名稱及註解信息,那麼當 EventBus 註冊訂閱者時,就能夠直接經過方法名稱
拿到 Method 對象。這樣就減小了經過遍歷尋找方法的時間。
那如今咱們繼續學習 EventBus3 中是如何建立索引類的。索引類的建立是經過 APT
技術,若是你不瞭解這門技術,你可能須要查看文章 Android-註解系列之APT工具(三)
APT(Annotation Processing Tool)
是 javac 中提供的一種編譯時掃描和處理註解的工具,它會對源代碼文件進行檢查,並找出其中的註解,而後根據用戶自定義的註解處理方法進行額外的處理。APT工具不只能解析註解,還能根據註解生成其餘的源文件,最終將生成的新的源文件與原來的源文件共同編譯(注意:APT並不能對源文件進行修改操做,只能生成新的文件,例如在已有的類中添加方法
)
使用APT技術須要建立本身的註解處理器,在 EventBus 中也建立了本身的註解處理器,從其源代碼中咱們就能夠看出。
那下面,咱們就直接查看源碼:
如下的代碼,都出至於 EventBusAnnotationProcessor
查看 EventBusAnnotationProcessor 中的 process()
方法:
process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv)
:註解處理器實際處理方法,通常要求子類實現該抽象方法,你能夠在在這裏寫你的掃描與處理註解的代碼,以及生成 Java 文件。其中參數 RoundEnvironment ,可讓你查詢出包含特定註解的被註解元素.
@SupportedAnnotationTypes(「org.greenrobot.eventbus.Subscribe」)
@SupportedOptions(value = {"eventBusIndex", "verbose」})
public class EventBusAnnotationProcessor extends AbstractProcessor {
public static final String OPTION_EVENT_BUS_INDEX = 「eventBusIndex」;
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment env) {
Messager messager = processingEnv.getMessager();
try {
//步驟1:👇獲取咱們配置的索引類,
String index = processingEnv.getOptions().get(OPTION_EVENT_BUS_INDEX);
if (index == null) {
messager.printMessage(Diagnostic.Kind.ERROR, "No option " + OPTION_EVENT_BUS_INDEX +
" passed to annotation processor」);
return false;
}
//省略部分代碼
//步驟2:👇收集當前訂閱者信息
collectSubscribers(annotations, env, messager);
//步驟3:👇建立索引類文件
if (!methodsByClass.isEmpty()) {
createInfoIndexFile(index);
} else {
messager.printMessage(Diagnostic.Kind.WARNING, "No @Subscribe annotations found」);
}
writerRoundDone = true;
} catch (RuntimeException e) {
//省略部分代碼
}
return true;
}
}
複製代碼
該方法中主要邏輯爲三個邏輯:
@Subscribe
註解的方法。並將訂閱者與訂閱方法進行記錄在 methodsByClass
Map 集合中。createInfoIndexFile()
方法開始建立索引類文件。由於聲明瞭
@SupportedAnnotationTypes("org.greenrobot.eventbus.Subscribe")
在註解處理器上,那麼 APT 只會處理包含該註解的文件。
咱們接下來看看步驟2中的方法 collectSubscribers()
方法:
private void collectSubscribers(Set<? extends TypeElement> annotations, RoundEnvironment env, Messager messager) {
for (TypeElement annotation : annotations) {
Set<? extends Element> elements = env.getElementsAnnotatedWith(annotation);
for (Element element : elements) {
if (element instanceof ExecutableElement) {
ExecutableElement method = (ExecutableElement) element;
if (checkHasNoErrors(method, messager)) {
//獲取包含`@Subscribe`類的class對象
TypeElement classElement = (TypeElement) method.getEnclosingElement();
methodsByClass.putElement(classElement, method);
}
} else {
messager.printMessage(Diagnostic.Kind.ERROR, "@Subscribe is only valid for methods", element);
}
}
}
}
複製代碼
在註解處理過程當中,咱們須要掃描全部的Java源文件,源代碼的每個部分都是一個特定類型的
Element
,也就是說 Element 表明源文件中的元素,例如包、類、字段、方法等。
在上述方法中,annotations
爲掃描到包含 @Subscribe
註解 的 Element
集合。其中 ExecutableElement 表示類或接口的方法、構造函數或初始化器(靜態或實例),由於咱們能夠經過 getEnclosingElement()方法,拿到當前 ExecutableElement
的最近的父 Element,那麼咱們就能得到當前的類的 element 對象了。那麼經過該方法,咱們就能知道全部訂閱者與其對應的訂閱方法了。
咱們繼續跟蹤查看索引類文件的建立:
private void createInfoIndexFile(String index) {
BufferedWriter writer = null;
try {
JavaFileObject sourceFile = processingEnv.getFiler().createSourceFile(index);
int period = index.lastIndexOf('.’);
String myPackage = period > 0 ? index.substring(0, period) : null;
String clazz = index.substring(period + 1);
writer = new BufferedWriter(sourceFile.openWriter());
if (myPackage != null) {
writer.write("package " + myPackage + ";\n\n」);
}
writer.write("import org.greenrobot.eventbus.meta.SimpleSubscriberInfo;\n」);
writer.write("import org.greenrobot.eventbus.meta.SubscriberMethodInfo;\n」);
writer.write("import org.greenrobot.eventbus.meta.SubscriberInfo;\n」);
writer.write("import org.greenrobot.eventbus.meta.SubscriberInfoIndex;\n\n」);
writer.write("import org.greenrobot.eventbus.ThreadMode;\n\n」);
writer.write("import java.util.HashMap;\n」);
writer.write("import java.util.Map;\n\n」);
writer.write("/** This class is generated by EventBus, do not edit. */\n」);
writer.write("public class " + clazz + " implements SubscriberInfoIndex {\n」);
writer.write(" private static final Map<Class<?>, SubscriberInfo> SUBSCRIBER_INDEX;\n\n」);
writer.write(" static {\n」);
writer.write(" SUBSCRIBER_INDEX = new HashMap<Class<?>, SubscriberInfo>();\n\n」);
//👇這裏是關鍵的代碼
writeIndexLines(writer, myPackage);
writer.write(" }\n\n」);
writer.write(" private static void putIndex(SubscriberInfo info) {\n」);
writer.write(" SUBSCRIBER_INDEX.put(info.getSubscriberClass(), info);\n」);
writer.write(" }\n\n」);
writer.write(" @Override\n」);
writer.write(" public SubscriberInfo getSubscriberInfo(Class<?> subscriberClass) {\n」);
writer.write(" SubscriberInfo info = SUBSCRIBER_INDEX.get(subscriberClass);\n」);
writer.write(" if (info != null) {\n」);
writer.write(" return info;\n」);
writer.write(" } else {\n」);
writer.write(" return null;\n」);
writer.write(" }\n」);
writer.write(" }\n」);
writer.write("}\n」);
} catch (IOException e) {
throw new RuntimeException("Could not write source for " + index, e);
} finally {
if (writer != null) {
try {
writer.close();
} catch (IOException e) {
//Silent
}
}
}
}
複製代碼
在該方法中,經過 processingEnv.getFiler().createSourceFile(index)
拿到咱們須要建立的索引類文件對象,而後經過文件IO流向該文件中輸入索引類中須要的內容。在該方法中,最爲主要的就是 writeIndexLines()
方法了。查看該方法:
private void writeIndexLines(BufferedWriter writer, String myPackage) throws IOException {
for (TypeElement subscriberTypeElement : methodsByClass.keySet()) {
if (classesToSkip.contains(subscriberTypeElement)) {
continue;
}
//當前訂閱對象的class對象
String subscriberClass = getClassString(subscriberTypeElement, myPackage);
if (isVisible(myPackage, subscriberTypeElement)) {
writeLine(writer, 2,
"putIndex(new SimpleSubscriberInfo(" + subscriberClass + ".class,」,
"true,", "new SubscriberMethodInfo[] {「);
List<ExecutableElement> methods = methodsByClass.get(subscriberTypeElement);
//👇關鍵代碼
writeCreateSubscriberMethods(writer, methods, "new SubscriberMethodInfo", myPackage);
writer.write(" }));\n\n」);
} else {
writer.write(" // Subscriber not visible to index: " + subscriberClass + "\n」);
}
}
}
複製代碼
在該方法中,會從 methodsByClass
Map 中遍歷獲取咱們以前的訂閱者,而後獲取其全部的訂閱方法,並書寫模板方法。其中關構造 SubscriberMethodInfo
代碼的關鍵方法爲 writeCreateSubscriberMethods()
,跟蹤該方法:
private void writeCreateSubscriberMethods(BufferedWriter writer, List<ExecutableElement> methods,
String callPrefix, String myPackage) throws IOException {
for (ExecutableElement method : methods) {
//獲取當前方法上的參數
List<? extends VariableElement> parameters = method.getParameters();
TypeMirror paramType = getParamTypeMirror(parameters.get(0), null);
//獲取第一個參數的類型
TypeElement paramElement = (TypeElement) processingEnv.getTypeUtils().asElement(paramType);
//獲取方法的名稱
String methodName = method.getSimpleName().toString();
//獲取訂閱的事件class類型字符串信息
String eventClass = getClassString(paramElement, myPackage) + ".class」;
//獲取方法上的註解信息
Subscribe subscribe = method.getAnnotation(Subscribe.class);
List<String> parts = new ArrayList<>();
parts.add(callPrefix + "(\"" + methodName + "\",」);
String lineEnd = "),」;
//設置優先級,是否粘性,線程模式,訂閱事件class類型
if (subscribe.priority() == 0 && !subscribe.sticky()) {
if (subscribe.threadMode() == ThreadMode.POSTING) {
parts.add(eventClass + lineEnd);
} else {
parts.add(eventClass + ",」);
parts.add("ThreadMode." + subscribe.threadMode().name() + lineEnd);
}
} else {
parts.add(eventClass + ",」);
parts.add("ThreadMode." + subscribe.threadMode().name() + ",」);
parts.add(subscribe.priority() + ",」);
parts.add(subscribe.sticky() + lineEnd);
}
writeLine(writer, 3, parts.toArray(new String[parts.size()]));
if (verbose) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Indexed @Subscribe at 「 +
method.getEnclosingElement().getSimpleName() + "." + methodName +
"(" + paramElement.getSimpleName() + ")」);
}
}
}
複製代碼
在該方法中,會獲取訂閱方法的參數信息,並構建 SubscriberMethodInfo
信息。這裏就不對該方法進行詳細的介紹了,你們能夠根據代碼中的註釋進行理解。
在使用 EventBus3 的時候,若是你的項目採用了混淆,須要注意 keep 如下類及方法。官方中已經給出了詳細的 keep 規則,以下所示:
-keepattributes *Annotation*
-keepclassmembers class * {
@org.greenrobot.eventbus.Subscribe <methods>;
}
-keep enum org.greenrobot.eventbus.ThreadMode { *; }
# Only required if you use AsyncExecutor
-keepclassmembers class * extends org.greenrobot.eventbus.util.ThrowableFailureEvent {
<init>(java.lang.Throwable);
}
複製代碼
android在打包的時候,應用程序會進行代碼優化,優化的過程就把註解給去掉了。爲了在程序運行期間讀取到註解信息,因此咱們須要保存註解信息不被混淆。
由於當咱們在使用索引類時,獲取相關訂閱的方法是經過方法名稱
獲取的,那麼當代碼被混淆事後,訂閱者的方法名稱將會發生改變,好比原來訂閱方法名稱爲onMessageEvent,混淆後有可能改成a,或b方法。這個時候是找不到相關的訂閱者的方法的 ,就會拋出 Could not find subscriber method in + subscriberClass + Maybe a missing ProGuard rule?
的異常,因此在混淆的時候咱們須要保留訂閱者全部包含 @Subscribe
註解的方法。
若是咱們沒有在混淆規則中添加以下語句:
-keep public enum org.greenrobot.eventbus.ThreadMode { public static *; }
複製代碼
在運行程序的時候,會報java.lang.NoSuchFieldError: No static field POSTING
。緣由是由於在 SubscriberMethodFinder
的 findUsingReflection
方法中,在調用 Method.getAnnotation()
時獲取 ThreadMode
這個 enum
失敗了。
咱們都知道當咱們聲明枚舉類時,編譯器會爲咱們的枚舉,自動生成一個繼承 java.lang.Enum
的 final
類。以下所示:
//使用命令 javap ThreadMode.class
public final class com.tian.auto.ThreadMode extends java.lang.Enum<com.tian.auto.ThreadMode> {
public static final com.tian.auto.ThreadMode POSTING;
public static final com.tian.auto.ThreadMode MAIN;
public static final com.tian.auto.ThreadMode MAIN_ORDERED;
public static final com.tian.auto.ThreadMode BACKGROUND;
public static final com.tian.auto.ThreadMode ASYNC;
public static com.tian.auto.ThreadMode[] values();
public static com.tian.auto.ThreadMode valueOf(java.lang.String);
static {};
}
複製代碼
也就是說,咱們在枚舉中聲明的元素,其實最後對應的是類中的靜態公有的常量。
那麼在結合在沒有添加混淆規則時,程序所提示的錯誤信息。咱們能夠肯定當咱們在註解中包含枚舉類型
的註解元素時且設置了默認值時。該默認值是經過枚舉類的 class 對象.getField(String name) 去獲取的。由於只有該方法纔會拋出該異常。getField()
代碼以下所示:
public Field getField(String name)
throws NoSuchFieldException {
if (name == null) {
throw new NullPointerException("name == null」);
}
Field result = getPublicFieldRecursive(name);
if (result == null) {
throw new NoSuchFieldException(name);
}
return result;
}
複製代碼
那麼也就說若是不添加上述的 keep 規則,就會致使咱們編譯器自動生成的靜態常量名發生變化,又由於註解中的默認枚舉值,是經過 getField(String name)
得到的。因此就會出現找不到字段的狀況。
其實在不少狀況下,咱們須要添加 keep 規則,經常是由於代碼中是直接拿混淆前的方法名稱或字段名稱去直接尋找混淆後的方法與字段名稱,咱們只要在項目中注意這些狀況,添加相應的 keep 規則,就能夠避免由於代碼被混淆而產生的異常啦。
EventBus3 中的索引類及其相關內容到這裏就講完啦!我相應你們已經瞭解了索引類在性能優化上的重要做用。但願你們在後續使用EventBus3時,必定要使用索引類呦。在接下來的一段時間內,我可能不會繼續更新博客啦,由於做者我要去學習 flutter 去啦~ 沒有辦法,總要保持前進呢。優秀的人還在努力,更況且本身並不聰明呢。哎~傷心