1. 问题描述
公司的项目中引入了JessYan大佬的AndriodAutoSize框架,作为适配设计图尺寸的解决方案。
由于项目是作为带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表示设计图的宽度,副单位的表示,如果没有设置,则取sizeInDpfloat 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) { // 计算适配后的densitytargetDensity = 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) {// 计算适配后的xdpitargetXdpi = 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适配过的。不过这种情况不多见,先作为一个遗留问题吧,如果大家有更好的处理方案,也请在留言区一起讨论。
6. 其他
上面留了一个小尾巴,就是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也动不了的东西。以后获取系统状态栏高度,可以使用这个方法。
5. 总结
当自己开发SDK给第三方集成时,需要注意以下两点
- 尽量避免在自己开发的SDK中引入第三方开源的SDK。由于我们公司的项目是提供的带UI的SDK,所以难免会引入一些第三方的开源框架,那么这种情况下也要注意一定要选一些业内非常热门的开源框架,否则容易和集成方产生冲突
- 警惕在自己开发的SDK中引入包含全局修改的开源框架。如果必须得引入,一定要注意控制影响范围,否则一旦到了客户集成时发现问题,会显得你很不专业
本文到这里就结束啦,主要还是记录自己分析问题和解决问题的过程,如果刚好能帮到你,那真是我莫大的荣幸,期待下次再见。