MADEYE

使用 Scala 编写 Android 应用

Why?

Android 开发一直有两个无法回避的问题:基于 JVM 的设计使得系统的性能受到拖累;Java 冗繁的语法令人绝望。

手机性能的大幅提升、Android 系统的持续改进,以及应用中 NDK 的广泛使用使得 JVM 带来的额外开销变得微不足道。但 Java 作为一门民工语言已经远远落后于时代潮流则是无法改变的事实。更令人揪心的是,落在 Oracle 手上的 Java 对于整个开源社区来说都是潜在的威胁,这一点从 Oracle 针对 Google 的一系列 Android 相关的诉讼就可以大概明了。

Scala 作为一门 state-of-the-art 的编程语言,兼具面向对象以及函数式语言的特点。其设计在 JVM 之上,与 Java 类库完全兼容,甚至可以与 Java 代码相互转换。另外 Scala 以类似 BSD 的协议发布,对于开源社区也更为友好。总的来看,Scala 是当前替代 Java 的最好选择,在不需要放弃已经无比先进的 JVM 和足够完备的 Java 生态的前提下,开发者们可以获得更先进的语言特性和更高的开发效率。实际上类似的目标在 Groovy 和 JRuby 中都有所体现,但都做得不好。

至于 Android 开发,Scala 则提供了全新的体验。以一段常见的 Android 代码为例,在配合 Scaloid 的情况下代码量可以大大减少。

Register BroadcastReceiver in Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
BroadcastReceiver connectivityListener = null;

void onResume() {
  super.onResume();
  // ...
  connectivityListener = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
      doSomething();    
    }
  };
  registerReceiver(connectivityListener, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
}

void onPause() {
  unregisterReceiver(connectivityListener);
  // ...
  super.onPause();
}

上面是 Java 的实现,可以看出两个问题:Java 的回调用的是匿名类,显得颇为繁琐;注册与反注册这样一对调用需要成对出现,当代码量变大时,一不小心就会漏掉。

Register BroadcastReceiver in Scala
1
2
3
broadcastReceiver(ConnectivityManager.CONNECTIVITY_ACTION) { (context, intent) =>
  doSomething()
}

同样的功能,用 Scala 实现的代码可以非常简洁:函数式的写法替代了匿名类;隐式的方法被用来统一管理生命周期。

类似的例子还有很多,在这里就不逐一列出了,有兴趣的可以关注 Scala Android Blog

How?

现阶段使用 Scala 开发的 Android 应用还不多,技术上也不够统一。在被坑了几次后,总算有了一套靠谱的方案。

若是想要在现有的 Android 项目的基础上进行重构,建议先将项目 Maven 化,具体方法可以见本博客的上一篇文章。在此基础上通过将 Maven 替换为 SBT,并引入 sbt-android-plugin 这个插件,可以快速重构为标准的 Scala 项目结构。一个典型的 Scala Android 项目如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
project/
  Build.scala
  plugins.sbt
src/
  main/
    res/
       <resource files>
    assets/
       <asset files>
    jni/
       <native codes>
    libs/
       <jar and native libraries>
    scala/
       <main Scala sources>
    java/
       <main Java sources>
    AndroidManifest.xml
       <manifest template>
  test/
    scala/
       <test Scala sources>
    java/
       <test Java sources>

需要注意的是项目中的 AndroidManifest.xml 是不含 android:versionandroid:versionCode 这两个属性的。这两个属性会根据 Build.scala 中的设定自动生成。

在重构代码之前,我们可以把已有的 Java 和 JNI 代码放置到相应目录中,将所有的依赖加入 Build.scala 文件或放在 libs 文件夹下。之后则可以挨个的将原来的 Java 代码重构为 Scala。

Scala 是支持和 Java 混合编译的,因此你可以随时执行以下命令编译并测试:

1
2
3
4
5
# build debug apk
sbt android:package-debug

# build signed apk
sbt android:prepare-market

Android 的接口是专为 Java 设计的,而为了写出更加地道的 Scala 代码,建议再引入 Scaloid 来简化 API 的调用。当熟练使用 Scala 编写代码后,代码量可以减少至少一半。

一个完整的例子可以见我的 shadowsocks-android 项目。而更多的细节请参考 sbt-android-plugin 的 Wiki 页面。

Tips

学习 Scala

Scala 虽然许多地方长的和 Java 很像,但是想要写出「函数式」的风格需要重新学习很多内容。对于比较资深的 Java 程序员,建议直接去看《Programming in Scala》这本书,和《Scala API Doc》。之前还翻过一本《Scala for the Impatient》,标题很诱人但内容太浅显,这里不做推荐。

sbt-android-plugin

由于缺乏文档,sbt-android-plugin 里有不少的坑,这里大概列一下:

  • 签名用的 keystore 要位于 ~/.keystore
  • 若是项目依赖于 APK Library,如 ActionBarSherlock。请务必在 Build.scala 中将 compileOrder 设定为 CompileOrder.JavaThenScala
  • Scala 对于 Java 7 的支持不好,所以如果系统中装的是 JDK 7 以上版本,务必在 javacOptions 中加入 Seq("-source", "1.6", "-target", "1.6")

Proguard

Android 上是没有 Scala 标准库的,但若是将所有 Scala 的库都打包进 APK,体积上会非常惊人(>20MB)。因此 sbt-android-plugin 默认会对没有用到的类和方法进行精简。由于其规则过于激进,偶尔会发生代码被裁减的问题。比如一个自定义的 View,且只在布局文件中被使用,这时 Proguard 因无法从代码中检测到相关引用而会错误的将其裁减。因此建议你至少加入以下规则:

1
2
3
4
5
-keep class android.support.v4.app.** { *; }
-keep interface android.support.v4.app.** { *; }
-keep class com.actionbarsherlock.** { *; }
-keep class your.project.** { *; }
-keepattributes *Annotation*

Happy Hacking!

使用 Maven 和 Travis 构建 Android 应用

Android 应用开发时往往会涉及大量的依赖,官方的做法是将所有依赖放在 libs 目录下,使用 eclipse 或 ant 构建项目时都可以自动从中寻找到相应的 jar 包或 so 库。这样的实现似乎显得不够优雅,而最为理想的实现则是所有依赖都可以像 gem 或者 pip 那样在构建时自动的下载到本地并编译。

对于 Java 来说,Maven 是当下最为流行的自动构建方案,通过编写配置文件的方式,我们可以非常容易的自动化解决依赖,编译与链接,单元测试,以及安装与部署。具体到 Android 应用的构建,还需要引入 android-maven-plugin 来预编译资源文件、转换为 dex 格式、打包成 apk 文件、对 apk 进行签名等。一些现成的例子可以见这个仓库:android-maven-samples

若要把已经存在 Android 项目改写成由 Maven 构建并不需要太多的工作,只是有些细节需要注意。

  1. 使用 maven-android-sdk-deployer 来链接 Google Maps / Play 等闭源依赖。Maven 的公共仓库中不会有闭源的 jar 包,因此 Google 相关的依赖需要在本地通过 SDK 来导入。这种做法的最大问题依然是不够优美,人为的造成构建环节变得复杂。因此有一种不算办法的办法是自己搭建私人的 Maven 仓库,然后把这个仓库放在 GitHub 或者 Google Code 上。一个简单的例子见我在 GitHub 上的仓库:madeye/maven-repository
  2. Maven 对 apk 文件进行签名时需要为其指定 keystore 和 alias。当涉及到多个 key 时(比如 release 和 debug 两个版本的 key),可以通过 profile 的方式解决。一个更加优美的方式是利用 properties 文件来指定不同的 key 文件,不过需要引入 properties-maven-plugin
  3. 自动为 apk 文件命名并自动修改 AndroidManifest。通过使用 android-maven-plugin 可以非常方便为生成的 apk 文件命名,比如 ${project.artifactId}-${project.version}.apk 意味着生成的 apk 文件将以版本号结尾,另外还可以额外的加入编译时间、构建号、渠道类型等信息。

完成对 Android 项目的 Maven 化改造后,第一个用处就是引入持续集成系统。当前最成熟的持续集成系统是 Jenkins,但考虑到持续集成系统部署与维护的成本较高,若你恰好正在 GitHub 上做一款开源的应用,那么 Travis 则是你最好的选择。Travis 是一款非常灵活的新型持续集成系统,虽然暂时功能还不完善(缺少 Jenkins 庞大的社区与插件支持),但用在 Android 应用构建上已经绰绰有余。

Travis 依赖于 .travis.yml 这样一个配置文件来完成整个构建流程。对于 Android 项目,可以直接套用下面这个配置文件:

Sample Travis configuration for Android Project
1
2
3
4
5
6
7
8
9
10
11
language: java
before_install:
  - wget http://dl.google.com/android/android-sdk_r21-linux.tgz
  - tar -zxf android-sdk_r21-linux.tgz
  - export ANDROID_HOME=~/builds/username/project/android-sdk-linux
  - export PATH=${PATH}:${ANDROID_HOME}/tools:${ANDROID_HOME}/platform-tools
  - android update sdk --filter tools,platform-tools,android-16,addon-google_apis-google-16,extra-android-support,extra-google-admob_ads_sdk,extra-google-analytics_sdk,extra-google-gcm,extra-google-play_apk_expansion,extra-google-play_billing,extra-google-play_licensing --no-ui --force --no-https -a
install:
  - sudo pip install python-swiftclient
script:
  - mvn clean install

简单解释一下上面的配置。首先我们在 before_install 小节完成了 Android SDK 的下载与环境变量的设置。需要注意的是 android update adk 时使用的 filter 关键词,针对不同的应用,我们要在这儿安装不同的 SDK 版本与相关的闭源依赖。

另外如例子中 pip 一句,在 Travis 环境中我们甚至可以使用 sudo 命令安装某些依赖,这儿不用担心安全性的问题,Travis 的所有构建节点都在不同的虚拟机实例中互不干扰,每一次新的构建都会退回到一个完全干净的 SNAPSHORT。

在 Travis 的构建环境中,我们还可以引入自动测试的环节,甚至直接将构建结果部署到开发环境或发布 Nightly Build。值得称赞的是 Travis 的构建服务器性能非常棒,从下载所有依赖到完成整个项目的构建往往只需要几分钟时间。

于是一个理想的开发环境是这样:使用任意的设备在任意的地点编写修改代码,偶尔连接一次网络提交代码并等待构建结果。

更多细节可以参考我的几个开源项目:github/madeye

修改 Android 系统的 Wifi 代理设置

介绍一个最近发现的 Android 私有 API

这个 API 只工作在 Android 4.0 以上的系统中,并且存在着一定的副作用。代码如下:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package me.madeye;

import android.content.Context;
import android.net.ProxyProperties;
import android.net.wifi.WifiConfiguration;
import android.net.wifi.WifiManager;
import android.os.Handler;
import android.util.Log;

import java.util.List;

public class WifiProxyManager {

  private static int networkID = -1;

  private static final String TAG = "WifiProxyManager";

  public static boolean setWifiProxy(String proxy, int port, Context context) {

    try {

      Handler handler = new Handler(context.getMainLooper());

      final WifiManager manager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);

      if (!manager.isWifiEnabled()) return true;
      List<WifiConfiguration> configurationList = manager.getConfiguredNetworks();
      WifiConfiguration configuration = null;
      int cur = manager.getConnectionInfo().getNetworkId();
      for (WifiConfiguration wifiConfiguration : configurationList) {
        if (wifiConfiguration.networkId == cur)
          configuration = wifiConfiguration;
      }
      if (configuration == null) return true;

      WifiConfiguration config = new WifiConfiguration(configuration);
      config.ipAssignment = WifiConfiguration.IpAssignment.UNASSIGNED;
      config.proxySettings = WifiConfiguration.ProxySettings.STATIC;
      config.linkProperties.clear();

      config.linkProperties.setHttpProxy(new ProxyProperties("127.0.0.1", port, ""));
      manager.updateNetwork(config);

      manager.setWifiEnabled(false);
      handler.postDelayed(new Runnable() {
        @Override
        public void run() {
          manager.setWifiEnabled(true);
        }
      }, 1000);

      networkID = cur;
    } catch (Exception ignored) {
      // Ignore all private API exception
      Log.d(TAG, "Non support API", ignored);
      return false;
    }
    return true;
  }

  public static void clearWifiProxy(Context context) {
    if (networkID == -1) return;
    try {

      Handler handler = new Handler(context.getMainLooper());

      final WifiManager manager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
      List<WifiConfiguration> configurationList = manager.getConfiguredNetworks();
      WifiConfiguration configuration = null;
      for (WifiConfiguration wifiConfiguration : configurationList) {
        if (wifiConfiguration.networkId == networkID)
          configuration = wifiConfiguration;
      }
      if (configuration == null) return;

      WifiConfiguration config = new WifiConfiguration(configuration);
      config.ipAssignment = WifiConfiguration.IpAssignment.UNASSIGNED;
      config.proxySettings = WifiConfiguration.ProxySettings.NONE;
      config.linkProperties.clear();

      manager.updateNetwork(config);

      manager.setWifiEnabled(false);
      handler.postDelayed(new Runnable() {
        @Override
        public void run() {
          manager.setWifiEnabled(true);
        }
      }, 1000);

      networkID = -1;
    } catch (Exception ignored) {
      // Ignore all private API exception
      Log.d(TAG, "Non support API", ignored);
    }
  }
}

逻辑上比较简单,主要作用就是覆盖当前 Wifi 连接的代理设置,包括代理的地址和端口。代理设置后不会立即生效,需要等待用户重新连接或者由代码自动重启连接,后者可能会造成短暂的断网。

想要正常编译这个文件需要使用经过特殊处理的 SDK,假若你的项目恰好是使用 Maven 来管理的,那么你可以直接添加以下内容到你的 pom.xml 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<repositories>
    <repository>
        <id>madeye-maven-repository</id>
        <url>http://madeye-maven-repository.googlecode.com/git</url>
        <releases>
            <enabled>true</enabled>
        </releases>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
</repositories>

<dependencies>
    <dependency>
        <groupId>com.google.android</groupId>
        <artifactId>android</artifactId>
        <version>15</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

其实这个 Private API 的潜在危险是很大的,恶意程序可以在用户完全没有察觉的情况下拦截整个系统的所有 HTTP 请求,并被用来钓鱼、中间人攻击等。