Android - 支持multidex后NoClassDefFoundError的解决办法

问题

Android应用支持multiDex以后,在某些5.0以下的机型上,会遇到某些类由于没有被打进MainDex中,而导致的 NoClassDefFoundError 错误。

更多关于 ClassNotFoundException 和 NoClassDefFoundError 的讨论 可以到这里查看.

解决方式

过时的方式

在使用Android gradle build tools 1.2.3 版本的时候,我们在 build.gradle 的 defaultConfig 中加入一个非公开属性 multiDexKeepFile

multiDexKeepFile file(“multidex.keep”)

这个multidex.keep的内容,是要保持类在MainDex中的Class列表,如:

com/alibaba/mobileim/channel/util/SimpleKVStore.class
com/alibaba/mobileim/channel/util/SimpleKVStore$SingletonHolder.class
android/webkit/JniUtil.class
mtopsdk/xstate/XState.class
android/app/ANRManagerProxy.class

新的方式

但是随着升级 build tools 到 1.3.+ 的版本以后,这个功能就失效了。

分析

查询了代码 1.2.3 和 1.3.1 的 build tools源码后,发现原来在 1.2.3版本 TaskManager.groovy 中 createPostCompilationTasks()方法里读取 multiDexKeepFile属性的代码都被干掉了。

File multiDexKeepFile = config.getMultiDexKeepFile();

1.3.0 及以后的版本中,优化了代码结构,设计了一些新的用于处理MultiDex的Task类:

CreateMainDexList.groovy
CreateManifestKeepList.groovy
JarMergingTask.groovy
RetraceMainDexList.groovy

通过查看CreateMainDexList.groovy 可以看出这个任务最终
会生成一个存储了所有应该在MainDex中的类列表的文件:maindexlist.txt

生成的文件路径是从 VariantScope.java getMainDexListFile()中获取的:

1
2
3
4
5
@NonNull
public File getMainDexListFile() {
return new File(globalScope.getIntermediatesDir(), "multi-dex/" +
getVariantConfiguration().getDirName() + "/maindexlist.txt");
}

最终生成的这个 maindexlist.txt 文件会被DexProcessBuilder.java通过--multi-dex --main-dex-list参数传递给Build tools中的 dx.jar(在Android sdk跟目录下有个build-tools目录,你可以在这里找到这个jar),dx.jar会用这个列表文件生成MainDex。

解决思路

关键的来了,我们知道新的build tools是通过 createMainDexList 这个Task来生成的maindexlist.txt,然后才给 dx.jar去读取生成 MainDex,那么我们是不是可以hack一下构建过程,在 createMainDexList 执行完以后,我们去修改maindexlist.txt文件,以达到手工设置MainDex内容的目的呢?

代码实现

结合gradle的构建过程,原来的multidex.keep文件位置和内容不变,最终实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
afterEvaluate {
tasks.matching{
it.name.startsWith('create') && it.name.endsWith('MainDexClassList')
}.each { tk ->
tk.doLast {
keepMainMultiDex(tk.outputFile);
}
}
}

/**
* 控制MainDex中的class列表
* 将multidex.keep的内容追加到 maindexlist.txt 中
* @param outputFile
*/

def keepMainMultiDex(File outputFile){
File keepFile = file("multidex.keep");
outputFile << '\n'
outputFile << keepFile.getText('UTF-8')
}

打完收工~

Android - 琢磨隐藏前台服务的过程-隐藏的!

在国内,Android平台下的应用因为种种原因,都必须要各自做各自的推送通道。然而,由于Android系统的特性,这些需要保持通道连接的应用,又要想方设法的防止被系统杀掉。(这时候真的觉得Apple的APNs 是一个神一样的存在!)

于是乎各种综合手段就都会用上了,比如分出一个守护进程,这个进程做的都是轻量的事情,甚至只检测主要进程的存活性。同时又在这个进程中启动一个前台服务(Foreground Service),尽可能减少这个守护进程被系统回收的机会。这里我就只说一下前台服务的事。

Android 系统的内存回收真正的执行者是 LowMemoryKiller(这里不做过多描述,可以参考: http://www.cnblogs.com/angeldevil/archive/2013/05/21/3090872.html) ,它将进程分了几个层次,其中最高层次的是:

// This is the process running the current foreground app.  We'd really
// rather not kill it!
static final int FOREGROUND_APP_ADJ = 0;

也就是说,前台进程是最后了,逼不得已的情况下才会去释放它占用的内存的。前台进程就是当前正在前台显示的,用户正在操作的界面,或者是一个前台的服务。

Android系统要求前台服务必须要发送一个Notification,以便告知用户有应用一直在运行中,让用户感知到这个应用可能会耗费他的电池或流量。但是有些时候,我们确实想隐藏这种Notification,还要保持在前台服务的运行,因为常驻通知栏的图标,有的用户需要,有的用户则强烈要求去掉。

我们发现,手机QQ有一个前台服务,可是状态栏和通知中心都看不到QQ的任何通知图标,用如下命令可以dump出来系统中正在运行的所有的Service :

adb shell dumpsys activity services

可以看到手机QQ有三个Service:

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
* ServiceRecord{43a6db20 u0 com.tencent.mobileqq/.app.CoreService}
intent={cmp=com.tencent.mobileqq/.app.CoreService}
packageName=com.tencent.mobileqq
processName=com.tencent.mobileqq
baseDir=/data/app/com.tencent.mobileqq-2.apk
dataDir=/data/data/com.tencent.mobileqq
app=ProcessRecord{435c1a98 8126:com.tencent.mobileqq/u0a10145}
createTime=-9m52s660ms lastActivity=-4m52s742ms
executingStart=-4m52s742ms restartTime=-9m52s660ms
startRequested=true stopIfKilled=false callStart=true lastStartId=7


* ServiceRecord{4378c818 u0 com.tencent.mobileqq/.app.CoreService$KernelService}
intent={cmp=com.tencent.mobileqq/.app.CoreService$KernelService}
packageName=com.tencent.mobileqq
processName=com.tencent.mobileqq
baseDir=/data/app/com.tencent.mobileqq-2.apk
dataDir=/data/data/com.tencent.mobileqq
app=ProcessRecord{435c1a98 8126:com.tencent.mobileqq/u0a10145}
isForeground=true foregroundId=537041609 foregroundNoti=Notification(pri=0 icon=7f020314 contentView=com.tencent.mobileqq/0x10900b4 vibrate=null sound=null defaults=0x0 flags=0x62 when=1429702926311 ledARGB=0x0 contentIntent=Y deleteIntent=N contentTitle=QQ正在执行中 contentText=触控来取得更多信息,或停止应用程序 tickerText=N kind=[null])
createTime=-9m51s902ms lastActivity=-9m51s902ms
executingStart=-9m51s897ms restartTime=-9m51s902ms
startRequested=true stopIfKilled=true callStart=true lastStartId=1


* ServiceRecord{43a12670 u0 com.tencent.mobileqq/.msf.service.MsfService}
intent={cmp=com.tencent.mobileqq/.msf.service.MsfService}
packageName=com.tencent.mobileqq
processName=com.tencent.mobileqq:MSF
baseDir=/data/app/com.tencent.mobileqq-2.apk
dataDir=/data/data/com.tencent.mobileqq
app=ProcessRecord{432900e0 3974:com.tencent.mobileqq:MSF/u0a10145}
createTime=-8d3h5m17s705ms lastActivity=-9m50s414ms
……………………

注意上面的标红的 .app.CoreService$KernelService ,其中有一个 isForeground=true 属性,其值为true,并且也设置了 foregroundNoti 的值,从这个Notification也看不出来有什么异常。
一开始以为他们用了什么手段搞了个看不见的通知,比如透明的图标或者高度为0的RemoteView,我也按这种思路去尝试了下,总有一个占位的Notification在。再 dump 出 Notification :

adb shell dumpsys statusbar

查看 Notification list ,可以看到我做的透明的通知,却看不到有他们的任何 StatusBarNotification 那么它是怎么做到隐藏这个通知的呢?试试反编,看看他们的代码!

于是我就用 Android反编工具 试着反编手机QQ最新版 5.5.1.2435 ,直接就失败了,因为他们做了反apkTool的工作,无法用apkTool去解压他们的apk包。没关系,那我就直接解压缩QQ的安装包,毕竟apk也就是一种zip格式嘛。unzip解压后,可以看到目录中有一个 9.9M 的 classes.dex , 用 dex2jar 2.0 反编译它:

./decompileAndroid/dex2jar-2.0/d2j-dex2jar.sh -o source.jar classes.dex

竟然成功了!!用JD—GUI,打开这个source.jar文件,找到 CoreService 和其内部类 KernelService : com.tencent.mobileqq.app.CoreService 、 com.tencent.mobileqq.app.CoreService$KernelService ,代码内容我就不贴出来了,有兴趣的同学可以自己动动手或者从附件中下载。这里我只说下我理解的大致过程:

1、 CoreService 是一个假的Service,应用启动时即开始启动这个Service,这个Service 会在 onCreate() 中就 startForeground() ,传的 Notification 是 new 出的一个空的通知,通知 ID 为固定的值。

2、 启动完 CoreService 就开始启动真正的 KernelService

3、 KernelService中将 CoreService 再次 startForeground() ,然后再把自己 startForeground() ,最后再把 CoreService stopForeground() 掉。这是非常关键的一步。同时,要确保两个 Service startForeground()时使用的 Notification ID 都是同一个!

最终,仿照手机QQ的实现代码为:

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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131

public class MessageCenterService extends Service {

private static final String sTAG = "MessageCenterService";

//9521 就是你的终身代号 :)
static final int NOTIFY_ID = 9521;

private static MessageCenterService instance;

public static MessageCenterService getInstance() {
return instance;
}

/**
* 启动前台服务
*/

public static void start() {
try {
Intent intent = new Intent(App.getContext(), MessageCenterService.class);
App.getContext().startService(intent);
} catch (Exception e) {
LogUtil.e(sTAG, "", e);
}
}

/**
* 终止前台服务。包含{@link MessageCenterService.KernelService}
*/

public static void stop() {
try {
Intent intent = new Intent(App.getContext(), MessageCenterService.class);
App.getContext().stopService(intent);

stopKernel();
} catch (Exception e) {
LogUtil.e(sTAG, "", e);
}
}

static void startKernel() {
try {
Intent intent = new Intent(App.getContext(), KernelService.class);
App.getContext().startService(intent);
} catch (Exception e) {
LogUtil.e(sTAG, "", e);
}
}

static void stopKernel() {
try {
Intent intent = new Intent(App.getContext(), KernelService.class);
App.getContext().stopService(intent);
} catch (Exception e) {
LogUtil.e(sTAG, "", e);
}
}

@Override
public void onCreate() {
super.onCreate();
instance = this;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
startForeground(NOTIFY_ID, new Notification());
}
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
//启动真正的Service
startKernel();
return super.onStartCommand(intent, flags, startId);
}

@Override
public void onDestroy() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
stopForeground(true);
}
super.onDestroy();
}

@Override
public IBinder onBind(Intent intent) {
return null;
}


/**
*
*/

public static class KernelService extends Service {
private static KernelService instance;

public static KernelService getInstance() {
return instance;
}

@Override
public void onCreate() {
super.onCreate();
instance = this;
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
super.onStartCommand(intent, flags, startId);
try {
MessageCenterService fakeService = MessageCenterService.getInstance();
fakeService.startForeground(NOTIFY_ID, new Notification());
startForeground(NOTIFY_ID, new Notification());
fakeService.stopForeground(true);
} catch (Exception e) {
LogUtil.e(sTAG, " **** Can not start foreground service !! ****", e);
}
return START_STICKY;
}

@Override
public void onDestroy() {
stopForeground(true);
instance = null;
super.onDestroy();
}

@Override
public IBinder onBind(Intent intent) {
return null;
}
}
}

里面有一些自己的工具类什么的,大家可以替换为自己的东西,同时别忘了在 AndroidManifest.xml中定义下这两个Service:

<service
     android:name=".service.MessageCenterService$KernelService"
     android:label="@string/message_center_service"
     android:exported="false"/>
<service
     android:name=".service.MessageCenterService"
     android:label="@string/message_center_service"
     android:exported="false"/>

今天又去stackOverflow上有针对性的查了下这个方法,还真有人说过这种处理方式:

http://stackoverflow.com/a/18281520

以上就可以实现一个隐藏的前台服务,增加应用的存活率。看下LowMemoryKiller的分析后,你就应该知道,无论是那种级别的进程,都会有一个内存占用的阈值的,超过这个阈值同样会被杀,所以,优化好应用的内存使用也同样重要。

Android - AppCompat-v7包踩坑记:活动菜单

#坑
最近为了使用Android 5.0里的一些UI特性,又懒得写一堆不同系统版本的styles文件。就引入了AppCompat-v7 Support Library ,使用了 AppCompatActivity ,可是在测试期间就发现了兼容性问题: 小米手机 和 三星S6 无法打开活动菜单(OptionsMenu)。

多扯一点,对于OptionsMenu,官方文档 中说:

Beginning with Android 3.0, the Menu button is deprecated (some devices don’t have one), so you should migrate toward using the action bar to provide access to actions and other options.

千牛Android没有使用 ActionBar 或 ToolBar,因此只能弹出老式的活动菜单。不过活动菜单中的功能在千牛的界面中都是有入口的,活动菜单只是作为一个便捷的入口而提供的。

上面说到了小米手机和三星S6的问题,他们都有一个共同的特点,就是没有Menu键。Menu键都是通过长按某个按键模拟的:小米的菜单按键在新的MIUI中,单击是显示最近任务,长按才是菜单键。而三星S6是长按返回键模拟菜单键。

#分析
为了兼容各个版本的系统,AppCompatActivity中实际是使用了一个 AppCompatDelegate 来处理Activity的各个生命周期及事件回调。这个抽象类目前有这几个包访问级别的子类: AppCompatDelegateImplV7AppCompatDelegateImplV11AppCompatDelegateImplV14,他们负责对具体的系统版本做具体的处理,他们之间也存在继承关系。

##小米
通过追踪dispatchKeyEvent() 方法,一路下来,追到了 AppCompatDelegateImplV7.onKeyDownPanel() 方法,其代码如下:

1
2
3
4
5
6
7
8
9
10
private boolean onKeyDownPanel(int featureId, KeyEvent event) {
if (event.getRepeatCount() == 0) {
PanelFeatureState st = getPanelState(featureId, true);
if (!st.isOpen) {
return preparePanel(st, event);
}
}

return false;
}

这里的if (event.getRepeatCount() == 0) 决定了是否显示菜单的Panel。

某个键被长按时,这个键的 getRepeatCount 是会从0开始往上递增的,由于MIUI是在长按设备上的菜单键时才会把菜单键的点击事件交给App,单击菜单键就被系统拦截,显示最近任务。

当Activity收到Key code 为 82 的菜单键事件时,这个KeyEvent.getRepeatCount()取到的是已经不是0了,因为长按会把它累加成一个大于0的数字。

正是由于KeyEvent.getRepeatCount()大于0,才因为AppCompatDelegateImplV7.onKeyDownPanel()方法里的判断,菜单弹出事件而被过滤掉。

知道原因了,那我们就想办法把 dispatchKeyEvent() 获取到的KeyEvent中的getRepeatCount()值改成 0 就好了。

##三星S6
三星S6是通过长按返回键(Key code = 4)来模拟的菜单键(Key code = 82),在这款手机上的表现是,显示了菜单但是马上又被关闭了。
通过增加日志:

event.getAction(): 0 event.getKeyCode(): 4 event.getRepeatCount(): 0
event.getAction(): 0 event.getKeyCode(): 4 event.getRepeatCount(): 10
event.getAction(): 0 event.getKeyCode(): 82 event.getRepeatCount(): 0
event.getAction(): 1 event.getKeyCode(): 82 event.getRepeatCount(): 0
event.getAction(): 0 event.getKeyCode(): 4 event.getRepeatCount(): 0

eventAction = 1 表示 KeyUp , 0 表示 KeyDown

可以看出在长按返回键的过程中会发出模拟的菜单键的KeyDown 和 KeyUp事件,但是返回键的KeyUp在菜单键的事件完成后才执行。这就要了命了,我菜单刚显示出来,你一个返回键的KeyUp事件把我的菜单就又给关闭了。

#填坑代码
综上分析,解决办法如下:

AppCompatActivity 的子类

在 AppCompatActivity 子类的 onCreate() 方法中指定自定义的 WindowCallback ,代理掉 dispatchKeyEvent() ,利用装饰者模式,处理键盘事件,又不影响AppCompatActivity中原来的逻辑:

1
2
3
4
5
6
7
if(getWindow().getCallback() != null) {
getWindow().setCallback(new AppCompatWindowCallbackWrapper(getWindow().getCallback()));
}
```

## 实现 WindowCallbackWrapper
以下代码中有注释,就不多说了

package com.taobao.qianniu.common.widget;

import android.support.v7.internal.view.WindowCallbackWrapper;
import android.view.KeyEvent;
import android.view.Window;

import com.taobao.qianniu.common.utils.PhoneInfo;
import com.taobao.qianniu.component.utils.LogUtil;
import com.taobao.qianniu.controller.common.debugmode.DebugController;
import com.taobao.qianniu.controller.common.debugmode.DebugKey;

/**

  • 为了解决Support V7包的 AppCompatActivity 中对菜单事件的兼容问题,
  • 如小米手机和三星S6 通过长按某个按键模拟菜单键的问题
    *
  • @author jinzhaoyu
    */
    public class AppCompatWindowCallbackWrapper extends WindowCallbackWrapper {
    private static final String sTAG = “AppCompatWindowCallbackWrapper”;

    public AppCompatWindowCallbackWrapper(Window.Callback wrapped) {

    super(wrapped);
    

    }

    @Override
    public boolean dispatchKeyEvent(KeyEvent event) {

    KeyEvent newEvent = event;
    if(DebugController.isEnable(DebugKey.LOG_DEBUG)) {
        LogUtil.d(sTAG, "KeyEvent.getAction:" + event.getAction() + "    getKeyCode:" + event.getKeyCode() + "    getRepeatCount:" + event.getRepeatCount());
    }
    if (PhoneInfo.isXiaoMiMobile()) {
        //解决小米手机,长按菜单键才能调出菜单的问题
        if (event.getAction() == KeyEvent.ACTION_DOWN
                && event.getKeyCode() == KeyEvent.KEYCODE_MENU
                && event.getRepeatCount() > 0) {
            //需要将event.mRepeatCount设置为0,才能绕过 AppCompatDelegateImplV7#onKeyDownPanel
            //方法中对repeatCount的条件判断,进而弹出菜单。
            newEvent = KeyEvent.changeTimeRepeat(event, event.getEventTime(), 0);
        }
    } else if (PhoneInfo.isSamsungMobile()) {
        //解决三星S6,长按返回键调出菜单的问题
        if (checkSamsungKeyEvent(event)){
            return true;
        }
    }
    return super.dispatchKeyEvent(newEvent);
    

    }

private boolean isSamsungBackLongPressed = false;
private boolean isSamsungMockMenuKey = false;
/**
 * 兼容三星手机用长按返回模拟菜单键的问题
 * @param keyEvent
 * @return
 */
private boolean checkSamsungKeyEvent(KeyEvent keyEvent){
    switch (keyEvent.getAction()){
        case KeyEvent.ACTION_DOWN:
            //三星S6 通过长按返回键模拟菜单键
            if(keyEvent.getKeyCode() == KeyEvent.KEYCODE_BACK && keyEvent.getRepeatCount() > 0){
                isSamsungBackLongPressed = true;
            }
            //如果在长按返回没有结束时,收到了菜单键的KeyDown,那么就是Mock的菜单
            if(isSamsungBackLongPressed && keyEvent.getKeyCode() == KeyEvent.KEYCODE_MENU){
                isSamsungMockMenuKey = true;
            }
            break;
        case KeyEvent.ACTION_UP:
            if(keyEvent.getKeyCode() == KeyEvent.KEYCODE_BACK){
                isSamsungBackLongPressed = false;
                if(isSamsungMockMenuKey){
                    isSamsungMockMenuKey = false;
                    return true;
                }
            }
            break;

    }
    return false;
}

}
```

可能还有其他机型有类似的问题,也只能碰到一例解决一例了。或者让这逆潮流的功能慢慢消失也不一定是件坏事。