Android9.0 应用待机群组

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

一、概述

系统将根据用户的使用模式限制应用对 CPU 或电池等设备资源的访问。 这是 Android 9 中新增的一项功能,即应用待机群组。 应用待机群组可以基于应用最近使用时间和使用频率,帮助系统排定应用请求资源的优先级。 根据使用模式,每个应用都会归类到五个优先级群组之一中。 系统将根据应用所属的群组限制每个应用可以访问的设备资源。

这个功能的实现是在UsageStatsService中实现的,在这个service的onstart函数中创建了AppStandbyController对象,而且对于各个app设置自己属于哪个群组的对外接口是reportEvent函数。

二、实现

这一节我们主要讲述了设置bucket,获取bucket。

2.1 设置bucket

这个接口会在AMS以及NotificationManagerService中调用UsageStatsService的reportEvent,这个函数会调用AppStandbyController的reportEvent函数,我们来看下这个函数主要还是调用了AppUsageHistory的reportUsage函数。但是我们要注意其两个时间的参数不一样。这里后面有一个延迟消息很重要,主要就是多一段时间再来检测这个app的idle状态。我们来看这个延迟消息,当是Notification类型的消息的延迟时间为12小时,SYSTEM_INTERACTION为10分钟(这种主要是调用startInstrumentation函数),其他我1小时(正常应用启动)。

    void reportEvent(UsageEvents.Event event, long elapsedRealtime, int userId) {
        if (!mAppIdleEnabled) return;//mAppIdleEnabled必须使能
        synchronized (mAppIdleLock) {
            // TODO: Ideally this should call isAppIdleFiltered() to avoid calling back
            // about apps that are on some kind of whitelist anyway.
            final boolean previouslyIdle = mAppIdleHistory.isIdle(
                    event.mPackage, userId, elapsedRealtime);
            // Inform listeners if necessary
            if ((event.mEventType == UsageEvents.Event.MOVE_TO_FOREGROUND
                    || event.mEventType == UsageEvents.Event.MOVE_TO_BACKGROUND
                    || event.mEventType == UsageEvents.Event.SYSTEM_INTERACTION
                    || event.mEventType == UsageEvents.Event.USER_INTERACTION
                    || event.mEventType == UsageEvents.Event.NOTIFICATION_SEEN
                    || event.mEventType == UsageEvents.Event.SLICE_PINNED
                    || event.mEventType == UsageEvents.Event.SLICE_PINNED_PRIV)) {

                final AppUsageHistory appHistory = mAppIdleHistory.getAppUsageHistory(
                        event.mPackage, userId, elapsedRealtime);
                final int prevBucket = appHistory.currentBucket;
                final int prevBucketReason = appHistory.bucketingReason;
                final long nextCheckTime;
                final int subReason = usageEventToSubReason(event.mEventType);
                final int reason = REASON_MAIN_USAGE | subReason;
                if (event.mEventType == UsageEvents.Event.NOTIFICATION_SEEN
                        || event.mEventType == UsageEvents.Event.SLICE_PINNED) {
                    // Mild usage elevates to WORKING_SET but doesn't change usage time.
                    mAppIdleHistory.reportUsage(appHistory, event.mPackage,
                            STANDBY_BUCKET_WORKING_SET, subReason,
                            0, elapsedRealtime + mNotificationSeenTimeoutMillis);
                    nextCheckTime = mNotificationSeenTimeoutMillis;//12小时
                } else if (event.mEventType == UsageEvents.Event.SYSTEM_INTERACTION) {
                    mAppIdleHistory.reportUsage(appHistory, event.mPackage,
                            STANDBY_BUCKET_ACTIVE, subReason,
                            0, elapsedRealtime + mSystemInteractionTimeoutMillis);
                    nextCheckTime = mSystemInteractionTimeoutMillis;//10分钟
                } else {
                    mAppIdleHistory.reportUsage(appHistory, event.mPackage,
                            STANDBY_BUCKET_ACTIVE, subReason,
                            elapsedRealtime, elapsedRealtime + mStrongUsageTimeoutMillis);
                    nextCheckTime = mStrongUsageTimeoutMillis;//1小时
                }
                mHandler.sendMessageDelayed(mHandler.obtainMessage
                        (MSG_CHECK_PACKAGE_IDLE_STATE, userId, -1, event.mPackage),
                        nextCheckTime);//延迟消息,检查Idle的状态
                final boolean userStartedInteracting =
                        appHistory.currentBucket == STANDBY_BUCKET_ACTIVE &&
                        prevBucket != appHistory.currentBucket &&
                        (prevBucketReason & REASON_MAIN_MASK) != REASON_MAIN_USAGE;
                maybeInformListeners(event.mPackage, userId, elapsedRealtime,
                        appHistory.currentBucket, reason, userStartedInteracting);

                if (previouslyIdle) {
                    notifyBatteryStats(event.mPackage, userId, false);
                }
            }
        }
    }

下面我们再来看AppUsageHistory的reportUsage函数,这里主要就是设置每个app的状态的参数

    public AppUsageHistory reportUsage(AppUsageHistory appUsageHistory, String packageName,
            int newBucket, int usageReason, long elapsedRealtime, long timeout) {
        // Set the timeout if applicable
        if (timeout > elapsedRealtime) {//timeout就是用来检查app后续的状态
            // Convert to elapsed timebase//这里非Active和working_set状态的不让设置timeout
            final long timeoutTime = mElapsedDuration + (timeout - mElapsedSnapshot);
            if (newBucket == STANDBY_BUCKET_ACTIVE) {
                appUsageHistory.bucketActiveTimeoutTime = Math.max(timeoutTime,
                        appUsageHistory.bucketActiveTimeoutTime);
            } else if (newBucket == STANDBY_BUCKET_WORKING_SET) {
                appUsageHistory.bucketWorkingSetTimeoutTime = Math.max(timeoutTime,
                        appUsageHistory.bucketWorkingSetTimeoutTime);
            } else {
                throw new IllegalArgumentException("Cannot set a timeout on bucket=" +
                        newBucket);
            }
        }

        if (elapsedRealtime != 0) {
            appUsageHistory.lastUsedElapsedTime = mElapsedDuration
                    + (elapsedRealtime - mElapsedSnapshot);
            appUsageHistory.lastUsedScreenTime = getScreenOnTime(elapsedRealtime);
        }

        if (appUsageHistory.currentBucket > newBucket) {//设置的Bucket必须必以前的小
            appUsageHistory.currentBucket = newBucket;//也就是说越活跃才能重新设置
            if (DEBUG) {
                Slog.d(TAG, "Moved " + packageName + " to bucket=" + appUsageHistory
                        .currentBucket
                        + ", reason=0x0" + Integer.toHexString(appUsageHistory.bucketingReason));
            }
        }
        appUsageHistory.bucketingReason = REASON_MAIN_USAGE | usageReason;

        return appUsageHistory;
    }

下面我们重点看延迟消息MSG_CHECK_PACKAGE_IDLE_STATE的处理,是在checkAndUpdateStandbyState函数中处理。我们来看这个函数其实逻辑很简单,有一些特殊的app我们做特殊的处理。然后我们通过getBucketForLocked函数来获取当前app当前应该是属于哪个bucket,最后设置app新的bucket

    private void checkAndUpdateStandbyState(String packageName, @UserIdInt int userId,
            int uid, long elapsedRealtime) {
        ......
        final boolean isSpecial = isAppSpecial(packageName,
                UserHandle.getAppId(uid),
                userId);

        if (isSpecial) {//特殊app,比如系统app,带android字眼的app等,这些app做特殊处理
            synchronized (mAppIdleLock) {
                mAppIdleHistory.setAppStandbyBucket(packageName, userId, elapsedRealtime,
                        STANDBY_BUCKET_EXEMPTED, REASON_MAIN_DEFAULT);
            }
            maybeInformListeners(packageName, userId, elapsedRealtime,
                    STANDBY_BUCKET_EXEMPTED, REASON_MAIN_DEFAULT, false);
        } else {
            synchronized (mAppIdleLock) {
                final AppIdleHistory.AppUsageHistory app =
                        mAppIdleHistory.getAppUsageHistory(packageName,
                        userId, elapsedRealtime);
                int reason = app.bucketingReason;
                final int oldMainReason = reason & REASON_MAIN_MASK;

                // If the bucket was forced by the user/developer, leave it alone.
                // A usage event will be the only way to bring it out of this forced state
                if (oldMainReason == REASON_MAIN_FORCED) {
                    return;
                }
                final int oldBucket = app.currentBucket;
                int newBucket = Math.max(oldBucket, STANDBY_BUCKET_ACTIVE); // Undo EXEMPTED
                boolean predictionLate = predictionTimedOut(app, elapsedRealtime);
                // Compute age-based bucket
                if (oldMainReason == REASON_MAIN_DEFAULT
                        || oldMainReason == REASON_MAIN_USAGE
                        || oldMainReason == REASON_MAIN_TIMEOUT
                        || predictionLate) {

                    if (!predictionLate && app.lastPredictedBucket >= STANDBY_BUCKET_ACTIVE
                            && app.lastPredictedBucket <= STANDBY_BUCKET_RARE) {
                        newBucket = app.lastPredictedBucket;
                        reason = REASON_MAIN_PREDICTED | REASON_SUB_PREDICTED_RESTORED;
                        if (DEBUG) {
                            Slog.d(TAG, "Restored predicted newBucket = " + newBucket);
                        }
                    } else {
                        newBucket = getBucketForLocked(packageName, userId,//该函数就是获取当前app的Bucket
                                elapsedRealtime);
                        if (DEBUG) {
                            Slog.d(TAG, "Evaluated AOSP newBucket = " + newBucket);
                        }
                        reason = REASON_MAIN_TIMEOUT;
                    }
                }
......

                if (oldBucket < newBucket || predictionLate) {
                    mAppIdleHistory.setAppStandbyBucket(packageName, userId,//设置新状态
                            elapsedRealtime, newBucket, reason);
                    maybeInformListeners(packageName, userId, elapsedRealtime,
                            newBucket, reason, false);
                }
            }
        }
    }

这里核心函数无非就是getBucketForLocked函数,这个函数就是调用了AppIdleHistoryLocked的getThresholdIndex函数。然后返回一个index,再从THRESHOLD_BUCKETS获取bucket。

    @StandbyBuckets int getBucketForLocked(String packageName, int userId,
            long elapsedRealtime) {
        int bucketIndex = mAppIdleHistory.getThresholdIndex(packageName, userId,
                elapsedRealtime, mAppStandbyScreenThresholds, mAppStandbyElapsedThresholds);
        return THRESHOLD_BUCKETS[bucketIndex];
    }

我们来看下THRESHOLD_BUCKETS数组就是index对应的各个bucket。

    static final int[] THRESHOLD_BUCKETS = {
            STANDBY_BUCKET_ACTIVE,
            STANDBY_BUCKET_WORKING_SET,
            STANDBY_BUCKET_FREQUENT,
            STANDBY_BUCKET_RARE
    };

我们先来看这两个值,这两个值就是mAppStandbyScreenThresholds和mAppStandbyElapsedThresholds的值,这里COMPRESS_TIME是false。

    static final long[] SCREEN_TIME_THRESHOLDS = {
            0,
            0,
            COMPRESS_TIME ? 120 * 1000 : 1 * ONE_HOUR,
            COMPRESS_TIME ? 240 * 1000 : 2 * ONE_HOUR
    };

    static final long[] ELAPSED_TIME_THRESHOLDS = {
            0,
            COMPRESS_TIME ?  1 * ONE_MINUTE : 12 * ONE_HOUR,
            COMPRESS_TIME ?  4 * ONE_MINUTE : 24 * ONE_HOUR,
            COMPRESS_TIME ? 16 * ONE_MINUTE : 48 * ONE_HOUR
    };

我们接下来AppIdleHistoryLocked的getThresholdIndex函数,获取app之前的lastUsedScreenTime和lastUsedElapsedTime。然后计算到现在的差值,得出两个值一个就是上一次屏幕亮屏的时间到现在屏幕亮度的时间差;上一次开机的时间和这一次距离开机的时间差。这两个差值和我们上面的数组对比,降序比,哪一列值都大,那一列就是当前的bucket。

    int getThresholdIndex(String packageName, int userId, long elapsedRealtime,
            long[] screenTimeThresholds, long[] elapsedTimeThresholds) {
        ArrayMap<String, AppUsageHistory> userHistory = getUserHistory(userId);
        AppUsageHistory appUsageHistory = getPackageHistory(userHistory, packageName,
                elapsedRealtime, false);
        // If we don't have any state for the app, assume never used
        if (appUsageHistory == null) return screenTimeThresholds.length - 1;

        long screenOnDelta = getScreenOnTime(elapsedRealtime) - appUsageHistory.lastUsedScreenTime;
        long elapsedDelta = getElapsedTime(elapsedRealtime) - appUsageHistory.lastUsedElapsedTime;

        for (int i = screenTimeThresholds.length - 1; i >= 0; i--) {
            if (screenOnDelta >= screenTimeThresholds[i]
                && elapsedDelta >= elapsedTimeThresholds[i]) {
                return i;
            }
        }
        return 0;
    }

然后从上面两个表格看也就说

1. 开机时间从上一次使用app的时间差超过12小时bucket为working_set,

2. 亮屏时间差超过1小时、使用时间差超过24小时bucket为FREQUENT
3. 亮屏时间差超过2小时、使用时间差超过48小时bucket为RARE

然后比第一种情况下的就是active的bucket。

而这个延迟消息的用处主要就是当timeout之后将app的bucket从Active降到working_set。而将app的bucket设置为Frequent或者rare我们还需看2.3节,系统会每隔一天来检查所有的app的bucket。

2.2 获取bucket

获取app的bucket我们从UsageStatsService的getAppStandbyBucket到AppStandbyController的getAppStandbyBucket函数,到AppIdleHistory的getAppStandbyBucket函数就是获取其currentBucket的值。

    public int getAppStandbyBucket(String packageName, int userId, long elapsedRealtime) {
        ArrayMap<String, AppUsageHistory> userHistory = getUserHistory(userId);
        AppUsageHistory appUsageHistory =
                getPackageHistory(userHistory, packageName, elapsedRealtime, true);
        return appUsageHistory.currentBucket;
    }

2.3 检查各个App的bucket

前面我们通过延迟消息MSG_CHECK_PACKAGE_IDLE_STATE,最后在checkAndUpdateStandbyState函数中处理,来完成每个app的bucket的检查,但是如果隔了很长时间又如何检查呢?

在UsageStatsService的reportEvent中会调用UserUsageStatsService的reportEvent,每一个userId都有一个UserUsageStatsService用来统计数据。而每过一天会在UserUsageStatsService的reportEvent函数中调用rolloverStats函数,rolloverStats函数中会调用loadActiveStats函数,loadActiveStats函数会调用mListener.onStatsReloaded函数,而这个mLisener正是UsageStatsService。而UsageStatsService的onStatsReloaded函数,是调用了AppStandbyController的postOneTimeCheckIdleStates,这个函数如下,因为这个时候已经开机,因此发送了一个MSG_ONE_TIME_CHECK_IDLE_STATES消息。

    void postOneTimeCheckIdleStates() {
        if (mInjector.getBootPhase() < PHASE_SYSTEM_SERVICES_READY) {
            // Not booted yet; wait for it!
            mPendingOneTimeCheckIdleStates = true;
        } else {
            mHandler.sendEmptyMessage(MSG_ONE_TIME_CHECK_IDLE_STATES);
            mPendingOneTimeCheckIdleStates = false;
        }
    }

而MSG_ONE_TIME_CHECK_IDLE_STATES消息的处理如下:

                case MSG_ONE_TIME_CHECK_IDLE_STATES:
                    mHandler.removeMessages(MSG_ONE_TIME_CHECK_IDLE_STATES);
                    waitForAdminData();
                    checkIdleStates(UserHandle.USER_ALL);
                    break;

该函数就是当前userId所有的app都会调用checkAndUpdateStandbyState函数来检查现在app的bucket。因为这个距离上一次检查隔了一天,很多app的bucket可能已经是Frequent或者rare了。

    boolean checkIdleStates(int checkUserId) {
        if (!mAppIdleEnabled) {
            return false;
        }

        final int[] runningUserIds;
        try {
            runningUserIds = mInjector.getRunningUserIds();
            if (checkUserId != UserHandle.USER_ALL
                    && !ArrayUtils.contains(runningUserIds, checkUserId)) {
                return false;
            }
        } catch (RemoteException re) {
            throw re.rethrowFromSystemServer();
        }

        final long elapsedRealtime = mInjector.elapsedRealtime();
        for (int i = 0; i < runningUserIds.length; i++) {
            final int userId = runningUserIds[i];
            if (checkUserId != UserHandle.USER_ALL && checkUserId != userId) {
                continue;
            }
            if (DEBUG) {
                Slog.d(TAG, "Checking idle state for user " + userId);
            }
            List<PackageInfo> packages = mPackageManager.getInstalledPackagesAsUser(
                    PackageManager.MATCH_DISABLED_COMPONENTS,
                    userId);
            final int packageCount = packages.size();
            for (int p = 0; p < packageCount; p++) {
                final PackageInfo pi = packages.get(p);
                final String packageName = pi.packageName;
                checkAndUpdateStandbyState(packageName, userId, pi.applicationInfo.uid,
                        elapsedRealtime);
            }
        }
        if (DEBUG) {
            Slog.d(TAG, "checkIdleStates took "
                    + (mInjector.elapsedRealtime() - elapsedRealtime));
        }
        return true;
    }

2.4 应用待机模式

之前在android App Standby(应用待机模式)在这里和bucket合并了。也就是说之前的应用待机模式就是app的bucket为rare以及以上bucket(就是rare和never)。

在UsageStatsService的setAppInactive函数,最后是通过AppStandbyController的消息发送到forceIdleState函数中调用了AppIdleHistory.setIdle函数,下面设置为idle直接将app的bucket设置为rare,如果idle为false,就是设置app的bucket为active。

这里就是之前的App standby(之前使用时间的方式)这里和bucket合并起来了。

    public int setIdle(String packageName, int userId, boolean idle, long elapsedRealtime) {
        ArrayMap<String, AppUsageHistory> userHistory = getUserHistory(userId);
        AppUsageHistory appUsageHistory = getPackageHistory(userHistory, packageName,
                elapsedRealtime, true);
        if (idle) {
            appUsageHistory.currentBucket = STANDBY_BUCKET_RARE;
            appUsageHistory.bucketingReason = REASON_MAIN_FORCED;
        } else {
            appUsageHistory.currentBucket = STANDBY_BUCKET_ACTIVE;
            // This is to pretend that the app was just used, don't freeze the state anymore.
            appUsageHistory.bucketingReason = REASON_MAIN_USAGE | REASON_SUB_USAGE_USER_INTERACTION;
        }
        return appUsageHistory.currentBucket;
    }

而查看app是否为idle,就是看该app的bucket是否为rare以上状态(就是rare和never),其他状态都是false。

    public boolean isIdle(String packageName, int userId, long elapsedRealtime) {
        ArrayMap<String, AppUsageHistory> userHistory = getUserHistory(userId);
        AppUsageHistory appUsageHistory =
                getPackageHistory(userHistory, packageName, elapsedRealtime, true);
        if (appUsageHistory == null) {
            return false; // Default to not idle
        } else {
            return appUsageHistory.currentBucket >= STANDBY_BUCKET_RARE;
            // Whether or not it's passed will now be externally calculated and the
            // bucket will be pushed to the history using setAppStandbyBucket()
            //return hasPassedThresholds(appUsageHistory, elapsedRealtime);
        }
    }

2.5 配置

这里面所有的时间都是可以配置的。bucket是否enable也可以配置,需要config_enableAutoPowerModes Global.APP_STANDBY_ENBALE同时开启。

        boolean isAppIdleEnabled() {
            final boolean buildFlag = mContext.getResources().getBoolean(
                    com.android.internal.R.bool.config_enableAutoPowerModes);
            final boolean runtimeFlag = Global.getInt(mContext.getContentResolver(),
                    Global.APP_STANDBY_ENABLED, 1) == 1
                    && Global.getInt(mContext.getContentResolver(),
                    Global.ADAPTIVE_BATTERY_MANAGEMENT_ENABLED, 1) == 1;
            return buildFlag && runtimeFlag;
        }

三、限制

这节主要介绍系统将根据应用所属的群组限制每个应用可以访问的设备资源。

这里我们先看一张资源限制表格,具体地址:https://developer.android.google.cn/topic/performance/power/power-details

Setting Jobs * Alarms † Network ‡ Firebase Cloud Messaging §
User Restricts Background Activity        
Restrictions enabled: Never Never Never No restriction
Doze        
Doze active: Deferred to window Regular alarms: Deferred to window
While-idle alarms: Deferred up to 9 minutes
Deferred to window High priority: No restriction
Normal priority: Deferred to window
App Standby Buckets
(by bucket)
       
Active: No restriction No restriction No restriction No restriction
Working set: Deferred up to 2 hours Deferred up to 6 minutes No restriction No restriction
Frequent: Deferred up to 8 hours Deferred up to 30 minutes No restriction High priority: 10/day
Rare: Deferred up to 24 hours Deferred up to 2 hours Deferred up to 24 hours High priority: 5/day

3.1 Alarm

Alarm的限制主要在设置alarm的时候,重新调整了发送时间。

主要在setImplLocked函数中,调用了adjustDeliveryTimeBasedOnStandbyBucketLocked函数。还有其他一些地方调用了这个函数,比如当app_standby_bucket配置改变等。

我们来看这个函数其实就是通过改变alarm的whenElapsed来改变触发时间,主要是通过

    private boolean adjustDeliveryTimeBasedOnStandbyBucketLocked(Alarm alarm) {
        if (isExemptFromAppStandby(alarm)) {
            return false;
        }
        if (mAppStandbyParole) {
            if (alarm.whenElapsed > alarm.expectedWhenElapsed) {
                // We did defer this alarm earlier, restore original requirements
                alarm.whenElapsed = alarm.expectedWhenElapsed;
                alarm.maxWhenElapsed = alarm.expectedMaxWhenElapsed;
                return true;
            }
            return false;
        }
        final long oldWhenElapsed = alarm.whenElapsed;
        final long oldMaxWhenElapsed = alarm.maxWhenElapsed;

        final String sourcePackage = alarm.sourcePackage;
        final int sourceUserId = UserHandle.getUserId(alarm.creatorUid);
        final int standbyBucket = mUsageStatsManagerInternal.getAppStandbyBucket(
                sourcePackage, sourceUserId, SystemClock.elapsedRealtime());

        final Pair<String, Integer> packageUser = Pair.create(sourcePackage, sourceUserId);
        final long lastElapsed = mLastAlarmDeliveredForPackage.getOrDefault(packageUser, 0L);//获取上一次的发送alarm的时间
        if (lastElapsed > 0) {
            final long minElapsed = lastElapsed + getMinDelayForBucketLocked(standbyBucket);//根据bucket获取下一次发送的时间
            if (alarm.expectedWhenElapsed < minElapsed) {
                alarm.whenElapsed = alarm.maxWhenElapsed = minElapsed;
            } else {
                // app is now eligible to run alarms at the originally requested window.
                // Restore original requirements in case they were changed earlier.
                alarm.whenElapsed = alarm.expectedWhenElapsed;
                alarm.maxWhenElapsed = alarm.expectedMaxWhenElapsed;
            }
        }
        return (oldWhenElapsed != alarm.whenElapsed || oldMaxWhenElapsed != alarm.maxWhenElapsed);
    }

getMinDelayForBucketLocked函数就是获取alarm发送的延迟时间,我们来看下下面的表格。从0,6分钟,半小时,2小时。最后达到一个延迟发送alarm的目的。

        private final long[] DEFAULT_APP_STANDBY_DELAYS = {
                0,                       // Active
                6 * 60_000,              // Working
                30 * 60_000,             // Frequent
                2 * 60 * 60_000,         // Rare
                10 * 24 * 60 * 60_000    // Never
        };

3.2 网络

是通过UsageStatsService的isAppIdle接口来判断是否当前应用处于应用待机模式,也就是app的bucket的为rare以上。这个时候会限制网络,通过iptables 制作rule限制uid的方式。

3.3 JobSchedulerService

这个后续补充

四、命令

您可以使用 ADB 为您的应用手动指定应用待机群组。 要更改应用的群组,请使用以下命令:

$ adb shell am set-standby-bucket packagename active|working_set|frequent|rare
您还可以使用该命令一次设置多个软件包:

$ adb shell am set-standby-bucket package1 bucket1 package2 bucket2...
要检查应用处于哪一个群组,请运行以下命令:

$ adb shell am get-standby-bucket [packagename]
如果您不传递 packagename 参数,命令将列出所有应用的群组。 应用还可以调用新函数 

猜你喜欢

转载自blog.csdn.net/kc58236582/article/details/82018654