1.概述
在Android系统中,闹钟和唤醒功能都是由Alarm Manager Service控制并管理的。我们所熟悉的RTC闹钟以及定时器都和它有莫大的关系。为了便于称呼,我常常也把这个service简称为ALMS。
另外,ALMS还提供了一个AlarmManager辅助类。在实际的代码中,应用程序一般都是通过这个辅助类来和ALMS打交道的。就代码而言,辅助类只不过是把一些逻辑语义传递给ALMS服务端而已,具体怎么做则完全要看ALMS的实现代码了。
ALMS的实现代码并不算太复杂,主要只是在管理“逻辑闹钟”。它把逻辑闹钟分成几个大类,分别记录在不同的列表中。然后ALMS会在一个专门的线程中循环等待闹钟的激发,一旦时机到了,就“回调”逻辑闹钟对应的动作。
以上只是一些概要性的介绍,下面我们来看具体的技术细节。
2.AlarmManager
前文我们已经说过,ALMS只是服务端的东西。它必须向外提供具体的接口,才能被外界使用。在Android平台中,ALMS的外部接口为IAlarmManager。其定义位于frameworksasecorejavaandroidappIAlarmManager.aidl脚本中,定义截选如下:
interface IAlarmManager {
void set(int type, long triggerAtTime, in PendingIntent operation);
void setRepeating(int type, long triggerAtTime, long interval, in PendingIntent operation);
void setInexactRepeating(int type, long triggerAtTime, long interval, in PendingIntent operation);
void setTime(long millis);
void setTimeZone(String zone);
void remove(in PendingIntent operation);
}
在一般情况下,service的使用者会通过Service Manager Service接口,先拿到它感兴趣的service对应的代理I接口,然后再调用I接口的成员函数向service发出请求。所以按理说,我们也应该先拿到一个IAlarmManager接口,然后再使用它。可是,对Alarm Manager Service来说,情况略有不同,其最常见的调用方式如下:
manager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
其中,getSystemService()返回的不再是IAlarmManager接口,而是AlarmManager对象。
我们参考AlarmManager.java文件,可以看到AlarmManager类中聚合了一个IAlarmManager接口,
private final IAlarmManager mService;
也就是说在执行实际动作时,AlarmManager只不过是把外界的请求转发给内部聚合的IAlarmManager接口而已。
2.1 AlarmManager的成员函数
AlarmManager的成员函数有:
AlarmManager(IAlarmManager service)
public
void set(int type, long triggerAtTime, PendingIntent operation)
public
void setRepeating(int type, long triggerAtTime, long interval,
PendingIntent operation)
public void setInexactRepeating(int type, long triggerAtTime, long interval,
PendingIntent operation)
public void cancel(PendingIntent operation)
public void setTime(long millis)
public void setTimeZone(String timeZone)
即1个构造函数,6个功能函数。基本上完全和IAlarmManager的成员函数一一对应。
另外,AlarmManager类中会以不同的公共常量来表示多种不同的逻辑闹钟,在Android 4.0的原生代码中有4种逻辑闹钟:
1) RTC_WAKEUP
2) RTC
3) ELAPSED_REALTIME_WAKEUP
4) ELAPSED_REALTIME
应用侧通过调用AlarmManager对象的成员函数,可以把语义传递到AlarmManagerService,并由它进行实际的处理。
3.AlarmManagerService
ALMS的重头戏在AlarmManagerService中,这个类继承于IAlarmManager.Stub,所以是个binder实体。它包含的重要成员如下:
其中,mRtcWakeupAlarms等4个ArrayList<Alarm>数组分别对应着前文所说的4种“逻辑闹钟”。为了便于理解,我们可以想象在底层有4个“实体闹钟”,注意,是4个,不是4类。上面每一类“逻辑闹钟”都会对应一个“实体闹钟”,而逻辑闹钟则可以有若干个,它们被存储在ArrayList中,示意图如下:
当然,这里所说的“实体闹钟”只是个概念而已,其具体实现和底层驱动有关,在frameworks层不必过多关心。
Frameworks层应该关心的是那几个ArrayList<Alarm>。这里的Alarm对应着逻辑闹钟。
3.1 逻辑闹钟
Alarm是AlarmManagerService的一个内嵌类Alarm,定义截选如下:
private static class Alarm {
public int type;
public int count;
public long when;
public long repeatInterval;
public PendingIntent operation;
public int uid;
public int pid;
. . . . . .
其中记录了逻辑闹钟的一些关键信息。
- type域:记录着逻辑闹钟的闹钟类型,比如RTC_WAKEUP、ELAPSED_REALTIME_WAKEUP等;
- count域:是个辅助域,它和repeatInterval域一起工作。当repeatInterval大于0时,这个域可被用于计算下一次重复激发alarm的时间,详细情况见后文;
- when域:记录闹钟的激发时间。这个域和type域相关,详细情况见后文;
- repeatInterval域:表示重复激发闹钟的时间间隔,如果闹钟只需激发一次,则此域为0,如果闹钟需要重复激发,此域为以毫秒为单位的时间间隔;
- operation域:记录闹钟激发时应该执行的动作,详细情况见后文;
- uid域:记录设置闹钟的进程的uid;
- pid域:记录设置闹钟的进程的pid。
总体来说还是比较简单的,我们先补充说明一下其中的count域。这个域是针对重复性闹钟的一个辅助域。重复性闹钟的实现机理是,如果当前时刻已经超过闹钟的激发时刻,那么ALMS会先从逻辑闹钟数组中摘取下Alarm节点,并执行闹钟对应的逻辑动作,然后进一步比较“当前时刻”和“Alarm理应激发的理想时刻”之间的时间跨度,从而计算出Alarm的“下一次理应激发的理想时刻”,并将这个激发时间记入Alarm节点,接着将该节点重新排入逻辑闹钟列表。这一点和普通Alarm不太一样,普通Alarm节点摘下后就不再还回逻辑闹钟列表了。
“当前时刻”和“理应激发时刻”之间的时间跨度会随实际的运作情况而变动。我们分两步来说明“下一次理应激发时刻”的计算公式:
1) count = (时间跨度 / repeatInterval ) + 1 ;
2) “下一次理应激发时刻” = “上一次理应激发时刻”+ count * repeatInterval ;
我们画一张示意图,其中绿色的可激发时刻表示“上一次理应激发时刻”,我们假定“当前时刻”分别为now_1处或now_2处,可以看到会计算出不同的“下一次理应激发时刻”,这里用桔红色表示。
可以看到,如果当前时刻为now_1,那么它和“上一次理应激发时刻”之间的“时间跨度”是小于一个repeatInterval的,所以count数为1。而如果当前时刻为now_2,那么“时间跨度”与repeatInterval的商取整后为2,所以count数为3。另外,图中那两个虚线箭头对应的可激发时刻,只是用来做刻度的东西。
3.2 主要行为
接下来我们来看ALMS中的主要行为,这些行为和AlarmManager辅助类提供的成员函数相对应。
3.2.1 设置alarm
外界能接触的设置alarm的函数是set():
public void set(int type, long triggerAtTime, PendingIntent operation)
type:表示要设置的alarm类型。如前文所述,有4个alarm类型。
triggerAtTime:表示alarm“理应激发”的时间。
operation:指明了alarm闹铃激发时需要执行的动作,比如执行某种广播通告。
设置alarm的动作会牵扯到一个发起者。简单地说,发起者会向Alarm Manager Service发出一个设置alarm的请求,而且在请求里注明了到时间后需要执行的动作。由于“待执行的动作”一般都不会马上执行,所以要表达成PendingIntent的形式。(PendingIntent的详情可参考其他文章)
另外,triggerAtTime参数的意义也会随type参数的不同而不同。简单地说,如果type是和RTC相关的话,那么triggerAtTime的值应该是标准时间,即从1970 年 1 月 1 日午夜开始所经过的毫秒数。而如果type是其他类型的话,那么triggerAtTime的值应该是从本次开机开始算起的毫秒数。
3.2.2 重复性alarm
另一个设置alarm的函数是setRepeating():
public
void setRepeating(int type,
long triggerAtTime, long interval,PendingIntent operation)
其参数基本上和set()函数差不多,只是多了一个“时间间隔”参数。事实上,在Alarm Manager Service一侧,set()函数内部也是在调用setRepeating()的,只不过会把interval设成了0。
setRepeating()的实现函数如下:
public void setRepeating(int type, long triggerAtTime,
long interval,
PendingIntent operation)
{
if (operation == null) {
Slog.w(TAG, "set/setRepeating ignored because there is no intent");
return;
}
synchronized (mLock) {
Alarm alarm = new Alarm();
alarm.type = type;
alarm.when = triggerAtTime;
alarm.repeatInterval = interval;
alarm.operation = operation;
// Remove this alarm if already scheduled.
removeLocked(operation);
if (localLOGV) Slog.v(TAG, "set: " + alarm);
int index = addAlarmLocked(alarm);
if (index == 0) {
setLocked(alarm);
}
}
}
代码很简单,会创建一个逻辑闹钟Alarm,而后调用addAlarmLocked()将逻辑闹钟添加到内部逻辑闹钟数组的某个合适位置。
private int addAlarmLocked(Alarm alarm) {
ArrayList<Alarm> alarmList = getAlarmList(alarm.type);
int index = Collections.binarySearch(alarmList, alarm, mIncreasingTimeOrder);
if (index < 0) {
index = 0 - index - 1;
}
if (localLOGV) Slog.v(TAG, "Adding alarm " + alarm + " at " + index);
alarmList.add(index, alarm);
. . . . . .
return index;
}
逻辑闹钟列表是依据alarm的激发时间进行排序的,越早激发的alarm,越靠近第0位。所以,addAlarmLocked()在添加新逻辑闹钟时,需要先用二分查找法快速找到列表中合适的位置,然后再把Alarm对象插入此处。
如果所插入的位置正好是第0位,就说明此时新插入的这个逻辑闹钟将会是本类alarm中最先被激发的alarm,而正如我们前文所述,每一类逻辑闹钟会对应同一个“实体闹钟”,此处我们在第0位设置了新的激发时间,明确表示我们以前对“实体闹钟”设置的激发时间已经不准确了,所以setRepeating()中必须重新调整一下“实体闹钟”的激发时间,于是有了下面的句子:
if (index == 0) {
setLocked(alarm);
}
setLocked()内部会调用native函数set():
private native void set(int fd, int type, long seconds, long nanoseconds);
重新设置“实体闹钟”的激发时间。这个函数内部会调用ioctl()和底层打交道。具体代码可参考frameworks/base/services/jni/com_android_server_AlarmManagerService.cpp文件:
static void android_server_AlarmManagerService_set(JNIEnv* env, jobject obj, jint fd,
jint type, jlong seconds, jlong nanoseconds)
{
struct timespec ts;
ts.tv_sec = seconds;
ts.tv_nsec = nanoseconds;
int result = ioctl(fd, ANDROID_ALARM_SET(type), &ts);
if (result < 0)
{
ALOGE("Unable to set alarm to %lld.%09lld: %s
", seconds, nanoseconds, strerror(errno));
}
}
我们知道,PendingIntent只是frameworks一层的概念,和底层驱动是没有关系的。所以向底层设置alarm时只需要type信息以及激发时间信息就可以了。
3.2.3 取消alarm
用户端是调用AlarmManager对象的cancel()函数来取消alarm的。这个函数内部其实是调用IAlarmManager的remove()函数。所以我们只来看AlarmManagerService的remove()就可以了。
public void remove(PendingIntent operation)
{
if (operation == null) {
return;
}
synchronized (mLock) {
removeLocked(operation);
}
}
注意,在取消alarm时,是以一个PendingIntent对象作为参数的。这个PendingIntent对象正是当初设置alarm时,所传入的那个operation参数。我们不能随便创建一个新的PendingIntent对象来调用remove()函数,否则remove()是不会起作用的。PendingIntent的运作细节不在本文论述范围之内,此处我们只需粗浅地知道,PendingIntent对象在AMS(Activity Manager Service)端会对应一个PendingIntentRecord实体,而ALMS在遍历逻辑闹钟列表时,是根据是否指代相同PendingIntentRecord实体来判断PendingIntent的相符情况的。如果我们随便创建一个PendingIntent对象并传入remove()函数的话,那么在ALMS端势必找不到相符的PendingIntent对象,所以remove()必然无效。
remove()中调用的removeLocked()如下:
public void removeLocked(PendingIntent operation)
{
removeLocked(mRtcWakeupAlarms, operation);
removeLocked(mRtcAlarms, operation);
removeLocked(mElapsedRealtimeWakeupAlarms, operation);
removeLocked(mElapsedRealtimeAlarms, operation);
}
简单地说就是,把4个逻辑闹钟数组都遍历一遍,删除其中所有和operation相符的Alarm节点。removeLocked()的实现代码如下:
private void removeLocked(ArrayList<Alarm> alarmList,
PendingIntent operation)
{
if (alarmList.size() <= 0) {
return;
}
// iterator over the list removing any it where the intent match
Iterator<Alarm> it = alarmList.iterator();
while (it.hasNext()) {
Alarm alarm = it.next();
if (alarm.operation.equals(operation)) {
it.remove();
}
}
}
请注意,所谓的取消alarm,只是删除了对应的逻辑Alarm节点而已,并不会和底层驱动再打什么交道。也就是说,是不存在针对底层“实体闹钟”的删除动作的。所以,底层“实体闹钟”在到时之时,还是会被“激发”出来的,只不过此时在frameworks层,会因为找不到符合要求的“逻辑闹钟”而不做进一步的激发动作。
3.2.4 设置系统时间和时区
AlarmManager还提供设置系统时间的功能,设置者需要具有android.permission.SET_TIME权限。
public void setTime(long millis)
{
mContext.enforceCallingOrSelfPermission("android.permission.SET_TIME", "setTime");
SystemClock.setCurrentTimeMillis(millis);
}
另外,还具有设置时区的功能:
public void setTimeZone(String tz)
相应地,设置者需要具有android.permission.SET_TIME_ZONE权限。
3.3 运作细节
3.3.1 AlarmThread和Alarm的激发
AlarmManagerService内部是如何感知底层激发alarm的呢?首先,AlarmManagerService有一个表示线程的mWaitThread成员:
private final AlarmThread mWaitThread = new AlarmThread();
在AlarmManagerService构造之初,就会启动这个专门的“等待线程”。
public AlarmManagerService(Context context)
{
mContext = context;
mDescriptor = init();
. . . . . .
. . . . . .
if (mDescriptor != -1)
{
mWaitThread.start(); // 启动线程!
}
else
{
Slog.w(TAG, "Failed to open alarm driver. Falling back to a handler.");
}
}
AlarmManagerService的构造函数一开始就会调用一个init()函数,该函数是个native函数,它的内部会打开alarm驱动,并返回驱动文件句柄。只要能够顺利打开alarm驱动,ALMS就可以走到mWaitThread.start()一句,于是“等待线程”就启动了。
3.3.1.1 AlarmThread中的run()
AlarmThread本身是AlarmManagerService中一个继承于Thread的内嵌类:
private class AlarmThread extends Thread
其最核心的run()函数的主要动作流程图如下:
我们分别来阐述上图中的关键步骤。
3.3.1.2 waitForAlarm()
首先,从上文的流程图中可以看到,AlarmThread线程是在一个while(true)循环里不断调用waitForAlarm()函数来等待底层alarm激发动作的。waitForAlarm()是一个native函数:
private native int waitForAlarm(int fd);
其对应的C++层函数是android_server_AlarmManagerService_waitForAlarm():
【com_android_server_AlarmManagerService.cpp】
static jint android_server_AlarmManagerService_waitForAlarm(JNIEnv* env, jobject obj, jint fd)
{
int result = 0;
do
{
result = ioctl(fd, ANDROID_ALARM_WAIT);
} while (result < 0 && errno == EINTR);
if (result < 0)
{
ALOGE("Unable to wait on alarm: %s
", strerror(errno));
return 0;
}
return result;
}
当AlarmThread调用到ioctl()一句时,线程会阻塞住,直到底层激发alarm。而且所激发的alarm的类型会记录到ioctl()的返回值中。这个返回值对外界来说非常重要,外界用它来判断该遍历哪个逻辑闹钟列表。
3.3.1.3 triggerAlarmsLocked()
一旦等到底层驱动的激发动作,AlarmThread会开始遍历相应的逻辑闹钟列表:
ArrayList<Alarm> triggerList = new ArrayList<Alarm>();
. . . . . .
final long nowRTC = System.currentTimeMillis();
final long nowELAPSED = SystemClock.elapsedRealtime();
. . . . . .
if ((result & RTC_WAKEUP_MASK) != 0)
triggerAlarmsLocked(mRtcWakeupAlarms, triggerList, nowRTC);
if ((result & RTC_MASK) != 0)
triggerAlarmsLocked(mRtcAlarms, triggerList, nowRTC);
if ((result & ELAPSED_REALTIME_WAKEUP_MASK) != 0)
triggerAlarmsLocked(mElapsedRealtimeWakeupAlarms, triggerList, nowELAPSED);
if ((result & ELAPSED_REALTIME_MASK) != 0)
triggerAlarmsLocked(mElapsedRealtimeAlarms, triggerList, nowELAPSED);
可以看到,AlarmThread先创建了一个临时的数组列表triggerList,然后根据result的值对相应的alarm数组列表调用triggerAlarmsLocked(),一旦发现alarm数组列表中有某个alarm符合激发条件,就把它移到triggerList中。这样,4条alarm数组列表中需要激发的alarm就汇总到triggerList数组列表中了。
接下来,只需遍历一遍triggerList就可以了:
Iterator<Alarm> it = triggerList.iterator();
while (it.hasNext())
{
Alarm alarm = it.next();
. . . . . .
alarm.operation.send(mContext, 0,
mBackgroundIntent.putExtra(Intent.EXTRA_ALARM_COUNT, alarm.count),
mResultReceiver, mHandler);
// we have an active broadcast so stay awake.
if (mBroadcastRefCount == 0) {
setWakelockWorkSource(alarm.operation);
mWakeLock.acquire();
}
mInFlight.add(alarm.operation);
mBroadcastRefCount++;
mTriggeredUids.add(new Integer(alarm.uid));
BroadcastStats bs = getStatsLocked(alarm.operation);
if (bs.nesting == 0) {
bs.startTime = nowELAPSED;
} else {
bs.nesting++;
}
if (alarm.type == AlarmManager.ELAPSED_REALTIME_WAKEUP
|| alarm.type == AlarmManager.RTC_WAKEUP) {
bs.numWakeup++;
ActivityManagerNative.noteWakeupAlarm(alarm.operation);
}
}
在上面的while循环中,每遍历到一个Alarm对象,就执行它的alarm.operation.send()函数。我们知道,alarm中记录的operation就是当初设置它时传来的那个PendingIntent对象,现在开始执行PendingIntent的send()操作啦。
PendingIntent的send()函数代码是:
public void send(Context context, int code, Intent intent,
OnFinished onFinished, Handler handler) throws CanceledException
{
send(context, code, intent, onFinished, handler, null);
}
调用了下面的send()函数:
public void send(Context context, int code, Intent intent,
OnFinished onFinished, Handler handler, String requiredPermission)
throws CanceledException
{
try
{
String resolvedType = intent != null
? intent.resolveTypeIfNeeded(context.getContentResolver())
: null;
int res = mTarget.send(code, intent, resolvedType,
onFinished != null
? new FinishedDispatcher(this, onFinished, handler)
: null,
requiredPermission);
if (res < 0)
{
throw new CanceledException();
}
}
catch (RemoteException e)
{
throw new CanceledException(e);
}
}
mTarget是个IPendingIntent代理接口,它对应AMS(Activity Manager Service)中的某个PendingIntentRecord实体。需要说明的是,PendingIntent的重要信息都是在AMS的PendingIntentRecord以及PendingIntentRecord.Key对象中管理的。AMS中有一张哈希表专门用于记录所有可用的PendingIntentRecord对象。
相较起来,在创建PendingIntent对象时传入的intent数组,其重要性并不太明显。这种intent数组主要用于一次性启动多个activity,如果你只是希望启动一个activity或一个service,那么这个intent的内容有可能在最终执行PendingIntent的send()动作时,被新传入的intent内容替换掉。
AMS中关于PendingIntentRecord哈希表的示意图如下:
3.3.1.4 进一步处理“唤醒闹钟”
在AlarmThread.run()函数中while循环的最后,会进一步判断,当前激发的alarm是不是“唤醒闹钟”。如果闹钟类型为RTC_WAKEUP或ELAPSED_REALTIME_WAKEUP,那它就属于“唤醒闹钟”,此时需要通知一下AMS:
if (alarm.type == AlarmManager.ELAPSED_REALTIME_WAKEUP
|| alarm.type == AlarmManager.RTC_WAKEUP)
{
bs.numWakeup++;
ActivityManagerNative.noteWakeupAlarm(alarm.operation);
}
这两种alarm就是我们常说的0型和2型闹钟,它们和我们手机的续航时间息息相关。
AMS里的noteWakeupAlarm()比较简单,只是在调用BatteryStatsService服务的相关动作,但是却会导致机器的唤醒:
public void noteWakeupAlarm(IIntentSender sender)
{
if (!(sender instanceof PendingIntentRecord))
{
return;
}
BatteryStatsImpl stats = mBatteryStatsService.getActiveStatistics();
synchronized (stats)
{
if (mBatteryStatsService.isOnBattery())
{
mBatteryStatsService.enforceCallingPermission();
PendingIntentRecord rec = (PendingIntentRecord)sender;
int MY_UID = Binder.getCallingUid();
int uid = rec.uid == MY_UID ? Process.SYSTEM_UID : rec.uid;
BatteryStatsImpl.Uid.Pkg pkg = stats.getPackageStatsLocked(uid, rec.key.packageName);
pkg.incWakeupsLocked();
}
}
}
好了,说了这么多,我们还是画一张AlarmThread示意图作为总结:
3.3.2 说说AlarmManagerService中的mBroadcastRefCount
下面我们说说AlarmManagerService中的mBroadcastRefCount,之所以要说它,仅仅是因为我在修改AlarmManagerService代码的时候,吃过它的亏。
我们先回顾一下处理triggerList列表的代码,如下:
Iterator<Alarm> it = triggerList.iterator();
while (it.hasNext())
{
Alarm alarm = it.next();
. . . . . .
alarm.operation.send(mContext, 0,
mBackgroundIntent.putExtra(Intent.EXTRA_ALARM_COUNT, alarm.count),
mResultReceiver, mHandler);
// we have an active broadcast so stay awake.
if (mBroadcastRefCount == 0) {
setWakelockWorkSource(alarm.operation);
mWakeLock.acquire();
}
mInFlight.add(alarm.operation);
mBroadcastRefCount++;
. . . . . .
. . . . . .
}
可以看到,在AlarmThread.run()中,只要triggerList中含有可激发的alarm,mBroadcastRefCount就会执行加一操作。一开始mBroadcastRefCount的值为0,所以会进入上面那句if语句,进而调用mWakeLock.acquire()。
后来我才知道,这个mBroadcastRefCount变量,是决定何时释放mWakeLock的计数器。AlarmThread的意思很明确,只要还有处于激发状态的逻辑闹钟,机器就不能完全睡眠。那么释放这个mWakeLock的地方又在哪里呢?答案就在alarm.operation.send()一句的mResultReceiver参数中。
mResultReceiver是AlarmManagerService的私有成员变量:
private final ResultReceiver mResultReceiver = newResultReceiver();
类型为ResultReceiver,这个类实现了PendingIntent.OnFinished接口:
class ResultReceiver implements PendingIntent.OnFinished
当send()动作完成后,框架会间接回调这个对象的onSendFinished()成员函数。
public void onSendFinished(PendingIntent pi, Intent intent, int resultCode,
String resultData, Bundle resultExtras)
{
. . . . . .
. . . . . .
if (mBlockedUids.contains(new Integer(uid)))
{
mBlockedUids.remove(new Integer(uid));
}
else
{
if (mBroadcastRefCount > 0)
{
mInFlight.removeFirst();
mBroadcastRefCount--;
if (mBroadcastRefCount == 0)
{
mWakeLock.release();
}
. . . . . .
}
. . . . . .
}
. . . . . .
}
也就是说每当处理完一个alarm的send()动作,mBroadcastRefCount就会减一,一旦减为0,就释放mWakeLock。
我一开始没有足够重视这个mBroadcastRefCount,所以把alarm.operation.send()语句包在了一条if语句中,也就是说在某种情况下,程序会跳过alarm.operation.send()一句,直接执行下面的语句。然而此时的mBroadcastRefCount还在坚定不移地加一,这直接导致mBroadcastRefCount再也减不到0了,于是mWakeLock也永远不会释放了。令人头痛的是,这个mWakeLock虽然不让手机深睡眠下去,却也不会点亮屏幕,所以这个bug潜藏了好久才被找到。还真是应了我说的那句话:“魔鬼总藏在细节中。”
转自http://blog.csdn.net/codefly/article/details/17058425