1. 问题描述
公司的项目中引入了JessYan大佬的AndriodAutoSize框架,作为适配设计图尺寸的解决方案。
作者:iPeak
链接:https://juejin.cn/post/7360216766373052453
由于项目是作为带UI的SDK提供给第三方客户集成,在客户集成的过程中发现他们自身的APP在获取状态栏高度时,获取的高度值变小了。下面是集成方的代码:
/**
* context是Activity的实例
*/
public static int getstatusBarHeight(context context) {
// 获得状态栏高度
int resourceId = context.getResources().getIdentifier( name: "status_bar_height", defype: "dimen", defpackage: "android");
return context.getResources().getDimensionPixelSize(resourceId);
}
我第一时间就怀疑可能是AndroidAutoSize
框架导致的,然后我就写了个demo去尝试复现问题
2. 问题复现
模拟项目中AutoSize的初始化方法:
AutoSizeConfig.getInstance().unitsManager
.setSupportDP(false)
.setSupportSP(false).supportSubunits = Subunits.PT
设置不支持DP和SP,使用子单位PT,然后在一个Activity中获取状态栏高度
class StatusBarActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_status_bar)
val resourceId: Int = resources.getIdentifier("status_bar_height", "dimen", "android")
if (resourceId > 0) {
val height = resources.getDimensionPixelSize(resourceId)
Log.i("Test", "status_bar_height: $height")
Log.i("Test", "status_bar_height: ${ScreenUtils.getStatusBarHeight()}")
}
}
}
运行后的输出结果为:
status_bar_height:49 // 经过AutoSize适配过后的状态栏高度
status_bar_height:99 // 真实的状态栏高度
暂时先不管ScreenUtils.getStatusBarHeight()
这个方法,只需要知道这个方法获取的状态栏高度是真实的高度即可,后面再解释原因。接下来针对AndroidAutoSize
的源码来定位问题
3. 问题分析
直接从源码入手,寻找问题根本原因。
首先,找初始化的位置
AutoSizeConfig init(final Application application, boolean isBaseOnWidth, AutoAdaptStrategy strategy) {
// 这里只放了针对我遇到问题的关键代码,其他源码可以直接在github中查看
mActivityLifecycleCallbacks = new ActivityLifecycleCallbacksImpl(strategy == null ? new WrapperAutoAdaptStrategy(new DefaultAutoAdaptStrategy()) : strategy);
application.registerActivityLifecycleCallbacks(mActivityLifecycleCallbacks);
}
这里是初始化AutoSize
的位置,具体调用该方法的地方在一个叫InitProvider
的ContentProvider
中,具体就不再展示了,不是解决本文中提到问题的重点。
先来看下ActivityLifecycleCallbacksImpl
类,该类的部分源码如下:
public class ActivityLifecycleCallbacksImpl implements Application.ActivityLifecycleCallbacks {
private AutoAdaptStrategy mAutoAdaptStrategy;
public ActivityLifecycleCallbacksImpl(AutoAdaptStrategy autoAdaptStrategy) {
mFragmentLifecycleCallbacks = new FragmentLifecycleCallbacksImpl(autoAdaptStrategy);
mAutoAdaptStrategy = autoAdaptStrategy;
}
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
// 这里就是实际进行适配的位置
if (mAutoAdaptStrategy != null) {
mAutoAdaptStrategy.applyAdapt(activity, activity);
}
}
@Override
public void onActivityStarted(Activity activity) {
if (mAutoAdaptStrategy != null) {
mAutoAdaptStrategy.applyAdapt(activity, activity);
}
}
/**
* 设置屏幕适配逻辑策略类
*
* @param autoAdaptStrategy {@link AutoAdaptStrategy}
*/
public void setAutoAdaptStrategy(AutoAdaptStrategy autoAdaptStrategy) {
mAutoAdaptStrategy = autoAdaptStrategy;
mFragmentLifecycleCallbacks.setAutoAdaptStrategy(autoAdaptStrategy);
}
}
public interface AutoAdaptStrategy {
/**
* 开始执行屏幕适配逻辑
*
* @param target 需要屏幕适配的对象 (可能是 {@link Activity} 或者 {@link Fragment})
* @param activity 需要拿到当前的 {@link Activity} 才能修改 {@link DisplayMetrics#density}
*/
void applyAdapt(Object target, Activity activity);
}
ActivityLifecycleCallbacksImpl
类实现了Activity生命周期的回调事件,在Activity
的onCreate()
事件回调中实际调用了applyAdapt
方法,进行尺寸的适配。AutoAdaptStrategy
是一个接口,只定义了一个applayAdapt
方法,这里可以看到采用了适配器模式来进行屏幕的适配逻辑。
再来看AutoAdaptStrategy
的初始化,在AutoSizeConfig
的init
方法中,创建ActivityLifecycleCallbacksImpl
对象时,构造方法传入了一个WrapperAutoAdaptStrategy
对象。
public class WrapperAutoAdaptStrategy implements AutoAdaptStrategy {
private final AutoAdaptStrategy mAutoAdaptStrategy;
public WrapperAutoAdaptStrategy(AutoAdaptStrategy autoAdaptStrategy) {
mAutoAdaptStrategy = autoAdaptStrategy;
}
@Override
public void applyAdapt(Object target, Activity activity) {
onAdaptListener onAdaptListener = AutoSizeConfig.getInstance().getOnAdaptListener();
if (onAdaptListener != null){
onAdaptListener.onAdaptBefore(target, activity);
}
if (mAutoAdaptStrategy != null) {
mAutoAdaptStrategy.applyAdapt(target, activity);
}
if (onAdaptListener != null){
onAdaptListener.onAdaptAfter(target, activity);
}
}
}
该类的构造方法接受一个AutoAdaptStrategy
对象,这里给出的是默认的DefaultAutoAdaptStrategy
。这里的设计采用了装饰器模式,在实际的applyAdapt
方法执行前后添加了两个事件回调,分别是适配前onAdaptBefore
和适配后onAdaptAfter
。
再来看DefaultAutoAdaptStrategy
类:
public class DefaultAutoAdaptStrategy implements AutoAdaptStrategy {
// 部分代码没有展示,与本文问题关系不大
@Override
public void applyAdapt(Object target, Activity activity) {
//如果 target 实现 CancelAdapt 接口表示放弃适配, 所有的适配效果都将失效
if (target instanceof CancelAdapt) {
LogUtils.w(String.format(Locale.ENGLISH, "%s canceled the adaptation!", target.getClass().getName()));
AutoSize.cancelAdapt(activity);
return;
}
//如果 target 实现 CustomAdapt 接口表示该 target 想自定义一些用于适配的参数, 从而改变最终的适配效果
if (target instanceof CustomAdapt) {
LogUtils.d(String.format(Locale.ENGLISH, "%s implemented by %s!", target.getClass().getName(), CustomAdapt.class.getName()));
AutoSize.autoConvertDensityOfCustomAdapt(activity, (CustomAdapt) target);
} else {
LogUtils.d(String.format(Locale.ENGLISH, "%s used the global configuration.", target.getClass().getName()));
AutoSize.autoConvertDensityOfGlobal(activity);
}
}
}
可以看到这里有三种情况,本文中遇到的问题是走了AutoSize.autoConvertDensityOfGlobal(activity)
这行代码,其他两种情况是在全局适配的基础上针对某些特定的Activity
执行适配或者不适配的操作,这里不再详细解析,github中有详细用法的解释。
我们继续看AutoSize.autoConvertDensityOfGlobal(activity)
的执行,重点就在下面的代码里了:
public final class AutoSize {
/**
* 使用 AndroidAutoSize 初始化时设置的默认适配参数进行适配 (AndroidManifest 的 Meta 属性)
* @param activity {@link Activity}
*/
public static void autoConvertDensityOfGlobal(Activity activity) {
// 默认以屏幕的宽度作为适配基准
if (AutoSizeConfig.getInstance().isBaseOnWidth()) {
autoConvertDensityBaseOnWidth(activity, AutoSizeConfig.getInstance().getDesignWidthInDp());
} else {
autoConvertDensityBaseOnHeight(activity, AutoSizeConfig.getInstance().getDesignHeightInDp());
}
}
/**
* 以宽度为基准进行适配
*
* @param activity {@link Activity}
* @param designWidthInDp 设计图的总宽度
*/
public static void autoConvertDensityBaseOnWidth(Activity activity, float designWidthInDp) {
autoConvertDensity(activity, designWidthInDp, true);
}
/**
* sizeInDp表示设计图的宽度,以dp为单位
**/
public static void autoConvertDensity(Activity activity, float sizeInDp, boolean isBaseOnWidth) {
// designWidth表示设计图的宽度,副单位的表示,如果没有设置,则取sizeInDp
float subunitsDesignSize = isBaseOnWidth ? AutoSizeConfig.getInstance().getUnitsManager().getDesignWidth()
: AutoSizeConfig.getInstance().getUnitsManager().getDesignHeight();
subunitsDesignSize = subunitsDesignSize > 0 ? subunitsDesignSize : sizeInDp;
DisplayMetricsInfo displayMetricsInfo = mCache.get(key);
if (displayMetricsInfo == null) {
if (isBaseOnWidth) {
// 计算适配后的density
targetDensity = AutoSizeConfig.getInstance().getScreenWidth() * 1.0f / sizeInDp;
} else {
targetDensity = AutoSizeConfig.getInstance().getScreenHeight() * 1.0f / sizeInDp;
}
float scale = AutoSizeConfig.getInstance().isExcludeFontScale() ? 1 : AutoSizeConfig.getInstance().
getInitScaledDensity() * 1.0f / AutoSizeConfig.getInstance().getInitDensity();
targetScaledDensity = targetDensity * scale;
targetDensityDpi = (int) (targetDensity * 160);
if (isBaseOnWidth) {
// 计算适配后的xdpi
targetXdpi = AutoSizeConfig.getInstance().getScreenWidth() * 1.0f / subunitsDesignSize;
} else {
targetXdpi = AutoSizeConfig.getInstance().getScreenHeight() * 1.0f / subunitsDesignSize;
}
// 放入缓存中
mCache.put(key, new DisplayMetricsInfo(targetDensity, targetDensityDpi, targetScaledDensity, targetXdpi));
setDensity(activity, targetDensity, targetDensityDpi, targetScaledDensity, targetXdpi);
}
}
常规情况下,基于尺寸的转换公式:px = dp * (dpi / 160)
,需要关注targetDensity
和targetDensityDpi
。
本文中demo的初始化,是默认以屏幕的宽度作为基准,采用了副单位作为适配适配标准,那么后续会主要关注subunitsDesignSize
的使用。下面继续看setDensity
方法:
private static void setDensity(Activity activity, float density, int densityDpi, float scaledDensity, float xdpi) {
// 忽略对MIUI兼容的代码
// 针对Activity的displayMetrics进行适配
DisplayMetrics activityDisplayMetrics = activity.getResources().getDisplayMetrics();
setDensity(activityDisplayMetrics, density, densityDpi, scaledDensity, xdpi);
// 针对Application的displayMetrics进行适配
DisplayMetrics appDisplayMetrics = AutoSizeConfig.getInstance().getApplication().getResources().getDisplayMetrics();
setDensity(appDisplayMetrics, density, densityDpi, scaledDensity, xdpi);
}
private static void setDensity(DisplayMetrics displayMetrics, float density, int densityDpi, float scaledDensity, float xdpi) {
if (AutoSizeConfig.getInstance().getUnitsManager().isSupportDP()) {
displayMetrics.density = density;
displayMetrics.densityDpi = densityDpi;
}
if (AutoSizeConfig.getInstance().getUnitsManager().isSupportSP()) {
displayMetrics.scaledDensity = scaledDensity;
}
switch (AutoSizeConfig.getInstance().getUnitsManager().getSupportSubunits()) {
case NONE:
break;
case PT:
displayMetrics.xdpi = xdpi * 72f;
break;
case IN:
displayMetrics.xdpi = xdpi;
break;
case MM:
displayMetrics.xdpi = xdpi * 25.4f;
break;
default:
}
}
可以看到,直接修改了activity
的Resources
中的displayMetrics
,修改了displayMetrics
的density
和densityDpi
,这样就直接影响了最终px的转换值,保证了不同屏幕尺寸下适配同样的设计度尺寸。
本文中demo设置了supportDp=false
,supportSp=false
,使用副单位PT
,这是由于我们公司的设计图是以iOS的屏幕尺寸为标准进行设计的。那么修改的就是displayMetrics.xdpi
。
AndroidAutoSize
的源码先分析这里,大概了解了该框架的运行原理,其实就是在修改Activity
的displayMetrics
属性值,从而影响最终px的计算结果,达到适配不同屏幕尺寸的目的。
接下来,我们来看一下获取状态栏高度的代码:
package android.content.res;
// Resources.java
public int getDimensionPixelSize(@DimenRes int id) throws NotFoundException {
final TypedValue value = obtainTempTypedValue();
try {
final ResourcesImpl impl = mResourcesImpl;
impl.getValue(id, value, true);
if (value.type == TypedValue.TYPE_DIMENSION) {
return TypedValue.complexToDimensionPixelSize(value.data, impl.getDisplayMetrics());
}
throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id)
+ " type #0x" + Integer.toHexString(value.type) + " is not valid");
} finally {
releaseTempTypedValue(value);
}
}
// android.util.TypedValue
public static int complexToDimensionPixelSize(int data, DisplayMetrics metrics) {
final float value = complexToFloat(data);
final float f = applyDimension(
(data>>COMPLEX_UNIT_SHIFT)&COMPLEX_UNIT_MASK,
value,
metrics);
final int res = (int) ((f >= 0) ? (f + 0.5f) : (f - 0.5f));
if (res != 0) return res;
if (value == 0) return 0;
if (value > 0) return 1;
return -1;
}
public static float applyDimension(@ComplexDimensionUnit int unit, float value, DisplayMetrics metrics) {
switch (unit) {
case COMPLEX_UNIT_PX:
return value;
case COMPLEX_UNIT_DIP:
return value * metrics.density;
case COMPLEX_UNIT_SP:
return value * metrics.scaledDensity;
case COMPLEX_UNIT_PT:
return value * metrics.xdpi * (1.0f/72);
case COMPLEX_UNIT_IN:
return value * metrics.xdpi;
case COMPLEX_UNIT_MM:
return value * metrics.xdpi * (1.0f/25.4f);
}
return 0;
}
首先通过resources.getIdentifier
获取系统status_bar_height
的资源值,然后通过调用resources.getDimensionPixelSize()
获取该资源值对应的实际大小,该方法内调用了TypedValue.complexToDimensionPixelSize()
,然后调用applyDimension()
方法进行实际尺寸的转换。
这里会执行COMPLEX_UNIT_MM
这个分支,可以看到它是使用metrics.xdpi
进行计算的,那么由于AndroidAutoSize
对该值做了修改,就会导致计算得到的值有偏差。
4. 解决方案
解决问题的思路是,能不能告诉AutoSize
框架只针对某种Activity
进行适配,而不是全局所有Activity
都进行适配,这样可以避免影响到集成方的Activity
。
如何实现呢,由于AutoSize
的主要适配逻辑都发生在DefaultAutoAdaptStrategy
的applyAdapt
方法中,那么我们能不能自己定义一个AutoAdaptStrategy
来实现上面的解决思路呢?答案是可以的。
回到ActivityLifecycleCallbacksImpl
这个类,其中有一个setAutoAdaptStrategy
方法,接收一个AutoAdaptStrategy
类型的参数,直接修改ActivityLifecycleCallbacksImpl
的mAutoAdaptStrategy
属性,mAutoAdaptStrategy
的applyAdapt
方法也是实际执行了适配操作。那我们可以把自定义实现的AutoAdaptStrategy
实例设置进来。
再来看如何调用ActivityLifecycleCallbacksImpl
的setAutoAdaptStrategy
方法:
public AutoSizeConfig setAutoAdaptStrategy(AutoAdaptStrategy autoAdaptStrategy) {
mActivityLifecycleCallbacks.setAutoAdaptStrategy(new WrapperAutoAdaptStrategy(autoAdaptStrategy));
return this;
}
在AutoSizeConfig
类中提供了一个方法来实现自定义的适配策略类,太棒了!
接下来实现我自己的适配策略类,这里直接展示新的AndroidAutoSize
初始化的代码:
val myStrategy = object: DefaultAutoAdaptStrategy() {
override fun applyAdapt(target: Any?, activity: Activity?) {
Log.i("Test", "target: $target, activity: $activity")
if (target is IAutoSizeAdaptActivity) {
super.applyAdapt(target, activity)
}
}
}
AutoSizeConfig.getInstance()
.setAutoAdaptStrategy(myStrategy)
.unitsManager
.setSupportDP(false)
.setSupportSP(false)
.supportSubunits = Subunits.PT
新增一个接口IAutoSizeAdaptActivity
,并让StatusBarActivity
实现这个接口,标识该Activity
需要进行适配:
interface IAutoSizeAdaptActivity {
// 不需要定义任何方法
}
class StatusBarActivity : AppCompatActivity(), IAutoSizeAdaptActivity {
override fun onResume() {
super.onResume()
val resourceId: Int = resources.getIdentifier("status_bar_height", "dimen", "android")
if (resourceId > 0) {
val height = resources.getDimensionPixelSize(resourceId)
Log.i("Test", "StatusBarActivity status_bar_height: $height")
}
}
}
我直接继承了AndroidAutoSize
的DefaultAutoAdaptStrategy
类,这样可以只针对实现了IAutoSizeAdaptActivity
接口的Activity
进行适配,也就是调用super.applyAdapt
方法,执行DefaultAutoAdaptStrategy
中的默认适配逻辑。实际项目中,IAutoSizeAdaptActivity
也可以替换为一个通用的基类Activity
,只要能覆盖所有需要适配的Activity
即可。
到此为止,我以为问题解决了。。。然后,并没有,看下面的demo
写一个CustomerActivity
模拟客户的使用场景,CustomerActivity
不实现IAutoSizeAdaptActivity
接口,添加一个按钮,点击后跳转到StatusBarActivity
:
class CustomerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
findViewById<Button>(R.id.btn_jump).setOnClickListener {
startActivity(Intent(this, StatusBarActivity::class.java))
}
}
override fun onResume() {
super.onResume()
val resourceId: Int = resources.getIdentifier("status_bar_height", "dimen", "android")
if (resourceId > 0) {
val height = resources.getDimensionPixelSize(resourceId)
Log.i("Test", "CustomerActivity status_bar_height: $height")
}
}
}
运行Demo,默认进入CustomerActivity
,点击跳转到StatusBarActivity
,看控制台打印:
CustomerActivity status_bar_height:99
StatusBarActivity status_bar_height:49
目前来看,是没有问题的,当按返回键从StatusBarActivity
回到CustomerActivity
时,控制台打印了如下日志:
CustomerActivity status_bar_height:49
状态栏高度不对了,这是怎么回事?
经过一系列的源码分析,问题原因找到了。虽然不同的Activity持有的Resources
对象不同,但是Resources
对象内部的DisplayMetrics
属性却是同一个对象,这就导致当AutoSize修改了StatusBarActivity
的DisplayMetrics
时,应用内其他Activity的DisplayMetrics
也都被更改了。
那还有没有其他解决方案呢?继续研究AutoSize的源码,发现有两个方法,一个是AutoSizeConfig.getInstance().restart()
,一个是AutoSizeConfig.getInstance().stop(activity)
。stop()
方法,可以停止AutoSize的适配,将Activity的DisplayMetrics
恢复到初始状态,restart()
方法可以重新启动AutoSize的适配。
基于我的项目是提供给客户的带UI SDK,那么一旦进入SDK的范围内,就不再出现集成方的Activity,那么我可以记录Activity栈内的IAutoSizeAdaptActivity
的实例数量,当实例从0变为1时,调用AutoSize的restart()
方法启动AutoSize的适配,当最后一个IAutoSizeAdaptActivity
的实例finish
的时候,调用AutoSize的stop()
方法,停止AutoSize的适配,恢复到初始状态,这样就可以解决问题了。
修改
IAutoSizeAdaptActivity
,把它改为一个抽象类,让StatusBarActivity
继承它。新建一个
AutoSizeActivityManager
类,用于存放IAutoSizeAdaptActivity
的实例,并控制AutoSize的启动和停止。
object AutoSizeActivityManager {
private val mStack: Stack<IAutoSizeAdaptActivity> = Stack()
fun addActivity(activity: IAutoSizeAdaptActivity) {
if (mStack.isEmpty()) {
AutoSizeConfig.getInstance().restart()
}
mStack.add(activity)
}
fun removeActivity(activity: IAutoSizeAdaptActivity) {
mStack.remove(activity)
if (mStack.isEmpty()) {
AutoSizeConfig.getInstance().stop(activity)
}
}
}
abstract class IAutoSizeAdaptActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.i("Test", "onCreate, $this")
AutoSizeActivityManager.addActivity(this)
}
override fun finish() {
Log.i("Test", "finish, $this")
AutoSizeActivityManager.removeActivity(this)
super.finish()
}
}
再次运行Demo程序,看控制台输出已经正常了:
CustomerActivity status_bar_height:99
StatusBarActivity status_bar_height:49
CustomerActivity status_bar_height:99
到此为止,问题真正解决了。
不过这个方案还不是完美的,因为这里我假定了一旦进入我的UI SDK的范围内,就不会再出现集成方的Activity。如果这个假设不成立,举个例子,CustomerActivity1 -> StatusBarActivity -> CustomerActivity2
,当Activity栈中出现这样的调用顺序时,CustomerActivity2
获取的状态栏高度也是被AutoSize适配过的。不过这种情况不多见,先作为一个遗留问题吧,如果大家有更好的处理方案,也请在留言区一起讨论。
5. 其他
上面留了一个小尾巴,就是ScreenUtils.getStatusBarHeight()
获取到的是真实的系统状态栏高度,是为什么呢?ScreenUtils
是AutoSize
提供的一个工具类,还是看源码:
public static int getStatusBarHeight() {
int result = 0;
try {
int resourceId = Resources.getSystem().getIdentifier("status_bar_height", "dimen", "android");
if (resourceId > 0) {
result = Resources.getSystem().getDimensionPixelSize(resourceId);
}
} catch (Resources.NotFoundException e) {
e.printStackTrace();
}
return result;
}
原来跟我们写的代码差不多,唯一的差别处就是,他的resources
用的是Resources.getSystem()
,这又是个啥呢?
/**
* Return a global shared Resources object that provides access to only
* system resources (no application resources), is not configured for the
* current screen (can not use dimension units, does not change based on
* orientation, etc), and is not affected by Runtime Resource Overlay.
*/
public static Resources getSystem() {
synchronized (sSync) {
Resources ret = mSystem;
if (ret == null) {
ret = new Resources();
mSystem = ret;
}
return ret;
}
}
Resources
是Android系统源码,在android/content/res
目录下。getSystem
方法返回的是mSystem
实例,这个实例是一个全局共享的Resources
实例,只用来访问系统资源,它并不是application
的resources
对象,它不是针对具体某一个Activity
的,也不会被修改,哪怕是AutoSize也没法修改它。
原来如此,是AutoSize也动不了的东西。以后获取系统状态栏高度,可以使用这个方法.
6.总结
当自己开发SDK给第三方集成时,需要注意以下两点
尽量避免在自己开发的SDK中引入第三方开源的SDK。由于我们公司的项目是提供的带UI的SDK,所以难免会引入一些第三方的开源框架,那么这种情况下也要注意一定要选一些业内非常热门的开源框架,否则容易和集成方产生冲突
警惕在自己开发的SDK中引入包含全局修改的开源框架。如果必须得引入,一定要注意控制影响范围,否则一旦到了客户集成时发现问题,会显得你很不专业
本文到这里就结束啦,第一次在掘金写技术文章,主要还是记录自己分析问题和解决问题的过程,如果刚好能帮到你,那真是我莫大的荣幸,期待下次再见。
关注我获取更多知识或者投稿