在很久之前,业界推出了一种屏幕适配的方案--动态修改屏幕的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文件下的值。
我当时第一反应是:
我看到这个问题,难以置信,因为这个方案的可行性是不容置疑的,网上有很多使用的例子,包括Google也要求这样使用的。但是固定的复现路径直接贴我脸上,让我不得不怀疑自身。
于是,我自己先搞了一个Demo,试了下,没有问题啊?后来,同事告诉我,咱们的项目会修改屏幕的density,于是我把修改density的代码CV到Demo工程中,一运行,哦吼!完美复现。
总结下,我们这个问题的复现场景:
- Activity是横屏的,
screenOrientation
声明为sensorLandscape
, 保证能够随传感器变换方向。- 在Activity的onCreate方法里面,会动态修改屏幕的density。
- 此时再去通过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)
}
代码中,最为关键的两步分别是:
- 更新
configuration
的信息,将configuration
的screenWidthDp
、screenHeightDp
和densityDpi
更新为预期值- 将更新完的
configuration
和metrics
,传递给Resource
的updateConfiguration
方法,去刷新内部的信息。这一步非常重要,也是解决此问题的关键所在。
这里还需要关注的一点就是,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
。
总结如下:
- 在Android 7.0 及其以下,我们可以通过
updateConfiguration
方法去更新Resource内部的信息,进而解决dimension不对的问题。- 在Android 7.0以上版本,需要通过
createConfigurationContext
去更新Resource内部的信息,同时setDensity
方法也是需要调用的,进而解决dimension不对的问题。
额外说一句,其实在高版本上调用updateConfiguration
也是可行的,就是需要看下兼容性问题;完全没必要重写getResources
方法,毕竟createConfigurationContext
方法成本也挺大的。
3. 问题分析
在分析源码,我们先对这个问题进行一些分析。项目中声明多个values-wxxdp文件,在获取值的时候,系统会根据当前的屏幕宽度,自动匹配符合要求的values文件下,然后再从对应的values文件下获取定义好的值。
现在出现的问题,就是匹配错了values文件夹。为啥会匹配错呢?是不是屏幕宽度不对呢?所以,当时排查方向就是,反复校验屏幕的宽度是否正确。
屏幕的绝对像素值是不会改变的,但是屏幕宽度的dp值会跟随修改的density而变换。所以,我们当时第一排查方向是,是不是哪里没有改到,导致系统自己在获取屏幕宽度有问题?
获取dimension基本都是通过Resource的getdimensionionXXX
方法。于是,我们从这个方法开始入手,查看方法内部的代码实现,但是从Java层并没有发现可疑的点。
getdimensionionXXX
方法,最终会调用到AssetManager
的getResourceValue
方法。
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);
}
这个方法里面,我们重点关注两个点。
- 通过
assetmanager
的GetResource
方法,获取了一个value值。这个value字段里面就包含我们想要的dimension。- 将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个部分。
configuration_
赋值给desired_config
,用于下述的匹配工作。这个configuration_
非常重要,它是我们解决问题的关键所在,所以这里单独拎出来。- 去寻找最优匹配的value。这里的
isBetterThan
方法,目的是为了寻找最优的values。怎么理解这个最优呢?比如说,当前定义了两个values文件夹:values-w100dp,values-w200dp, 此时屏幕宽度是600dp。按照规则,其实两个values都符合要求,此时就要选择最优的values,即差值最小的,也就是最终会取values-w200dp内部的值; 这里还有一个match
方法,就是去校验values的config跟屏幕的config(即AssetManager2的config)是否匹配。- 最后一步,就是将找到的结果,赋值给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
方法内部主要做了两件事:
- 将传递过来最新值,更新到configuration上去。
- 再将configuration传递给
AssetManager2
,用以刷新它内部的值。
NativeSetConfiguration
是一个native 方法,它对应的Java方法是哪一个呢?当然是AssetManager
的nativeSetConfiguration
方法。
然后,我们再去寻找,哪里在调用nativeSetConfiguration
方法呢?最终就找到了Resource
的updateConfiguration
方法,这也是为什么上面的解决方案中,我们调用下这个方法,就能解决这个问题。
同理,调用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. 总结
最后,我们来做个总结。
- 在我们修改屏幕的density之后,仅更新Java层的值,并没有更新C++层。所以导致在获取dimension时,C++层用的是旧值去判断,所以导致dimension获取的不对。
- 在我们更新完density之后,需要调用
Resource
的updateConfiguration
方法,去更新C++层的屏幕宽度(dp单位)
额外补充两句,可能大家在实际开发过程中很少遇到这种问题,原因应该是,系统默认的屏幕宽度和我们修改density之后的屏幕宽度都比指定的values-wxxdp要大,或者要小,所以难以发现这个问题。
为什么将Activity
声明为sensorLandscape
就会复现这个问题呢?其实跟screenOrientation
没有关系,只要values设置的dp值在默认的屏幕宽度和我们修改density之后的屏幕宽度中间,就会有问题。只不过,在我们的场景,刚好是横屏才会遇到这种情况。