RecyclerView的Scrollbar绘制异常问题追查

最近在开发过程中发现了一个奇怪的问题,

详情页评论区滑动到底部时,红色的MD过量滑动效果都有了

滚动条却没有显示到底,如下图

这个问题在多个业务的评论区中都有出现,虽然它们的实现略有区别

页面结构分析

正常的RecyclerView显然是不会有这样的问题的,那就得先看看我哪里用的有问题了

详情页的基本结构是一个PagingRecyclerView

(源码:https://github.com/dss886/PagingRecyclerView

这个PagingRecyclerView的实现是在传入的Adapter外面包装了一层装饰器

在头部和底部多插入了一个header和footer用于显示下拉刷新和LoadMore的效果

@Override
public int getItemCount() {
    int count = mAdapter.getItemCount();
    if (mHeaderEnable) {
        count++;
    }
    if (mFooterEnable) {
        count++;
    }
    return count;
}

在load结束没有更多数据时会隐藏,同时高度设为0

void onHide() {
    setHeight(itemView, 0);
    itemView.setVisibility(View.GONE);
}

直觉告诉我,滚动条没有触底肯定跟这个Footer有关系

但是通过Layout Inspector抓到的布局显示,这个Footer确实是隐藏的,高度也为0

那么,问题出在哪呢?

滚动条绘制分析

ScrollBar是View的Foreground的一部分,我们先从View.draw()方法看起

View :

-> draw()

-> onDrawForeground()

-> onDrawScrollBars()

-> onDrawVerticalScrollBar()

protected void onDrawVerticalScrollBar(Canvas canvas, Drawable scrollBar,
            int l, int t, int r, int b) {
        scrollBar.setBounds(l, t, r, b);
        scrollBar.draw(canvas);
    }

可以看到,View在处理ScrollBar的时候,最终是委托给了ScrollBarDrawable这个类进行处理的

我们跟踪进ScrollBarDrawable.draw()方法看一下,打个断点

整个RecyclerView的高度(也就是scrollBarLength)是1181px,这和布局文件是一致的,

而scrollbar的显示长度(thumbLength)和起始位置(thumbOffset)分别是467px和545px,加起来还差了169px,也和现象一致

ScrollBarUtils的代码量很少,只有几个判断和一个除法,

从传入的range、extent、offset变量中算出实际的像素值,

这么简单的计算我不相信有问题,那么问题肯定出在传入的这几个值上了

public class ScrollBarUtils {

    public static int getThumbLength(int size, int thickness, int extent, int range) {
        // Avoid the tiny thumb.
        final int minLength = thickness * 2;
        int length = Math.round((float) size * extent / range);
        if (length < minLength) {
            length = minLength;
        }
        return length;
    }

    public static int getThumbOffset(int size, int thumbLength, int extent, int range, int offset) {
        // Avoid the too-big thumb.
        int thumbOffset = Math.round((float) (size - thumbLength) * offset / (range - extent));
        if (thumbOffset > size - thumbLength) {
            thumbOffset = size - thumbLength;
        }
        return thumbOffset;
    }
}

看一下这几个值是怎么来的

if (drawVerticalScrollBar) {
    scrollBar.setParameters(computeVerticalScrollRange(),
            computeVerticalScrollOffset(),
            computeVerticalScrollExtent(), true);
    final Rect bounds = cache.mScrollBarBounds;
    getVerticalScrollBarBounds(bounds, null);
    onDrawVerticalScrollBar(canvas, scrollBar, bounds.left, bounds.top,
            bounds.right, bounds.bottom);
    if (invalidate) {
        invalidate(bounds);
    }
}

View的三个方法computeVerticalScrollRange/Extent/Offset返回了相应的值

  1. Range:代表整个可滚动View的总高度
  2. Extent:代表当前可视区域的实际高度
  3. Offset:代表当前可视区域在整个可滚动View的起始位置

而RecyclerView继承后委托了LayoutManager的相应方法来处理

我们这里用的是LinearLayoutManager,看下代码

它在找到视野内的第一个和最后一个View后,传入了ScrollbarHelper进行计算

private int computeScrollOffset(RecyclerView.State state) {
    if (getChildCount() == 0) {
        return 0;
    }
    ensureLayoutState();
    return ScrollbarHelper.computeScrollOffset(state, mOrientationHelper,
            findFirstVisibleChildClosestToStart(!mSmoothScrollbarEnabled, true),
            findFirstVisibleChildClosestToEnd(!mSmoothScrollbarEnabled, true),
            this, mSmoothScrollbarEnabled, mShouldReverseLayout);
}

我们继续跟进去看一下

Range的计算:

可见的item的高度/可见item的个数乘以整个RecyclerView的item总数,没问题

final int laidOutArea = orientation.getDecoratedEnd(endChild)
        - orientation.getDecoratedStart(startChild);
final int laidOutRange = Math.abs(lm.getPosition(startChild)
        - lm.getPosition(endChild))
        + 1;
// estimate a size for full list.
return (int) ((float) laidOutArea / laidOutRange * state.getItemCount());

Extend的计算:

endChild的底部 - startChild的顶部,再和totalSpace取最小值,没问题

final int extend = orientation.getDecoratedEnd(endChild)
        - orientation.getDecoratedStart(startChild);
return Math.min(orientation.getTotalSpace(), extend);

Offset的计算:

用可见item的高度/可见item的个数算出平均每个item的高度,再乘以前面的item个数,也没问题

final int laidOutArea = Math.abs(orientation.getDecoratedEnd(endChild)
        - orientation.getDecoratedStart(startChild));
final int itemRange = Math.abs(lm.getPosition(startChild)
        - lm.getPosition(endChild)) + 1;
final float avgSizePerRow = (float) laidOutArea / itemRange;

return Math.round(itemsBefore * avgSizePerRow + (orientation.getStartAfterPadding()
        - orientation.getDecoratedStart(startChild)));

这个算法是没问题的,但是从算法上来看,如果endChild取的不对

(在这个例子中,如果endChild取的是抢沙发的View,而不是隐藏的LoadMore Footer)

算出来的offset和range加起来就不等于extend,而且是刚好差了一个item的高度

(即上图中的 avgSizePerRow 值)

问题原因分析

那么,LinearLayoutManager在取endChild的时候为什么会取错呢?

LinearLayoutManager在计算的时候最终调用的是findOneFirstVisibleChild这个方法

private int computeScrollOffset(RecyclerView.State state) {
    if (getChildCount() == 0) {
        return 0;
    }
    ensureLayoutState();
    return ScrollbarHelper.computeScrollOffset(state, mOrientationHelper,
            findFirstVisibleChildClosestToStart(!mSmoothScrollbarEnabled, true),
            findFirstVisibleChildClosestToEnd(!mSmoothScrollbarEnabled, true),
            this, mSmoothScrollbarEnabled, mShouldReverseLayout);
}
private View findFirstVisibleChildClosestToEnd(boolean completelyVisible,
        boolean acceptPartiallyVisible) {
    if (mShouldReverseLayout) {
        return findOneVisibleChild(0, getChildCount(), completelyVisible,
                acceptPartiallyVisible);
    } else {
        return findOneVisibleChild(getChildCount() - 1, -1, completelyVisible,
                acceptPartiallyVisible);
    }
}

既然是找Visible,就会取判断一下是否可见

RecyclerView的范围是0-1181,Footer的范围是1181-1181,在这里直接被跳过了,

最后命中的就是倒数第二个:抢沙发的item,最终导致了问题的出现

其它业务的评论区实现虽然不是用的PagingRecyclerView

但是在LoadMore的原理上都是一致的,都是在没有更多数据时会隐藏尾部的Item

这导致了同样的Scrollbar的位置绘制错误

解决方案

#1 治标

既然是因为高度为0的问题,那在Footer隐藏的时候高度不设为0就可以了(手动滑稽)

void onHide() {
    setHeight(itemView, 1);
    itemView.setVisibility(View.INVISIBLE);
}

#2 治本

高度设为1px的方案虽然能解决问题,但是太不优雅,不是我的风格

考虑根治这个问题的话,其实只要将FindOneVisibleChild方法的判断条件修改一下就行

但是因为这个方法用到的地方比较多,而且是package-private的,没办法直接修改

因此为保持影响范围可控,只能从几个compute方法入手,Copy一下代码去修改了

除了Copy的代码,还需要通过反射去取一下内部的mOrientationHelper对象

这里代码就不贴了,真正需要修改的逻辑其实只有一行:将 < 和 > 改为 <= 和 >=

for (int i = fromIndex; i != toIndex; i+=next) {
    final View child = getChildAt(i);
    final int childStart = mOrientationHelperCopy.getDecoratedStart(child);
    final int childEnd = mOrientationHelperCopy.getDecoratedEnd(child);
    // 这里将 < 和 > 改为 <= 和 >=
    if (childStart <= end && childEnd >= start) {
        if (completelyVisible) {
            if (childStart >= start && childEnd <= end) {
                return child;
            } else if (acceptPartiallyVisible && partiallyVisible == null) {
                partiallyVisible = child;
            }
        } else {
            return child;
        }
    }
}

编译运行一下,完美,问题解决~

多渠道打包的进化史

Android开发比iOS麻烦的地方在于,应用市场和发布渠道众多。

为了统计和区分各个渠道包的效果和数据,就需要有一种方法来标识它们。

石器时代

为了区分不同的渠道包,最直观也是最原始的方案,

就是使用一个变量来表示渠道名,

在Release时,每修改一次变量打一次包,

最后就能在代码中读取这个变量的值,判断属于哪个渠道了。

.

在这其中,以「友盟+ProductFlavors」的方案最具有代表性:

友盟需要在Manifest中使用meta-data标签定义一个UMENG_CHANNEL表示渠道,

而这种方案,则将UMENG_CHANNEL的值使用占位符代替,

在build.gradle中定义不同的ProductFlavors,

然后利用Gradle脚本在打包时动态替换AndroidManifest中占位符的值

<application
    android:name=".base.App"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name">

    <!-- 友盟统计SDK组件 -->
    <meta-data android:name="UMENG_APPKEY" android:value="********************"/>
    <meta-data android:name="UMENG_CHANNEL" android:value="${UMENG_CHANNEL_VALUE}"/>

...
productFlavors {
    local_test {
        dimension "default"
        manifestPlaceholders = [UMENG_CHANNEL_VALUE: "local_test"]
    }
    baidu {
        dimension "default"
        manifestPlaceholders = [UMENG_CHANNEL_VALUE: "baidu"]
    }
    qh360 {
        dimension "default"
        manifestPlaceholders = [UMENG_CHANNEL_VALUE: "360"]
    }
    qq {
        dimension "default"
        manifestPlaceholders = [UMENG_CHANNEL_VALUE: "qq"]
    }
}

基于同样的原理,还有使用Gradle插件来实现字符串替换的方案,如「Gradle-Packer」。

但是无论采用哪种替换方案,本质都是需要多少个渠道包就Build多少次,

在项目较大、渠道数量较多时,全部打一遍渠道包可能需要十几个小时,这是不可忍受的。

青铜时代

上面的打包过程中,耗时最长的就是Build了,

有没有办法只替换字符串,不用重新Build呢?

答案是有的,例如可以利用apktool修改资源文件不影响dex二进制文件的特性,

先Build一次生成待处理的apk包,然后复制一份然后使用apktool工具对其进行解包,

修改完AndroidManifest文件的内容后,再重新进行封包和签名即可。

.

这种方案将多次Build的过程压缩成一次,大幅降低了生成渠道包的时间,

然而使用apktool修改apk和重新签名依然是有时间成本的,以美团的实际应用为例[1]

近900个渠道,使用这种方案全部打完需要约3个小时,还是有点慢。

铁器时代

既然签名也很慢,那能不能无需重新签名就能在apk的某个位置写入信息呢?

.

现在通用的Android的签名方式是使用Java对jar文件签名的方案jarsigner,

这种方案的原理,是在目录下建立一个META-INF文件夹,然后对所有文件进行遍历,

使用SHA1摘要算法对每一个文件进行计算,然后保存进MANIFEST.MF文件,

为了防止MANIFEST.MF被篡改,再对它进行摘要生成CERT.SF文件,

最后对SF文件使用RSA非对称加密算法加密,生成CERT.RSA文件。

Name: res/anim/abc_fade_in.xml
SHA1-Digest: ohPEA4mboaFUu9LZMUwk7FmjbPI=
Name: res/anim/abc_fade_out.xml
SHA1-Digest: MTJWZc22b5LNeBboqBhxcQh5xHQ=

这里可以看到,签名过程用到了SHA1和RSA算法,看上去无懈可击,

但是仔细想想,整个签名算法都只针对根目录中的其它目录和文件,

对于META-INF目录中除了上述三个签名算法生成的文件外,并无其它校验机制。

也就是说,在已签名的apk文件的META-INF目录下添加文件,是不会触发校验失败的。

.

美团的方案就是利用这个特性,在打好的Release包的META-INF目录下添加一个空文件,

利用文件名来标示渠道,然后在代码中去读取这个文件的文件名。

这种方案的渠道包生成速度非常快,900个渠道不到一分钟就能打完。

蒸汽时代

美团的方案对于多渠道打包需求来说,在当时几乎已经非常完美了。

不过由于文件名的字符和长度有限制,并且从zip包中解压读取文件在运行时有性能损耗,

想利用这个特性来做复杂一点需求还是有点力不从心,典型的例如动态跳转需求:

  1. 小说下载:用户在Web页面点击下载阅读小说的广告,第一步下载App,第二部用户打开该App直接打开该小说开始阅读
  2. 应用下载:用户搜索某应用后点击下载按钮,先下载打开应用市场App后打开市场App,然后开始自动下载用户真正要下载的应用

更进一步,产生了一种使用zip-comment的方案,

即利用了apk文件使用zip格式进行压缩的特性。

.

zip文件格式中定义了comment-length和comment字段,

length字段长度为两个字节,也即comment中最多能写入2^16=65535字节。

apk文件默认的comment-length为0,也就是说,

我们可以通过修改这两个字节的内容来向comment字节写入自定义内容

(例如一段scheme)

这种方案的打包速度比美团的META-INF方案略快一点,更重要是,

由于是直接读取文件的二进制内容,不需要zip解压缩,读取效率非常高(10ms级别)

目前市面上绝大多数动态跳转的解决方案,都是使用zip-comment实现的。

电气时代

如果签名算法到此为止的话,似乎也不需要更快更好的解决方案了,

但是,在多渠道打包技术蓬勃发展的时候,Google也及时看到了apk签名算法的漏洞,

能往已经签名过的apk文件中添加任意文件(META-INF方案),

能写入几乎任意长字符串(Zip-Comment方案)

这个算法的安全性已经不能用糟糕来形容了

.

因此,Android 7.0推出了新的应用签名方案:APK Signature Scheme v2

v2方案是一种全文件签名方案,针对整个zip包而不只是其中的文件进行校验。

在上图中,EOCD块中定义了Central Directory起始位置的偏移值,

默认情况下它是紧跟着Contents of ZIP entries的,

Google利用这一特性,人为增大了offset值,

在Contents和Central Directory之间强行插入了一块用于存储文件的签名信息。

.

上图红色部分中的签名信息会对其它三块内容进行保护,

因此如果要写入渠道信息,就只能在这个Signing Block中做文章了

根据Google的说明文档,APK Signing Block的格式如下:

  1. size of block,以字节数(不含此字段)计 (uint64)
  2. 带 uint64 长度前缀的“ID-值”对序列:
    • ID (uint32)
    • value(可变长度:“ID-值”对的长度 - 4 个字节)
  3. size of block,以字节数计 - 与第一个字段相同 (uint64)
  4. magic APK 签名分块 42(16 个字节)

上面的第二部分是由「ID-值」组成的一组序列,

v2的签名信息是其中的一项,其ID为「0x7109871a」

不过可能是为了以后方便扩展,Google在文档中明确说明了,

在解译该分块时会忽略 ID 未知的「ID-值」对,

这样一来,就给了开发者写入渠道或者其它信息的机会,

美团的新一代多渠道打包方案「Walle」就是基于这个原理。

.

这种方案虽然是基于Android 7.0才支持的APK Signature Scheme v2,

但是由于v1和v2签名相互兼容,这种方式打出来的包,

即使在低版本中也可以直接读取二进制文件获取写入的信息,

而不影响签名验证和安装过程。

.

以美团的实际应用为例[2],对一个30M大小的APK包,

只需要100多毫秒(包含文件复制时间)就能生成一个渠道包,

而且由于都是直接解析二进制文件格式,

读取信息的性能与Zip-Comment方案相当,都在10毫秒级别。

暂时…没有更给力的方案了

参考资料

  1. 美团Android自动化之旅—生成渠道包
  2. 新一代开源Android渠道包生成工具Walle
  3. 一种动态为apk写入信息的方案
  4. Android APK签名原理及方法
  5. APK 签名方案 v2
  6. Android7.0新签名对多渠道打包的影响
  7. https://github.com/mcxiaoke/gradle-packer-plugin
  8. https://github.com/mcxiaoke/packer-ng-plugin
Android不同版本上注解作用域不同的问题追查

问题

有一个需求是要将传入的数据结构用GSON序列化存入数据库, 为了避免被混淆,需要使用 @android.support.annotation.Keep 注解来标记存入的数据结构。

因此我在代码里做了一个判断,如果这个类没有使用Keep注解就在Debug时抛出异常:

if (Logger.debug()) {
    if (!clazz.isAnnotationPresent(Keep.class)) {
        throw new IllegalArgumentException("DataBaseManager: Data type " + clazz.getName() + " must be annotated by @Keep !");
    }
}

在我的测试机上运行正常(Nexus 5,Android 6.0.1),然而在测试过程中发现,一台 4.3 的手机抛出了异常。

在查找问题的过程中发现,@Keep 这个注解的作用域(RetentionPolicy)是CLASS级别的,也就是说,理论上所有版本的系统在运行时都是取不到这个注解的,都应该抛出异常才对,但是为什么我的手机没有呢??先写一个小 Demo 来测试一下:

Demo 测试

首先定义三个注解:

@Retention(SOURCE)
public @interface Source {

}

@Retention(CLASS)
public @interface Clazz {

}

@Retention(RUNTIME)
public @interface Runtime {

}

然后定义三个类:

@Source
public class A {

}

@Source
@Clazz
public class B {

}

@Source
@Clazz
@Runtime
public class C {

}

然后使用Class中的方法来读取类中的注解:

Annotation[] as = A.class.getDeclaredAnnotations();
Log.d("AnnotationDebug", "A Annotations length = " + as.length);
for (Annotation a : as) {
    Log.d("AnnotationDebug", "A : " + a.toString());
}

Annotation[] bs = B.class.getDeclaredAnnotations();
Log.d("AnnotationDebug", "B Annotations length = " + bs.length);
for (Annotation b : bs) {
    Log.d("AnnotationDebug", "B : " + b.toString());
}

Annotation[] cs = C.class.getDeclaredAnnotations();
Log.d("AnnotationDebug", "C Annotations length = " + cs.length);
for (Annotation c : cs) {
    Log.d("AnnotationDebug", "C : " + c.toString());
}

Log.d("AnnotationDebug", "C  @Source presentation: " + (C.class.isAnnotationPresent(Source.class) ? "true" : "false"));
Log.d("AnnotationDebug", "C  @Clazz presentation: " + (C.class.isAnnotationPresent(Clazz.class) ? "true" : "false"));
Log.d("AnnotationDebug", "C  @Runtime presentation: " + (C.class.isAnnotationPresent(Runtime.class) ? "true" : "false"));

最后分别在4.3、5.0、6.0、7.1、8.0的机器上运行,结果…很魔幻:

系统版本 4.3 5.0 6.0 7.1 8.0
getDeclaredAnnotations() 0-0-1 0-0-1 0-0-1 0-0-1 0-0-1
isAnnotationPresent(Source.class) false false false false false
isAnnotationPresent(Clazz.class) false true true false false
isAnnotationPresent(Runtime.class) true true true true true

可以看到,getDeclaredAnnotations() 方法返回的注解在不同版本上都是正确的,确实只有类 C 中存在一个 Runtime 注解。

但是在不同版本上,isAnnotationPresent() 方法返回的结果却各不相同,5.0 和 6.0 版本在判断 CLASS 级别的注解时均会返回 True!

5.x/6.x 源码探究

问题出在6.0(API 23)上,那就先看看 6.0 的 Class.java 是怎么实现的:

@Override
public boolean isAnnotationPresent(Class<? extends Annotation> annotationType) {
    return AnnotationAccess.isAnnotationPresent(this, annotationType);
}

可以看到,6.0版本的 Class.isAnnotationPresent() 方法委托给了 AnnotationAccess这个类实现,而 AnnotationAccess 这个类不在SDK内,无法用 Android Studio 直接查看,那就去官网看这个类的源码:

https://android.googlesource.com/platform/libcore/+/android-6.0.1_r79/luni/src/main/java/libcore/reflect/AnnotationAccess.java

这个方法的实现路径依次为:

  1. Class.isAnnotationPresent()
  2. AnnotationAccess.isAnnotationPresent()
  3. AnnotationAccess.isDeclaredAnnotationPresent()
  4. AnnotationAccess.getAnnotation()

最终在下面的方法里面去尝试取这个Annotation:

private static com.android.dex.Annotation getAnnotation(
        AnnotatedElement element, Class<? extends Annotation> annotationClass) {
    int annotationSetOffset = getAnnotationSetOffset(element);
    if (annotationSetOffset == 0) {
        return null; // no annotation
    }
    Class<?> dexClass = getDexClass(element);
    Dex dex = dexClass.getDex();
    Dex.Section setIn = dex.open(annotationSetOffset); // annotation_set_item
    String annotationInternalName = InternalNames.getInternalName(annotationClass);
    for (int i = 0, size = setIn.readInt(); i < size; i++) {
        int annotationOffset = setIn.readInt();
        Dex.Section annotationIn = dex.open(annotationOffset); // annotation_item
        // The internal string name of the annotation is compared here and deliberately not
        // the value of annotationClass.getTypeIndex(). The annotationClass may have been
        // defined by a different dex file, which would make the indexes incomparable.
        com.android.dex.Annotation candidate = annotationIn.readAnnotation();
        String candidateInternalName = dex.typeNames().get(candidate.getTypeIndex());
        if (candidateInternalName.equals(annotationInternalName)) {
            return candidate;
        }
    }
    return null; // This set doesn't contain the annotation.
}

看到这里简直惊呆了,从Dex文件里面直接读取这个类的注解,然后直接返回,没有任何判断!问题是,Dex文件里面的不仅包含 Runtime 级别的注解,也包含 CLASS 级别的注解,也就是说,使用 Class.isAnnotationPresent() 方法来判断,CLASS 级的注解也会返回 True

我们再来看看getDeclaredAnnotations()方法的实现:

  1. Class.getDeclaredAnnotations()
  2. AnnotationAccess.getDeclaredAnnotations()
  3. AnnotationAccess.annotationSetToAnnotations()

最后的实现过程:

private static List<Annotation> annotationSetToAnnotations(Class<?> context, int offset) {
    if (offset == 0) {
        return Collections.emptyList(); // no annotations in the set
    }
    Dex dex = context.getDex();
    Dex.Section setIn = dex.open(offset); // annotation_set_item
    int size = setIn.readInt();
    List<Annotation> result = new ArrayList<Annotation>(size);
    for (int i = 0; i < size; i++) {
        int annotationOffset = setIn.readInt();
        Dex.Section annotationIn = dex.open(annotationOffset); // annotation_item
        com.android.dex.Annotation annotation = annotationIn.readAnnotation();
        if (annotation.getVisibility() != VISIBILITY_RUNTIME) {
            continue;
        }
        Class<? extends Annotation> annotationClass =
                getAnnotationClass(context, dex, annotation.getTypeIndex());
        if (annotationClass != null) {
            result.add(toAnnotationInstance(context, dex, annotationClass, annotation.getReader()));
        }
    }
    return result;
}

注意这个方法的第13行,在转换Annotation的时候判断了一下,将不是RUNTIME的注解被过滤掉了,因此最后的结果是没有问题的

5.0 系统的实现和 6.0 是一致的,均是委托给 AnnotationAccess 这个类,而且都在判断的时候少了一个过滤,导致了最后的结果不正确

4.3 源码探究

再看 4.3 系统的源码:

https://android.googlesource.com/platform/libcore/+/android-4.3.1_r1/luni/src/main/java/java/lang/Class.java

Class.isAnnotationPresent() 方法最终的实现是一个 Native 的方法:

/**
 * Returns true if the annotation exists.
 */
native private boolean isDeclaredAnnotationPresent(Class<? extends Annotation> annotationClass);

跟踪一下这个 Native 的方法源码:

https://android.googlesource.com/platform/dalvik/+/android-4.3.1_r1/vm/native/java_lang_Class.cpp

https://android.googlesource.com/platform/dalvik/+/android-4.3.1_r1/vm/reflect/Annotation.cpp

  1. [vm/native/java_lang_Class.cpp] Dalvik_java_lang_Class_isDeclaredAnnotationPresent()
  2. [vm/reflect/Annotation.cpp] dvmIsClassAnnotationPresent()
  3. [vm/reflect/Annotation.cpp] getAnnotationItemFromAnnotationSet()

最终实现为:

/*
 * Return the annotation item of the specified type in the annotation set, or
 * NULL if the set contains no annotation of that type.
 */
static const DexAnnotationItem* getAnnotationItemFromAnnotationSet(
        const ClassObject* clazz, const DexAnnotationSetItem* pAnnoSet,
        int visibility, const ClassObject* annotationClazz)
{
    DexFile* pDexFile = clazz->pDvmDex->pDexFile;
    const DexAnnotationItem* pAnnoItem;
    int i;
    const ClassObject* annoClass;
    const u1* ptr;
    u4 typeIdx;
    /* we need these later; make sure they're initialized */
    if (!dvmIsClassInitialized(gDvm.classOrgApacheHarmonyLangAnnotationAnnotationFactory))
        dvmInitClass(gDvm.classOrgApacheHarmonyLangAnnotationAnnotationFactory);
    if (!dvmIsClassInitialized(gDvm.classOrgApacheHarmonyLangAnnotationAnnotationMember))
        dvmInitClass(gDvm.classOrgApacheHarmonyLangAnnotationAnnotationMember);
    for (i = 0; i < (int) pAnnoSet->size; i++) {
        pAnnoItem = dexGetAnnotationItem(pDexFile, pAnnoSet, i);
        if (pAnnoItem->visibility != visibility)
            continue;
        ptr = pAnnoItem->annotation;
        typeIdx = readUleb128(&ptr);
        annoClass = dvmDexGetResolvedClass(clazz->pDvmDex, typeIdx);
        if (annoClass == NULL) {
            annoClass = dvmResolveClass(clazz, typeIdx, true);
            if (annoClass == NULL) {
                ALOGE("Unable to resolve %s annotation class %d",
                      clazz->descriptor, typeIdx);
                Thread* self = dvmThreadSelf();
                assert(dvmCheckException(self));
                dvmClearException(self);
                continue;
            }
        }
        if (annoClass == annotationClazz) {
            return pAnnoItem;
        }
    }
    return NULL;
}

可以看到,在这个方法的第22行,也是对可见性做了判断了的,因此最后的结果没有问题。

而 Android 在 7.0 之后,不知道为什么又去掉了 5.x/6.x 中的 AnnotationAccess 类,改回了用 Native实现,最后的结果也没有问题……

其它一些版本的源码

  1. 4.4(API 19)的源码是 Native 实现,没有问题
  2. 4.4.2 版本的手机就已经有了 AnnotationAccess 这个类,并且也没有做可见行过滤,但实测结果是正确的
  3. 4.4.3 版本的源码,与 4.4.2 相同,不过暂时没有手机做测试

精力有限,暂时就没有一个一个版本去仔细看了

结论

因此可以得到一些结论:

  1. 使用 Class.getAnnotations() 方法得到的结果在所有版本都是正确的
  2. 使用 Class.isAnnotationPresent() 方法在 5.x /6.x 版本上对于 CLASS 级别的注解会返回错误的结果 True,其它版本返回正确的结果 False
  3. 使用 Class.isAnnotationPresent() 方法对于 SOURSE 和 RUNTIME 级别的注解在各版本均能返回正确的结果

基于这几个结论,我们在代码里应该尽量避免使用 Class.isAnnotationPresent() 这个方法,如果必须要使用,请确保你传入的注解类是 Runtime 级别的。

ps:理论上这是系统实现的BUG,应该很多人遇到了才对,但是不知道为什么我搜了好久都没有搜到相关的文章 Orz…

Android 视图动画的两个坑

AnimationSet 顺序执行动画的问题

有个需求是要做的一个TextView弹跳三次的动画,基本上就是六个参数不同的位移动画,比较简单,所以就懒得用属性动画,准备直接用xml写一个Animation实现了。

于是在xml文件里面是这么写的:第一个translate动画从原位置(0%)移动到上面一定高度(-40%),然后再从这个高度(-40%)移动到原位置(0%),以此类推,然后用startOffset参数控制动画顺序。

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:fromYDelta="0%"
        android:toYDelta="-40%"
        android:duration="350" />
    <translate
        android:fromYDelta="-40%"
        android:toYDelta="0%"
        android:startOffset="350"
        android:duration="200" />
    ...
</set>

然后就发现悲剧了,动画播放的时候TextView总会突然跳上去,然后继续向上运动,动画全部完成后又再跳回来。

研究了一下,发现是掉入fillEnabled和fillBefore的坑中了:

  1. fill开头的属性总共有三个:fillEnabled、fillBefore和fillAfter
  2. 其中fillAfter属性是独立的、与其它两个属性无关,表示动画结束以后是否将动画的最后一帧应用到View上
  3. fillAfter默认为false,即回到原处,上面的例子里没有涉及这个属性
  4. fillEnabled属性是fillBefore属性的开关,fillBefore属性表示动画执行的第一帧是否应用到这个View
  5. 默认fillEnabled和fillBefore都为true
  6. 这两个属性的逻辑比较绕,是因为fillEnabled属性是Google为了解决一些问题后续版本才加上去的

在上面的例子中,虽然我是想让这几个translate动画顺序执行,设置了不同的startOffset,但是AnimationSet在加载并判断是否应该fillBefore时,却并没有考虑startOffset。

这就导致了一个问题,fillBefore默认为true,那么在试图startAnimation时,系统会将AnimationSet中的每一个动画的初始值都设置到这个View上,相同状态后面的会覆盖前面的,在上面这个代码的三次弹跳(6个translate动画)中,最后一次恰好是从上到下,即初始状态在上。所以整个动画刚开始的时候这个TextView会向上跳一下,然后再向上运动,这就与我们所期望的效果不一致了

明白了问题的原因,就有办法解决了。

最直观的解决办法就是使用AnimationListener,在一个动画播放结束以后再播放下一个动画,避免后面的动画被提前fillBefore。但显然,这个方法需要写大量的Java代码,太过啰嗦,多了几个内部类对方法数也有影响,不够优雅。

第二个办法就是利用AnimationSet播放时整个动画播放完毕才会判断是否fillAfter的特性,每个动画都只以自身为原点做相对运动,保持初始状态均为0,即:

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:fromYDelta="0%"
        android:toYDelta="-40%"
        android:duration="350" />
    <translate
        android:fromYDelta="0%"
        android:toYDelta="40%"
        android:startOffset="350"
        android:duration="200" />
    <translate
        android:fromYDelta="0%"
        android:toYDelta="-25%"
        android:startOffset="550"
        android:duration="300" />
    <translate
        android:fromYDelta="0%"
        android:toYDelta="25%"
        android:startOffset="850"
        android:duration="200" />
    <translate
        android:fromYDelta="0%"
        android:toYDelta="-8%"
        android:startOffset="1050"
        android:duration="200" />
    <translate
        android:fromYDelta="0%"
        android:toYDelta="8%"
        android:startOffset="1250"
        android:duration="150" />
</set>

其中,fromYDelta默认为零,可以省去。运行一下,效果完美。

Interpolator被覆盖的问题

需求中的另一个要求,就是这个弹跳动画要用贝塞尔曲线进行插值,

因为上弹和下落的贝塞尔曲线插值器的参数不同,不能在set中统一设置,需要单独设置:

mAnimSet = AnimationUtils.loadAnimation(getContext(), R.anim.textview_jump);
if (mAnimSet instanceof AnimationSet) {
    Interpolator upInterpolator = PathInterpolatorCompat.create(xx, xx, xx, xx);
    Interpolator downInterpolator = PathInterpolatorCompat.create(xx, xx, xx, xx);
    boolean even = true;
    for (Animation anim : ((AnimationSet) mAnimSet).getAnimations()) {
        anim.setInterpolator(even ? upInterpolator : downInterpolator);
        even = !even;
    }
}

如上,我在代码里对这个AnimationSet的每一个子Animation设置插值器后,发现并没有任何变化,感觉仍然是默认的加速减速插值器,效果很差。

研究了好一会,打了断点、跟踪了一下这个AnimationSet的执行过程,才发现问题的所在:

AnimationSet是Animation的子类,意味着AnimationSet本身就是一个Animation,也有自己的Interpolator插值器。而同时AnimationSet有一个名为PROPERTY_SHARE_INTERPOLATOR_MASK的flag,即是否分享(覆盖)自己的插值器到所有子Animation中。

默认情况下这个值为true,即AnimationSet中所有子Animation的Interpolator是默认被覆盖了的,在xml文件和Java代码中对子动画的插值器进行设置,都不会起任何作用!

要分别设置插值器的话,其实只需要在xml文件的set中加入一句:「android:shareInterpolator=”false”」即可,这一点确实有点坑啊……

dex-method-counts 工具分析

dex-method-counts

Github链接: https://github.com/mihaip/dex-method-counts

README文件内的描述为:

Simple tool to output per-package method counts in an Android DEX executable grouped by package, to aid in getting under the 65,536 referenced method limit.

即它是一个用于统计Android的dex文件中方法数的工具。

启动流程

根据README文件内的使用方法可以看到,该项目入口是一个shell脚本(Windows下是.bat批处理文件)

这个shell脚本的内容不多,前面copy了一段sh-realpath开源库的代码,看名字知道是用于*nix系统查询文件真实地址的,先略过。

剩下的主体部分非常简单,基本上只做了一件事,就是运行一个可执行的jar文件:

# Alternatively, this will extract any parameter "-Jxxx" from the command line
# and pass them to Java (instead of to dexdeps).
while expr "x$1" : 'x-J' >/dev/null; do
    opt=`expr "$1" : '-J\(.*\)'`
    javaOpts="${javaOpts} -${opt}"
    shift
done
if [ "$OSTYPE" = "cygwin" ] ; then
    jarpath=`cygpath -w  "$libdir/$jarfile"`
else
    jarpath="$libdir/$jarfile"
fi
exec java $javaOpts -jar "$jarpath" "$@"

再看项目的结构,是一个典型的使用Gradle构建(也有Ant配置)的Java工程

那么,这个项目的主要启动流程就是:

  1. 使用Gradle/Ant构建项目,生成可执行的jar文件
  2. 使用脚本运行这个可执行文件

主要工作流程

既然是一个Java工程,那入口肯定就是main函数了,看一下里面主要做了什么:

String[] inputFileNames = parseArgs(args);
int overallCount = 0;
for (String fileName : collectFileNames(inputFileNames)) {
    System.out.println("Processing " + fileName);
    DexCount counts;
    if (countFields) {
        counts = new DexFieldCounts(outputStyle);
    } else {
        counts = new DexMethodCounts(outputStyle);
    }
    List<RandomAccessFile> dexFiles = openInputFiles(fileName);
    for (RandomAccessFile dexFile : dexFiles) {
        DexData dexData = new DexData(dexFile);
        dexData.load();
        counts.generate(dexData, includeClasses, packageFilter, maxDepth, filter);
        dexFile.close();
    }
    counts.output();
    overallCount = counts.getOverallCount();
}
System.out.println(String.format("Overall %s count: %d", countFields ? "field" : "method", overallCount));

基本流程:

  1. 根据输入参数找到对应的要统计的文件
  2. 根据输入参数配置一些环境变量(如统计属性还是方法、需不需要统计类、最大统计深度等等)
  3. 如果文件名后缀是.apk或.jar时先解压,提取到里面的.dex文件

对每一个.dex文件都作为RandomAccessFile打开,然后进行如下操作:

for (RandomAccessFile dexFile : dexFiles) {
    DexData dexData = new DexData(dexFile);          // DexData
    dexData.load();                // DexData
    counts.generate(dexData, includeClasses, packageFilter, maxDepth, filter); // DexData
    dexFile.close();               // DexData
}

那么其中有两个重点,就是DexData这个类是如何解析.dex文件的,以及counts类进行统计的逻辑。

DexData解析.dex文件

根据README文件内的说明可以看到,DexData类是AOSP源代码中提供用于解析.dex文件的官方工具,包名也是 com.android.dexdeps。

.dex文件是有公开规范的二进制文件,简单了解其结构可以参考这篇文章:《Dex文件结构》

DexData文件代码比较清晰,load()方法的内容如下:

public void load() throws IOException {
    parseHeaderItem();  // .dex
    loadStrings();   //
    loadTypeIds();
    loadProtoIds();
    loadFieldIds();
    loadMethodIds();  // id
    loadClassDefs();  //
    markInternalClasses(); //
}

这些方法内部基本就是按照固定字节取出相应的信息,然后用数组保存起来了。

值得注意的是,methodId仅仅只是方法的序号,方法名需要用这个序号去前面String查询,其他的id也同理。

文件结构中可以看到,methodId的长度是4个字节一共32位,最多可以索引2^31也就是2147483648个方法

那么Android系统的65536(2^16)方法数上限就不是这里索引长度限制的。

统计方法数

在项目目录里面除了DexData相关工具和Main类外,就只有三个类了:DexCount、DexMethodCount和DexFieldCount

其中DexCount是一个抽象类,定义了一些数据结构、枚举类和输入输出格式,而DexMethodCount和 DexFieldCount都继承于DexCount。

以DexMethodCount类为例,看看里面做了些什么:

for (MethodRef methodRef : methodRefs) {
    String classDescriptor = methodRef.getDeclClassName();
    String packageName = Output.packageNameOnly(classDescriptor);
    overallCount++;
    if (outputStyle == OutputStyle.TREE) {
        //... package
    } else if (outputStyle == OutputStyle.FLAT) {
        //... package
    }
}

看代码,其实DexData工具解析完成后所有的方法定义就已经在dexData.mMethodIds数组里面了,数组的长度就是这个.dex文件内的方法数

DexMethodCount做了很多额外的工作,用于支持不同参数的调用。

如统计属性还是方法、需不需要统计类、最大统计深度和输出格式等等,

对我们比较有用的功能就是根据methodRef.getDeclClassName()来得到这个方法所在的包,然后进行分类统计。