Android - 修改屏幕的density,竟然会导致获取的dimension是错误的?

2,105 阅读15分钟

  在很久之前,业界推出了一种屏幕适配的方案--动态修改屏幕的density。这个方案,当时轰动一时,各种分析文章层出不穷,大家似乎找到了屏幕适配的仙丹灵药。

  时间过去了这么久,不可否认,此方案确实非常的优秀,它能帮助我们快速且准确的还原视觉。正如此,在我们的项目中,也是使用了此方案来进行屏幕适配。

  但是,最近我们陆续收到一些bug,正如标题所言,在我们代码中,正常的获取dimension,得到的值却是意料之外的,进而导致我们页面上,很多UI显示不正确。因此,我们对此问题进行了深入分析,找到了其中原因,给出了解决方案。

1. 背景

  在几个月前,我在负责中大屏(包括平板,折叠屏)的适配工作。在很多页面里面,中大屏相比于普通手机,UI的差别可能在于尺寸的不同。基于此,我就采用了如下的方案:

同一个dimension,在不同的values文件定义不同的值。

例如:有一个button_width,在values文件下定义为10dp,在values-w500dp文件下就定义为30dp。 如此,同一行代码,如下:

resources.getdimensionionPixelOffset(R.dimen.button_width)

在宽度小于500dp的手机上,获取的就是10dp; 反之,获取的就是30dp

  如上方案,在一定程度上能够简化中大屏适配的工作。因为无论是什么屏幕尺寸,对应的代码都是一样的,唯一不同的就是values里面定义的值不同的。

  适配工作按照上面的方案,快乐的进行中,最后也是准时上线,似乎一切都万事大吉?

  事实证明,我还是想的过于天真。直到有一天,有同事反馈说:dimension获取的不对,我的设备屏幕宽度没有达到xxdp,但是还是获取了values-wxxdp文件下的值。

  我当时第一反应是:

48dac6cc499e7ea81b49acf520accd6.jpg

  我看到这个问题,难以置信,因为这个方案的可行性是不容置疑的,网上有很多使用的例子,包括Google也要求这样使用的。但是固定的复现路径直接贴我脸上,让我不得不怀疑自身。

  于是,我自己先搞了一个Demo,试了下,没有问题啊?后来,同事告诉我,咱们的项目会修改屏幕的density,于是我把修改density的代码CV到Demo工程中,一运行,哦吼!完美复现。

4d2d9595fea564266e77382fbb34c25.jpg

  总结下,我们这个问题的复现场景:

  1. Activity是横屏的,screenOrientation声明为sensorLandscape, 保证能够随传感器变换方向。
  2. 在Activity的onCreate方法里面,会动态修改屏幕的density。
  3. 此时再去通过resource获取dimension,屏幕经过修改density之后,宽度没有达到xxdp,但是还是取了values-wxxdp文件下的dimension值。

  复现代码可参考:DensityDemo

2. 解决方案

  为了不浪费各位的宝贵时间,我先贴出解决方案,然后分析此问题的原因。

  解决方案非常简单,如下是我们修改density的代码:

fun Resources.setDensity() {
    val metrics = displayMetrics ?: return

    val width = metrics.widthPixels
    val height = metrics.heightPixels + getNavigationBarHeight()
    //获取以设计图总宽度360dp下的density值
    val targetDensity =
        (min(width, height) / 360f).toInt()
    //获取以设计图总宽度360dp下的dpi值
    val targetDensityDpi = (160f * targetDensity).toInt()

    // 更新displayMetrics的信息
    metrics.density = targetDensity.toFloat()
    metrics.scaledDensity = targetDensity.toFloat()
    metrics.densityDpi = targetDensityDpi

    // 更新configuration的信息
    val configuration = configuration ?: return
    configuration.screenWidthDp = (width / targetDensity)
    configuration.screenHeightDp = (height / targetDensity)
    configuration.densityDpi = targetDensityDpi

    // 修复dimen获取不对的问题,7.0及其以下调用此方法。
//    updateConfiguration(configuration, metrics)
}

  代码中,最为关键的两步分别是:

  1. 更新configuration的信息,将configurationscreenWidthDpscreenHeightDpdensityDpi更新为预期值
  2. 将更新完的configurationmetrics,传递给ResourceupdateConfiguration方法,去刷新内部的信息。这一步非常重要,也是解决此问题的关键所在。

  这里还需要关注的一点就是,updateConfiguration方法在Android 7.0 以上,被Google标记为过时。所以我们还需要去找代替的方法。代替的代码大致如下:

override fun getResources(): Resources {
    val resources = super.getResources()
    resources.setDensity()
    return createConfigurationContext(resources.configuration).resources
}

  在通过上面的setDensity方法,更新完对应的信息之后,我们需要重写Activity的getResource方法,通过createConfigurationContext方法去更新Resource里面的configuration

  总结如下:

  1. 在Android 7.0 及其以下,我们可以通过updateConfiguration方法去更新Resource内部的信息,进而解决dimension不对的问题。
  2. 在Android 7.0以上版本,需要通过createConfigurationContext去更新Resource内部的信息,同时setDensity方法也是需要调用的,进而解决dimension不对的问题。

  额外说一句,其实在高版本上调用updateConfiguration也是可行的,就是需要看下兼容性问题;完全没必要重写getResources方法,毕竟createConfigurationContext方法成本也挺大的。

3. 问题分析

  在分析源码,我们先对这个问题进行一些分析。项目中声明多个values-wxxdp文件,在获取值的时候,系统会根据当前的屏幕宽度,自动匹配符合要求的values文件下,然后再从对应的values文件下获取定义好的值。

  现在出现的问题,就是匹配错了values文件夹。为啥会匹配错呢?是不是屏幕宽度不对呢?所以,当时排查方向就是,反复校验屏幕的宽度是否正确。

  屏幕的绝对像素值是不会改变的,但是屏幕宽度的dp值会跟随修改的density而变换。所以,我们当时第一排查方向是,是不是哪里没有改到,导致系统自己在获取屏幕宽度有问题?

  获取dimension基本都是通过Resource的getdimensionionXXX方法。于是,我们从这个方法开始入手,查看方法内部的代码实现,但是从Java层并没有发现可疑的点。

  getdimensionionXXX方法,最终会调用到AssetManagergetResourceValue方法。

boolean getResourceValue(@AnyRes int resId, int densityDpi, @NonNull TypedValue outValue,
        boolean resolveRefs) {
    Objects.requireNonNull(outValue, "outValue");
    synchronized (this) {
        ensureValidLocked();
        final int cookie = nativeGetResourceValue(
                mObject, resId, (short) densityDpi, outValue, resolveRefs);
        if (cookie <= 0) {
            return false;
        }

        // Convert the changing configurations flags populated by native code.
        outValue.changingConfigurations = ActivityInfo.activityInfoConfigNativeToJava(
                outValue.changingConfigurations);

        if (outValue.type == TypedValue.TYPE_STRING) {
            if ((outValue.string = getPooledStringForCookie(cookie, outValue.data)) == null) {
                return false;
            }
        }
        return true;
    }
}

  getResourceValue方法有一个TypedValue参数,最终获取的dimension值这个参数的data字段。data字段虽然是一个int类型,但是它自身是一个复合数据,内部只有部分bit位才表示最终的dimension值。

  经过上面的分析,我们并没有找到匹配屏幕宽度的地方,且TypedValue内部字段的赋值操作,也是通过nativeGetResourceValue这个native方法进行的。到这里,看来必须深入到C++层,去查看对应的实现。

  友情提示,下面将进入枯燥繁杂的C++代码分析环节。如下C++代码均参考于Android 14.0版本的aosp。

  AssetManager.java对应的C++文件是android_util_AssetManager.cpp。这个文件内部的nativeGetResourceValue方法实现如下:

static jint NativeGetResourceValue(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid,
                                   jshort density, jobject typed_value,
                                   jboolean resolve_references) {
  ScopedLock<AssetManager2> assetmanager(AssetManagerFromLong(ptr));
  ResourceTimer _timer(ResourceTimer::Counter::GetResourceValue);
  // 1. 获取value。
  auto value = assetmanager->GetResource(static_cast<uint32_t>(resid), false /*may_be_bag*/,
                                         static_cast<uint16_t>(density));
  if (!value.has_value()) {
    return ApkAssetsCookieToJavaCookie(kInvalidCookie);
  }

  if (resolve_references) {
    auto result = assetmanager->ResolveReference(value.value());
    if (!result.has_value()) {
      return ApkAssetsCookieToJavaCookie(kInvalidCookie);
    }
  }
  // 2. 将value里面的值赋值给typed_value
  return CopyValue(env, *value, typed_value);
}

  这个方法里面,我们重点关注两个点。

  1. 通过assetmanagerGetResource方法,获取了一个value值。这个value字段里面就包含我们想要的dimension。
  2. 将value字段里面的值赋值给typed_value。这个typed_value里面,就是在Java层看到TypedValue。

  所以,我们重点就要放到GetResource方法里面去。这个assetmanager是一个AssetManager2的对象,对应代码文件是AssetManager2.cpp。我们来看下GetResource方法的实现:

base::expected<AssetManager2::SelectedValue, NullOrIOError> AssetManager2::GetResource(
    uint32_t resid, bool may_be_bag, uint16_t density_override) const {
  // 1. 找到对应的结果。
  auto result = FindEntry(resid, density_override, false /* stop_at_first_match */,
                          false /* ignore_configuration */);
  if (!result.has_value()) {
    return base::unexpected(result.error());
  }

  auto result_map_entry = std::get_if<incfs::verified_map_ptr<ResTable_map_entry>>(&result->entry);
  if (result_map_entry != nullptr) {
    if (!may_be_bag) {
      LOG(ERROR) << base::StringPrintf("Resource %08x is a complex map type.", resid);
      return base::unexpected(std::nullopt);
    }

    // Create a reference since we can't represent this complex type as a Res_value.
    return SelectedValue(Res_value::TYPE_REFERENCE, resid, result->cookie, result->type_flags,
                         resid, result->config);
  }

  // Convert the package ID to the runtime assigned package ID.
  // 2. 从result里面获取entry字段
  Res_value value = std::get<Res_value>(result->entry);
  result->dynamic_ref_table->lookupResourceValue(&value);

  // 3. 取entry中的data字段,为dimension的结果
  return SelectedValue(value.dataType, value.data, result->cookie, result->type_flags,
                       resid, result->config);
}

  通过上述代码,我们知道了,dimension的值最终取自于result->entry.data字段。看来,关键在于result字段,也就是要去看FindEntry方法FindEntry方法的代码较长,我仅截取重要部分进行分析,代码如下:

base::expected<FindEntryResult, NullOrIOError> AssetManager2::FindEntry(
    uint32_t resid, uint16_t density_override, bool stop_at_first_match,
    bool ignore_configuration) const {
  // ······省略部分代码········
  // Might use this if density_override != 0.
  ResTable_config density_override_config;
  // Select our configuration or generate a density override configuration.
  // 1. 记录AssetManager2自带的赋值给desired_config,用于下面的匹配工作。
  const ResTable_config* desired_config = &configuration_;
  if (density_override != 0 && density_override != configuration_.density) {
    density_override_config = configuration_;
    density_override_config.density = density_override;
    desired_config = &density_override_config;
  }
  // ······省略部分代码········

  if (!stop_at_first_match && !ignore_configuration && !apk_assets_[result->cookie]->IsLoader()) {
    for (const auto& id_map : package_group.overlays_) {
      auto overlay_entry = id_map.overlay_res_maps_.Lookup(resid);
      if (!overlay_entry) {
        // No id map entry exists for this target resource.
        continue;
      }
      if (overlay_entry.IsInlineValue()) {
        // The target resource is overlaid by an inline value not represented by a resource.
        ConfigDescription best_frro_config;
        Res_value best_frro_value;
        bool frro_found = false;
        for( const auto& [config, value] : overlay_entry.GetInlineValue()) {
           // 2.寻找最优匹配的values。注意,这里match方法传入的desired_config是屏幕的,不是资源的。
          if ((!frro_found || config.isBetterThan(best_frro_config, desired_config))
              && config.match(*desired_config)) {
            frro_found = true;
            best_frro_config = config;
            best_frro_value = value;
          }
        }
        if (!frro_found) {
          continue;
        }
        // 3. 找到最优结果之后,赋值给result->entry。最后返回。
        result->entry = best_frro_value;
        result->dynamic_ref_table = id_map.overlay_res_maps_.GetOverlayDynamicRefTable();
        result->cookie = id_map.cookie;
        // ······省略部分代码········
      }
      // ······省略部分代码········
    }
  }
  // ······省略部分代码········

  return result;
}

  这个方法重点代码,我将其分为3个部分。

  1. configuration_赋值给desired_config,用于下述的匹配工作。这个configuration_非常重要,它是我们解决问题的关键所在,所以这里单独拎出来。
  2. 去寻找最优匹配的value。这里的isBetterThan方法,目的是为了寻找最优的values。怎么理解这个最优呢?比如说,当前定义了两个values文件夹:values-w100dp,values-w200dp, 此时屏幕宽度是600dp。按照规则,其实两个values都符合要求,此时就要选择最优的values,即差值最小的,也就是最终会取values-w200dp内部的值; 这里还有一个match方法,就是去校验values的config跟屏幕的config(即AssetManager2的config)是否匹配。
  3. 最后一步,就是将找到的结果,赋值给result->entry,然后返回。

  通过上面的分析,我们基本了解,要找的答案,就在match方法里面。isBetterThan方法是寻找最优的结果,内部是通过差值来计算最优值,这里就不过多介绍。来看下match方法的实现,代码实现在ResourceTypes.cpp文件中,如下:

bool ResTable_config::match(const ResTable_config& settings) const {
    //····省略代码·······
    if (screenSizeDp != 0) {
        // 这里的screenWidthDp就是values文件后面声明的后缀,类似:w500dp。
        // settings.screenWidthDp表示系统自身的屏幕宽度,这个是从AssetManager.cpp自带的。
        if (screenWidthDp != 0 && screenWidthDp > settings.screenWidthDp) {
            if (kDebugTableSuperNoisy) {
                ALOGI("Filtering out width %d in requested %d", screenWidthDp,
                        settings.screenWidthDp);
            }
            return false;
        }
        if (screenHeightDp != 0 && screenHeightDp > settings.screenHeightDp) {
            if (kDebugTableSuperNoisy) {
                ALOGI("Filtering out height %d in requested %d", screenHeightDp,
                        settings.screenHeightDp);
            }
            return false;
        }
    }
    //····省略代码·······
    return true;
}

  在上面的方法中,我们终于看到values的声明的宽度跟屏幕宽度的比较代码了。那为什么这个判断就错了呢?

  可以这么来理解,screenWidthDp != 0 && screenWidthDp > settings.screenWidthDp本来这个要判断为true的,最终match方法也是要返回为false。但是由于这个判断失效了,导致最终return true,取错了dimension。

  那么,什么情况下,如上的判断会失效呢?首先,screenWidthDp是values文件静态声明的,所以不会有错;唯一可能有问题的就是settings.screenHeightDp。那这个值又是从哪里来的呢?

  settings是从AssetManager2.cpp里面传递过来,而这个参数是它的成员变量。所以,只要settings.screenHeightDp是一个错误的值,那么这个判断就可能不生效了。

  看上去,我们基本分析到原因所在。

屏幕宽度(dp单位)其实在系统的代码中,有两个地方保存了,一个是Java层,一个C++层。而我们在更新屏幕density时,仅更新了Java层,C++层并没有更新。

所以,我们在获取dimension时,就会看到屏幕宽度(dp单位,这里指的是Java层的)明明没有达到xxdp,但还是取了values-xxdp下的值,因为C++层的屏幕宽度(dp单位)达到了甚至超过了xxdp。

  既然,我们知道了原因,那么就可以对症下药。只要我们把修改density之后的屏幕宽度,更新到C++层,再去获取对应的dimension就没有问题了吧?答案正是如此,那么怎么更新C++层的屏幕宽度呢?这个就要回去看AssetManager2.cpp的代码了。

4. 解决方案的分析

  上面有介绍到,AssetManager2.cpp内部有一个configuration_的成员变量,match方法就是从这个变量去获取当前屏幕的宽度。所以,只要我们能找到这个变量在哪里设置,就可以知道怎么去更新它了。通过搜索AssetManager2的代码,我发现了如下方法:

void AssetManager2::SetConfiguration(const ResTable_config& configuration) {
  const int diff = configuration_.diff(configuration);
  configuration_ = configuration;

  if (diff) {
    RebuildFilterList();
    InvalidateCaches(static_cast<uint32_t>(diff));
  }
}

  AssetManager2有一个SetConfiguration方法,在这个方法里面,在更新configuration_。那么,哪里在调用SetConfiguration这个方法呢?我们就要回到android_util_AssetManager.cpp中,如下:

static void NativeSetConfiguration(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint mcc, jint mnc,
                                   jstring locale, jint orientation, jint touchscreen, jint density,
                                   jint keyboard, jint keyboard_hidden, jint navigation,
                                   jint screen_width, jint screen_height,
                                   jint smallest_screen_width_dp, jint screen_width_dp,
                                   jint screen_height_dp, jint screen_layout, jint ui_mode,
                                   jint color_mode, jint grammatical_gender, jint major_version) {
  ATRACE_NAME("AssetManager::SetConfiguration");

  ResTable_config configuration;
  memset(&configuration, 0, sizeof(configuration));
  // 1. 传递过来的参数,更新到configuration。
  configuration.mcc = static_cast<uint16_t>(mcc);
  configuration.mnc = static_cast<uint16_t>(mnc);
  configuration.orientation = static_cast<uint8_t>(orientation);
  configuration.touchscreen = static_cast<uint8_t>(touchscreen);
  configuration.density = static_cast<uint16_t>(density);
  configuration.keyboard = static_cast<uint8_t>(keyboard);
  configuration.inputFlags = static_cast<uint8_t>(keyboard_hidden);
  configuration.navigation = static_cast<uint8_t>(navigation);
  configuration.screenWidth = static_cast<uint16_t>(screen_width);
  configuration.screenHeight = static_cast<uint16_t>(screen_height);
  configuration.smallestScreenWidthDp = static_cast<uint16_t>(smallest_screen_width_dp);
  configuration.screenWidthDp = static_cast<uint16_t>(screen_width_dp);
  configuration.screenHeightDp = static_cast<uint16_t>(screen_height_dp);
  configuration.screenLayout = static_cast<uint8_t>(screen_layout);
  configuration.uiMode = static_cast<uint8_t>(ui_mode);
  configuration.colorMode = static_cast<uint8_t>(color_mode);
  configuration.grammaticalInflection = static_cast<uint8_t>(grammatical_gender);
  configuration.sdkVersion = static_cast<uint16_t>(major_version);

  if (locale != nullptr) {
    ScopedUtfChars locale_utf8(env, locale);
    CHECK(locale_utf8.c_str() != nullptr);
    configuration.setBcp47Locale(locale_utf8.c_str());
  }

  // Constants duplicated from Java class android.content.res.Configuration.
  static const jint kScreenLayoutRoundMask = 0x300;
  static const jint kScreenLayoutRoundShift = 8;

  // In Java, we use a 32bit integer for screenLayout, while we only use an 8bit integer
  // in C++. We must extract the round qualifier out of the Java screenLayout and put it
  // into screenLayout2.
  configuration.screenLayout2 =
      static_cast<uint8_t>((screen_layout & kScreenLayoutRoundMask) >> kScreenLayoutRoundShift);

  ScopedLock<AssetManager2> assetmanager(AssetManagerFromLong(ptr));
  // 2. 刷新AssetManager2的configuration。
  assetmanager->SetConfiguration(configuration);
}

  NativeSetConfiguration方法内部主要做了两件事:

  1. 将传递过来最新值,更新到configuration上去。
  2. 再将configuration传递给AssetManager2,用以刷新它内部的值。

  NativeSetConfiguration是一个native 方法,它对应的Java方法是哪一个呢?当然是AssetManagernativeSetConfiguration方法。

  然后,我们再去寻找,哪里在调用nativeSetConfiguration方法呢?最终就找到了ResourceupdateConfiguration方法,这也是为什么上面的解决方案中,我们调用下这个方法,就能解决这个问题。

  同理,调用createConfigurationContext方法,因为重新创建Resource,所以也会去调用nativeSetConfiguration方法,间接的刷新了C++层的屏幕宽度。

5. 一个彩蛋

  我们排查的问题过程中,发现了另外一个问题。将Activity的screenOrientation刷新声明为sensorLandscape之后,切换屏幕方向时(正向横屏和反向横屏之间的切换),并不会回调onConfigurationChanged方法。同时,切换方向之后,屏幕的density还被重置了。

  所以,我们还需要在这种情况下重新设置屏幕的density。那么去处理这种问题呢?

  目前,我想到了两种方案。

(1).切换方向变换,重新设置density

  实现代码如下:

        findViewById<View>(R.id.container).apply {
            viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
                private var mLastRotation = -1
                override fun onGlobalLayout() {
                    if (display.rotation != mLastRotation) {
                        mLastRotation = display.rotation
                        // 更新屏幕的density。
                        setupDensity()
                    }
                }

            })
        }

  但是这种方案有一个弊端,由于再layout阶段再去设置density,可能会导致measure阶段获取的dimension有些问题。

(2). 重写getResource方法,去更新density

  代码实现如下:

override fun getResources(): Resources {
    val resources = super.getResources()
    resources.setDensity()
    return createConfigurationContext(resources.configuration).resources
}

  但是由于getResources方法会频繁调用,所以最好给setDensity方法加一个判断,我这里仅是为了简单,所以没做处理。

  实现代码可参考:DensityDemo

6. 总结

  最后,我们来做个总结。

  1. 在我们修改屏幕的density之后,仅更新Java层的值,并没有更新C++层。所以导致在获取dimension时,C++层用的是旧值去判断,所以导致dimension获取的不对。
  2. 在我们更新完density之后,需要调用ResourceupdateConfiguration方法,去更新C++层的屏幕宽度(dp单位)

  额外补充两句,可能大家在实际开发过程中很少遇到这种问题,原因应该是,系统默认的屏幕宽度和我们修改density之后的屏幕宽度都比指定的values-wxxdp要大,或者要小,所以难以发现这个问题。

  为什么将Activity声明为sensorLandscape就会复现这个问题呢?其实跟screenOrientation没有关系,只要values设置的dp值在默认的屏幕宽度和我们修改density之后的屏幕宽度中间,就会有问题。只不过,在我们的场景,刚好是横屏才会遇到这种情况。