实验目的
掌握Handler及Looper进行线程间消息通信的方式;
实验要求
使用Timer、TimerTask及Handler实现秒表计时器;
实验内容
本次实验需要实现如图1. 秒表计时器App运行效果所示的秒表计时器App。 App主界面顶部为两个计时时间,分别为总计时时间和当前间隔;界面中部为所有间隔(计次、tick)计时时间列表; 底部包含三个操作按钮,分别为复位、开始(暂停)、间隔计时。
App启动后,用户点击开始按钮开始秒表计时,此时用户可点击底部最右侧的间隔计时按钮新增一次间隔计时记录,并且 App顶部的当前间隔计时时间清零;当用户点击暂停按钮时,秒表计时暂停,用户可点击底部最左侧的复位按钮清除所有 间隔计时记录,并复位秒表计时器。
要实现本实验App所要求的的功能,需要使用Timer计时器的schedule()方法定时运行某一TimerTask(计时器任务), 在TimerTask中通过Handler向UI主线程发送消息,并由UI主线程处理消息更新显示计时时间及当前间隔的UI视图元素。
步骤一,创建新工程
打开Android Studio,新建名为GGTimer的工程。
步骤二,设置activity_main.xml布局
在本实验中,秒表计时器App只有一个Activity,将其命名为MainActivity,其对应的布局文件名为activity_main.xml。 根据App运行效果图1. 秒表计时器App运行效果所示,顶部为两个TextView用于表示当前总计时时间以及当前间隔时间;中部由ListView用于表示所有间隔(计次、tick)计时时间列表;底部为三个操作按钮,具体布局代码详如下所示。
<?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文件:
<resources>
<string name="app_name">GGTimer</string>
<string name="init_value">00:00:00</string>
</resources>
colors.xml文件:
<?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()方法进行了重写,调用了TimeUtils类的intervalToString()方法将Long类型的时间戳转换为字符串类型。
TimerLog类定义:
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类定义:
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运行效果图所示,需要显示的信息包括当前记录序号、当前记录间隔时间、以及当前记录对应的总时间戳等。其布局包含视图元素具体代码如下所示。
<?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,使用方式与第七个项目中的NewsAdapter类似,这里不再进行赘述,具体代码如下所示。
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()方法中对应的三个switch分支的作用分别是:
R.id.ib_play_pause分支,通过timerStatus布尔型变量判断App处于开始计时或暂停计时状态,从而调用startTimer()或stopTimer()方法;并使能或禁用【间隔计时】、【复位】按钮;
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()方法。通常情况下使用匿名类形式对Handler对象进行初始化。handleMessage()方法在指定线程中进行执行,如果Handler对象在UI主线程进行初始化,则handlerMessage()方法将在主线程中执行。本实验正是通过这一机制,在TimerTask中运行子线程,并在子线程中调用UI主线程的Handler对象发送消息实现定时更新UI中的总计时时间及间隔计时时间等视图元素。
3. 定义startTimer()、stopTimer()方法
startTimer()通过Timer.schedule()方法启动TimerTask,该方法接受3个参数:
TimerTask t,需要实际执行的TimerTask对象;
long delay,需要延期delay毫秒执行TimerTask;
long period,间隔period毫秒后重复执行TimerTask;
TimerTask通常使用匿名类形式进行初始化,并需要重写其唯一的run()方法,在该方法内放入需要执行的代码端,这里仅调用UI主线程的Handler对象发送消息。
stopTimer()方法则只需要调用Timer.cancel()及TimerTask.cancel()方法即可。
4. 使用SoundPool增加音效效果
在很多App中,当用户触发某个操作或App出现新状态时,通常会使用短促的音效进行提醒。在Android框架中提供了SoundPool组件进行音频播放功能,SoundPool使用MediaPlayer进行音频流解码,与MediaPlayer相比,其操作状态图更为简单。SoundPool可指定同时播放的音频流数量(通过SoundPool.Builder.setMaxStreams()方法)、重复播放次数、播放速率等属性,主要适用于播放短音频文件。
在本实验中,秒表计时器开始工作时,可以播放指定的计时音频文件(该文件是从网上获取的音频文件),实现秒表计时音效效果。
首先,在Android Studio中导入准备好的计时音频文件,其名为tick.m4a。导入后该资源位于res/raw文件夹下。
其次,初始化SoundPool组件,在initData()方法中使用SoundPool.Builder类构造SoundPool对象。此外还需要通过AudioAttributes类对象设置音频播放属性行为,包括:音频使用用途(通过setUsage()方法设置)、音频类型(通过setContentType()方法设置)等属性。AudioAttributes通过AudioAttributes.Builder类进行构造。完成SoundPool组件初始化后,通过其load()方法用于同步加载媒体资源,由于可以SoundPool可以同时播放多个音频文件,因此load()方法返回一个int型的参数表示音频流id,需要保存该该id以便后续对该音频流播放进行控制。
最后,在startTimer()、stopTimer()中调用SoundPool组件播放或暂停播放已加载的音频流。
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的逻辑是否正确。
仔细观察本项目中计时器与手机系统提供的计时器相比计时是否精确,有没有其他方法能提高计时精度?
实验小结
通过本次实验,你应该掌握了如下知识内容: