Re: 用Go实现渠道打包工具walle

Walle,是美团点评技术团队针对 Android Signature V2 Scheme 签名过的APK的一个多渠道打包工具

近来恰巧在学习Go,就想着把它“翻译”成Go,权当作练手,同时还能给“包管理后台”用用。

Walle 的工作原理

想要把一个工具用另一种语言实现,首先得明白它的原理。

其实原理官方已经解释的很详细了,这里就不再详细缀述,主要列出一些关键的点:

APK 签名的本质

APK 文件其实是一个 ZIP(Jar包),所谓签名其实就是对包或者包中的文件做签名

V1 其实就是 Jar 签名,“签名”要保护的是包中的文件不被修改,并不保护包本身(如ZIP Comment);V2 则是针对V1的缺陷,在V1的基础上将包本身视为Blob再做一次签名。

这就要求 V2 需要在不破坏、不影响 ZIP 文件格式(能被普通的ZIP工具解压),不污染V1签名正确性(APK能被老Andriod系统识别)的前提下写入到包中。
APK 签名验证过程
(图来自https://source.android.com/security/apksigning/)
故而需要针对性的在不伤害 ZIP 文件格式的前提下做一些“魔改”,将 V2 签名数据块插入到 ZIP 的 Central Directory 之前。
APK V2 签名之前与之后

V2 签名之后的APK

APK 被分为 4 部分:

  1. Contents of ZIP entries (从文件头开始直到 APK Signing Block)
  2. APK Signing Block
  3. ZIP Central Directory
  4. ZIP End of Central Directory

1、3、4的内容受到APK Signing Block 保护,准确的说是受 APK Signing Block 中的签名信息保护

APK Signing Block 格式

  • Signing Block 字节数 (不含自身计数) (uint64)
  • “ID-value” 对序列:
    • 此“ID-value” 长度 (uint64)
    • ID (uint32)
    • value (可变长度: “ID-value” 长度 - 4个字节)
  • Signing Block 字节数 (与前面的数值相同) (uint64)
  • magic “APK Sig Block 42” (16 个字节)

为向后兼容考虑,ID-value对设计成了可以存在多个。

V2 签名是数据块中的一个ID-value对,其ID0x7109871a,值即为“签名信息”(signed data)。

在验证 V2 签名过程中,会跳过验证器不认识的 ID

插入渠道信息

从上面的原理可以得到结论APK Signing Block中可以增加一个ID-value来存储渠道,且不会破坏原有签名信息,即毋须重签名做到插入渠道

并发与并行之我见(concurrency vs parallelism)

引子

近来把玩了Java 8 的一些特性,如Lambda 表达式Stream,实在过瘾,有初学编程之快感,相见恨晚之情且按下不表。

其中 Stream 有一个parallelStream()的API则着实勾起了我的好奇心,parallel(译过来是“并行”)这个词于我而言可谓陌生,在不算深入的使用和品味一番后,记以本文。本人才疏学浅,如有谬误之处,请不吝指教。

咬文嚼字

在 Android 的 Java 代码世界里并发(concurrency)这个词比较常见,而并行(parallelism)这个词则少之又少,且常常被人与前者混为一谈。实质上,这两个词是完全不同的概念。

援引Java官方的文档中定义(翻译可能不够准确,建议看原文):

parallelism 译为并行,指至少两个线程同一时间在运行

concurrency 译为并发,指存在至少两个线程在执行着任务。更为一般性的并行,以时间分片作为执行单元的虚拟的并行。

我的理解:

首先得跟官方文档一样明确一个词义范围,否则就没有一点“可比性”了,提到这两个词多用其程序上执行“任务”层面的含义。

并行,就是字面意思,同一时刻执行多个任务。指执行程序代码的机器的物理上的一种能力,各个任务如同平行世界般的,形式上可以是多线程乃至多进程,被多个CPU同时运作。任务之间无任何依赖关系,不互斥、不交叉。

并发,指程序逻辑上的一种能力,支持多个任务同时存在一起被处理的能力。一个任务通常被划分为一个个的时间分片,比肩接踵般轮流的、可交替切换的被一个或多个CPU所执行,即不同任务的时间分片之间会有互斥、等待,一个任务可能因为CPU资源被抢走而被中断执行。

举个简单例子,单核CPU的机器,执行多线程化处理过的Java程序,一样称该程序支持并发,但它囿于机器性能永远做不到并行。

所提的并发和并行的概念都是为了最大化的利用多核CPU的能力,从而加速程序的执行。从这点来讲,那些将两个词混为一谈的人多半为一知半解的(看到这里的你已经不是了~)。

瞎拍(2)

闲来无事到植物园瞎拍了几张,也不枉这春光无限好

✿

✿

✿

✿

又走了一遭步行街,远远的观摩了一下大都市的夜

南京路

Android 不想和你说话,抛了个 java.lang.VerifyError

一个奇怪的崩溃

E/AndroidRuntime(22035): FATAL EXCEPTION: main
E/AndroidRuntime(22035): java.lang.VerifyError: com/sample/FileUtils
E/AndroidRuntime(22035): at com.sample.App.onCreate(App.java:16)
E/AndroidRuntime(22035): at android.app.Instrumentation.callApplicationOnCreate(Instrumentation.java:999)
E/AndroidRuntime(22035): at android.app.ActivityThread.handleBindApplication(ActivityThread.java:4220)
E/AndroidRuntime(22035): at android.app.ActivityThread.access$1300(ActivityThread.java:137)
E/AndroidRuntime(22035): at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1262)
E/AndroidRuntime(22035): at android.os.Handler.dispatchMessage(Handler.java:99)
E/AndroidRuntime(22035): at android.os.Looper.loop(Looper.java:137)
E/AndroidRuntime(22035): at android.app.ActivityThread.main(ActivityThread.java:4819)
E/AndroidRuntime(22035): at java.lang.reflect.Method.invokeNative(Native Method)
E/AndroidRuntime(22035): at java.lang.reflect.Method.invoke(Method.java:511)
E/AndroidRuntime(22035): at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:793)
E/AndroidRuntime(22035): at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:560)
E/AndroidRuntime(22035): at dalvik.system.NativeStart.main(Native Method)

为什么说奇怪?一般地,java.lang.VerifyError 是说 JVM 在加载一个类时,会去校验类的正确性,只有类文件不合法才会报这个Error。
比如,一个类试图extends一个标记为final的类,或者试图override final方法(发生在外部依赖类改变声明且应用没有完整重新编译的情况下)。
Android 中会发生这种情况的,一般是需要兼容API的时候,比如用到了高版本SDK中有的类,低版本没有,或者使用高版本API中有低版本没有的方法。

然而这个FileUtils类在com.sample.App中使用时候并没有用到与Android 版本相关的兼容性方法。

百思不得其解

Debug,有时候看堆栈是不够的,还需要查看Logcat中一些有用的上下文

W/dalvikvm(22035): VFY: unable to resolve static method 13457: Landroid/system/Os;.stat (Ljava/lang/String;)Landroid/system/StructStat;
W/dalvikvm(22035): VFY: unable to resolve exception class 1594 (Landroid/system/ErrnoException;)
W/dalvikvm(22035): VFY: unable to find exception handler at addr 0xe
W/dalvikvm(22035): VFY: rejected Lcom/sample/FileUtils;.getUid (Ljava/lang/String;)I
W/dalvikvm(22035): VFY: rejecting opcode 0x0d at 0x000e
W/dalvikvm(22035): VFY: rejected Lcom/sample/FileUtils;.getUid (Ljava/lang/String;)I
W/dalvikvm(22035): Verifier rejected class Lcom/sample/FileUtils;

Log也似乎与平常使用高版本SDK类时的兼容性警告类似:

W/dalvikvm(22524): VFY: unable to resolve virtual method 684: Landroid/content/res/Resources;.getColor (ILandroid/content/res/Resources$Theme;)I
W/dalvikvm(22524): VFY: unable to resolve virtual method 686: Landroid/content/res/Resources;.getColorStateList (ILandroid/content/res/Resources$Theme;)Landroid/content/res/ColorStateList;
W/dalvikvm(22524): VFY: unable to resolve virtual method 693: Landroid/content/res/Resources;.getDrawable (ILandroid/content/res/Resources$Theme;)Landroid/graphics/drawable/Drawable;

回到崩溃开始的警告,”unable to resolve static method” 这条日志应该不会是导致VerifyError的元凶。(注:出现这个警告意味着你如果运行时用到了这个方法,运行时将会报错,如InstantiationError、NoSuchMethodError之类)

那么应该是关键的一句:”unable to find exception handler at addr 0xe“,导致后面的”rejected Lcom/sample/FileUtils;.getUid (Ljava/lang/String;)I” 并最终导致”Verifier rejected class Lcom/sample/FileUtils;”

仔细查看 FileUtils 这个类里的方法getUid()是否有try-catch代码块:

1
2
3
4
5
6
7
8
9
10
11
@TargetApi(21)
public static int getUid(String path) {
if (Build.VERSION.SDK_INT >= 21) {
try {
return Os.stat(path).st_uid;
} catch (android.system.ErrnoException e) {
return -1;
}
}
return -1;
}

确实有尝试catch一个低版本不存在的Exception,但问题在于这个方法并没有使用到!!
而且看起来也十分的正常,一般兼容老版本SDK不都是这样的写法吗?为何单单这里会导致FileUtils类“不合法”?

为了证明是这个在低版本不存在的Exception导致的,对该方法里的try-catch做了简单的处理:

1
2
3
4
5
try {
return Os.stat(path).st_uid;
} catch (Exception e) {
return -1;
}

不出所料,警告只剩下了VFY: unable to resolve static method 13457: Landroid/system/Os;.stat (Ljava/lang/String;)Landroid/system/StructStat;而且没有导致VerifyError

what ??????

想必看到现在的你也是一脸问号……

在启动zsh时自动加载node

折腾之心不死

如果你关注本人 blog 比较久的话,你会发现界面又变了~~ 那是因为从用了许久的 octopress 迁移到 hexo 了。

其实很早就把玩过 node.js,当时用的 nvm 来安装的node
瞎折腾,竟是安装到了一个非推荐目录(推荐的目录是~/.nvm),导致使用 hexo 时发生了一些奇异的事:比如,重启 zsh 后执行hexo就提示 zsh: command not found: hexo,又或是执行npm zsh: command not found: npm

nvm如何下载使用就不多说了,本文主要讲述一下如何解决“启动zsh时自动加载node”。

如果你了解 zsh 的话,你应该知道它可以在启动时加载插件 (plugins)。

简而言之就是在 ~/.zshrc 中的 plugins=(foo) 一行,添加上你需要加载的插件。

插件目录一般是: ~/.oh-my-zsh/plugins/<foo>
插件文件名一般是:~/.oh-my-zsh/plugins/<foo>/<foo>.plugin.zsh
如 git ~/.oh-my-zsh/plugins/git/git.plugin.zsh

我们要做的就是在 ~/.oh-my-zsh/plugins 目录下添加一个自己的插件

添加插件 nvm_auto_load

到插件目录下

1
cd ~/.oh-my-zsh/plugins/

创建 nvm_auto_load 目录

1
mkdir nvm_auto_load

将初始化 nvm 写入 nvm_auto_load.plugin.zsh

1
2
3
4
if [ -z "$NVM_DIR" ]; then
NVM_DIR="$HOME/.nvm"
fi
[ -s "$NVM_DIR/nvm.sh" ] && source $NVM_DIR/nvm.sh

一点小小的经验: Android 上的「安全音量」

一个小问题

在做Android MediaPlayer相关的开发时,可能都会遇到一个需求:自定义音量控制条,如触摸屏幕某块区域则随之调整媒体播放音量并显示自定义的提示条。

设置音量的代码一般形如下:

1
2
AudioManager audioManager = (AudioManager)context.getSystemService(AUDIO_SERVICE);
audioManager.setStreamVolume(STREAM_MUSIC, newIndex, 0); // flags 为0因为我们要自己做提示

但当 Android 设备插上耳机,为了避免音量过高伤害用户听力,会触发其“安全音量”(Safe Media Volume)机制,如果在未经用户确认允许使用大音量时,且这时设置音量newIndex超过其推荐阈值,则这段代码执行完你会发现毫无反应,播放的声音依然不会很大。

如何处理?

其实很简单,关键在于往往被人忽略的最后一个参数 flags

只要在设置音量后,复查一次当前值是否相当,如果比较小,则交由系统来显示音量提示对话框。而此时因欲设定的值超过推荐值,一般会触发「音量过高警告」提示用户,用户确认后即可设置成功。

代码如下:

1
2
3
4
audioManager.setStreamVolume(STREAM_MUSIC, newIndex, 0);
if (Build.VERSION.SDK_INT >= 18 && audioManager.getStreamVolume(STREAM_MUSIC) < newIndex) {
audioManager.setStreamVolume(STREAM_MUSIC, newIndex, FLAG_SHOW_UI);
}

效果如图:(用哔哩哔哩Android 客户端播放视频时滑动屏幕调整音量)

Safe media volume warning dialog

ps:这个对话框实现各个ROM厂商不一定一致。

pps:这个对话框原生Android M 只会在20小时内提示一次,如果你点过了确定。

下面为废话时间。

瞎拍(1)

a6000 f5.6 32mm 1/160s ISO 100

柳绿花红,春草茵长

高楼林立之间,这一片绿到底争出了些春的颜色

乱弹琴:移动端APP所谓的闪屏

“闪屏”又叫“启动画面”,追根溯源,移动端App上“闪屏”这个词似乎还是苹果“爸爸”拿过来在它那一套“HIG”里“重新定义”的。大概是为了解决应用冷启动加载时间长的问题,而采用的一个取巧的办法:先显示一个“占位图”以示程序加载中。有在这个占位图上显示LOGO的,也有干脆放个“菊花”转半天的,也有的放个应用首页的轮廓图。真可谓八仙过海,各显神通。

本人也不是iOS开发,个中细节也不甚了了,但我似乎记得在 Android 应用的开发中 Google 官方曾是不推荐用“闪屏”这种玩意的。也许我记忆混乱了,但至少相当长的一段时间 Google 的全家桶是没有“闪屏”的,直到15年某个版本全线加上了这个玩意。当时我就“震惊”了,Google你的节操呢。。。

你想,一个承载着企业品牌的LOGO的启动画面(“闪屏”),怎么也得至少显示个1秒吧,那你的应用真正被用户所看到得干等这一小段时间,不是本末倒置吗?

说起来,随着应用复杂度提升、代码越来越庞大,程序的冷启动随之变慢,那么用户似乎怎么着都得干等一段时间。。那怎么行(#‵′)凸,怎么能让“大爷们”看着黑/白屏干等呢?!

一探究竟:Android M 如何获取 Wifi MAC地址(1)

引子

1
2
WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
String macAddress = wifiManager.getConnectionInfo().getMacAddress();

早在 Android M 预览版发布时就有人发现,通过WifiInfo.getMacAddress()获取的MAC地址是一个“假”的固定值,其值为 “02:00:00:00:00:00”。对于这个,官方的说法当然不外乎“保护用户隐私数据”

大胆假设

不知是有意还是一时不查,Google却忘了Java获取设备网络设备信息的API——NetworkInterface.getNetworkInterfaces()——仍然可以间接地获取到MAC地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
while (interfaces.hasMoreElements()) {
NetworkInterface iF = interfaces.nextElement();
byte[] addr = iF.getHardwareAddress();
if (addr == null || addr.length == 0) {
continue;
}
StringBuilder buf = new StringBuilder();
for (byte b : addr) {
buf.append(String.format("%02X:", b));
}
if (buf.length() > 0) {
buf.deleteCharAt(buf.length() - 1);
}
String mac = buf.toString();
Log.d("mac", "interfaceName="+iF.getName()+", mac="+mac);
}

输出如下:

1
2
3
4
interfaceName=dummy0, mac=e6:f9:44:3c:ee:da
interfaceName=p2p0, mac=ae:22:0b:3e:d4
interfaceName=wlan0, mac=ac:22:0b:3e:d4
...

顾名思义,猜想wlan0对应的mac地址应该就是我们要找的。

小心求证

既然NetworkInterface可以正常获取,那得好好看看它在 Android framework 中的实现源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public byte[] getHardwareAddress() throws SocketException {
try {
// Parse colon-separated bytes with a trailing newline: "aa:bb:cc:dd:ee:ff\n".
String s = IoUtils.readFileAsString("/sys/class/net/" + name + "/address");
byte[] result = new byte[s.length()/3];
for (int i = 0; i < result.length; ++i) {
result[i] = (byte) Integer.parseInt(s.substring(3*i, 3*i + 2), 16);
}
// We only want to return non-zero hardware addresses.
for (int i = 0; i < result.length; ++i) {
if (result[i] != 0) {
return result;
}
}
return null;
} catch (Exception ex) {
throw rethrowAsSocketException(ex);
}
}

原来MAC地址是直接从"/sys/class/net/" + name + "/address"文件中读取的!

这个name是什么呢?

RecyclerView Tips(2) SortedListAdapter

上一篇说到Tabs+ViewPager+ListView是最常见的组合,这篇就议一议如何用RecyclerView快速实现列表页面。

如一个简单的列表场景:TodoList。

分页加载现有Todo
现有数据基础上增、删、改

RecyclerView的使用在此就不赘述了,本文主要讨论RecyclerView.Adapter的实现

使用最简单的ArrayList实现,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class ListAdapter extends RecyclerView.Adapter<TodoViewHolder> {
final ArrayList<Item> mData;
final LayoutInflater mLayoutInflater;
public SortedListAdapter(Context context) {
mLayoutInflater = LayoutInflater.from(context);
mData = new ArrayList<>();
}
public void addItem(Item item) {
mData.add(item); // 需要自己通知更新
}
@Override
public TodoViewHolder onCreateViewHolder(final ViewGroup parent, int viewType) {
return new TodoViewHolder (
mLayoutInflater.inflate(R.layout.list_todo_item, parent, false));
}
@Override
public void onBindViewHolder(TodoViewHolder holder, int position) {
holder.bindTo(mData.get(position));
}
@Override
public int getItemCount() {
return mData.size();
}
}

这样的Adapter一个显而易见的问题就是,如何做数据的去重

  1. 添加一项数据:最简单的是在addItem()之前,遍历一次mData,定位后再决定是插入还是更新现有数据,并调用notifyItemInserted(pos)
  2. 添加多个数据:多次重复上面的方法…

对于少量数据来说这样做并不见得有什么问题,而且写得多了,都有自己封装好的诸如ArrayObjectAdapter之类方便使用。

这样就够了吗?

答案肯定是不。Android Support Library 悄悄给我们提供了一个叫SortedList的工具类,它默默的藏在support库的角落中,鲜为人知。

SortedList?

文档对它的定义:

  1. 是一个有序列表
  2. 数据变动会触发回调SortedList.Callback的方法,如onChanged()