- Android Looper 和 Handler 分析
- Android MediaScanner 详尽分析
- Android 深入浅出之 Binder 机制
- 第一部分 AudioTrack 分析
- 第二部分 AudioFlinger 分析
- Android 深入浅出之 Audio 第三部分 Audio Policy
- Android 深入浅出之 Zygote
- Android 深入浅出之 Surface
- Linux Kernel 系列一 开篇和 Kernel 启动概要
- Linux Kernel 系列二 用户空间的初始化
- Linux Kernel 系列三 Kernel 编译和链接中的 linker script 语法详解
- 第五章 深入理解常见类
- linux kernel 系列四 嵌入式系统中的文件系统以及 MTD
- 随笔之 Android 平台上的进程调度探讨
- Android 4.0 External 下功能库说明
- 随笔之 Android 不吐不快
- Android Rom 移植知识普及
- 深入理解 Android 系列书籍的规划路线图
- Android 4.1 初识 - 7月12号
- Android 4.1 初识 - 7月13号
- Android 4.1 Surface 系统变化说明
- Android BSP 成长计划随笔之虚拟设备搭建和 input 系统
- 深入理解 Android 写作背后的故事
- 随笔之 GoldFish Kernel 启动过程中 arm 汇编分析
- Android Project Butter 分析
- Android Says Bonjour
- MTP in Android
- DRM in Android
- Tieto 公司 Android 多窗口解决方案展示
- 深入理解 SELinux SEAndroid 之二
- 深入理解 SELinux SEAndroid(最后部分)
- 前言
- 附录
- 第一章 准备工作
- 第二章 深入理解 Netd
- 第三章 Wi-Fi 基础知识
- 第四章 深入理解 wpa_supplicant
- 第五章 深入理解 WifiService
- 第六章 深入理解 wi-Fi Simple Configuration
- 第七章 深入理解 Wi-Fi P2P
- 第八章 深入理解 NFC
- 第九章 深入理解 GPS
- Google I/O 2014 之 Android 面面观
- 深入理解 Android 之 Java Security 第一部分
- 深入理解 Android 之 Java Security 第二部分(Final)
- 深入理解 Android 之设备加密 Device Encryption
- 第一章 阅读前的准备工作
- 第二章 深入理解 JNI
- 第三章 深入理解 init
- 第四章 深入理解 Zygote
- 第五章 深入理解常见类
- 第六章 深入理解 Binder
- 第七章 深入理解 Audio 系统
- 第八章 深入理解 Surface 系统
- 第九章 深入理解 Vold 和 Rild
- 第十章 深入理解 MediaScanner
- 第一章 开发环境部署
- 第二章 深入理解 Java Binder 和 MessageQueue
- 第三章 深入理解 SystemServer
- 第四章 深入理解 PackageManagerService
- 第五章 深入理解 PowerManagerService
- 第六章 深入理解 ActivityManagerService
- 第七章 深入理解 ContentProvider
- 第八章 深入理解 ContentService 和 AccountManagerService
- 第一章 开发环境部署
- 第二章 深入理解 Java Binder 和 MessageQueue
- 第三章 深入理解 AudioService
- 第四章 深入理解 WindowManagerService
- 第五章 深入理解 Android 输入系统
- 第六章 深入理解控件(ViewRoot)系统
- 第七章 深入理解 SystemUI
- 第八章 深入理解 Android 壁纸
- 边缘设备、系统及计算杂谈(16)——Apache 学习
- 边缘设备、系统及计算杂谈(17)——Ansible 学习
- ZFS 和 LVM
- Android 4.2 蓝牙介绍
- 了解一下 Android 10 中的 APEX
- 关于 Android 学习的三个终极问题
- 深入理解 Android 之 AOP
- Android 系统性能调优工具介绍
- 深入理解 SELinux SEAndroid(第一部分)
- Android Wi-Fi Display(Miracast)介绍
- 深入理解 Android 之 Gradle
第三章 深入理解 AudioService
本章主要内容:
· 探讨AudioService如何进行音量管理。
· 了解音频外设的管理机制。
· 探讨AudioFocus的工作原理。
· 介绍Android 4.1下AudioService的新特性。
本章涉及的源代码文件名及位置:
· AudioManager.java
framework\base\media\java\android\media\AudioManager.java
· AudioService.java
framework\base\media\java\android\media\AudioService.java
· AudioSystem.java
framework\base\media\java\android\media\AudioSystem.java
· VolumePanel.java
Framework\base\core\java\android\view\VolumePanel.java
· WiredAccessoryObserver.java
Framework\base\services\java\com\android\server\WiredAccessoryObserver.java
· PhoneWindow.java
Framework\base\policy\src\com\android\internal\policy\impl\PhoneWindow.java
· Activity.java
Framework\base\core\java\android\app\Activity.java
3.1概述
通过学习对《深入理解Android:卷I》(以后简称“卷I”)第7章的学习,相信大家已经对AudioTrack、AudioRecord、音频设备路由等知识有了深入的了解。这一章将详细介绍音频系统在Java层的实现,围绕AudioService这个系统服务深入探讨在Android SDK 中看到的音频相关的机制的实现。
在分析Android音频系统时,习惯将其实现分为两个部分:数据流和策略。数据流描述了音频数据从数据源流向目的地的过程。而策略则是管理及控制数据流的路径与呈现的过程。在卷I所探讨的Native 层音频系统里,AudioTrack、AudioRecord和AudioFlinger可以被划归到数据流的范畴去讨论。而AudioPolicy相关的内容则属于策略范畴。
音频系统在Java层中基本上是不参与数据流的。虽然有AudioTrack和AudioRecord这两个类,但是他们只是Native层同名类的Java封装。抛开这两个类,AudioService这个系统服务包含或使用了几乎所的音频相关的内容,所以说AudioService是一个音频系统的大本营,它的功能非常多,而且它们之间的耦合性也不大,本章将从三个方面来探讨AudioService。
· 音量控制。
· 从按下音量键到弹出音量调提示框的过程,以及静音功能的工作原理。
· 音频IO设备的管理。
我们将详细探讨从插入耳机到声音经由耳机发出这个过程中,AudioService的工作内容。
· AudioFocus机制。
AudioService在2.3及以后版本中提供了AudioFocus机制用以结束多个音频应用混乱的交互现状。音频应用在播放音频的过程中需要合理的申请与释放AudioFocus,并根据AudioFocus所有权的变化来调整自己的播放行为。我们将从音频应用开始播放音频,到播放完成的过程中探讨AudioFocus的作用及原理。
AudioService的类图如下:
图 3‑1 AudioService
由图3-1可知:
· AudioService继承自IAudioService.Stub。IAudioService.Stub类很明显是通过IAudioService.aidl自动生成的。AudioService位于Bn端。
· AudioManager拥有AudioService的Bp端,是AudioService在客户端的一个代理。几乎所有客户端对AudioManager进行的请求,最终都会交由AudioService实现。
· AudioService的功能实现依赖AudioSystem类,AudioSystem无法实例化,它是java层到native层的代理。AudioService将通过它与AudioPolicyService以及AudioFlinger进行交互。
那么,开始AudioService之旅吧。
3.2 音量管理
在Android手机上有两种改变系统音量的方式。最直接的做法就是通过手机的音量键进行音量调整,还有就是从设置界面中调整某一种类型音频的音量。另外,应用程序可以随时将某种类型的音频静音。他们都是都是通过AudioService进行的。
本节将从上述的三个方面对AudioService的音量管理进行探讨。
3.2.1音量键的处理流程
1. 触发音量键
音量键被按下后,Android输入系统将该事件一路派发给Activity,如果无人截获并消费这个事件,承载当前Activity的显示的PhoneWindow类的onKeyDown()或onKeyUp()函数将会将其处理,从而开始了通过音量键调整音量的处理流程。输入事件的派发机制以及PhoneWindow类的作用将在后续章节中详细介绍,现在只需要知道,PhoneWindow描述了一片显示区域,用于显示与管理我们所看到的Activity、对话框等内容。同时,它还是输入事件的派发对象,而且只有显示在最上面的PhoneWindow才会收到事件。
注意按照Android的输入事件派发策略,Window对象在事件的派发队列中排在Activity的后面(应该说排在队尾比较合适),所以应用程序可以重写自己的onKeyDown()函数,将音量键用作其他的功能。比如说,在一个相机应用中,按下音量键所执行的动作是拍照而不是调节音量。
PhoneWindow的onKeyDown()函数实现如下:
[PhoneWindow.java-->PhoneWindow.onKeyDown()]
......//加省略号, 略过一些内容
switch (keyCode) {
caseKeyEvent.KEYCODE_VOLUME_UP:
caseKeyEvent.KEYCODE_VOLUME_DOWN:
case KeyEvent.KEYCODE_VOLUME_MUTE: {
// 直接调用到AudioManager的handleKeyUp里面去了。是不是很简单而且直接呢
getAudioManager().handleKeyDown(event,mVolumeControlStreamType);
return true;
}
……
}
注意handleKeyDown()函数的第二个参数,它的意义是指定音量键将要改变哪一种流类型的音量。在Android中,音量的控制与流类型是密不可分的,每种流类型都独立地拥有自己的音量设置,绝大部分情况下互不干扰,例如音乐音量、通话音量就是相互独立的。所以说,离开流类型谈音量是没有意义的。在Android中,音量这个概念一定是描述的某一种流类型的音量。
这里传入了mVolumeControlStreamType,那么这个变量的值是从哪里来的呢?做过多媒体应用程序的读者应该知道,Activity类中有一个函数名为setVolumeControlStream(int streamType)。应用可以通过调用这个函数来指定显示这个Activity时音量键所控制的流类型。这个函数的内容很简单,就一行如下:
[Activity.java-->Activity.setVolumeControlStream()]
getWindow().setVolumeControlStream(streamType);
getWindow()的返回值的就是用于显示当前Activity的PhoneWindow。从名字就可以看出,这个调用改变了mVolumeControlStreamType,于是也就改变了按下音量键后传入AudioManager.handleKeyUp()函数的参数,从而达到了setVolumeControlStream的目的。同时,还应该能看出,这个设置是被绑定到Activity的Window上的,不同Activity之间切换时,接受按键事件的Window也会随之切换,所以应用不需要去考虑在其生命周期中音量键所控制的流类型的切换问题。
AudioManager的handleKeyDown()的实现很简单,在一个switch中,它调用了AudioService的adjustSuggestedStreamVolume(),所以直接看一下AudioService的这个函数。
2. adjustSuggestedStreamVolume()分析
我们先来看函数原型,
public voidadjustSuggestedStreamVolume(int direction,
int suggestedStreamType,
int flags)
adjustSuggestedStreamVolume()有三个参数,而第三个参数flags的意思就不那么容易猜了。其实AudioManager在handleKeyDown()里设置了两个flags,分别是FLAG_SHOW_UI和FLAG_VIBRATE。从名字上我们就能看出一些端倪。前者用于告诉AudioService我们需要弹出一个音量控制面板。而在handleKeyUp()里设置了FLAG_PLAY_SOUND,这是为什么当松开音量键后“有时候”会有一个提示音。注意,handleKeyUp()设置了FLAG_PLAY_SOUND,但是只是有时候这个flag才会生效,我们在下面的代码中能看到为什么。还须要注意的是,第二个参数名为suggestedStreamType,从其命名来推断,这个参数传入的流类型对于AudioService来说只是一个建议,是否采纳这个建议AudioService则有自己的考虑。
[AudioService.java-->AudioService.adjustSuggestedStreamVolume()]
public void adjustSuggestedStreamVolume(intdirection, int suggestedStreamType,
int flags) {格式要调整好
int streamType;
// ①从这一小段代码中,可以看出在 AudioService中还有地方可以强行改变音量键控制的流类型
if(mVolumeControlStream != -1) {
streamType = mVolumeControlStream;
} else {
// ②通过getActiveStreamType()函数获取要控制的流类型
// 这里根据建议的流类型与AudioService的实际情况,返回一个值
streamType = getActiveStreamType(suggestedStreamType);
}
// ③这个啰嗦的if判断的目的,就是只有在特定的流类型下,并且没有处于锁屏状态时才会播放声音
if((streamType != STREAM_REMOTE_MUSIC) &&
(flags & AudioManager.FLAG_PLAY_SOUND) != 0 &&
((mStreamVolumeAlias[streamType] != AudioSystem.STREAM_RING)
|| (mKeyguardManager != null &&mKeyguardManager.isKeyguardLocked()))) {
flags&= ~AudioManager.FLAG_PLAY_SOUND;
}
if(streamType == STREAM_REMOTE_MUSIC) {
…… //我们不讨论远程播放的情况
} else {
// ④调用adjustStreamVolume
adjustStreamVolume(streamType, direction, flags);
}
}
注意初看着段代码时,可能有读者会对下面这句话感到疑惑:
VolumeStreamState streamState =mStreamStates[mStreamVolumeAlias[streamType]];
其实这是为了满足所谓的“将铃声音量用作通知音量”这种需求。这样就需要实现在两个有这个需求的流A与B之间建立起一个A→B映射。当我们对A流进行音量操作时,实际上是在操作B流。其实笔者个人认为这个功能对用户体验的提升并不大,但是却给AudioService的实现增加了不小的复杂度。直观上来想,我们可能想使用一个HashMap解决这个问题,键是源流类型,值目标流类型。而Android使用了一个更简单那但是却不是那么好理解的方法来完成这件事。AudioService用一个名为mStreamVolumeAlias的整形数组来描述这个映射关系。
如果想要实现“以铃声音量用作音乐音量”,只需要修改相应位置的值为STREAM_RING即可,就像下面这样:
mStreamVolumeAlias[AudioSystem.STREAM_MUSIC] =AudioSystem.STREAM_RING;
之后,因为需求要求对A流进行音量操作时,实际上是在操作B流,所以就不难理解为什么在很多和流相关的函数里都会先做这样的一个转换:
streamType = mStreamVolumeAlias[streamType];
其具体的工作方式就留给读者进行思考了。在本章的分析过程中,大可忽略这种转换,这并不影响我们对音量控制原理的理解。
这个函数简单来说,做三件事:
· 确定要调整音量的流类型。
· 在某些情况下屏蔽FLAG_PLAY_SOUND。
· 调用adjustStreamVolume()。
关于这个函数仍然有几点需要说明一下。它刚开始的时候有一个判断,条件是一个名为mVolumeControlStream的整型变量是否等于-1,从这块代码来看,mVolumeControlStream比参数传入的suggestedStreamType厉害多了,只要它不是-1,那么要调整音量的流类型就是它。那这么厉害的控制手段,是做什么用的呢?其实,mVolumeControlStream是VolumePanel通过forceVolumeControlStream()函数设置的。什么是VolumePanel呢?就是我们按下音量键后的那个音量条提示框了。VolumePanel在显示时会调用forceVolumeControlStream强制后续的音量键操作固定为促使它显示的那个流类型。并在它关闭时取消这个强制设置,即置mVolumeControlStream为-1。这个我们在后面分析VolumePanel时会看到。
接下来我们继续看一下adjustStreamVolume()的实现。
3. adjustStreamVolume()分析
[AudioService.java-->AudioService.adjustStreamVolume()]
public void adjustStreamVolume(int streamType, intdirection, int flags) {
// 首先还是获取streamType映射到的流类型。这个映射的机制确实给我们的分析带来不小的干扰
// 在非必要的情况下忽略它们吧
int streamTypeAlias = mStreamVolumeAlias[streamType];
// 注意VolumeStreamState类
VolumeStreamState streamState = mStreamStates[streamTypeAlias];
final intdevice = getDeviceForStream(streamTypeAlias);
// 获取当前音量,注意第二个参数的值,它的目的是如果这个流被静音,则取出它被静音前的音量
final intaliasIndex = streamState.getIndex(device,
(streamState.muteCount()!= 0)
booleanadjustVolume = true;
// rescaleIndex用于将音量值的变化量从源流类型变换到目标流类型下
// 由于不同的流类型的音量调节范围不同,所以这个转换是必需的
int step= rescaleIndex(10, streamType, streamTypeAlias);
//上面准备好了所需的所有信息,接下来要做一些真正有用的动作了
// 比如说checkForRingerModeChange()。调用这个函数可能变更情景模式
// 它的返回值adjustVolume是一个布尔变量,用来表示是否有必要继续设置音量值
// 这是因为在一些情况下,音量键用来改变情景模式,而不是设置音量值
if(((flags & AudioManager.FLAG_ALLOW_RINGER_MODES) != 0) ||
(streamTypeAlias == getMasterStreamType())) {
……
adjustVolume = checkForRingerModeChange(aliasIndex, direction, step);
……
}
int index;
// 取出调整前的音量值。这个值稍后被用在sendVolumeUpdate()的调用中
final intoldIndex = mStreamStates[streamType].getIndex(device,
(mStreamStates[streamType].muteCount() != 0) /* lastAudible */);
// 接下来我们可以看到,只有流没有被静音时,才会设置音量到底层去,否则只调整其静音前的音量
// 为了简单起见,暂不考虑静音时的情况
if(streamState.muteCount() != 0) {
……
} else {
// 为什么还要判断streamState.adjustIndex的返回值呢?
// 因为如果音量值在adjust之后并没有发生变化,比如说达到了最大值,就不需要继续后面的操作了
if(adjustVolume && streamState.adjustIndex(direction * step, device)) {
// 发送消息给AudioHandler
// 这个消息在setStreamVolumeInt()函数的分析中已经看到过了
// 这个消息将把音量设置到底层去,并将其存储到SettingsProvider中去
sendMsg(mAudioHandler,
MSG_SET_DEVICE_VOLUME,
SENDMSG_QUEUE,
device,
0,
streamState,
0);
}
index= mStreamStates[streamType].getIndex(device, false /* lastAudible */);
}
// 最后,调用sendVolumeUpdate函数,通知外界音量值发生了变化
sendVolumeUpdate(streamType, oldIndex, index, flags);
}
在这个函数的实现中,有一个非常重要的类型:VolumeStreamState。前面我们提到过,Android的音量是依赖于某种流类型的。如果Android定义了N个流类型,AudioService就需要维护N个音量值与之对应。另外每个流类型的音量等级范围不一样,所以还需要为每个流类型维护他们的音量调节范围。VolumeStreamState类的功能就是为了保存了一个流类型所有音量相关的信息。AudioService为每一种流类型都分配了一个VolumeStreamState对象,并以流类型的值为索引,保存在一个名为数组mStreamStates中。在这个函数中调用了VolumeStreamState对象的adjustIndex()函数,于是就改变了这个对象中存储的音量值。不过,仅仅是改变了它的存储值,并没有把这个变化设置到底层。
总结一下这个函数都作了什么:
· 准备工作。计算按下音量键的音量步进值。细心的读者一定注意到了,这个步进值是10而不是1。原来,在VolumeStreamState中保存的音量值是其实际值的10倍。为什么这么做呢?这是为了在不同流类型之间进行音量转换时能够保证一定精度的一种奇怪的实现,其转换过程读者可以参考rescaleIndex()函数的实现。我们可以将这种做法理解为在转换过程中保留了小数点后一位的精度。其实,直接使用float类型来保存岂不是更简单呢?
· 检查是否需要改变情景模式。checkForRingerModeChange()和情景模式有关。读者可以自行研究其实现。
· 调用adjustIndex()更改VolumeStreamState对象中保存的音量值。
· 通过sendMsg()发送消息MSG_SET_DEVICE_VOLUME到mAudioHandler。
· 调用sendVolumeUpdate()函数,通知外界音量发生了变化。
我们将重点分析后面三个内容:adjustIndex()、MSG_SET_DEVICE_VOLUME消息的处理和sendVolumeUpdate()。
4. VolumeStreamState的adjustIndex()分析
我们看一下这个函数的定义:
[AudioService.java-->VolumeStreamState.adjustIndex()]
public boolean adjustIndex(int deltaIndex, intdevice) {
// 将现有的音量值加上变化量,然后调用setIndex设置下去
// 返回值与setIndex一样
return setIndex(getIndex(device, false /* lastAudible */) + deltaIndex,
device,
true /* lastAudible */);
}
这个函数很简单,我们再看一下setIndex()的实现:
[AudioService.java-->VolumeStreamState.setIndex()]
public synchronized boolean setIndex(int index, intdevice, boolean lastAudible) {
intoldIndex = getIndex(device, false /*lastAudible */);
index =getValidIndex(index);
// 在VolumeStreamState中保存设置的音量值,注意是用了一个HashMap
mIndex.put(device, getValidIndex(index));
if(oldIndex != index) {
// 保存到lastAudible
if(lastAudible) {
mLastAudibleIndex.put(device, index);
}
// 同时设置所有映射到当前流类型的其他流的音量
boolean currentDevice = (device == getDeviceForStream(mStreamType));
intnumStreamTypes = AudioSystem.getNumStreamTypes();
for(int streamType = numStreamTypes - 1; streamType >= 0; streamType--) {
……
}
return true;
} else {
return false;
}
}
在这个函数中有三个工作要做:
· 首先是保存设置的音量值,这是VolumeStreamState的本职工作,这和4.1之前的版本不一样,音量值与设备相关联了。于是对于同一种流类型来说,在不同的音频设备下将会拥有不同的音量值。
· 然后就是根据参数的要求保存音量值到mLastAudibleIndex里面去。从名字就可以看出,它保存了静音前的音量。当取消静音时,AudioService就会恢复到这里保存的音量。
· 再就是对流映射的处理。既然A->B,那么设置B的音量时,同时要改变A的音量。这就是后面那个循环的作用。
可以看出,VolumeStreamState.adjustIndex()除了更新自己所保存的音量值外,没有做其他的事情,接下来就看一下MSG_SET_DEVICE_VOLUME的消息处理做了什么。
5. MSG_SET_DEVICE_VOLUME消息的处理
adjustStreamVolume()函数使用sendMsg()函数发送了MSG_SET_DEVICE_VOLUME消息给了mAudioHandler,这个Handler运行在AudioService的主线程上。直接看一下在mAudioHandler中负责处理MSG_SET_DEVICE_VOLUME消息的setDeviceVolume()函数:
[AudioService.java-->AudioHandler.setIndex()]
private void setDeviceVolume(VolumeStreamStatestreamState, int device) {
// 调用VolumeStreamState的applyDeviceVolume。
// 这个函数的内容很简单,就是在调用AudioSystem.setStreamVolumeIndex()
// 到这里,音量就被设置到底层的AudioFlinger里面去了
streamState.applyDeviceVolume(device);
// 和上面一样,需要处理流音量映射的情况。这段代码和上面setIndex的相关代码很像,不是么
intnumStreamTypes = AudioSystem.getNumStreamTypes();
for (int streamType = numStreamTypes - 1; streamType >= 0;streamType--) {
……
}
}
// 发送消息给mAudioHandler,其处理函数将会调用persitVolume()函数这将会把音量的
//设置信息存储到SettingsProvider中
// AudioService在初始化时,将会从SettingsProvider中将音量设置读取出来并进行设置
sendMsg(mAudioHandler,
MSG_PERSIST_VOLUME,
SENDMSG_QUEUE,
PERSIST_CURRENT|PERSIST_LAST_AUDIBLE,
device,
streamState,
PERSIST_DELAY);
}
注意 sendMsg()是一个异步的操作,这就意味着,完成adjustIndex()更新音量信息后adjustStreamVolume()函数就返回了,但是音量并没有立刻地被设置到底层。而且由于Handler处理多个消息的过程是串行的,这就隐含着一个风险:当Handler正在处理某一个消息时发生了阻塞,那么当按下音量键时,调用adjustStreamVolume()虽然可以立刻返回,而且从界面上看或者用getStreamVolume()获取音量值发现都是没有问题的,但是手机发出声音时的音量大小并没有改变。
6. sendVolumeUpdate()分析
接下来,分析一下sendVolumeUpdate()函数,它用于通知外界音量发生了变化。
[AudioService.java-->AudioService.sendVolumeUpdate()]
private void sendVolumeUpdate(int streamType, intoldIndex, int index, int flags) {
// 读者可能会对这句话感到有点奇怪,mVoiceCapable是从SettingsProvider中取出来的一个常量
// 从某种意义上来说,它可以用来判断设备是否拥有通话功能。对于没有通话能力的设备来说,RING流类
// 型自然也就没有意义了。这句话应该算是一种从语义操作上进行的保护
if(!mVoiceCapable && (streamType == AudioSystem.STREAM_RING)) {
streamType = AudioSystem.STREAM_NOTIFICATION;
}
//mVolumePanel是一个VolumePanel类的实例,就是它显示了音量提示框
mVolumePanel.postVolumeChanged(streamType, flags);
// 发送广播。可以看到它们都有(x+5)/10的一个操作。为什么要除以10可以理解,但是+5的意义呢
// 原来是为了实现四舍五入
oldIndex= (oldIndex + 5) / 10;
index =(index + 5) / 10;
Intentintent = new Intent(AudioManager.VOLUME_CHANGED_ACTION);
intent.putExtra(AudioManager.EXTRA_VOLUME_STREAM_TYPE, streamType);
intent.putExtra(AudioManager.EXTRA_VOLUME_STREAM_VALUE, index);
intent.putExtra(AudioManager.EXTRA_PREV_VOLUME_STREAM_VALUE, oldIndex);
mContext.sendBroadcast(intent);
}
这个函数将音量的变化通过广播的形式通知给了其他感兴趣得模块。同时,它还特别通知了mVolumePanel。mVolumePanel是VolumePanel类的一个实例。我们所看到的音量调节通知框就是它了。
至此,从按下音量键开始的整个处理流程就完结了。在继续分析音量调节通知框的工作原李之前,先对之前的分析过程作一个总结,请参考下面的序列图:
图 3-2 音量键调整音量的处理流程
结合上面分析的结果,由图 3-2可知:
· 音量键处理流程的发起者是PhoneWindow。
· AudioManager仅仅起到代理的作用。
· AudioService接受AudioManager的调用请求,操作VolumeStreamState的实例进行音量的设置。
· VolumeStreamState负责保存音量设置,并且提供了将音量设置到底层的方法。
· AudioService负责将设置结果以广播的形式通知外界。
到这里,相信大家对音量量调节的流程已经有了一个比较清晰的认识了。接下来我们将介绍音量调节通知框的工作原理。
4. 音量调节通知框的工作原理
在分析sendVolumeUpdate()函数时曾经注意到它调用了mVolumePanel的postVolumeChanged()函数。mVolumePanel是一个VolumePanel的实例。作为一个Handler的子类,它承接了音量变化的UI/声音的通知工作。在继续上面的讨论之前,先了解一下其工作的基本原理。
VolumePanel为于android.view包下,但是却没有在API中被提供。因为它只能被AudioService使用,所以和AudioService放在一个包下可能更合理一些。从这个类的注释上可以看到,谷歌的开发人员对它被放在android.view下也有极大的不满(What A Mass! 他们这么写道……)。
VolumePanel下定义了两个重要的子类型,分别是StreamResources和StreamControl。StreamResources实际上是一个枚举。它的每一个可用元素保存了一个流类型的通知框所需要的各种资源,如图标、提示文字等等。其定义就像下面这样:
[VolumePanel.java-->VolumePanel.StreamResources]
private enum StreamResources {
BluetoothSCOStream(AudioManager.STREAM_BLUETOOTH_SCO,
R.string.volume_icon_description_bluetooth,
R.drawable.ic_audio_bt,
R.drawable.ic_audio_bt,
false),
// 后面的几个枚举项我们省略了其构造参数,与BluetoothSCOStream的内容是一致的
RingerStream(……),
VoiceStream(……),
AlarmStream(……),
MediaStream(……),
NotificationStream(……),
MasterStream(……),
RemoteStream(……);
intstreamType; // 流类型
intdescRes; // 描述信息
inticonRes; // 图标
inticonMuteRes;// 静音图标
booleanshow; // 是否显示
StreamResources(intstreamType, int descRes, int iconRes, int iconMuteRes, boolean show) {
……
}
};
这几个枚举项组成了一个数组名为STREAM如下:
[VolumePanel.java-->VolumePanel.STREAMS]
private static final StreamResources[] STREAMS = {
StreamResources.BluetoothSCOStream,
StreamResources.RingerStream,
StreamResources.VoiceStream,
StreamResources.MediaStream,
StreamResources.NotificationStream,
StreamResources.AlarmStream,
StreamResources.MasterStream,
StreamResources.RemoteStream
};
VolumePanel将从这个STREAMS数组中获取它所支持的流类型的相关资源。这么做是不是觉得有点啰嗦呢?事实上,在这里使用枚举并没有什么特殊的意义,使用普通的一个Java类来定义StreamResources就已经足够了。
StreamControl类则保存了一个流类型的通知框所需要显示的控件。其定义如下:
[VolumePanel.java-->VolumePanel.StreamControl]
private class StreamControl {
intstreamType;
ViewGroupgroup;
ImageViewicon;
SeekBarseekbarView;
inticonRes;
inticonMuteRes;
}
很简单对不对?StreamControl实例中保存了音量条提示框中所需的所用控件。关于这个类在VolumePanel的使用,我们可能很直观的认为只有一个StreamControl实例,在对话框显示时,使其保存的控件按需加载指定流类型的StreamResources实例中定义的资源。其实不然,应该是出于对运行效率的考虑,StreamControl实例也是每个流类型人手一份,和StreamResources实例形成了一个一一对应的关系。所有的StreamControl 实例被保存在了一个以流类型的值为键的Hashtable中,名为mStreamControls。我们可以在StreamControl的初始化函数createSliders()中一窥其端倪:
[VolumePanel-->VolumePanel.createSliders()]
private void createSliders() {
……
// 遍历STREAM中所有的StreamResources实例
for (inti = 0; i < STREAMS.length; i++) {
StreamResources streamRes = STREAMS[i];
intstreamType = streamRes.streamType;
……
// 为streamType创建一个StreamControl
StreamControl sc = new StreamControl();
// 这里将初始化sc的成员变量
……
// 将初始化好的sc放入mStreamControls中去。
mStreamControls.put(streamType, sc);
}
}
值得一提的是,这个初始化的工作并没有在构造函数中进行,而是在postVolumeChanged()函数里处理的。
既然已经有了通知框所需要的资源和通知框的控件了,那么接下来就要有一个对话框承载它们。没错,VolumePanel保存了一个名为mDialog的Dialog实例,这就是通知框的本尊了。每当有新的音量变化到来时,mDialog的内容就会被替换为制定流类型对应的StreamControl中所保存的控件,并根据音量变化情况设置其音量条的位置,最后调用mDialog.show()显示出来。同时,发送一个延时消息MSG_TIMEOUT,这条延时消息生效时,将会关闭提示框。
StreamResource、StreamControl与mDialog的关系就像下面这附图一样,StreamControl可以说是mDialog的配件,随需拆卸。
图 3-3 StreamResource、StreamControl与mDialog的关系
接下来具体看一下VolumePanel在收到音量变化通知后都做了什么。我们在上一小节中说到了mVolumePanel.postVolumeChanged()函数。它的内容很简单,直接发送了一条消息MSG_VOLUME_CHANGED,然后在handleMessage中调用onVolumeChanged()函数进行真正的处理。
注意 VolumePanel在MSG_VOLUME_CHANGED的消息处理函数中调用onVolumeChanged()函数而不直接在postVolumeChanged()函数中直接调,。这么做是有实际意义的。由于Android要求只能在创建控件的线程中对控件进行操作。postVolumeChanged()作为一个回调性质的函数,不能要求调用者位于哪个线程中。所以必须通过向Handler发送消息的方式,将后续的操作转移到指定的线程中去。在大家设计具有UI Controller功能的类时,VolumePanel的实现方式有很好的参考意义。
看一下onVolumeChanged()函数的实现:
[VolumePanel.java-->VolumePanel.onVolumeChanged()]
protected void onVolumeChanged(int streamType, intflags) {
// 需要flags中包含AudioManager.FLAG_SHOW_UI才会显示音量调通知框
if((flags & AudioManager.FLAG_SHOW_UI) != 0) {
synchronized (this) {
if (mActiveStreamType != streamType) {
reorderSliders(streamType); // 在Dialog里装载需要的StreamControl
}
// 这个函数负责最终的显示
onShowVolumeChanged(streamType, flags);
}
}
// 是否要播出Tone音,注意有个小延迟
if((flags & AudioManager.FLAG_PLAY_SOUND) != 0 && ! mRingIsSilent) {
removeMessages(MSG_PLAY_SOUND);
sendMessageDelayed(obtainMessage(MSG_PLAY_SOUND, streamType, flags),PLAY_SOUND_DELAY);
}
// 取消声音与振动的播放
if((flags & AudioManager.FLAG_REMOVE_SOUND_AND_VIBRATE) != 0) {
removeMessages(MSG_PLAY_SOUND);
removeMessages(MSG_VIBRATE);
onStopSounds();
}
// 开始安排回收资源
removeMessages(MSG_FREE_RESOURCES);
sendMessageDelayed(obtainMessage(MSG_FREE_RESOURCES), FREE_DELAY);
// 重置音量框超时关闭的时间。
resetTimeout();
}
注意最后一个resetTimeout()的调用。它其实是重新延时发送了MSG_TIMEOUT消息。当MSG_TIMEOUT消息生效时,mDialog将会被关闭。
之后就是onShowVolumeChanged了。这个函数负责为通知框的内容填充音量、图表等信息,然后再把通知框显示出来,如果还没有显示的话。以铃声音量为例,省略掉其他的代码。
[VolumePanel.java-->VolumePanel.onShowVolumeChanged()]
protectedvoid onShowVolumeChanged(int streamType, int flags) {
// 获取音量值
intindex = getStreamVolume(streamType);
// 获取音量最大值,这两个将用来设置进度条
intmax = getStreamMaxVolume(streamType);
switch (streamType) {
// 这个switch语句中,我们要根据每种流类型的特点,进行各种调整。
// 例如Music就有时就需要更新它的图标,因为使用蓝牙耳机时的图标和和平时的不一样
// 所以每一次都需要更新一下
case AudioManager.STREAM_MUSIC: {
// Special case for when Bluetooth is active for music
if ((mAudioManager.getDevicesForStream(AudioManager.STREAM_MUSIC) &
(AudioManager.DEVICE_OUT_BLUETOOTH_A2DP |
AudioManager.DEVICE_OUT_BLUETOOTH_A2DP_HEADPHONES|
AudioManager.DEVICE_OUT_BLUETOOTH_A2DP_SPEAKER)) != 0) {
setMusicIcon(R.drawable.ic_audio_bt,
R.drawable.ic_audio_bt_mute);// 设置蓝牙图标
} else {
setMusicIcon(R.drawable.ic_audio_vol,
R.drawable.ic_audio_vol_mute);//设置为普通图标
}
break;
}
……
}
// 取出Music流类型对应的StreamControl。并设置其SeekBar的音量显示
StreamControl sc = mStreamControls.get(streamType);
if(sc != null) {
if (sc.seekbarView.getMax() != max) {
sc.seekbarView.setMax(max);
}
sc.seekbarView.setProgress(index);
……
}
if(!mDialog.isShowing()) { // 如果对话框还没有显示
/* forceVolumeControlStream()的调用在这里,一旦此通知框被显示,之后的按下音量键,都只能调节当前流类型的音量。直到通知框关闭时,重新调用forceVolumeControlStream(),并设置streamType为-1。*/
mAudioManager.forceVolumeControlStream(streamType);
// 为Dialog设置显示控件
// 注意,mView目前已经在reorderSlider()函数中安装好了Music流所对应的
//StreamControl了
mDialog.setContentView(mView);
……
//显示对话框
mDialog.show();
}
}
至此,音量条提示框就被显示出来了。总结一下它的工作过程:
· postVolumeChanged() 是VolumePanel显示的入口。
· 检查flags中是否有FLAG_SHOW_UI。
· VolumePanel会在第一次被要求弹出时初始化其控件资源。
· mDialog 加载指定流类型对应的StreamControl,也就是控件。
· 显示对话框,并开始超时计时。
· 超时计时到达,关闭对话框。
到此为止,AudioService对音量键的处理流程就介绍完了。而 Android还有另外一种改变音量的方式。
3.2.2通用的音量设置函数setStreamVolume()
除了通过音量键可以调节音量以外,用户还可以在系统设置中进行调节。AudioManager.setStreamVolume()是系统设置界面中调整音量所使用的接口。
1. setStreamVolume()分析
setStreamVolume()是SDK中提供给应用的API,它的作用是为特定的流类型设置范围内允许的任意音量。我们看一下它的实现:
[AudioService.java-->AudioService.setStreamVolume()]
public void setStreamVolume(int streamType, intindex, int flags) {
// 这里先判断一下流类型这个参数的有效性
ensureValidStreamType(streamType);
// 获取保存了指定流类型音量信息的VolumeStreamState对象。
// 注意这里面使用mStreamVolumeAlias对这个数组进行了流类型的转换
VolumeStreamState streamState =mStreamStates[mStreamVolumeAlias[streamType]];
// 获取当前流将使用哪一个音频设备进行播放。它最终会调用到AudioPolicyService里去
final intdevice = getDeviceForStream(streamType);
// 获取流当前的音量
final intoldIndex = streamState.getIndex(device,
(streamState.muteCount()!= 0) /* lastAudible */);
// 将原流类型下的音量值映射到目标流类型下的音量值
// 因为不同流类型的音量值刻度不一样,所以需要进行这个转换
index =rescaleIndex(index * 10, streamType, mStreamVolumeAlias[streamType]);
//暂时先忽略下面这段if中的代码。它的作用根据flags的要求修改手机的情景模式
if(((flags & AudioManager.FLAG_ALLOW_RINGER_MODES) != 0) ||
(mStreamVolumeAlias[streamType] == getMasterStreamType())) {
……
}
// 调用setStreamVolumeInt()
setStreamVolumeInt(mStreamVolumeAlias[streamType], index, device, false,true);
// 获取设置的结果
index =mStreamStates[streamType].getIndex(device,
(mStreamStates[streamType].muteCount() != 0) /*lastAudible */);
// 广播通知
sendVolumeUpdate(streamType, oldIndex, index, flags);
}
看明白这个函数了吗?抛开被忽略掉的那个if块归纳一下:它的工作其实很简单的,就是执行下面这三方面的工作:
· 为调用setStreamVolumeInt准备参数。
· 调用setStreamVolumeInt。
· 广播音量发生变化的通知。
分析的主线将转向setStreamVolumeInt()的内容了。
2. setStreamVolumeInt()分析
看一下setStreamVolumeInt函数的代码,和往常一样,暂时忽略目前与分析目标无关的部分代码。
[AudioService.java-->AudioService.setStreamVolumeInt()]
private void setStreamVolumeInt(int streamType,
int index,
int device,
boolean force,
booleanlastAudible) {
// 获取保存音量信息的VolumeStreamState对象
VolumeStreamState streamState = mStreamStates[streamType];
if(streamState.muteCount() != 0) {
// 这里的内容是为了处理当流已经被静音后的情况。我们在讨论静音的实现时在考虑这段代码
……
} else {
// 调用streamState.setIndex()
if(streamState.setIndex(index, device, lastAudible) || force) {
// 如果setIndex返回true或者force参数为true的话就在这里给mAudioHandler
//
sendMsg(mAudioHandler,
MSG_SET_DEVICE_VOLUME,
SENDMSG_QUEUE,
device,
0,
streamState,
0);
}
}
}
此函数有两个工作,一个是streamState.setIndex() 另一个则是根据setIndex()的返回值和force参数决定是否要发送MSG_SET_DEVICE_VOLUME消息。这两个内容在3.2.1节中已经又介绍了。在此不再赘述。
其执行过程可以参考下面的序列图:
图 3‑3setStreamVolume的处理流程
注意看到这个序列图后,是否有读者感到眼熟呢?如果我们把setStreamVolumeInt()的内容替换掉在setStreamVolume()的对它的调用,再和adjustStreamVolume()函数进行以下比较,就会发现他们的内容出奇地相似。Android在其他地方也有这样的情况出现。从这一点上来说,已经发展到4.1版本的Android源码仍然尚不够精致。读者可以思考一下,有没有办法把这两个函数融合为一个函数呢?
到此,对于音量设置相关的内容就告一段落。接下来我们将讨论和音量相关的另一个重要的内容——静音。
3.2.3静音控制
静音控制的情况与音量调节又很大的不同。因为每个应用都有可能进行静音操作,所以为了防止状态发生紊乱,就需要为静音操作进行计数,也就是说多次静音后需要多次取消静音才可以。
不过,如果进行了静音计数后还会引入另外一个问题。如果一个应用在静音操作(计数加1)后因为某种原因不小心挂了,那么将不会有人再为它进行取消静音的操作,静音计数无法再回到0,也就是说这个倒霉的流将被永远静音下去。
那么怎么处理应用异常退出后的静音计数呢?AudioService的解决办法是记录下来每个应用的自己的静音计数,当应用崩溃时,在总的静音计数中减去崩溃应用自己的静音计数,也就是说,由我们为这个应用完成它没能完成的取消静音这个操作。为此,VolumeStreamState定义了一个继承自DeathRecepient的内部类名为VolumeDeathHandler,并为每个进行静音操作的进程创建一个实例。它保存了对应进程的静音计数,并在进程死亡时进行计数清零的操作。从这个名字来看可能是Google希望这个类将来能够承担更多与音量相关的事情吧,不过眼下它只负责静音。我们将在后续的内容对这个类进行深入的讲解。
经过前面的介绍,我们不难得出AudioService、VolumeStreamState与VolumeDeathHandler的关系如下:
图 3‑4 与静音相关的类
1. setStreamMute()分析
同音量设置一样,静音控制也是相对于某一个流类型而言的。而且正如本节开头所提到的,静音控制涉及到引用计数和客户端进程的死亡监控。所以相对与音量控制来说,静音控制有一定的复杂度。不过还好,静音控制对外入口只有一个函数,就是AudioManager.setStreamMute()。第二个参数state为true表示静音,否则为解除静音。
[AudioManager.java-->AudioManager.setStreamMute()]
public void setStreamMute(int streamType, booleanstate) {
IAudioService service = getService();
try {
// 调用AudioService的setStreamMute,注意第三个参数mICallBack。
service.setStreamMute(streamType, state, mICallBack);
} catch(RemoteException e) {
Log.e(TAG, "Dead object in setStreamMute", e);
}
}
AudioManager一如既往地充当着一个AudioService代理的一个角色。但是这次有一个小小的却很重要的动作。AudioManager给AudioService传入了一个名为mICallBack的变量。查看一下它的定义:
private final IBinder mICallBack = new Binder();
真是简单得不得了。全文搜索一下,我们发现它只被用来作为AudioService的几个函数调用的参数。从AudioManager这边看来它没有任何实际意义。其实,这在Android中进程间交互通讯中是一种常见且非常重要的技术。mICallBack这个简单的变量可以充当Bp端在Bn端的一个唯一标识。Bn端,也就是AudioService拿到这个标识后,就可以通过DeathRecipient机制获取到Bp端异常退出的回调。这是AudioService维持静音状态正常变迁的一个基石。
注意服务端把客户端传入的这个Binder对象作为客户端的一个唯一标识,能做的事情不仅仅DeathRecipient这一个。还以这个标识为键创建一个Hashtable,用来保存每个客户端相关信息。这在Android各个系统服务的实现中是一种很常见的用法。
另外,本例中传入的mICallBack是直接从Binder类实例化出来的,是一个很原始的IBinder对象。进一步讲,如果传递了一个通过AIDL定义的IBinder对象,这个对象就有了交互能力,服务端可以它向客户端进行回调。在后面探讨AudioFocus机制时会遇到这种情况。
2. VolumeDeathHandler分析
我们继续跟踪AudioService.setStreamMute()的实现,记得注意第三个参数cb,它是代表特定客户端的标识。
[AudioService.java-->AudioService.setStreamMute()]
public void setStreamMute(int streamType, booleanstate, IBinder cb) {
// 只有可以静音的流类型才能执行静音操作。这说明,并不是所有的流都可以被静音
if(isStreamAffectedByMute(streamType)) {
// 直接调用了流类型对应的mStreamStates的mute()函数
// 这里没有做那个令人讨厌的流类型的映射。这是出于操作语义上的原因。读者可以自行思考一下
mStreamStates[streamType].mute(cb, state);
}
}
接下来是VolumeStreamState的mute()函数。VolumeStreamState的确是音量相关操作的核心类型。
[AudioService.java-->VolumeStreamState.mute()]
public synchronized void mute(IBinder cb, booleanstate) {
// 这句话是一个重点,VolumeDeathHandler与cb一一对应
// 用来管理客户端的静音操作,并且监控客户端的生命状态
VolumeDeathHandler handler = getDeathHandler(cb, state);
if(handler == null) {
Log.e(TAG, "Could not get client deathhandler for stream: "+mStreamType);
return;
}
// 通过VolumeDeathHandler执行静音操作
handler.mute(state);
}
上述代码引入了静音控制的主角,VolumeDeathHandler,也许叫做MuteHandler更合适一些。它其实只有两个成员变量,分别是mICallBack和mMuteCount。其中mICallBack保存了客户端的传进来的标识,mMuteCount则保存了当前客户端执行静音操作的引用计数。另外,它继承自IBinder.DeathRecipient,所以它拥有监听客户端生命状态的能力。而成员函数则只有两个,分别是mute()和binderDied()。说到这里,再看看上面VolumeStreamState.mute()的实现,读者能否先想想VolumeDeathHandler的具体实现是什么样子的么?
继续上面的脚步,看一下它的mute()函数。它的参数state的取值指定了进行静音还是取消静音。所以这个函数也就分成了两部分,分别处理静音与取消静音两个操作。其实,这完全可以放在两个函数中完成。先看看静音操作是怎么做的吧。
[AudioService.java-->VolumeDeathHandler.mute()part1]
public void mute(boolean state) {
if (state) {
// 静音操作
if(mMuteCount == 0) {
// 如果mMuteCount等于0,则表示客户端是第一次执行静音操作
//此时我们linkToDeath,开始对客户端的生命状况进行监听
//这样做的好处是可以避免非静音状态下对Binder资源的额外占用
try {
// linkToDeath! 为什么要判断是否为空?AudioManager不是写死了会把一个有效的
// Binder传递进来么?原来AudioManager也可能会调用mute()
// 此时的mICallback为空
if (mICallback != null) {
mICallback.linkToDeath(this, 0);
}
// 保存的mDeathHandlers列表中去
mDeathHandlers.add(this);
// muteCount() 我们在后面会介绍,这是全局的静音操作的引用计数
// 如果它的返回值为0,则表示这个流目前还没有被静音
if (muteCount() == 0) {
// 在这里设置流的音量为0
......//你打出来的省略号咋这么小呢?^_^
}
}catch (RemoteException e) {
......
}
}
// 引用计数加1
mMuteCount++;
} else {
// 暂时先不看取消静音的操作
……
}
}
看明白了么?这个函数的条件嵌套比较多,仔细归纳一下,就会发现这段代码的思路是非常清晰的。静音操作根据条件满足与否,有三个任务要做:
· 无论什么条件下,只要执行了这个函数,静音操作的引用计数都会加1。
· 如果这是客户端第一次执行静音,则开始监控其生命状态,并把自己加入到VolumeStreamState的mDeathHandlers列表中去。这是这段代码中很精练的一个操作,只有在客户端执行过静音操作后才会对其生命状态感兴趣,才有保存其VolumeDeathHandler的必要。
· 更进一步的,如果这是这个流类型第一次被静音,则设置流音量为0,这才是真正的静音动作。
不得不说,这段代码是非常精练的,不是说代码量少,而是它的行为非常干净。决不会做多余的操作,也不会保存多余的变量。
下面我们要看一下取消静音的操作。取消静音作为静音的逆操作,相信读者已经可以想象得到取消静音都做什么事情了吧?我们就不再对其进行说明了。
[AudioService.java-->VolumeDeathHandler.mute()part 2]
public void mute(boolean state) {
if (state) {
// 忽略掉静音操作
......
} else {
if(mMuteCount == 0) {
Log.e(TAG, "unexpected unmute for stream: "+mStreamType);
}else {
// 引用计数减1先
mMuteCount--;
if (mMuteCount == 0) {
// 如果这是客户端最后一次有效地取消静音
mDeathHandlers.remove(this);
if (mICallback != null) {
mICallback.unlinkToDeath(this, 0);
}
if (muteCount() == 0) {
// 将流的音量值设置回静音前的音量,也就是lastAudibleIndex
……
}
}
}
}
}
然后就剩下最后的binderDied()函数了。当客户端发生异常,没能取消其执行过的静音操作时,需要替它完成它应该做却没做的事情。
[AudioService.java-->VolumeDeathHandler.binderDied()]
public void binderDied() {
if(mMuteCount != 0) {
mMuteCount = 1;
mute(false);
}
}
这个实现不难理解。读者可以将自行分析一下为什么这么做可以消除意外退出的客户端遗留下来的影响。
3.2.4 音量控制总结
音量控制是AudioService最重要的功能之一。经过上面的讨论,相信读者对AudioService的音量管理流程已经有了一定的理解。
总结一下我们在这一节里所学到的内容:
· AudioService音量管理的核心是VolumeStreamState。它保存了一个流类型所有的音量信息。
· VolumeStreamState保存了运行时的音量信息,而音量的生效则是在底层AudioFlinger完成。所以音量设置需要做两件事情,更新VolumeStreamState存储的音量值。设置音量到Audio底层系统。
· VolumeDeathHandler是VolumeStreamState的一个内部类。它的实例对应了在一个流类型上执行了静音操作的一个客户端,是实现静音功能的核心对象。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论