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>