最近维护了一段时间公司自研的移动端跨平台框架,发现了不少早期设计上的问题,其中有一个Fragment相关的显示隐藏案例还是比较有意思的,可以分享给大家避免后续踩到同样的坑
案例构造
为了方便演示,这里我们构造一个简单的案例来进行,在Activity里面有一个容器(FrameLayout),根据变量isOneShow的不同显示对应的Fragment并隐藏其他Fragment
scss
假设有两个frgamnt
val one = FragmentOne()
val two = FragmentTwo()
var isOneShow = true
在Activity 里面
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
省略...
val btn = this.findViewById<TextView>(R.id.btn)
btn.setOnClickListener {
val sf = supportFragmentManager.beginTransaction()
if (isOneShow) {
sf.show(two)
sf.hide(one)
} else {
sf.show(one)
sf.hide(two)
}
isOneShow = !isOneShow
sf.commitAllowingStateLoss()
}
val sf = supportFragmentManager.beginTransaction()
sf.add(R.id.container_fg, one)
sf.add(R.id.container_fg, two)
sf.show(one)
sf.hide(two)
sf.commitAllowingStateLoss()
}
这里有个场景是,One假设是我们的原生页面Fragment,Two是跨平台渲染的Fragment(采取的方案是类RN,这里不重要),在显示OneFragment时,由于内部的一些异步渲染逻辑并没有判断TwoFragment当前是否可见就执行了刷新操作,导致最终的UI展示异常(类似Fragment视图叠加),如右下图
kotlin
OneFragment 展示时isHidden = false,TwoFragment isHidden 为true
执行了以下代码
two.view?.visibility = View.VISIBLE
为了方便演示,我们在onBackPressed修改
override fun onBackPressed() {
two.view?.visibility = View.VISIBLE
}
出现这种情况一开始还觉得挺违反直觉的,因为TwoFragment当前的确属于hidden状态,但是里面的视图却展示出来了,但是看了一下Fragment是如何管理内部视图之后,就焕然开朗了。
FragmentTransaction的hide 究竟做了什么?
回到上面代码,我们再重新看一下hide方法
less
public FragmentTransaction hide(@NonNull Fragment fragment) {
addOp(new Op(OP_HIDE, fragment));
return this;
}
可以看到,最终hide方法只是构造了一个OP_HIDE的指令放入了一个List当中,最终它这个操作是可以异步执行的,如果我们采取的是类似commitxxx这种方式的话
最终FragmentTransaction包装的事务处理会在executeOps方法中,根据不同的指令执行对应的方法
ini
void executeOps() {
final int numOps = mOps.size();
for (int opNum = 0; opNum < numOps; opNum++) {
final Op op = mOps.get(opNum);
final Fragment f = op.mFragment;
....
switch (op.mCmd) {
....
case OP_HIDE:
f.setAnimations(op.mEnterAnim, op.mExitAnim, op.mPopEnterAnim, op.mPopExitAnim);
mManager.hideFragment(f);
break;
case OP_SHOW:
f.setAnimations(op.mEnterAnim, op.mExitAnim, op.mPopEnterAnim, op.mPopExitAnim);
mManager.setExitAnimationOrder(f, false);
mManager.showFragment(f);
break;
case OP_DETACH:
f.setAnimations(op.mEnterAnim, op.mExitAnim, op.mPopEnterAnim, op.mPopExitAnim);
mManager.detachFragment(f);
break;
....
}
if (!mReorderingAllowed && op.mCmd != OP_ADD && f != null) {
if (!FragmentManager.USE_STATE_MANAGER) {
mManager.moveFragmentToExpectedState(f);
}
}
}
if (!mReorderingAllowed && !FragmentManager.USE_STATE_MANAGER) {
//这里是关键,如何显示与隐藏视图
mManager.moveToState(mManager.mCurState, true);
}
}
那么我们在Fragment添加的View,是什么时候被设置为隐藏的呢?其实在下面的mManager.moveToState方法moveToState最终会调用moveFragmentToExpectedState方法,把当前的Fragment表现设置为预期的行为
scss
void moveFragmentToExpectedState(@NonNull Fragment f) {
if (!mFragmentStore.containsActiveFragment(f.mWho)) {
if (isLoggingEnabled(Log.DEBUG)) {
Log.d(TAG, "Ignoring moving " + f + " to state " + mCurState
+ "since it is not added to " + this);
}
return;
}
moveToState(f);
// 执行隐藏动画
if (f.mView != null) {
if (f.mIsNewlyAdded && f.mContainer != null) {
// Make it visible and run the animations
if (f.mPostponedAlpha > 0f) {
f.mView.setAlpha(f.mPostponedAlpha);
}
f.mPostponedAlpha = 0f;
f.mIsNewlyAdded = false;
// run animations:
FragmentAnim.AnimationOrAnimator anim = FragmentAnim.loadAnimation(
mHost.getContext(), f, true, f.getPopDirection());
if (anim != null) {
if (anim.animation != null) {
f.mView.startAnimation(anim.animation);
} else {
anim.animator.setTarget(f.mView);
anim.animator.start();
}
}
}
}
这里会隐藏hidefragment的视图
if (f.mHiddenChanged) {
completeShowHideFragment(f);
}
}
最终是在completeShowHideFragment方法中,会判断fragment当前的mHidden标记,如果mHidden为true(即一开始展示,但是执行后需要隐藏),就会在动画结束时通过设置fragment.mView.setVisibility(View.GONE) 把当前Fragemnt的View隐藏
scss
private void completeShowHideFragment(@NonNull final Fragment fragment) {
if (fragment.mView != null) {
FragmentAnim.AnimationOrAnimator anim = FragmentAnim.loadAnimation(
mHost.getContext(), fragment, !fragment.mHidden, fragment.getPopDirection());
if (anim != null && anim.animator != null) {
anim.animator.setTarget(fragment.mView);
if (fragment.mHidden) {
if (fragment.isHideReplaced()) {
fragment.setHideReplaced(false);
} else {
final ViewGroup container = fragment.mContainer;
final View animatingView = fragment.mView;
container.startViewTransition(animatingView);
// Delay the actual hide operation until the animation finishes,
// otherwise the fragment will just immediately disappear
anim.animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
container.endViewTransition(animatingView);
animation.removeListener(this);
if (fragment.mView != null && fragment.mHidden) {
fragment.mView.setVisibility(View.GONE);
}
}
});
}
} else {
fragment.mView.setVisibility(View.VISIBLE);
}
anim.animator.start();
} else {
if (anim != null) {
fragment.mView.startAnimation(anim.animation);
anim.animation.start();
}
final int visibility = fragment.mHidden && !fragment.isHideReplaced()
? View.GONE
: View.VISIBLE;
fragment.mView.setVisibility(visibility);
if (fragment.isHideReplaced()) {
fragment.setHideReplaced(false);
}
}
}
invalidateMenuForFragment(fragment);
fragment.mHiddenChanged = false;
fragment.onHiddenChanged(fragment.mHidden);
}
这里我们其实就明白了,其实Fragment本身并不是一个纯粹隔离的容器,因为最终View的隐藏其实还是通过fragment.mView.setVisibility的方式设置的,如果当前Fragment mHidden 即使为true(即隐藏),我们还是可以通过设置fragment.mView本身的可见性来达到视图展示的目的,一般这种情况都是属于未预期的行为
因此,我们如果直接操作fragment.mView本身的话,很容易造成非预期的行为,在我们的方案中,因为JS执行是异步的,当JS传输最终的json数据给到原生侧渲染视图时,并没有判断当前Fragemnt是否可见,而是直接操作了fragment.mView本身并设置了fragment.mView为可见,导致了一些视图混乱的bug出现
最后
Fragment本身并不是一个隔离的容器,其显示与隐藏最终操作的还是本身的View,这点需要注意。
最后,这个问题一直隐藏了3年多的时间,当我们在多Fragment页面时用这套跨平台方案时才把它暴露出来,实属有点小坑,不过这里也给了我们一个教训,当设计跨平台框架的RootView时(一般是一个根节点用于派发后续的渲染节点)采取异步刷新回调的方式时,需要刷新的只是RootView的子控件,RootView不参与任何刷新才是正解。