是bug还是有意为之?屏幕旋转后Activity不重启还有别的方式控制?(未破案,待续~)

811 阅读10分钟

忽然有一天,我想要做一件事:去代码中去验证那些曾经被“灌输”的理论。

                        -- 服装学院的IT男【Android 13源码分析】

在我知识体系里,如果说旋转屏幕后一个 Activity 如果不想重启(排除强制横竖屏场景),就在清单文件加上="orientation|screenSize”就不会走重启流程,如果没加,旋转屏幕则会走重启流程,但是现在发现好像也不一定是这样,也不知道是bug 还是个google有意为之,难道还有别的地方可以控制屏幕旋转后 Activity 不走重启逻辑~。

本篇记录项目中遇到一个奇怪的问题,解决方案是有了,但是问题的根本原因还没有找到。写这篇的目的一是为了记录,二也是希望有道友能够指点迷津。

1. 问题描述

问题现象: 在高通预置的应用内的一个 Activity 旋转后,界面的 EditText 内容被置空了。

期望结果: 旋转后 EditText 内容保留。

刚看到这个问题感觉就是一个普通的旋转后 Activity 重启的问题,查看日志后确实也看到了重启的日志。 查看该 Activity 的清单文件,发现配置如下:

android:configChanges="orientation|keyboardHidden|layoutDirection|fontScale|locale"

看这个配置,原业务逻辑加了 “orientation”说明确实不希望走重启流程,但是这个配置确实是有问题的,需要再加上 “screenSize” 才不会走重启罗辑,修改后验证也正常了。

但是隐隐约约感觉不对,高通不应该犯这么简单的错误,然后拿了一个同为 T 平台的另一个高通项目验证,发现另一个项目是正常的,用 BCompare 比对了2个项目的应用的代码后,发现代码一模一样。

为了便于描述,把有问题的项目成为项目A,显示正常的成为项目 B。

现在感觉更不对了, 项目 A,B 都是 T 平台的高通项目,应用代码也没区别,按理说表现都是一致的,但是可能基线版本不一样,于是我又写了一个demo, Activity 在清单文件的配置与高通应用保持一致。 然后发现这个 demo 的 Activity 在项目A,B 都走了重启逻辑。

问题愈发迷惑,整理下信息:

    1. 同高通T平台项目,同样的高通应用代码,一个重启,一个不重启
    1. 清单文件一样的配置的2个 Activity ,在项目B上,高通的那个应用不重启,我的demo重启

按照已知只是,清单文件需要加 “orientation|screenSize” 才会走 onConfigurationChanged 回调,如果只有 “orientation” 是会走重启的。 目前几个验证操作,发现只有在项目B上,高通自己的那个应用不会重启,其他的还是走正常重启逻辑。

为了搞懂问题原因,还是得撸一遍代码。(结论是还没搞懂~)

2. 代码分析

2.1 旋转后重启逻辑

旋转后会走到 ActivityRecord::ensureActivityConfiguration 在这个方法会对清单文件的配置做判断,Activity 是否要走重启逻辑。

# ActivityRecord
    boolean ensureActivityConfiguration(int globalChanges, boolean preserveWindow,
            boolean ignoreVisibility) {
                
                ......//忽略其他代码,有很多的ProtoLog
                // 计算出改变
                final int changes = getConfigurationChanges(mTmpConfig);
                // 这里会输出这次配置的改变,由此可判断当前Activity清单文件是否配置了忽略,要不要走重启流程
                ProtoLog.v(WM_DEBUG_CONFIGURATION, "Configuration changes for %s, "
                        + "allChanges=%s", this, Configuration.configurationDiffToString(changes));
                ......
                // 重点 根据清单文件的配置判断是不是走重启逻辑
                if (shouldRelaunchLocked(changes, mTmpConfig) || forceNewConfig) {
                    // Aha, the activity isn't handling the change, so DIE DIE DIE.
                    configChangeFlags |= changes;
                    // 冻屏
                    startFreezingScreenLocked(globalChanges);
                    ......
                    if (mState == PAUSING) {
                        ......
                    } else {
                        // 根据日志走的这
                        ProtoLog.v(WM_DEBUG_CONFIGURATION, "Config is relaunching %s",
                                this);
                        if (!mVisibleRequested) {
                            ProtoLog.v(WM_DEBUG_STATES, "Config is relaunching invisible "
                                    + "activity %s called by %s", this, Debug.getCallers(4));
                        }
                        // 重启处理
                        relaunchActivityLocked(preserveWindow);
                    }
                    return false;
                }
                ......
            }

这个方法代码如下,打开 WM_DEBUG_CONFIGURATION 后可以看到都会打印 "Configuration changes for XXX“ 这段日志,这个日志很重要,打印了本次 Configuration 哪些改变, 然后执行 ActivityRecord::shouldRelaunchLocked 这个方法就是判断是否要走重启逻辑,如果走重启,则会打印 "Config is relaunching XXX"。 也就是说可以根据这个日志来判断 Activity 是不是走了重启逻辑。

Activity 如果需要重启会执行 ActivityRecord::relaunchActivityLocked ,在这个方法里会打印 wm_relaunch_resume_activity 日志,并且构建执行 ActivityRelaunchItem 来触发重启逻辑。

# ActivityRecord
    void relaunchActivityLocked(boolean preserveWindow) {
        ......
        // 正常为true
        final boolean andResume = shouldBeResumed(null /*activeActivity*/);
        ......
        // 这里的events 日志很重要,可以知道到底走的哪个逻辑
        if (andResume) {
            // wm_relaunch_resume_activity
            EventLogTags.writeWmRelaunchResumeActivity(mUserId, System.identityHashCode(this),
                    task.mTaskId, shortComponentName);
        } else {
            // wm_resume_activity
            EventLogTags.writeWmRelaunchActivity(mUserId, System.identityHashCode(this),
                    task.mTaskId, shortComponentName);
        }
        // ActivityRelaunchItem 事务的创建与执行
        ......
    }

所以判断是否要重启的关键在于 ActivityRecord::shouldRelaunchLocked 的返回值,这个方法如下:

    /**
     * When assessing a configuration change, decide if the changes flags and the new configurations
     * should cause the Activity to relaunch.
     *
     * @param changes the changes due to the given configuration.
     * @param changesConfig the configuration that was used to calculate the given changes via a
     *        call to getConfigurationChanges.
     */
    private boolean shouldRelaunchLocked(int changes, Configuration changesConfig) {
        // 判断当前配置的改变是否在 清单文件配置了需要忽略
        int configChanged = info.getRealConfigChanged();
        boolean onlyVrUiModeChanged = onlyVrUiModeChanged(changes, changesConfig);

        // Override for apps targeting pre-O sdks
        // If a device is in VR mode, and we're transitioning into VR ui mode, add ignore ui mode
        // to the config change.
        // For O and later, apps will be required to add configChanges="uimode" to their manifest.
        if (info.applicationInfo.targetSdkVersion < O
                && requestedVrComponent != null
                && onlyVrUiModeChanged) {
            configChanged |= CONFIG_UI_MODE;
        }
        // 如果都被忽略了则就是0,返回false,不需要relaunch
        return (changes&(~configChanged)) != 0;
    }

这个方法简单理解下来就是对看传进来的标志位里改变的配置,是不是都被 Activity 在清单文件忽略了,如果是,就不用重启,如果不是,则位运算不为0,返回true, 表示 Activity 需要重启。

也就是说,当前的问题是高通的那个 Activity 在这里返回了 false。

根据自己加的日志以及已有的proto 日志发现这里返回 false 的原因在于传进来的参数。

配置改变日志打印.png

下面的日志是不重启的,所以问题就在这个 changes ,为什么少了 "CONFIG_SCREEN_SIZE" 。

而这个 changes 的赋值在 ActivityRecord::getConfigurationChanges 方法,所以要看看这个方法的具体实现

2.2 配置的改变--ActivityRecord::getConfigurationChanges

# ActivityRecord

    private int getConfigurationChanges(Configuration lastReportedConfig) {
        // Determine what has changed.  May be nothing, if this is a config that has come back from
        // the app after going idle.  In that case we just want to leave the official config object
        // now in the activity and do nothing else.
        int changes = lastReportedConfig.diff(getConfiguration());
		android.util.Log.d("biubiubiu", "  getConfigurationChanges 1: "+Configuration.configurationDiffToString(changes));
        changes = SizeConfigurationBuckets.filterDiff(
                    changes, lastReportedConfig, getConfiguration(), mSizeConfigurations);
		android.util.Log.d("biubiubiu", "  getConfigurationChanges 2: "+Configuration.configurationDiffToString(changes));
        // We don't want window configuration to cause relaunches.
        if ((changes & CONFIG_WINDOW_CONFIGURATION) != 0) {
            changes &= ~CONFIG_WINDOW_CONFIGURATION;
        }
		android.util.Log.d("biubiubiu", "  getConfigurationChanges 3: "+Configuration.configurationDiffToString(changes));
        return changes;
    }

这边加上3个打印后,输出如下:

3个日志.png

可以看到第二个地方就开始少了 “CONFIG_SCREEN_SIZE” ,也就是说执行完 SizeConfigurationBuckets::filterDiff 后就出现了异常。

# SizeConfigurationBuckets

    public static int filterDiff(int diff, @NonNull Configuration oldConfig,
            @NonNull Configuration newConfig, @Nullable SizeConfigurationBuckets buckets) {
        if (buckets == null) {
            return diff;
        }
		
		android.util.Log.d("biubiubiu", "  filterDiff diff 1: "+Configuration.configurationDiffToString(diff));
		android.util.Log.d("biubiubiu", "  filterDiff oldConfig: "+oldConfig + " newConfig = " + newConfig + " buckets=" +buckets );
        final boolean nonSizeLayoutFieldsUnchanged =
                areNonSizeLayoutFieldsUnchanged(oldConfig.screenLayout, newConfig.screenLayout);

        // 如果目前标记了 CONFIG_SCREEN_SIZE 则进行再次确认
        if ((diff & CONFIG_SCREEN_SIZE) != 0) {

            final boolean crosses = buckets.crossesHorizontalSizeThreshold(oldConfig.screenWidthDp,
                    newConfig.screenWidthDp)
                    || buckets.crossesVerticalSizeThreshold(oldConfig.screenHeightDp,
                    newConfig.screenHeightDp);
            if (!crosses) {
                // 撤销了 CONFIG_SCREEN_SIZE 标志位
                diff &= ~CONFIG_SCREEN_SIZE;
            }
        }
		android.util.Log.d("biubiubiu", "  filterDiff diff 2: "+Configuration.configurationDiffToString(diff));
        ......//忽略其他对diff的赋值
        return diff;
    }

3个diff.png

通过日志和代码可以确定,就是在这里 撤销了 CONFIG_SCREEN_SIZE 标志位 ,要进入撤销的条件就是 crosses = false ,而这个值根据目前理解是说屏幕宽高是否发生了交互改变(横竖屏切换的话,确实就是宽高交换)

而对比正常的流程,这个 crosses = true 。 所以下一步就是确定这个 crosses 什么场景下为 false 。

需要 crossesHorizontalSizeThreshold 和 crossesVerticalSizeThreshold 都为 false 的时候 crosses 才为 false。

而正常逻辑 crossesHorizontalSizeThreshold 返回 true 后就结束了,不会执行后面的 crossesVerticalSizeThreshold 。

所以先看一下 crossesHorizontalSizeThreshold

# SizeConfigurationBuckets

    /** Horizontal (screenWidthDp) buckets */
    @Nullable
    private final int[] mHorizontal;

    /** Vertical (screenHeightDp) buckets */
    @Nullable
    private final int[] mVertical;

    private boolean crossesHorizontalSizeThreshold(int firstDp, int secondDp) {
        return crossesSizeThreshold(mHorizontal, firstDp, secondDp);
    }

    @VisibleForTesting
    public static boolean crossesSizeThreshold(int[] thresholds, int firstDp,
            int secondDp) {
        android.util.Log.d("biubiubiu", "crossesSizeThreshold thresholds= "+Arrays.toString(thresholds) + " firstDp="+ firstDp+ " firstDp="+ secondDp );
        if (thresholds == null) {
            return false;
        }
        for (int i = thresholds.length - 1; i >= 0; i--) {
            final int threshold = thresholds[i];
            if ((firstDp < threshold && secondDp >= threshold)
                    || (firstDp >= threshold && secondDp < threshold)) {
                return true;
            }
        }
        return false;
    }

    @Override
    public String toString() {
        return Arrays.toString(mHorizontal) + " " + Arrays.toString(mVertical) + " "
                + Arrays.toString(mSmallest) + " " + Arrays.toString(mScreenLayoutSize) + " "
                + mScreenLayoutLongSet;
    }

SizeConfigurationBuckets::crossesHorizontalSizeThreshold 和 SizeConfigurationBuckets::crossesVerticalSizeThreshold 方法内部其实都是执行 SizeConfigurationBuckets::crossesSizeThreshold 方法,只不过传递的参数不同。

第2,3 个参数就是之前的宽(高)和现在最新的宽(高)。然后做一堆计算,这2个参数一般不会有问题。 而第一个参数,却会出现异常。

根据代码,如果第一个参数是 null,则直接返回 false 了,或者后面的比较不满足条件也返回 false 。 宽(高)参数就是 Configuration 里的,这个前面也有打印没啥问题,主要是第一个参数。

第一个参数传进来的是 mHorizontal 或者 mVertical 。这个类的 toString 方法有打印。 我在 SizeConfigurationBuckets::filterDiff 法也加上了打印。 对比正常和移除的日志如下:

Buckets打印.png

发现异常的执行了2次, 正常重启的 Activity 就打印了一次。 这是因为前面说了 crosses 的赋值 是2个方法的 “ || ” ,所以正常情况下,执行第一个返回true,就不会执行 “ || ” 的方法了,也就只打印了一次。 而当前异常的这个逻辑, “ || ” 前面的方法也就是 SizeConfigurationBuckets::crossesHorizontalSizeThreshol 方法返回了false,(打印一次),所以会再执行 SizeConfigurationBuckets::crossesVerticalSizeThreshold (打印第二次)。

然后这里的打印要对着 SizeConfigurationBuckets::toString 方法,这个方法是打印了5个对象,看日志打印也是分为了5块, 我们之关系前面2个就好,分别对应 mHorizontal 和 mVertical。

通过日志可以知道异常逻辑下 mHorizontal = null, mVertical = [720] 。 把这2个值带入到 SizeConfigurationBuckets::crossesSizeThreshold 方法 。2次执行都会返回 false 。

最终结果就是把这次的 CONFIG_SCREEN_SIZE 标志位撤销了,最终让Activity不走重启逻辑。

正常情况打印应该是下面的那个 mHorizontal = [320, 360, 480, 600] , mVertical = [320, 360, 480, 550, 720] 。

2.3 mHorizontal 和 mVertical

所以现在的是需要找到 mHorizontal 和 mVertical 是怎么赋值的,为什么会出现异常的赋值情况。

这2个属性的赋值在构造 SizeConfigurationBuckets 对象的时候,不过它有2个构造方法。

# SizeConfigurationBuckets
    /** Horizontal (screenWidthDp) buckets */
    @Nullable
    private final int[] mHorizontal;

    /** Vertical (screenHeightDp) buckets */
    @Nullable
    private final int[] mVertical;
    // 直接构建
    public SizeConfigurationBuckets(
            @Nullable int[] horizontal,
            @Nullable int[] vertical,
            @Nullable int[] smallest,
            @Nullable int[] screenLayoutSize,
            boolean screenLayoutLongSet) {
        this.mHorizontal = horizontal;
        this.mVertical = vertical;
		android.util.Log.d("biubiubiu", "mVertical 1= "+Arrays.toString(mVertical));
        this.mSmallest = smallest;
        this.mScreenLayoutSize = screenLayoutSize;
        this.mScreenLayoutLongSet = screenLayoutLongSet;

        // onConstructed(); // You can define this method to get a callback
    }

    // 序列化构建
    SizeConfigurationBuckets(@NonNull android.os.Parcel in) {
        // You can override field unparcelling by defining methods like:
        // static FieldType unparcelFieldName(Parcel in) { ... }

        byte flg = in.readByte();
        boolean screenLayoutLongSet = (flg & 0x10) != 0;
        int[] horizontal = (flg & 0x1) == 0 ? null : in.createIntArray();
        int[] vertical = (flg & 0x2) == 0 ? null : in.createIntArray();
        int[] smallest = (flg & 0x4) == 0 ? null : in.createIntArray();
        int[] screenLayoutSize = (flg & 0x8) == 0 ? null : in.createIntArray();

        this.mHorizontal = horizontal;
        this.mVertical = vertical;
		android.util.Log.d("biubiubiu", "mVertical 2= "+Arrays.toString(mVertical));
        this.mSmallest = smallest;
        this.mScreenLayoutSize = screenLayoutSize;
        this.mScreenLayoutLongSet = screenLayoutLongSet;

        // onConstructed(); // You can define this method to get a callback
    }

触发的地方在应用进程 ActivityThread::reportSizeConfigurations 方法。

LaunchActivityItem 事务会触发 ActivityThread::handleLaunchActivity ,然后就会触发 ActivityThread::reportSizeConfigurations 方法的执行。

# ActivityThread
    private void reportSizeConfigurations(ActivityClientRecord r) {
        if (mActivitiesToBeDestroyed.containsKey(r.token)) {
            // Size configurations of a destroyed activity is meaningless.
            return;
        }
        // 获取资源配置
        Configuration[] configurations = r.activity.getResources().getSizeConfigurations();
        if (configurations == null) {
            return;
        }
        // 创建一个新的SizeConfigurationBuckets对象,存储从配置数组中获取的尺寸配置
        r.mSizeConfigurations = new SizeConfigurationBuckets(configurations);
        // 跨进程通知 system_service 报告活动的尺寸配置信息
        ActivityClient.getInstance().reportSizeConfigurations(r.token, r.mSizeConfigurations);
    }

这边看到先是直接 new 了一个 SizeConfigurationBuckets 对象,传递进去的是 Configuration 数组,然后下面的跨进程其实也会构建一个 SizeConfigurationBuckets 。

# ActivityClient

    void reportSizeConfigurations(IBinder token, SizeConfigurationBuckets sizeConfigurations) {
        try {
            // 拿到ActivityClientController
            getActivityClientController().reportSizeConfigurations(token, sizeConfigurations);
        } catch (RemoteException e) {
            e.rethrowFromSystemServer();
        }
    }
    
# ActivityClientController

    public void reportSizeConfigurations(IBinder token,
            SizeConfigurationBuckets sizeConfigurations) {
        ProtoLog.v(WM_DEBUG_CONFIGURATION, "Report configuration: %s %s",
                token, sizeConfigurations);
        synchronized (mGlobalLock) {
            final ActivityRecord r = ActivityRecord.isInRootTaskLocked(token);
            if (r != null) {
                r.setSizeConfigurations(sizeConfigurations);
            }
        }
    }

可以看到这里也有日志打印 SizeConfigurationBuckets 对象了,打印结果和之前的看到的一致。有人可能会好奇,这里和构建 SizeConfigurationBuckets 什么关系?

这个方法是个binder 跨进程通信,会序列化创建 SizeConfigurationBuckets 对象,看一下下面的代码就知道了。

跨进程创建.png

目前已经看到了个构建 SizeConfigurationBuckets 对象的地方,但是问题的本质还是 ActivityThread::reportSizeConfigurations 方法中获取的 Configuration 数组有问题,导致的最终 Activity 不走重启逻辑。

Configuration[] configurations = r.activity.getResources().getSizeConfigurations();

但是这句代码是什么情况,获取到的值是什么,我目前看到逻辑走到了 native 层。对这块没有了解,暂时还没发现问题所在。

3. 未完待续~

目前看不下去了,后续还会继续研究,如果有知道的道友还请指教。