Android实现时钟Widget
FraChristia
8年前
<p>本节内容,我将为大家带来一个完整的时钟 Widget 案例,为大家介绍实现一个桌面时钟需要哪些步骤,并为大家剖析开发过程中会遇到哪些 Bug 以及相应的解决方法。相信大家认真阅读后会有所收获。</p> <p>项目的效果就如同电脑右下角的时钟,需要的硬件设备是经过 Root 的安卓设备。</p> <p>项目的需求如下:</p> <p>1、基本显示如电脑桌面的时钟,会自动更新日期、时间</p> <p>2、点进去可以进行日期、时间、日期格式、时间格式、时区的设置(这点为了和系统同步,需要修改系统对应的参数,所以需要 Root)</p> <p>3、能修改时钟字体的大小、颜色及支持 Widget 整体大小的缩放</p> <p>4、能进行模拟时钟显示和基本显示的切换</p> <p>实现步骤:</p> <h3><strong>第一步,搭建一个 AppWidget</strong></h3> <pre> <code class="language-java"><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"/></code></pre> <p>这里的 resizeMode 就是实现 Widget 整体大小的缩放,支持水平和垂直方向缩放。 updatePeriodMillis 是默认的 30分钟,也就是调用 onUpdate 的周期,这个时间设置再小也没用,系统为了避免频繁的更新,在这里做了限制,30为最小值。</p> <pre> <code class="language-java"><?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></code></pre> <p>值得注意的是,在布局中,初始隐藏一个 AnalogClock ,为了实现需求4,用 Visibility 控制显示的切换。而且设置只有最外层布局可以响应点击事件,这为了优化体验。</p> <h3><strong>第二步,在后台启动 Service 用于计时</strong></h3> <p>因为 onUpdate 方法执行周期最少为 30 分钟,而时钟需要实时更新</p> <pre> <code class="language-java">/** * 更新UI */ public static void updateViews() { Date date = sCalendar.getTime(); updateDate(date, sDateFormat); updateTime(date, sTimeFormat); //把时间加一秒,不然Calendar的参数值不会改变 //不能用setTime,否则时区改变会出问题 sCalendar.add(Calendar.SECOND, 1); }</code></pre> <p>难点一,Calendar 不会自动更新时间</p> <p>刚接触 Calendar 开发时,会发现 Calendar 的参数取值一直保持不变,实际上确实如此,Calendar 的参数需要我们用代码进行修改,一个好的方法是用其 add 方法修改,如果用 set 方法修改,会因为后续改变时区的操作而导致错误。</p> <p>这里顺带分析一下,时区 TimeZone 的使用问题</p> <p>以下测试代码摘自谋博客(具体哪个忘了,在此感谢博主分享):</p> <pre> <code class="language-java">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</code></pre> <p>改变时区TimeZone后,只有获取的Hour是不一样的,获取到的Date(getTime)和getTimeInMillis是一样的,所以修改时区后,要显示修改后时区的时间,只能单独修改hour</p> <pre> <code class="language-java">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);</code></pre> <p>难点二,怎么在后台执行更新</p> <p>笔者第一次开发时,是用 Timer 做计时活动,每 1000 毫秒,让 Calendar add 1 秒,一切看起来运行正常。但在测试中发现,当减少系统时间时(如把日期从大到小设置,从21改为20,把分钟从4改成3),AppWidget 就像卡住了,时间不再更新显示了。经过原因排查,笔者发现是 Timer 停止运行了。</p> <p>笔者于是翻阅 Timer 的源码,发现停止运行的原因在其 run 方法中</p> <pre> <code class="language-java">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; }</code></pre> <p>timeToSleep = task.when - currentTime; //改小了时间,会使timeToSleep>0</p> <p>实际上Timer会等待两者的差值,之后再重新运行</p> <p>为了解决这个问题,笔者采用 Handler 的 sendMessageDelayed 解决,其内部实现为</p> <pre> <code class="language-java">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. */</code></pre> <p>那么 SystemClock.uptimeMillis 和 System.currentTimeMillis 这两种方法有何区别呢?</p> <p>SystemClock.uptimeMillis() // 从开机到现在的毫秒数(手机睡眠的时间不包括在内);</p> <p>System.currentTimeMillis() // 从1970年1月1日 UTC到现在的毫秒数</p> <p>但是,第2个时间,是可以通过System.setCurrentTimeMillis修改的,那么,在某些情况下,一但被修改,时间间隔就不准了。</p> <p>当一切安好时,笔者就在无意中发现了一个 Bug, <strong>用Handler计时,一旦锁屏,就会停止计时,导致错误</strong> ,由此,笔者最终找到了解决方法:使用 <strong>AlarmManager</strong></p> <pre> <code class="language-java">/** * 初始化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); }</code></pre> <h3><strong>第三步,开发点击后弹出的设置界面及相应的操作</strong></h3> <pre> <code class="language-java">//修改日期 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);</code></pre> <p>在 Root 设备上可用如下方法对系统进行日期、时间、时区的修改</p> <pre> <code class="language-java">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"); } } }</code></pre> <h3><strong>第四步,监听系统广播,在监听到系统时区、日期、时间的修改后,立刻同步修改桌面时钟</strong></h3> <pre> <code class="language-java">//时间、日期改变都能检测到 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; } }</code></pre> <p>在开发项目过程中,笔者还注意到以下问题:</p> <p>1、修改 Widget 的代码,要卸载原应用后再次安装才能生效</p> <p>2、如 Widget 突然失效,则可通过重启手机设备的方式激活,原因是你的设备变得卡顿</p> <p>3、在没有卸载应用并重新安装应用的情况下,注意测试过程中要真正停止 App 运行后,才能再次添加 widget,否则可能出现计时速度变成原来两倍甚至多倍的情况,原因是 Service 没被停止</p> <pre> <code class="language-java">// 最后一个widget被从屏幕移除 @Override public void onDisabled(Context context) { super.onDisabled(context); //必须在后台真正停止首次打开的APP运行(非ClockSettingActivity),才能停止服务,仅从桌面上移除widget不行 context.stopService(new Intent(context, TimerService.class)); }</code></pre> <p> </p> <p> </p> <p>来自:http://www.jianshu.com/p/ddf25d0ac5d7</p> <p> </p>