# 第二十二个项目——使用TimerTask及Handler实现秒表计时器

## 实验目的

* 掌握**Timer**及**TimerTask**实现定时任务；
* 掌握**Handler**及**Looper**进行线程间消息通信的方式；

## 实验要求

* 使用**Timer**、**TimerTask**及**Handler**实现秒表计时器；
* 使用**Handler**及**Looper**进行线程间通信；

## 实验内容

本次实验需要实现如[图1. 秒表计时器App运行效果](#app017_new_android_component)所示的秒表计时器App。 **App**主界面顶部为两个计时时间，分别为总计时时间和当前间隔；界面中部为所有间隔（计次、tick）计时时间列表； 底部包含三个操作按钮，分别为复位、开始（暂停）、间隔计时。

**App**启动后，用户点击开始按钮开始秒表计时，此时用户可点击底部最右侧的间隔计时按钮新增一次间隔计时记录，并且 **App**顶部的当前间隔计时时间清零；当用户点击暂停按钮时，秒表计时暂停，用户可点击底部最左侧的复位按钮清除所有 间隔计时记录，并复位秒表计时器。

![图1. 秒表计时器App运行效果](http://www.funnycode.net/guet/img/app01/app018_timer_running_screenshot.png)

要实现本实验App所要求的的功能，需要使用**Timer**计时器的**schedule()**&#x65B9;法定时运行某一**TimerTask**（计时器任务）， 在**TimerTask**中通过**Handler**向**UI**主线程发送消息，并由**UI**主线程处理消息更新显示计时时间及当前间隔的**UI**视图元素。

### 步骤一，创建新工程

打开**Android Studio**，新建名为**GGTimer**的工程。

### 步骤二，设置activity\_main.xml布局

在本实验中，秒表计时器App只有一个**Activity**，将其命名为**MainActivity**，其对应的布局文件名为**activity\_main.xml**。 根据App运行效果[图1. 秒表计时器App运行效果](#app017_new_android_component)所示，顶部为两个**TextView**用于表示当前总计时时间以及当前间隔时间；中部由**ListView**用于表示所有间隔（计次、tick）计时时间列表；底部为三个操作按钮，具体布局代码详如下所示。

```markup
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/tv_timer"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/init_value"
        android:textColor="@android:color/black"
        android:textSize="64sp"
        android:layout_marginTop="32dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

    <TextView
        android:id="@+id/tv_interval"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/init_value"
        android:textSize="32sp"
        app:layout_constraintTop_toBottomOf="@id/tv_timer"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

    <ListView
        android:id="@+id/lv_timer_log"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_marginTop="8dp"
        android:scrollbars="none"
        android:divider="@android:color/transparent"
        app:layout_constraintBottom_toTopOf="@id/ib_play_pause"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tv_interval" />

    <ImageButton
        android:id="@+id/ib_reset"
        android:layout_width="32dp"
        android:layout_height="32dp"
        android:layout_marginStart="8dp"
        android:layout_marginEnd="8dp"
        android:background="@drawable/ic_replay_black_24dp"
        app:layout_constraintBottom_toBottomOf="@+id/ib_play_pause"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/ib_play_pause"
        app:layout_constraintTop_toTopOf="@+id/ib_play_pause" />

    <ImageButton
        android:id="@+id/ib_play_pause"
        android:layout_width="60dp"
        android:layout_height="60dp"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="16dp"
        android:background="@drawable/ic_play_circle_outline_black_24dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <ImageButton
        android:id="@+id/ib_tick"
        android:layout_width="32dp"
        android:layout_height="32dp"
        android:layout_marginStart="8dp"
        android:layout_marginEnd="8dp"
        android:background="@drawable/ic_alarm_black_24dp"
        app:layout_constraintBottom_toBottomOf="@+id/ib_play_pause"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/ib_play_pause"
        app:layout_constraintTop_toTopOf="@+id/ib_play_pause" />

</android.support.constraint.ConstraintLayout>
```

**activity\_main.xml**所需使用到的**stings.xml**、**colors.xml**分别如下所示。

**strings.xml**文件：

```markup
<resources>
    <string name="app_name">GGTimer</string>
    <string name="init_value">00:00:00</string>
</resources>
```

**colors.xml**文件：

```markup
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#008577</color>
    <color name="colorPrimaryDark">#00574B</color>
    <color name="colorAccent">#D81B60</color>
    <color name="divider">#1A000000</color>
</resources>
```

### 步骤三，定义TimerLogAdapter及TimerLog类

**1. 定义TimerLog类**

首先看**TimerLog**类，用于记录每一次用户按下【间隔计时】按钮时，间隔计时时间以及总计时时间戳。 这类记录用户每次按下时均需要记录，因此**TimerLog**类包含了两个成员变量（其单位均为毫秒）：

* **Long tick**，表示秒表计时器开始工作以来的总计时时间戳；
* **Long interval**，表示用户按下【间隔计时】后产生间隔计时周期时间戳；

**TimerLog**还包含了一个**Integer**类型的变量**number**用于记录其序号。此外，**TimerLog**类的 **toString()**&#x65B9;法进行了重写，调用了**TimeUtils**类的**intervalToString()**&#x65B9;法将**Long**类型的时间戳转换为字符串类型。

**TimerLog**类定义：

```java
class TimerLog {
    private Long tick;
    private Integer number;
    private Long interval;

    private static Integer count = 0;

    public TimerLog() {
        this.tick = 0L;
        this.interval = 0L;
        this.number = ++count;
    }

    public TimerLog(Long tick, Long interval) {
        this.tick = tick;
        this.interval = interval;
        this.number = ++count;
    }

    public Integer getNumber() {
        return number;
    }

    public void setNumber(Integer number) {
        this.number = number;
    }

    public Long getInterval() {
        return interval;
    }

    public void setInterval(Long interval) {
        this.interval = interval;
    }

    @Override
    public String toString() {
        return TimeUtils.intervalToString(tick);
    }

    public String intervalToString() {
        return TimeUtils.intervalToString(interval);
    }
}
```

**TimeUtils**类定义：

```java
public class TimeUtils {
    public static String intervalToString(Long tick) {
        String timeString;
        if (tick < 0) {
            tick = 0L;
        }

        Long seconds = tick / 1000;
        Long millsec = tick % 1000/10;
        Long mins = seconds / 60;
        timeString = String.format("%02d:%02d:%02d", 
                    mins, seconds%60, millsec);
        return timeString;
    }
}
```

**2. 定义list\_item.xml布局**

**list\_item.xml**布局用于**ListView**组件中显示每一组间隔计时时间记录信息，根据App运行效果图所示，需要显示的信息包括当前记录序号、当前记录间隔时间、以及当前记录对应的总时间戳等。其布局包含视图元素具体代码如下所示。

```markup
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/tv_number"
        android:text="No. 1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:layout_marginTop="8dp"
        android:layout_marginStart="8dp"
        />

    <TextView
        android:id="@+id/tv_interval_item"
        android:text="Tick: "
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        app:layout_constraintStart_toStartOf="@id/tv_number"
        app:layout_constraintTop_toBottomOf="@id/tv_number"/>

    <TextView
        android:id="@+id/tv_timestamp"
        android:textSize="20sp"
        android:text="@string/init_value"
        android:textColor="@android:color/black"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginBottom="16dp"
        android:layout_marginTop="16dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

    <View
        android:id="@+id/divider"
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:layout_marginStart="8dp"
        android:background="@color/divider"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        />
</android.support.constraint.ConstraintLayout>
```

**3. 定义TimerLogAdapter类**

**TimerAdapter**用于**ListView**进行数据渲染的适配器，其继承于**ArrayAdapter**，使用方式与[第七个项目](https://xxgqin.gitbook.io/android/ch02/ch02-4)中的**NewsAdapter**类似，这里不再进行赘述，具体代码如下所示。

```java
public class TimerLogAdapter extends ArrayAdapter<TimerLog> {
    private Context mContext;
    private int resourceId;
    private List<TimerLog> mLogData;

    public TimerLogAdapter(@NonNull Context context,
                            int resource, List<TimerLog> data) {
        super(context, resource, data);
        this.mContext = context;
        this.resourceId = resource;
        this.mLogData = data;
    }

    @Override
    public View getView(int position,
                            View convertView, ViewGroup parent) {
        TimerLog log = getItem(position);
        View view;

        ViewHolder vh;

        if (convertView == null) {
            LayoutInflater inflater = LayoutInflater.from(mContex);
            view = inflater.inflate(resourceId, parent , false);
            vh = new ViewHolder();
            vh.tvNumber = view.findViewById(R.id.tv_number);
            vh.tvTimestamp = view.findViewById(R.id.tv_timestamp);
            vh.tvTickItem = view.findViewById(R.id.tv_interval_item);

            view.setTag(viewHolder);
        } else {
            view = convertView;
            vh = (ViewHolder) view.getTag();
        }

        vh.tvNumber.setText("No. " + (log.getNumber()));
        vh.tvTickItem.setText("Tick: " + log.intervalToString());
        vh.tvTimestamp.setText(log.toString());
        return view;
    }

    class ViewHolder {
        TextView tvNumber;
        TextView tvTimestamp;
        TextView tvTickItem;
    }
}
```

### 步骤四，修改MainActivity类定义

关于**activity\_main.xml**中控件对应的类对象定义、**TimerLogAdapter**等类对象定义不再赘述。

**1. 注册ibPlayPause、ibTick、ibReset三个ImageButton点击事件**

在**MainActivity**中，定义了名为**clickListener**的**View\.OnClickListener**接口对象，使用匿名类形式 对其进行初始化，在**onClick()**&#x65B9;法中对应的三个**switch**分支的作用分别是：

* **R.id.ib\_play\_pause**分支，通过**timerStatus**布尔型变量判断**App**处于开始计时或暂停计时状态，从而调用**startTimer()**&#x6216;**stopTimer()**&#x65B9;法；并使能或禁用【间隔计时】、【复位】按钮；
* **R.id.ib\_reset**分支，表示执行复位操作，清除掉**ListView**中的间隔计时记录，并清零总计时时间及间隔计时时间；
* **R.id.ib\_tick**分支，表示用户重新开始一次间隔计时，需要新增一条间隔计时记录；

**2. 声明并初始化Handler对象**

**Handler**对象用于向其所绑定(attach、实例化)的线程发送消息，消息进入对应线程的**Message Queue**(消息队列)， 并被对应线程的**Looper**进行处理。每个线程均对应一个**Looper**对象及一个**Message Queue**对象，但可拥有多个**Handler**对象。Android系统通过这个机制可以保证**Handler**对象发送的消息被对应线程的**Looper**进行处理。如果把A线程的**Handler**对象暴露给**B**线程，在**B**线程中通过**Handler**对象发送消息，则可实现线程间通信。

声明**Handler**对象，通常需要子类化**Handler**类并重写其**handleMessage()**&#x65B9;法。通常情况下使用匿名类形式对**Handler**对象进行初始化。**handleMessage()**&#x65B9;法在指定线程中进行执行，如果**Handler**对象在**UI**主线程进行初始化，则**handlerMessage()**&#x65B9;法将在主线程中执行。本实验正是通过这一机制，在**TimerTask**中运行子线程，并在子线程中调用**UI**主线程的**Handler**对象发送消息实现定时更新**UI**中的总计时时间及间隔计时时间等视图元素。

**3. 定义startTimer()、stopTimer()方法**

**startTimer()**&#x901A;过**Timer.schedule()**&#x65B9;法启动**TimerTask**，该方法接受3个参数：

* *TimerTask t*，需要实际执行的**TimerTask**对象；
* *long delay*，需要延期*delay*毫秒执行**TimerTask**；
* *long period*，间隔*period*毫秒后重复执行**TimerTask**；

**TimerTask**通常使用匿名类形式进行初始化，并需要重写其唯一的**run()**&#x65B9;法，在该方法内放入需要执行的代码端，这里仅调用**UI**主线程的**Handler**对象发送消息。

**stopTimer()**&#x65B9;法则只需要调用**Timer.cancel()**&#x53CA;**TimerTask.cancel()**&#x65B9;法即可。

**4. 使用SoundPool增加音效效果**

在很多**App**中，当用户触发某个操作或**App**出现新状态时，通常会使用短促的音效进行提醒。在**Android**框架中提供了**SoundPool**组件进行音频播放功能，**SoundPool**使用**MediaPlayer**进行音频流解码，与**MediaPlayer**相比，其操作状态图更为简单。**SoundPool**可指定同时播放的音频流数量（通过**SoundPool.Builder.setMaxStreams()**&#x65B9;法）、重复播放次数、播放速率等属性，主要适用于播放短音频文件。

在本实验中，秒表计时器开始工作时，可以播放指定的计时音频文件（该文件是从网上获取的音频文件），实现秒表计时音效效果。

* 首先，在**Android Studio**中导入准备好的计时音频文件，其名为**tick.m4a**。导入后该资源位于**res/raw**文件夹下。
* 其次，初始化**SoundPool**组件，在**initData()**&#x65B9;法中使用**SoundPool.Builder**类构造**SoundPool**对象。此外还需要通过**AudioAttributes**类对象设置音频播放属性行为，包括：音频使用用途（通过**setUsage()**&#x65B9;法设置）、音频类型（通过**setContentType()**&#x65B9;法设置）等属性。**AudioAttributes**通过**AudioAttributes.Builder**类进行构造。完成**SoundPool**组件初始化后，通过其**load()**&#x65B9;法用于同步加载媒体资源，由于可以**SoundPool**可以同时播放多个音频文件，因此**load()**&#x65B9;法返回一个**int**型的参数表示音频流**id**，需要保存该该**id**以便后续对该音频流播放进行控制。
* 最后，在**startTimer()**、**stopTimer()**&#x4E2D;调用**SoundPool**组件播放或暂停播放已加载的音频流。

```java
public class MainActivity extends AppCompatActivity {

    ...
    private static final int TIMER = 1000;

    private Long timestamp = 0L;
    private Long interval = 0L;

    private SoundPool mSoundPool;
    private int mSoundId;
    private int mStreamId = -1;

    private Timer mTimer;
    private TimerTask mTimerTask;
    private Handler mHandler = new Handler(Looper.getMainLooper()) {
      @Override
      public void handleMessage(Message msg) {
        super.handleMessage(msg);
        switch (msg.what) {
          case TIMER:
            timestamp += 10;
            interval += 10;
            tvTimerNumber.setText(TimeUtils.intervalToString(timestamp));
            tvInterval.setText(TimeUtils.intervalToString(interval));
            break;
          default:
            break;
        }
      }
    };

    private View.OnClickListener clickListener = new View.OnClickListener() {
      @Override
      public void onClick(View view) {
        switch (view.getId()) {
          case R.id.ib_play_pause:
            timerStatus = !timerStatus;
            if (timerStatus) {
              ibPlayPause.setBackground(
                getDrawable(R.drawable.ic_pause_circle_outline_black_24dp));
              startTimer();
            } else {
              ibPlayPause.setBackground(
                getDrawable(R.drawable.ic_play_circle_outline_black_24dp));
              stopTimer();
            }
            ibReset.setEnabled(!timerStatus);
            ibTick.setEnabled(timerStatus);
            break;

          case R.id.ib_tick:
            adapter.insert(new TimerLog(timestamp, interval), 0);
            adapter.notifyDataSetChanged();

            interval = 0L;
            tvInterval.setText(TimeUtils.intervalToString(interval));
            break;

          case R.id.ib_reset:
            ibReset.setEnabled(false);
            timestamp = 0L;
            interval = 0L;
            tvTimerNumber.setText(TimeUtils.intervalToString(timestamp));
            tvInterval.setText(TimeUtils.intervalToString(interval));
            adapter.clear();
            adapter.notifyDataSetChanged();
            break;
        }
      }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        initView();
        initData();
    }

    private void initData() {
        timeLogList = new ArrayList<TimerLog>();
        adapter = new TimerLogAdapter(MainActivity.this,
                             R.layout.list_item, timeLogList);
        lvTimeLog.setAdapter(adapter);

        AudioAttributes audioAttributes = new AudioAttributes.Builder()
                .setUsage(AudioAttributes.USAGE_MEDIA)
                .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                .build();

        mSoundPool = new SoundPool.Builder()
                .setMaxStreams(1)
                .setAudioAttributes(audioAttributes)
                .build();

        mSoundId = mSoundPool.load(MainActivity.this, R.raw.tick, 1);
    }

    private void initView() {
        ibPlayPause = findViewById(R.id.ib_play_pause);
        ibTick = findViewById(R.id.ib_tick);
        ibReset = findViewById(R.id.ib_reset);
        lvTimeLog = findViewById(R.id.lv_timer_log);
        tvTimerNumber = findViewById(R.id.tv_timer);
        tvInterval = findViewById(R.id.tv_interval);

        ibPlayPause.setOnClickListener(clickListener);
        ibTick.setOnClickListener(clickListener);
        ibReset.setOnClickListener(clickListener);

        ibTick.setEnabled(timerStatus);
    }

    private void startTimer() {
        mTimer = new Timer();

        mTimerTask = new TimerTask() {
            @Override
            public void run() {
                mHandler.sendEmptyMessage(TIMER);
            }
        };
        mTimer.schedule(mTimerTask, 0, 10);
        if (mStreamId == -1) {
            mStreamId = mSoundPool.play(mSoundId, 50, 50, 1, -1, 1);
        } else {
            mSoundPool.resume(mStreamId);
        }
    }

    private void stopTimer() {
        mTimer.cancel();
        mTimerTask.cancel();
        mSoundPool.pause(mStreamId);
    }
}
```

### 步骤五，编译并运行App

编译并运行本项目，尝试点击【开始计时】、【间隔计时】、【复位】等按钮，看看**App**的逻辑是否正确。

{% hint style="info" %}
仔细观察本项目中计时器与手机系统提供的计时器相比计时是否精确，有没有其他方法能提高计时精度？
{% endhint %}

## 实验小结

通过本次实验，你应该掌握了如下知识内容：

* 使用**Timer**及**TimerTask**执行定时任务；
* 使用**Handler**进行线程间消息通信；
