《Android 开发艺术探索》读书笔记(一)——Activity 的生命周期和启动模式

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/zgcqflqinhao/article/details/83583729

Activity 作为 Android 四大组件之首,作为和用户交互的界面,在开发中使用得可谓极其频繁,所以弄清楚 Activity 的生命周期和启动方式是非常重要的,要牢记。

1 Activity 的生命周期全面分析

1.1 典型情况下的生命周期分析

onCreate():该方法调用时表示 Activity 被创建,可以在该方法中做一些初始化工作,如调用 setContentView() 方法加载布局、初始化数据等。
onStart():该方法调用时表示 Activity 被启动,Acitivity 即将开始,这时其实 Activity 已经显示出来了,但是还没有获取到焦点,无法和用户交互。
onResume():该方法调用时表示 Activity 可见并获得到焦点,此时 Activity 已经出现在前台并开始活动,与 onStart() 相比两个方法都表示 Activity 可见,只是 onStart() 时 Activity 还在后台,onResume() 时才显示到前台。
onRestart():该方法调用时表示 Activity 被重新启动,一般情况下 Activity 从不可见状态重新变为可见状态时会调用该方法,这些情况一般是用户行为导致的,如用户按 Home 键返回到桌面再进入 App,或者从当前 Activity 跳转到一个新的 Activity 然后再回到当前 Activity 等。
onPause():该方法调用时表示 Activity 正在停止,正常情况下 onStop() 会紧接着被调用。特殊情况下如果在这个时候如果快速回到当前 Activity 则 onResume() 方法会被调用,但这属于极端情况,用户操作很难出现这种场景。该方法中可以做一些存储数据、停止动画等操作,但不能太耗时,因为当前 Activity 的 onPause() 方法必须执行完成后才会执行新 Activity 的 onResume() 方法。
onStop():该方法调用时表示 Activity 即将停止,可以做一些稍微重量级一点的回收工作,但仍然不能太耗时。
onDestory():该方法调用时表示 Activity 即将被销毁,可以做一些回收工作和最终资源的释放。

我们可以将 onCreate() 和 onDestory() 看作是一对,onStart() 和 onStop() 是一对,onResume() 和 onPause() 是一对。

这张图是 Activity 生命周期的经典图:

假设有一个 Activity1:

public class Activity1 extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_1);
        Log.i("daolema", "Activity1--->onCreate");
    }

    @Override
    protected void onStart() {
        super.onStart();
        Log.i("daolema", "Activity1--->onStart");
    }

    @Override
    protected void onResume() {
        super.onResume();
        Log.i("daolema", "Activity1--->onResume");
    }

    @Override
    protected void onRestart() {
        super.onRestart();
        Log.i("daolema", "Activity1--->onRestart");
    }

    @Override
    protected void onPause() {
        super.onPause();
        Log.i("daolema", "Activity1--->onPause");
    }

    @Override
    protected void onStop() {
        super.onStop();
        Log.i("daolema", "Activity1--->onStop");
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        Log.i("daolema", "Activity1--->onDestroy");
    }
}

启动 Activity1 时,然后按 Home 键回到桌面,然后回到应用,然后按 Back 键退出应用,走的生命周期如下:

又有一个 Activity2,在 Activity1 中增加一个按钮用于启动 Activity2,修改 onCreate() 方法:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_1);
        Log.i("daolema", "Activity1--->onCreate");
        findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.i("daolema", "点击跳转到 Activity2");
                startActivity(new Intent(Activity1.this, Activity2.class));
            }
        });
    }

Antivity2 的代码大致与 Activity1 相同,先启动 Activity1,然后点击按钮跳转到 Activity2,然后点击 Activity2 中的按钮调用 Activity2 的 finish() 方法,然后按 Back 键,所经历的生命周期如下:

可以看到 Activity2 的 onResume() 方法是在 Activity1 的 onPause() 方法后执行的,那么是不是必须得 Activity1 的 onPause() 方法执行完之后才能执行 Activity2 的 onResume() 方法呢?修改 Activity1 的 onPause() 方法,加一个延时:

    @Override
    protected void onPause() {
        super.onPause();
        Log.i("daolema", "Activity1--->onPause");
        SystemClock.sleep(3000);
    }

注意看红框中的时间,可以看到确实是前一个 Activity 的 onPause() 方法执行完才去创建要启动的 Activity,所以千万不能在 onPause() 方法中执行耗时的操作,不然会影响下一个 Activity 的显示。

如果要启动的 Activity 采用的是透明主题,则不会执行前一个 Activity 的onStop() 方法,这是因为新 Activity 是透明的,也就意味着前一个 Activity 还是“可见”的。

1.2 异常情况下的生命周期分析

1.2.1 资源相关的系统配置发生改变导致 Activity 被杀死并重新创建

默认情况下如果系统配置发生改变,当前 Activity 就会被销毁并重新创建,所以它的 onPause()、onStop()、onDestory() 方法均会被调用,然后重新执行 onCreate()、onStart()、onResume() 方法,并且这是异常终止,所以还会调用 onSaveInstanceState() 方法来保存当前 Activity 的状态,该方法会在 onStop() 之前调用,但是和 onPause() 方法没有固定的先后顺序,既可能在 onPause() 之前,也可能在 onPause() 之后。当  Activity 重新创建后会调用 onRestoreInstanceState() 方法恢复之前保存的状态,onRestoreInstanceState() 方法是在 onStart() 之后。

Activity 在调用 onSaveInstanceState() 方法保存状态时首先会委托 Window 去保存数据,然后 Window 再委托上层的顶级容器保存数据,顶层容器是一个 ViewGroup(一般来说是 DecorView),最后顶层容器一一通知子元素保存数据。恢复数据时也是一样。

以 TextView 的 onSaveInstanceState() 方法为例:

    @Override
    public Parcelable onSaveInstanceState() {
        Parcelable superState = super.onSaveInstanceState();

        // Save state if we are forced to
        final boolean freezesText = getFreezesText();
        boolean hasSelection = false;
        int start = -1;
        int end = -1;

        if (mText != null) {
            start = getSelectionStart();
            end = getSelectionEnd();
            if (start >= 0 || end >= 0) {
                // Or save state if there is a selection
                hasSelection = true;
            }
        }

        if (freezesText || hasSelection) {
            SavedState ss = new SavedState(superState);

            if (freezesText) {
                if (mText instanceof Spanned) {
                    final Spannable sp = new SpannableStringBuilder(mText);

                    if (mEditor != null) {
                        removeMisspelledSpans(sp);
                        sp.removeSpan(mEditor.mSuggestionRangeSpan);
                    }

                    ss.text = sp;
                } else {
                    ss.text = mText.toString();
                }
            }

            if (hasSelection) {
                // XXX Should also save the current scroll position!
                ss.selStart = start;
                ss.selEnd = end;
            }

            if (isFocused() && start >= 0 && end >= 0) {
                ss.frozenWithFocus = true;
            }

            ss.error = getError();

            if (mEditor != null) {
                ss.editorState = mEditor.saveInstanceState();
            }
            return ss;
        }

        return superState;
    }

它保存了 TextView 的文本内容和选中状态,在恢复状态时也确实恢复了这些数据,系统配置发生的这种情况一般发生在横竖屏切换时,所以我们切换一下横竖屏后观察它的界面状态和生命周期过程:

可以看到 Activity 第一次启动时并没有调用 onRestoreInstanceState() 方法,切换横竖屏后销毁时调用了 onSaveInstanceState() 方法,重新创建时才会调用 onRestoreInstanceState(),所以我们也可以通过这两个方法来判断 Activity 是否有重建。

1.2.2 资源内存不足导致低优先级的 Activity 被杀死

现在手机配置越来越高,这种情况越来越不容易出现,但是它的状态存储和状态恢复过程跟系统配置改变是一样的,所以我们搞懂 Activity 的优先级即可。

进程的优先级顺序为:前台进程>可见进程>服务进程>后台进程>空进程,

Activity 的优先级顺序也类似:前台 Activity > 可见 Activity > 后台 Activity。

前台 Activity 就是当前正在与用户交互的 Activity,可见 Activity 就是如当前 Activity 弹出一个对话框,导致 Activity 虽然可见但是无法与用户直接交互,后台 Activity 就是已经被暂停的 Activity。

系统内存不足时就会按照优先级杀死目标进程,并后续通过 onSaveInstance() 和 onRestoreSaveInstance() 来存储和恢复数据,如果一个进程没有四大组件在执行,则很容易被杀死,所以一些后台工作最好是放在 Service 中来保证进程有一定优先级,不容易被轻易杀死。

1.2.3 configChanges 属性

如何让系统配置发生改变时 Activity 不重新创建呢,就是给 Activity 指定 configChanges 属性,configChanges 的项目和含义如下:

含义
mcc SIM 卡中唯一标识 IMSI(国际移动用户识别码)中的国家代码,由三位数字组成
,中国为 460,此项标识 mcc 代码发生了改变。
mnc SIM 卡中唯一标识 IMSI(国际移动用户识别码)中的运营商代码,由两位数字组成,
中国移动为 00,中国联通为 01,中国电信为 03,此项标识 mcc 代码发生了改变。
locale 设备的本地位置发生改变,一般指切换了系统语言。
touchscreen 触摸屏发生了改变,正常情况下很难发生,可以忽略。
keyboard 键盘类型发生了改变,如用户使用了外接键盘。
keyboardHidden 键盘的可访问性发生了改变,如用户调出了键盘。
navigation 系统的导航方式发生了改变,如改用了轨迹球导航,正常情况下很难发生,可以忽略。
screenLayout 屏幕布局发生了改变,很可能是用户激活了另一个显示设备。
fontScale 系统字体缩放比例发生了改变,如用户选择了一个新字号。
uiMode 用户界面模式发生了改变,如开启了夜间模式(API 8 新增)。
orientation 屏幕方向发生了改变,这个最常用,如旋转了手机屏幕。
screenSize 屏幕的尺寸信息发生改变,当旋转屏幕设备时屏幕尺寸会发生改变,该选项比较特殊,与编译选项有关,
当编译选项中的 minSdkVersion 和 targetSdkVersion 均低于 13 时,此选项不会导致 Activity 重启,
否则会导致 Activity 重启(API 13 新增)。
smallestScreenSize 设备的物理尺寸发生改变,这个项目和屏幕的方向没关系,仅仅表示实际的物理屏幕尺寸发生改变时发生,
如用户切换到了外部的显示设备,这个选项也是当编译选项中的 minSdkVersion 和 targetSdkVersion 均低于 13 时,此选项不会导致 Activity 重启,
否则会导致 Activity 重启(API 13 新增)。
layoutDirection 布局方向发生改变,这个属性用得比较少,正常情况下无需修改布局的 layoutDirection 属性(API 17新增)。

如果我们没有在 AndroidManifest.xml 中为 Activity 的 configChanges 属性指定某个值时,当相关配置发生了改变就会导致 Activity 重新创建,虽然 configChanges 的值很多,但是常用的只有 locale、orientation、keyboardHidden 和 screenSize 值,这是面试中经常问到的屏幕旋转时 Activity 的生命周期的变化的问题,亲测后记录分别为 Activity1 指定不同的 configChanges 属性,当屏幕旋转时生命周期变化的打印结果。

测试项目的 sdkVersion 为:minSdkVersion 15 和 targetSdkVersion 26。

当为 Activity1 指定 android:configChanges="orientation" 时,切换两次屏幕方向打印如下:

当为 Activity1 指定 android:configChanges="orientation|keyboardHidden" 时(这里网上很多说设置成这样后横竖屏切换时 Activity 就不会重新创建,其实这跟 minSdkVersion 和 targetSdkVersion 有关,表中已经说明,均低于 13 时才不会重启,高于 13 时还需要设置 screenSize),切换两次屏幕方向打印如下:

当为 Activity1 指定 android:configChanges="orientation|keyboardHidden|screenSize" 时,切换两次屏幕方向打印如下:

可以看到只有为 Activity 指定 android:configChanges="orientation|keyboardHidden|screenSize" 时横竖屏切换才不会导致 Activity 重启,也不会调用 onSavaInstance() 和 onRestoreSaveInstance() 方法,而是只调用 onConfigrationChanged() 方法。

2 Activity 的启动模式

除生命周期外,Activity 的启动模式和各种标志位也是很重要的,有时候为了满足项目的特殊需求,就需要设置不同的启动模式和标志位。

2.1 Activity 的 LaunchMode

standard:标准模式,默认模式,每次启动一个 Activity 不管该 Activity 的实例是否已存在都会创建一个新的实例。一个任务栈可以有多个实例,每个实例也可以属于不同的任务栈,这种模式下,谁启动了这个 Activity,这个 Activity 就运行在启动它的那个 Activity 所在的栈中,如 Activity2 的 launchMode 为 standard,Activity1 启动了 Activity2,那么 Activity2 就会进入到 Activity1 所在的栈中。如果当前栈中有四个 Activity 的实例 1234,4 位于栈顶,如果 3 和 4 的 launchMode 均为 standard,此时如果再次启动 3,那么栈中为 12343,如果启动 4,栈中也会变为 12344。

singleTop:栈顶复用模式,如果需要启动的 Activity 已经位于任务栈的栈顶,那么此 Activity 就不会重新创建实例,而是回调 onNewIntent() 方法,该方法中可以获取当前请求的信息,如果需要启动的 Activity 已经存在但不是位于栈顶,那么仍然会创建新的实例。如果当前栈中有四个 Activity 的实例 1234,4 位于栈顶,如果 3 和 4 的 launchMode 均为 singleTop,此时如果再次启动 3,那么栈中变为 12343,但是如果启动 4,栈中则仍为 1234。

singleTask:栈内复用模式,如果需要启动的 Activity 已经在一个栈中存在,那么此 Activity 就不会重新创建实例,会将该 Activity 调到栈顶并回调 onNewIntent() 方法。如果当前栈 S1 中有四个 Activity 的实例 1234,4 位于栈顶,如果 2 的 launchMode 为 singleTask,那么启动 2 时则会将 2 调到栈顶,并清掉 2 之上的 Activity,S1 变为 12。如果当前栈 S1 中有四个 Activity 的实例 1234,4 位于栈顶,如果需要启动 5,并且需要的任务栈为 S2,那么则会先创建栈 S2,再创建 5 并入栈到 S2,S1 仍为 1234。

singleInstance:单实例模式,这是一种加强的 singleTask 模式,以该模式启动的 Activity 会单独位于一个任务栈中。

2.2 Activity 的 Flags

Activity 的 Flags 的作用很多,可以设定 Activity 的启动模式,也可以影响 Activity 的运行状态,设置 Flags 一般在 Java 代码中:

Intent intent = new Intent();
intent.setClass(packageContext, cls);
intent.addFlags(flags);
startActivity(intent);

通常我们不需要设置 Activity,所以这个并不是很重要,常用的 Flags 如下:

FLAG_ACTIVITY_NEW_TASK:相当于在 AndroidManifest.xml 中设置 launchMode 为 singleTask。
FLAG_ACTIVITY_SINGLE_TOP:相当于在 AndroidManifest.xml 中设置 launchMode 为 singleTop。
FLAG_ACTIVITY_CLEAR_TOP:此标记位一般与 FLAG_ACTIVITY_NEW_TASK 配合使用,当具有此标记位的 Activity 启动时,同一任务栈中位于它之上的 Activity 都将出栈。
FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS:具有此标记位的 Activity 不会出现在历史 Activity 列表中,某些情况下不希望用户通过历史列表回到该 Activity 时该标记比较有用,等同于 AndroidManifest.xml 中设置 Activity 的属性 android:excludeFromRecents="true"。

3 IntentFilter 的匹配规则

3.1 匹配规则

启动 Activity 的方式分为显式调用和隐式调用,我们一般常用的就是显式调用,明确的指定被启动组件的类名,而隐式调用则不需要指定组件信息,而是通过匹配目标组件的 IntentFilter 中设置的过滤信息来启动目标 Activity。过滤信息有 action、category 和 data。
匹配信息时,需同时匹配上 action、category 和 data 三个信息才算匹配成功。一个过滤列表中可以有多个 action、category 和 data,同类型匹配上任意一个该类型就算匹配成功,如有多个 action 信息,匹配上任意一个即算 action 匹配成功。一对 <activity></activity> 标签中可以有多对 <intent-filter></intent-filter>,成功匹配任意一对即可成功启动对应 Activity。

action:action 是一个字符串,系统有预定义一些 action,如 Intent.ACTION_DIAL(拨号界面)、Intent.ACTION_SENDTO(发送短信)等,我们也可以自定义 action,只有字符串完全一样才能匹配成功。设置 action 是调用 setAction(String action) 方法,说明 Intent 只能设置一个 action,但是 <intent-filter></intent-filter> 可以设置多个 action,匹配上其中任何一个就算匹配成功。Intent 必须设置 action。
如果过滤信息是这样:

            <intent-filter>
                <action android:name="com.qinshou.demo.action1"/>
                <action android:name="com.qinshou.demo.action2"/>
    ...
            </intent-filter>

那么 Intent 的 action 必须为 com.qinshou.demo.action1 或 com.qinshou.demo.action2 才能符合规则,如:

                Intent intent = new Intent("com.qinshou.demo.action1");

                Intent intent = new Intent();
                intent.setAction("com.qinshou.demo.action2");

category:category 是一个字符串,系统也预定义了一些 category,最常见的是 android.intent.category.LAUNCHER,它表示打开应用时启动哪一个 Activity,我们也可以自定义 category,只有字符串完全一样才能匹配成功。添加 category 是调用 addCategory(String category) 方法,说明 Intent 可以有多个 category,但是无论设置有几个,它都必须全部匹配,如果有任何一个匹配不上都算匹配不成功。如果是隐式启动的话 <intent-filter></intent-filter> 中必须要有一个 <category android:name="android.intent.category.DEFAULT"/>。同 action 不同的是,Intent 可以不设置 category,系统会默认添加 "android.intent.category.DEFAULT"。
如果过滤信息是这样:

            <intent-filter>
                <category android:name="android.intent.category.DEFAULT"/>
                <category android:name="com.qinshou.demo.category1"/>
                <category android:name="com.qinshou.demo.category2"/>
    ...
            </intent-filter>

那么 Intent 的 <category 必须为 android.intent.category.DEFAULT、com.qinshou.demo.category1、com.qinshou.demo.category2 其中零个或多个才能符合规则,如:

                intent.addCategory(Intent.CATEGORY_DEFAULT);
                intent.addCategory("com.qinshou.demo.category1");
                intent.addCategory("com.qinshou.demo.category2");

data:如果 <intent-filter></intent-filter> 有设置 <data></data> 那么 Intent 中就必须设置,如果没有设置则 Intent 不能设置。data 的语法如下:

                <data
                    android:mimeType="string"
                    android:host="string"
                    android:port="string"
                    android:path="string"
                    android:pathPattern="string"
                    android:pathPrefix="string"
                    android:scheme="string"/>

data 分为 mimeType 和 URI 两部分组成,mimeType  是指媒体类型,如 image/jpeg、text/plain 等,它不是必须要指定的。URI 是由 scheme、host、port、path 等共同组成,它的结构为:<scheme>://<host>:<port>/[<path>|<pathPattern>|<pathPrefix>]。其实就跟我们平时浏览器中的网址差不多,如 https://www.baidu.com:80/xxx。
scheme:URI 的模式,如上面的https,还有常用的 file、content 等,也可以自定义,如果 URI 中没有指定 scheme,那么整个 URI 无效。
host:URI 的主机名,如上面的 www.baidu.com,如果 URI 中没有指定 host,那么整个 URI 无效。
port:URI 的端口号,如上面的 80,只有指定了 scheme 和 host 后 port 才有意义,可以不设置。
path、pathPattern、pathPrefix:分别代表完整的路径信息、路径的正则匹配表达式、路径的前缀,如上面的 xxx,三者设置一个即可或者不设置。
mimeType  事实上在自定义 data 时一般很少指定,如果指定了则 Intent 中则必须设置 type,而且如果设置了 mimeType 而没有设置 URI 的话,事实上 URI 是有默认值的,必须为 content 或 file,也就是说如果设置了 mimeType 而没有设置 URI 那么 URI 要么不指定,要么必须是形如 "content://xxx" 或 "file://xxx"。设置完整的 data 是调用 setDataAndType(Uri data,String type) 方法,不能分别调用 setData(Uri data) 和 setType(String type),因为它们两个会互相清除对方的值。

    public @NonNull Intent setData(@Nullable Uri data) {
        mData = data;
        mType = null;
        return this;
    }

如果过滤信息是这样:

            <intent-filter>
                <action android:name="com.qinshou.demo.action1"/>
                <action android:name="com.qinshou.demo.action2"/>

                <category android:name="android.intent.category.DEFAULT"/>
                <category android:name="com.qinshou.demo.category1"/>
                <category android:name="com.qinshou.demo.category2"/>

                <data
                    android:mimeType="text/plain"/>
            </intent-filter>

那么 Intent 必须这样才能符合规则:

                intent.setType("text/plain");

                intent.setDataAndType(Uri.parse("file://xxx"), "text/plain");

如果过滤信息是这样:

            <intent-filter>
                <action android:name="com.qinshou.demo.action1"/>
                <action android:name="com.qinshou.demo.action2"/>

                <category android:name="android.intent.category.DEFAULT"/>
                <category android:name="com.qinshou.demo.category1"/>
                <category android:name="com.qinshou.demo.category2"/>

                <data
                    android:host="activity3"
                    android:path="/test"
                    android:port="123"
                    android:scheme="demo"/>
            </intent-filter>

那么必须 Intent 这样才能符合规则(没有设置 mimeType):

                intent.setData(Uri.parse("demo://activity3:123/test"));

隐式启动同样可以应用于 Service 和 BroadcastReceiver,不过系统建议是显式启动 Service 比较好。

3.2 检查 Activity 是否存在

当我们隐式启动 Activity 时如果没有成功匹配到 Activity 的话就会直接崩溃,所以我们在启动的时候可以 try catch 一下,或者更优雅的方式,在启动之前判断一下是否有成功匹配的 Activity,如果有才启动。可以使用 PackageManager 的 resolveActivity(Intent intent, @ResolveInfoFlags int flags) 方法判断,它会返回最佳的一个匹配结果,如果没有成功匹配的则返回 null,或者 PackageManager 的 queryIntentActivities(Intent intent,@ResolveInfoFlags int flags) 方法判断,它会返回所有成功匹配的 Activity 信息,如果没有成功匹配的则返回的集合为空。

3.3 实际应用

data 的 URI 就类似于网址,那么我们是不是在网页中可以直接启动 Activity 呢?完全可以,只需要简单设置一下,

我们创建一个 Activity4,设置过滤规则:

        <activity android:name=".Activity4">
            <intent-filter>
                <action android:name="android.intent.action.VIEW"/>

                <category android:name="android.intent.category.DEFAULT"/>
                <category android:name="android.intent.category.BROWSABLE"/>

                <data
                    android:host="activity4"
                    android:path="/test"
                    android:port="456"
                    android:scheme="demo"/>
            </intent-filter>
        </activity>

注意我们添加了一个 category “android.intent.category.BROWSABLE”,它是指浏览器在特定条件下可以打开你的 Activity,然后写一个这样的页面:

<!doctype html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
    <title>测试页面</title>
</head>
<body>
<a href="demo://activity4:456/test">跳转到 Activity4</a>
</body>
</html>

<a> 标签中的超链接完全符合 Activity 中 <data> 的过滤规则,使用 WebView 加载该页面你会神奇的发现:

而且我们还可以加参数:

<a href="demo://activity4:456/test?name=qinshou&age=24&sex=male">跳转到 Activity4</a>

在目标 Activity 中解析参数:

        Intent intent = getIntent();
        Uri uri = intent.getData();
        //获取跳转过来携带所有参数的 键名
        if (uri != null) {
            Set<String> queryParameterNames = uri.getQueryParameterNames();
            for (String key : queryParameterNames) {
                Log.i("daolema", "key--->" + key + ",value--->" + uri.getQueryParameter(key));
            }
        }

在 H5 的界面就能轻松调起 Android 原生界面,这在后端调用移动端页面的时候是很有用的。

猜你喜欢

转载自blog.csdn.net/zgcqflqinhao/article/details/83583729