Android實現時鐘Widget
本節內容,我將為大家帶來一個完整的時鐘 Widget 案例,為大家介紹實現一個桌面時鐘需要哪些步驟,并為大家剖析開發過程中會遇到哪些 Bug 以及相應的解決方法。相信大家認真閱讀后會有所收獲。
項目的效果就如同電腦右下角的時鐘,需要的硬件設備是經過 Root 的安卓設備。
項目的需求如下:
1、基本顯示如電腦桌面的時鐘,會自動更新日期、時間
2、點進去可以進行日期、時間、日期格式、時間格式、時區的設置(這點為了和系統同步,需要修改系統對應的參數,所以需要 Root)
3、能修改時鐘字體的大小、顏色及支持 Widget 整體大小的縮放
4、能進行模擬時鐘顯示和基本顯示的切換
實現步驟:
第一步,搭建一個 AppWidget
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/clock_show_text"
android:minHeight="40dp"
android:minWidth="80dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"/>
這里的 resizeMode 就是實現 Widget 整體大小的縮放,支持水平和垂直方向縮放。 updatePeriodMillis 是默認的 30分鐘,也就是調用 onUpdate 的周期,這個時間設置再小也沒用,系統為了避免頻繁的更新,在這里做了限制,30為最小值。
<?xml version="1.0" encoding="utf-8" ?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/touch_show_relativeLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true">
<TextView
android:id="@+id/time_show_textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_centerInParent="true"
android:clickable="false"
android:text="@string/time" />
<TextView
android:id="@+id/date_show_textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/time_show_textView"
android:layout_centerHorizontal="true"
android:layout_marginTop="5dp"
android:clickable="false"
android:text="@string/date" />
<!--模擬時鐘控件-->
<AnalogClock android:id="@+id/view_show_analogClock"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_centerInParent="true"
android:layout_gravity="center_horizontal"
android:visibility="gone" />
</RelativeLayout>
值得注意的是,在布局中,初始隱藏一個 AnalogClock ,為了實現需求4,用 Visibility 控制顯示的切換。而且設置只有最外層布局可以響應點擊事件,這為了優化體驗。
第二步,在后臺啟動 Service 用于計時
因為 onUpdate 方法執行周期最少為 30 分鐘,而時鐘需要實時更新
/**
* 更新UI
*/
public static void updateViews() {
Date date = sCalendar.getTime();
updateDate(date, sDateFormat);
updateTime(date, sTimeFormat);
//把時間加一秒,不然Calendar的參數值不會改變
//不能用setTime,否則時區改變會出問題
sCalendar.add(Calendar.SECOND, 1);
}
難點一,Calendar 不會自動更新時間
剛接觸 Calendar 開發時,會發現 Calendar 的參數取值一直保持不變,實際上確實如此,Calendar 的參數需要我們用代碼進行修改,一個好的方法是用其 add 方法修改,如果用 set 方法修改,會因為后續改變時區的操作而導致錯誤。
這里順帶分析一下,時區 TimeZone 的使用問題
以下測試代碼摘自謀博客(具體哪個忘了,在此感謝博主分享):
public static void main(String[] args) throws InterruptedException {
Calendar calendar1 = Calendar
.getInstance(TimeZone.getTimeZone("GMT+8"));
Calendar calendar2 = Calendar
.getInstance(TimeZone.getTimeZone("GMT+1"));
System.out.println("Millis = " + calendar1.getTimeInMillis());
System.out.println("Millis = " + calendar2.getTimeInMillis());
System.out.println("hour = " + calendar1.get(Calendar.HOUR));
System.out.println("hour = " + calendar2.get(Calendar.HOUR));
System.out.println("date = " + calendar1.getTime());
System.out.println("date = " + calendar2.getTime());
}
輸出:
Millis = 1358614681203
Millis = 1358614681203
hour = 3
hour = 8
date = Thu Nov 19 15:11:21 CST 2011
date = Thu Nov 19 15:11:21 CST 2011
改變時區TimeZone后,只有獲取的Hour是不一樣的,獲取到的Date(getTime)和getTimeInMillis是一樣的,所以修改時區后,要顯示修改后時區的時間,只能單獨修改hour
int preHour = TimerService.sCalendar.get(Calendar.HOUR_OF_DAY);
TimerService.sCalendar.setTimeZone(TimeZone.getTimeZone(timeZoneIDs[timeZonePosition]));
int currentHour = TimerService.sCalendar.get(Calendar.HOUR_OF_DAY);
TimerService.sCalendar.add(Calendar.HOUR_OF_DAY, currentHour - preHour);
難點二,怎么在后臺執行更新
筆者第一次開發時,是用 Timer 做計時活動,每 1000 毫秒,讓 Calendar add 1 秒,一切看起來運行正常。但在測試中發現,當減少系統時間時(如把日期從大到小設置,從21改為20,把分鐘從4改成3),AppWidget 就像卡住了,時間不再更新顯示了。經過原因排查,筆者發現是 Timer 停止運行了。
筆者于是翻閱 Timer 的源碼,發現停止運行的原因在其 run 方法中
long currentTime = System.currentTimeMillis();
task = tasks.minimum();
long timeToSleep;
synchronized (task.lock) {
if (task.cancelled) {
tasks.delete(0);
continue;
}
// check the time to sleep for the first task scheduled
timeToSleep = task.when - currentTime;//改小了時間,會使timeToSleep>0
}
if (timeToSleep > 0) {
// sleep!
try {
this.wait(timeToSleep);
} catch (InterruptedException ignored) {
}
continue;
}
timeToSleep = task.when - currentTime; //改小了時間,會使timeToSleep>0
實際上Timer會等待兩者的差值,之后再重新運行
為了解決這個問題,筆者采用 Handler 的 sendMessageDelayed 解決,其內部實現為
public final boolean sendMessageDelayed(Message msg, long delayMillis)
{
if (delayMillis < 0) {
delayMillis = 0;
}
return sendMessageAtTime(msg, SystemClock.uptimeMillis() + delayMillis);
}
/**
* Returns milliseconds since boot, not counting time spent in deep sleep.
*
* @return milliseconds of non-sleep uptime since boot.
*/
那么 SystemClock.uptimeMillis 和 System.currentTimeMillis 這兩種方法有何區別呢?
SystemClock.uptimeMillis() // 從開機到現在的毫秒數(手機睡眠的時間不包括在內);
System.currentTimeMillis() // 從1970年1月1日 UTC到現在的毫秒數
但是,第2個時間,是可以通過System.setCurrentTimeMillis修改的,那么,在某些情況下,一但被修改,時間間隔就不準了。
當一切安好時,筆者就在無意中發現了一個 Bug, 用Handler計時,一旦鎖屏,就會停止計時,導致錯誤 ,由此,筆者最終找到了解決方法:使用 AlarmManager
/**
* 初始化AlarmManager
* 用Handler取代Timer,解決時間往后設(即大到小22~21)Timer的run方法就會等待對應的時間差值(22-21)
* Timer核心是System.currentTimeMillis,Handler的核心是uptimeMillis
* 但Handler在鎖屏狀態下就會停止計時,所以用AlarmManager取代之
*/
private void initAlarmManager() {
AlarmManager alarmManager = (AlarmManager) this.getSystemService(ALARM_SERVICE);
String action = "RUN";
Intent intent = new Intent(action);
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime(), 1000, pendingIntent);
}
第三步,開發點擊后彈出的設置界面及相應的操作
//修改日期
mOnDateSetListener = new DatePickerDialog.OnDateSetListener() {
public void onDateSet(DatePicker view, int year, int monthOfYear, int dayOfMonth) {
//修改系統日期
try {
SystemUtil.setSystemDate(year, monthOfYear, dayOfMonth);
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
mDatePickerDialog = new DatePickerDialog(this, mOnDateSetListener, TimerService.sCalendar.get(Calendar.YEAR), TimerService.sCalendar.get(Calendar.MONTH), TimerService.sCalendar.get(Calendar.DAY_OF_MONTH));
//修改時間
mOnTimeSetListener = new TimePickerDialog.OnTimeSetListener() {
public void onTimeSet(TimePicker view, int hourOfDay, int minute) {
//修改系統時間
try {
SystemUtil.setSystemTime(hourOfDay, minute);
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
mTimePickerDialog = new TimePickerDialog(this, mOnTimeSetListener, TimerService.sCalendar.get(Calendar.HOUR_OF_DAY), TimerService.sCalendar.get(Calendar.MINUTE), true);
在 Root 設備上可用如下方法對系統進行日期、時間、時區的修改
public class SystemUtil {
//設置系統時區
public static void setSystemTimeZone(Context context, String timeZoneId) {
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
alarmManager.setTimeZone(timeZoneId);
}
//設置系統日期和時間
public static void setSystemDateTime(int year, int month, int day, int hour, int minute) throws IOException, InterruptedException {
requestPermission();
Calendar c = Calendar.getInstance();
c.set(Calendar.YEAR, year);
c.set(Calendar.MONTH, month - 1);
c.set(Calendar.DAY_OF_MONTH, day);
c.set(Calendar.HOUR_OF_DAY, hour);
c.set(Calendar.MINUTE, minute);
long when = c.getTimeInMillis();
if (when / 1000 < Integer.MAX_VALUE) {
SystemClock.setCurrentTimeMillis(when);
}
long now = Calendar.getInstance().getTimeInMillis();
if (now - when > 1000)
throw new IOException("failed to set Date.");
}
//設置系統日期
public static void setSystemDate(int year, int month, int day) throws IOException, InterruptedException {
requestPermission();
Calendar c = Calendar.getInstance();
c.set(Calendar.YEAR, year);
c.set(Calendar.MONTH, month);
c.set(Calendar.DAY_OF_MONTH, day);
long when = c.getTimeInMillis();
if (when / 1000 < Integer.MAX_VALUE) {
SystemClock.setCurrentTimeMillis(when);
}
long now = Calendar.getInstance().getTimeInMillis();
if (now - when > 1000)
throw new IOException("failed to set Date.");
}
//設置系統時間
public static void setSystemTime(int hour, int minute) throws IOException, InterruptedException {
requestPermission();
Calendar c = Calendar.getInstance();
c.set(Calendar.HOUR_OF_DAY, hour);
c.set(Calendar.MINUTE, minute);
long when = c.getTimeInMillis();
if (when / 1000 < Integer.MAX_VALUE) {
SystemClock.setCurrentTimeMillis(when);
}
long now = Calendar.getInstance().getTimeInMillis();
if (now - when > 1000)
throw new IOException("failed to set Time.");
}
private static void requestPermission() throws InterruptedException, IOException {
createSuProcess("chmod 666 /dev/alarm").waitFor();
}
private static Process createSuProcess(String cmd) throws IOException {
DataOutputStream os = null;
Process process = createSuProcess();
try {
os = new DataOutputStream(process.getOutputStream());
os.writeBytes(cmd + "\n");
os.writeBytes("exit $?\n");
} finally {
if (os != null) {
try {
os.close();
} catch (IOException e) {
}
}
}
return process;
}
private static Process createSuProcess() throws IOException {
File rootUser = new File("/system/xbin/ru");
if (rootUser.exists()) {
return Runtime.getRuntime().exec(rootUser.getAbsolutePath());
} else {
return Runtime.getRuntime().exec("su");
}
}
}
第四步,監聽系統廣播,在監聽到系統時區、日期、時間的修改后,立刻同步修改桌面時鐘
//時間、日期改變都能檢測到
private static final String ACTION_TIME_CHANGED = Intent.ACTION_TIME_CHANGED;
//時區改變能檢測到
private static final String ACTION_TIMEZONE_CHANGED = Intent.ACTION_TIMEZONE_CHANGED;
//計時廣播
private static final String ACTION_RUN = "RUN";
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
switch (action) {
case ACTION_TIME_CHANGED:
case ACTION_TIMEZONE_CHANGED:
//改變時區TimeZone后,只有獲取的Hour是不一樣的,獲取到的Date(getTime)和getTimeInMillis是一樣的
//所以修改時區后,要顯示修改后時區的時間,只能單獨修改hour
//這里widget為了和系統顯示同步,就不改變Calendar時區
updateDateTime();
break;
case ACTION_RUN:
TimerService.updateViews();
break;
default:
break;
}
}
在開發項目過程中,筆者還注意到以下問題:
1、修改 Widget 的代碼,要卸載原應用后再次安裝才能生效
2、如 Widget 突然失效,則可通過重啟手機設備的方式激活,原因是你的設備變得卡頓
3、在沒有卸載應用并重新安裝應用的情況下,注意測試過程中要真正停止 App 運行后,才能再次添加 widget,否則可能出現計時速度變成原來兩倍甚至多倍的情況,原因是 Service 沒被停止
// 最后一個widget被從屏幕移除
@Override
public void onDisabled(Context context) {
super.onDisabled(context);
//必須在后臺真正停止首次打開的APP運行(非ClockSettingActivity),才能停止服務,僅從桌面上移除widget不行
context.stopService(new Intent(context, TimerService.class));
}
來自:http://www.jianshu.com/p/ddf25d0ac5d7