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()来得到这个方法所在的包,然后进行分类统计。

Android:Toolbar的图标尺寸问题

之前一直使用的是Material Design的图标库,下载下来以后直接放入了对应文件夹,什么尺寸对应什么dpi都没有仔细研究过。

最近在Toolbar上添加几个不是MD图标库内的图标时发现,放入的图标在显示时有时候感觉被放大了,有时候又显得模糊。让我对这个图标的尺寸和显示系统产生了好奇,折腾了一番,终于算是基本弄清楚了。

PX、DP和DPI

首先复习一下屏幕像素密度的知识:

  1. px:像素点
  2. dpi:像素密度,即每英寸像素数
  3. dp:屏幕密度独立单位

不同手机的像素密度不同,同px的元素可能有不同的物理尺寸,这不利于多屏幕的适配。因此Android以160dpi(每英寸160像素)为基准定义了单位dp。

即1dp的元素在160dpi的屏幕用1个像素点px显示,在320dpi的屏幕用2px显示,但它们的显示实际物理长度均为1/160=0.00625英寸。320dpi在同样大小的屏幕内用了更多的像素显示,所以显得更「清晰」。

hdpi、xhdpi、xxhdpi

为了方便换算和显示,Android预定义了一系列的dpi作为基准,例如mdpi定义为160dpi、hdpi定义为240dpi(实际上是一定的范围,但不影响理解)。

我们拿到的图片资源文件是以像素px为单位的,图标的显示却是以dp为单位的。在使用ImageView进行显示时,在规定好图标的长宽后其内容会自动缩放(不同的ScaleType缩放的逻辑不一样),像素过低的图标会显得「不清晰」。

适用于高dpi屏幕的图标可以包含大量细节,在低dpi时直接缩放的话效果可能会出现锯齿、模糊或无法识别其中的元素等情况。为了分别针对不同显示密度的屏幕进行优化,Android在drawable和mipmap文件夹内为不同dpi的屏幕建立了不同的文件夹,在不同的设备上读取相应dpi文件夹内的图片资源进行显示。

Toolbar的icon显示逻辑

与ImageView这样的控件相比,Toolbar显示icon的逻辑就显得比较简单粗暴。在Material Design中,Toolbar的推荐高度为56dp,其中icon的尺寸建议为24dp,那么icon在不同dpi下的实际像素尺寸如下:

ldpi 120dpi 0.75 18px
mdpi 160dpi 1 24px
hdpi 240dpi 1.5 36px
xhdpi 320dpi 2 48px
xxhdpi 480dpi 3 72px
xxxhdpi 640dpi 4 96px

这里的问题在于,Toolbar的MenuView在显示时读取图片资源后,不会检查是否应该缩放,而是直接居中显示。那么,如果你的图片资源经过屏幕像素密度换算后不是「恰好」24dp的话,最后显示的效果就会与期望的效果不一致。

例如,xhdpi文件夹存放的应该是48px的icon,如果放入了96px大小的icon的话在Toolbar上就会显得2倍大。反之,在xxxhdpi中放入48px的icon看上去就会额外小。这也是为什么MD图标库中的icon会给mdpi到xxxhdpi一套图标的原因。

解决方案

通常情况下Toolbar的icon都是纯色的png图片,体积非常小。以ic_search_white_24dp.png这个图标为例,mdpi文件夹内的图片大小为396字节,而xxxhdpi文件夹内的图片大小也只有915字节,即使全部使用最大尺寸的图标,对安装包体积的影响也微乎其微。

而且Toolbar的icon都是抽象的图标、细节不多,在低dip的设备上进行缩放时效果并没有太大差别,根据Google发布的设备屏幕尺寸分布情况,hdpi以上的设备也已经占了85%以上。所以如果想要减小安装包体积的话,Toolbar的icon是可以全部只使用一份96px*96px的图片资源,并存放在xxxhdpi中的。

至于其他只在ImageView等控件中显示的资源,如果只有一份的话,放在哪个文件夹内其实是无所谓的。

图标设计规范

根据Material Design的设计规范,Toolbar icon的尺寸应为24dp,触摸响应大小为48dp(Toolbar会自动进行设置),而在icon内部应有一定的留白,一般为2-4dp。因此对于一张96px的icon来说,图片内的四周应有12px左右的边距。

这里推荐一个神器 iconmonstr,在搜索框输入关键词找到想要的icon后,选择png、调整大小为96px、边距12px后,就可以直接下载了。

博客主题升级

最近刚好有时间打算好好折腾一下博客的主题,

升级了一下HPSTR,发现它总算用上了SASS,这下魔改起来更方便了。

这次的升级,我在HPSTR 1.7.6的基础上做了一些微调:

功能

  1. 文章详情页底部增加了打赏功能
  2. 去掉了详情页底部的相关文章模块
  3. 左上角弹出菜单中增加了分类列表页面

提升国内访问速度

  1. 去掉了托管在Google Fonts上的字体Lato
  2. 把jQuery引用的CDN从Google替换成了BootCSS
  3. 将统计分析工具从Google Analytics换成了CNZZ
  4. 把第三方评论服务商从Disqus换成了国内的友言

视觉效果改进

  1. 汉化了默认的大部分英文文本
  2. 调整了首页标题的字体字号适应封面图(强迫症)
  3. 首页文章列表的题图裁剪、居中并固定高度,把标题放到了题图内
  4. Windows下默认字体改为微软雅黑,代码字体换成了Consolas(Mac下是Menlo)
  5. 调整了代码块(特别是Java)的高亮颜色,和AS/Intellij的Darcula主题的配色一致