美文网首页
Kotlin升级1.5版本synthetic引发的血案分析

Kotlin升级1.5版本synthetic引发的血案分析

作者: 申国骏 | 来源:发表于2021-07-14 16:59 被阅读0次

    场景重现

    因为项目里面Kotlin版本还停留在1.4,看到1.5版本更新记录提升了性能并且新加了一些特性,准备怒升级一波。怀着开心的心情升级完之后,运行起来就傻眼了!转载请注明来源「申国骏」

    1625820518128283

    视频列表有个浮层没有隐藏,就升级下Kotlin,居然还有这个问题,真是太不可思议了!把Kotlin降级回去,然后就好了,确定是因为Kotlin升级导致的问题。接下来就开始分析了。

    FindViewById?

    第一反应是找下代码看看!

    private fun tryHideTransitionImage() {
      // transition_image就是那个浮层
      if (transition_image.visibility != View.GONE) {
          transition_image.visibility = View.GONE
      }
    }
    

    debug一下,代码运行的顺序在Kotlin升级前和升级后没有区别!然后想到的就是transition_image使用Kotlin synthetic获取的,是不是和findViewById有什么区别呢?于是改成了

    private fun tryHideTransitionImage() {
      // transition_image就是那个浮层
      if (view.findViewById(R.id.transition_image).visibility != View.GONE) {
          view.findViewById(R.id.transition_image).visibility = View.GONE
      }
    }
    

    然而问题还是一样!果然问题不是那么简单!我们使用[Stetho](Download (facebook.github.io))来看看这个页面里面transition_image的状态:

    WechatIMG1033

    不出所料有两个transition_image,一个是在外层FrameLayout底下的悬浮层,另一个是在Recyclerview的ItemView里面的视频封面。悬浮层的属性显示确实是没有隐藏。这个时候就要看看FindViewById的原理了,我们看下findViewById的源码:

    // View.java
    public final <T extends View> T findViewById(@IdRes int id) {
      if (id == NO_ID) {
        return null;
      }
      return findViewTraversal(id);
    }
    

    在ViewGroup里面重写了findViewTraversal方法

    findViewTraversalprotected <T extends View> T findViewTraversal(@IdRes int id) {
      if (id == mID) {
        return (T) this;
      }
    
      final View[] where = mChildren;
      final int len = mChildrenCount;
    
      for (int i = 0; i < len; i++) {
        View v = where[i];
    
        if ((v.mPrivateFlags & PFLAG_IS_ROOT_NAMESPACE) == 0) {
          v = v.findViewById(id);
    
          if (v != null) {
            return (T) v;
          }
        }
      }
    
      return null;
    }
    
    image-20210714164915387

    可以看出是一个深度优先搜索算法,因此在我们使用[Stetho](Download (facebook.github.io))时看到的View树里面,会先遍历到RecyclerView底下的视频封面,因此如果直接使用findViewById(R.id.transition_image)来隐藏浮层的话,拿到的并不是浮层。

    那么为什么1.4版本的Kotlin里面不会出问题呢?这个时候得分析下编译之后的ByteCode了。

    ByteCode分析

    private fun tryHideTransitionImage() {
      // transition_image就是那个浮层
      if (transition_image.visibility != View.GONE) {
          transition_image.visibility = View.GONE
      }
    }
    

    同样的这段代码,在使用Kotlin1.4版本的android extensions compile编译之后的dex的二进制代码如下:

    .method private final tryHideTransitionImage()V
              .registers 4
    00000000  sget                v0, R$id->transition_image:I
    00000004  invoke-virtual      CommunityVideoListFragment->_$_findCachedViewById(I)View, p0, v0
    0000000A  move-result-object  v0
    0000000C  check-cast          v0, ImageView
    00000010  const-string        v1, "transition_image"
    00000014  invoke-static       Intrinsics->checkNotNullExpressionValue(Object, String)V, v0, v1
    0000001A  invoke-virtual      ImageView->getVisibility()I, v0
    00000020  move-result         v0
    00000022  const/16            v2, 8
    00000026  if-eq               v0, v2, :46
    :2A
    0000002A  sget                v0, R$id->transition_image:I
    0000002E  invoke-virtual      CommunityVideoListFragment->_$_findCachedViewById(I)View, p0, v0
    00000034  move-result-object  v0
    00000036  check-cast          v0, ImageView
    0000003A  invoke-static       Intrinsics->checkNotNullExpressionValue(Object, String)V, v0, v1
    00000040  invoke-virtual      ImageView->setVisibility(I)V, v0, v2
    :46
    00000046  return-void
    .end method
    
    .method public _$_findCachedViewById(I)View
              .registers 4
    00000000  iget-object         v0, p0, CommunityVideoListFragment->_$_findViewCache:HashMap
    00000004  if-nez              v0, :16
    :8
    00000008  new-instance        v0, HashMap
    0000000C  invoke-direct       HashMap-><init>()V, v0
    00000012  iput-object         v0, p0, CommunityVideoListFragment->_$_findViewCache:HashMap
    :16
    00000016  iget-object         v0, p0, CommunityVideoListFragment->_$_findViewCache:HashMap
    0000001A  invoke-static       Integer->valueOf(I)Integer, p1
    00000020  move-result-object  v1
    00000022  invoke-virtual      HashMap->get(Object)Object, v0, v1
    00000028  move-result-object  v0
    0000002A  check-cast          v0, View
    0000002E  if-nez              v0, :5C
    :32
    00000032  invoke-virtual      Fragment->getView()View, p0
    00000038  move-result-object  v0
    0000003A  if-nez              v0, :42
    :3E
    0000003E  const/4             p1, 0
    00000040  return-object       p1
    :42
    00000042  invoke-virtual      View->findViewById(I)View, v0, p1
    00000048  move-result-object  v0
    0000004A  iget-object         v1, p0, CommunityVideoListFragment->_$_findViewCache:HashMap
    0000004E  invoke-static       Integer->valueOf(I)Integer, p1
    00000054  move-result-object  p1
    00000056  invoke-virtual      HashMap->put(Object, Object)Object, v1, p1, v0
    :5C
    0000005C  return-object       v0
    .end method
    

    我们查看下dex指令文档,对这段dex二进制翻译一下:

    private final void tryHideTransitionImage() {
      ImageView v0 = (ImageView)this._$_findCachedViewById(id.transition_image);
      Intrinsics.checkNotNullExpressionValue(v0, "transition_image");
      if(v0.getVisibility() != View.Gone) {
        ImageView v0_1 = (ImageView)this._$_findCachedViewById(id.transition_image);
        Intrinsics.checkNotNullExpressionValue(v0_1, "transition_image");
        v0_1.setVisibility(View.Gone);
      }
    }
    
    public View _$_findCachedViewById(int arg3) {
      if(this._$_findViewCache == null) {
        this._$_findViewCache = new HashMap();
      }
      View v0 = (View)this._$_findViewCache.get(Integer.valueOf(arg3));
      if(v0 == null) {
        View v0_1 = this.getView();
        if(v0_1 == null) {
          return null;
        }
        v0 = v0_1.findViewById(arg3);
        this._$_findViewCache.put(Integer.valueOf(arg3), v0);
      }
      return v0;
    }
    

    在升级到Kotlin1.5版本后,二进制代码如下:

    .method private final tryHideTransitionImage()V
              .registers 4
    00000000  invoke-virtual      CommunityVideoListFragment->getView()View, p0
    00000006  move-result-object  v0
    00000008  const/4             v1, 0
    0000000A  if-nez              v0, :12
    :E
    0000000E  move-object         v0, v1
    00000010  goto                :1E
    :12
    00000012  sget                v2, R$id->transition_image:I
    00000016  invoke-virtual      View->findViewById(I)View, v0, v2
    0000001C  move-result-object  v0
    :1E
    0000001E  check-cast          v0, ImageView
    00000022  invoke-virtual      ImageView->getVisibility()I, v0
    00000028  move-result         v0
    0000002A  const/16            v2, 8
    0000002E  if-eq               v0, v2, :56
    :32
    00000032  invoke-virtual      CommunityVideoListFragment->getView()View, p0
    00000038  move-result-object  v0
    0000003A  if-nez              v0, :40
    :3E
    0000003E  goto                :4C
    :40
    00000040  sget                v1, R$id->transition_image:I
    00000044  invoke-virtual      View->findViewById(I)View, v0, v1
    0000004A  move-result-object  v1
    :4C
    0000004C  check-cast          v1, ImageView
    00000050  invoke-virtual      ImageView->setVisibility(I)V, v1, v2
    :56
    00000056  return-void
    .end method
    

    翻译成Java代码如下:

    private final tryHideTransitionImage() {
        Object v0 = this.getView();
        if (v0 != null) {
            v0 = v0.findViewById(R.id.transition_image);
        } else {
            v0 = null;
        }
        if (((ImageView) v0).getVisibility != View.Gone) {
            v0 = this.getView();
            if (v0 != null) {
                Object v1 = v0.findViewById(R.id.transition_image);
                ((ImageView) v1).setVisibility(View.Gone)
            }
        }
    }
    

    可以看出在1.5版本之后,Kotln Synthetic由原来的生成一个_findCachedViewById来保存View对象,变成了直接将findViewByIdinLine到调用的地方,没有使用view cache保存对象。这个改动的

    因此我们上面遇到的问题也能得到比较清晰的答案了。因为在1.4版本里面,代码里面的transition_image指的是第一次调用的对象,而我们发现代码里面第一次调用transition_image是在FragmentonViewCreated的代码中,这个时候由于列表还没加载,所以获取到的就是外层的浮层,之后对transitoin_image的调用都是指向这个浮层对象,因此没有问题。而在升级到1.5版本之后,由于view cache机制改成了直接findViewById,因此在列表加载之后再获取transition_image获取到的就是列表里面的封面对象,导致了浮层没有正常隐藏。

    Kotlin Synthetic原理

    在知道问题的答案之后,我们再进一步看看Kotlin Synthetic是怎么生成这些代码的。

    override fun generateClassSyntheticParts(codegen: ImplementationBodyCodegen) {
        val classBuilder = codegen.v
        val targetClass = codegen.myClass as? KtClass ?: return
    
            // 没有enable的话不生成
        if (!isEnabled(targetClass)) return
    
        val container = codegen.descriptor
        if (container.kind != ClassKind.CLASS && container.kind != ClassKind.OBJECT) return
    
        val containerOptions = ContainerOptionsProxy.create(container)
        // 判断目标是否Framgent或者Activity等需要生成cache的类
        if (containerOptions.getCacheOrDefault(targetClass) == NO_CACHE) return
    
            // 如果是LayoutContainer则需要开启experiment特性才会生成cache
        if (containerOptions.containerType == LAYOUT_CONTAINER && !isExperimental(targetClass)) {
            return
        }
    
        val context = SyntheticPartsGenerateContext(classBuilder, codegen.state, container, targetClass, containerOptions)
        // 生成_findCachedViewById方法
        context.generateCachedFindViewByIdFunction()
        context.generateClearCacheFunction()
        context.generateCacheField()
    
        if (containerOptions.containerType.isFragment) {
            val classMembers = container.unsubstitutedMemberScope.getContributedDescriptors()
            val onDestroy = classMembers.firstOrNull { it is FunctionDescriptor && it.isOnDestroyFunction() }
            if (onDestroy == null) {
                context.generateOnDestroyFunctionForFragment()
            }
        }
    }
    
    private fun SyntheticPartsGenerateContext.generateCachedFindViewByIdFunction() {
      val containerAsmType = state.typeMapper.mapClass(container)
    
        val viewType = Type.getObjectType("android/view/View")
    
        val methodVisitor = classBuilder.newMethod(
        JvmDeclarationOrigin.NO_ORIGIN, ACC_PUBLIC, CACHED_FIND_VIEW_BY_ID_METHOD_NAME, "(I)Landroid/view/View;", null, null)
        methodVisitor.visitCode()
        val iv = InstructionAdapter(methodVisitor)
    
        val cacheImpl = CacheMechanism.get(containerOptions.getCacheOrDefault(classOrObject), iv, containerAsmType)
    
        fun loadId() = iv.load(1, Type.INT_TYPE)
    
        // Get cache property
        cacheImpl.loadCache()
    
        val lCacheNonNull = Label()
        iv.ifnonnull(lCacheNonNull)
    
        // Init cache if null
        cacheImpl.initCache()
    
        // Get View from cache
        iv.visitLabel(lCacheNonNull)
        cacheImpl.loadCache()
        loadId()
        cacheImpl.getViewFromCache()
        iv.checkcast(viewType)
        iv.store(2, viewType)
    
        val lViewNonNull = Label()
        iv.load(2, viewType)
        iv.ifnonnull(lViewNonNull)
    
        // Resolve View via findViewById if not in cache
        iv.load(0, containerAsmType)
    
        val containerType = containerOptions.containerType
        // 根据不同的类型获取root View
        when (containerType) {
        AndroidContainerType.ACTIVITY, AndroidContainerType.ANDROIDX_SUPPORT_FRAGMENT_ACTIVITY, AndroidContainerType.SUPPORT_FRAGMENT_ACTIVITY, AndroidContainerType.VIEW, AndroidContainerType.DIALOG -> {
          loadId()
            iv.invokevirtual(containerType.internalClassName, "findViewById", "(I)Landroid/view/View;", false)
        }
        AndroidContainerType.FRAGMENT, AndroidContainerType.ANDROIDX_SUPPORT_FRAGMENT, AndroidContainerType.SUPPORT_FRAGMENT, LAYOUT_CONTAINER -> {
          if (containerType == LAYOUT_CONTAINER) {
            iv.invokeinterface(containerType.internalClassName, "getContainerView", "()Landroid/view/View;")
          } else {
            iv.invokevirtual(containerType.internalClassName, "getView", "()Landroid/view/View;", false)
          }
    
          iv.dup()
            val lgetViewNotNull = Label()
            iv.ifnonnull(lgetViewNotNull)
    
            // Return if getView() is null
            iv.pop()
            iv.aconst(null)
            iv.areturn(viewType)
    
            // Else return getView().findViewById(id)
            iv.visitLabel(lgetViewNotNull)
            loadId()
            iv.invokevirtual("android/view/View", "findViewById", "(I)Landroid/view/View;", false)
        }
        else -> throw IllegalStateException("Can't generate code for $containerType")
      }
      iv.store(2, viewType)
    
        // Store resolved View in cache
        cacheImpl.loadCache()
        loadId()
        cacheImpl.putViewToCache { iv.load(2, viewType) }
    
      iv.visitLabel(lViewNonNull)
        iv.load(2, viewType)
        iv.areturn(viewType)
    
        FunctionCodegen.endVisit(methodVisitor, CACHED_FIND_VIEW_BY_ID_METHOD_NAME, classOrObject)
    }
    

    这部分代码在1.4和1.5的版本之间并没有任何区别,那究竟是什么导致Kotlin1.5版本不生成_findCachedViewById方法呢?

    这个时候,我们在使用Kotlin1.5版本的基础上,通过在build.gradle文件中,加入下面这段代码,会发现_findCachedViewById方法会继续生成。而下面这段代码的意思是使用旧的JVM编译器。

    tasks.withType(org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompile) {
        kotlinOptions.useOldBackend = true
    }
    

    因此,可以推断是Kotin1.5版本中使用了新的JVM IR编译器导致的。
    报了个Bug给Jetbrains,后续保持关注:https://youtrack.jetbrains.com/issue/KT-47733

    注意事项

    1. 使用synthetic需要注意在1.5之前是使用cache机制的,在一个类里面使用synthetic获取view会按照第一个获取到的view为准,因此如果一个类里面对应的viewid有重复的话,会以第一个为准。在1.5之后,就是每个地方都通过findviewbyid获取。
    2. 尽量避免在同一个页面的不同级别的地方使用同样的id,特别注意列表的item的id不要和外层的id重复。
    3. import kotlinx.android.synthetic.xx导入的只是符号引用,有可能声明的是kotlinx.android.synthetic.a.view1但是实际上代码里面获取的是kotlinx.android.synthetic.b.view1
    4. 使用Kotlin Synthetics获取view可能会导致null pointer,特别是在某些回调函数里面view已经释放的情况下。
    5. 在2020年11月,Google官方宣布正式弃用Kotlin Android Extensions里面的Synthetics,而推荐使用Jecpack View Binding,android-kotlin-extensions将会在2021年的9月左右移除,后续代码尽量不要使用Kotlin Synthetics。

    参考

    相关文章

      网友评论

          本文标题:Kotlin升级1.5版本synthetic引发的血案分析

          本文链接:https://www.haomeiwen.com/subject/biwypltx.html