MENU

【代码札记】在安卓中实现骑行时多人聊天

September 5, 2023 • 瞎折腾

上一篇文章介绍了我这个语音聊天的协议设计。本文将介绍我是用安卓实现APP的过程。

前言

由于我不是专业的安卓开发,所以本文写的一些内容可能不是最佳实践,甚至可能与之背道而驰,只是姑且能用的草台班子罢了。如果你发现了这样的问题,欢迎在评论区中友善地提出来,我会十分感谢你的友好纠正。

此外本文的写作意图不是从头到尾的记录或手把手的教学,而是记录我在开发过程中遇到的问题,以供未来我或其他人参考之用。如果有部分描述得不够详细,属于正常范畴。

第一个Activity

新建项目后会自带一个主Activity,作为一个手生的懒人,我自然想着页面总数越少越好、每个页面越简单越好。所以没有那些花哨的广告和宣传,还有什么存钱贷款之类的功能,我的APP就要做一个小而美。

在程序成型之前,这个主页面被用来开发和测试新功能。在开发时我并不知道这个APP到底能不能成功,所以在主界面开发基本功能,测得差不多了再把他们打包封装成其他Activity。程序开发的差不多之后,主页面就作为设置页面,用来启动和关闭相应的功能。而另一个详情页面则用于展示广播中的其他节点和调整不同节点的音量。

整个app一共两个Activity和一个前台服务,整体APK打包之后不到5MB。朋友们,这才是小而美啊!

作为设置的页面,当然越简单越好。因为这个页面要管理服务,所以需要让用户进行一些配置:

  • 用户的昵称,用来向其他节点宣告
  • 加密用的密码,用来防止未经授权的监听
  • 组播信息,用于决定最终组播使用的ip和端口号

关于组播IP,上一篇文章已经决定了,我们要使用239.255.0.0/16。但是用户不一定记得住,说实话我也记不住,这个是从上一篇文章复制粘贴来的。怎么办呢?训练用户背下来就好了。我们当然可以把IP地址分成四段,前两段是固定的label,后两个是文本框,但这样一来我就需要两个文本输入来决定IP,太麻烦了。如果往根上说,IPv4地址就是一个32bit的数据,那对应的第16位就是我们这里要由用户决定的。16bit能搞一个什么数据类型呢?当然是short了。所以我们就搞一个文本框,让用户输入一个0到65535之间的数字,我们用这个数字和前面的239.255一拼接,这不就决定好ip了?这个懒偷得很妙,我是说,这个设计很简洁、巧妙。

当然,如果你再想想看,端口号的取值范围也是0到65535,两个16bit拼在一起就是一个int了,不如让用户输入一个整数,这样一来我们一个文本框就解决了两个参数,岂不是更妙?我确实考虑过这种设计,抛开数值过大,会给用户交互带来困扰不说,安卓本身也是个unix系统,所以1024以下的端口只有系统能够占用,我又不是拼多多,作为一个普通程序不能也不应该去使用root权限,所以端口号的范围就限制在了1024以上的端口。再说说看用户交互的问题。你让用户随机挑一个0到65535之间的数字,我觉得这个分布还是挺随机的;但如果你让用户在0到42亿之间随便挑一个数,你能指望用户给你输入10位的数字?更何况用户要避免这个数字被解析出小于1024的端口号,简直是难上加难。如果这样设计的话,恐怕用户的输入都要挤在6位或8位数字以内,而我的本意是希望所有的地址和端口都能够平等地被用户随机选择。所以很遗憾,懒偷得多了就要变成烂活,我选择单独搞一个输入端口号的文本框。

密码和用户昵称这个就彻底没办法偷懒了,只能老老实实地弄两个文本框。

最后再搞三个按钮,分别用于启停服务和切换到详情页。

是的,启动和停止我使用了两个按钮。我知道可以使用bind拿到服务,然后看启停状态,把启动和停止做到一个按钮上。但这不是懒嘛。换个角度想,我这种设计避免了页面整体被recompose的次数,四舍五入算省电设计。

持久化保存配置

当我测试完配置页面的逻辑之后,我发现了一个问题:我每次关掉app重新打开,就得把昵称、密码那些东西重新填写一遍,我要是用户,这个app肯定活不到它第三次启动。

为了解决这个问题,我搜索了一下,安卓说要用preferences这个东西。简单地说,它提供了一个十分简单但又够用的datastore,允许程序员快速存储配置。关于它的使用方法我就不多说了,关键词都给你列出来了,想知道就自己去谷歌啊。

在我的APP中,我使用了一种简单但又没那么粗暴的方式来使用它:通过订阅flow来实时更新对应的文本框,在启动服务时异步地将新配置保存回去。最开始我直接把文本框的内容和配置项绑定到了一起,导致文本项中的一个变化要传递到它的值上,然后这个值再被保存到配置项里,然后订阅的协程收到变化后再把变化的值赋给文本框,这一个流程下来会增大处理用户输入的延迟,并且这其中好多都是无用功。所以最终没有使用这种方案。

前台服务

服务保活可能是所有开发者最头疼的事情了。因为不同的厂家会在自己的ROM里实现不同的后台管理。有些厂商为了满足自己“省电”的宣传,可能杀后台会杀的比较狠,即便用户允许程序在后台运行,给app开了绿灯,后台服务可能还是难逃一死。前台服务比后台强点,但面对国产ROM还是无法保证效果。但还是那句话,我又不是拼多多,我只能尽力而为。用户选择软件,和软件选择用户,这都是对应的。

前台服务是普通服务的一种特殊形式。具体表现就是把一个服务和一个通知绑定在一起,这样就能够尽最大努力避免服务被杀掉。像是微信语音聊天等功能都用到了前台服务,避免你和朋友聊到一半系统突然把服务杀了。

为了贯彻小而美的宗旨,这个app只有一个前台服务,它负责四件事:

  • 录音、广播数据
  • 接收并处理其他人的数据
  • 将其他人的数据混音,输出给系统
  • 其他需要定期执行的杂事

    • 例如每隔一段时间就删掉不活跃的节点

以上这四点各自对应一个线程。但在启动线程之前还要做一些准备工作。这就不得不吐槽一下服务的生命周期了。目前我用到的函数重载有:onCreateonBindonStartCommandonDestroy,他们分别在服务被创建、用户调用startForegroundService、调用bindService和调用stopService时被调用。

那么服务什么时候被创建呢?当用户调用startForegroundServicebindService的时候会被创建,同时系统可能也会尝试创建服务。其中后者是你无法把控的,我曾经遇到过一个很罕见的由此产生的崩溃。也就是说,这个onCreate其实是允许调用系统服务的构造函数,因为在真正的构造函数中你可能没办法调用系统服务,因为他们还没有被正确初始化。

onStartCommand呢?听起来是用户明确调用startForegroundService时调用的,因为他会把用户传进来的intent传给我们的onStartCommand。但实际上系统也会调用这个函数,取决于你的返回值。因为前台服务并不保证一定能存活,因此当系统资源严重不足的时候,系统就会开始杀前台服务,但当资源恢复之后,系统会根据程序员的意图,尝试恢复被杀掉的前台服务,但这一点不能保证。一开始当我返回STICKY的时候,我希望系统能够恢复我的服务,但没有经过测试。机缘巧合之下,这确实发生了,但每次都会引发app崩溃。后来发现我这个onStartCommand实现要求intent不为空,因为要从中获取广播参数。而START_STICKY只是保证系统会重新调用onStartCommand来重启被杀掉的服务,但不会保留参数。要保留参数的话,我需要START_REDELIVER_INTENT。这样系统就会保留最后一次传给这个服务的intent,在重启时作为调用onStartCommand的参数。当然,安卓文档并没有保证系统一定会重启服务。我在朋友的手机上观测到,小米系统不仅当着我的面杀掉了前台服务,还不给我重启。

onBind呢?在启动服务时,我们创建一个intent,其中包含我们希望启动的服务的Class实例,随后安卓系统会代替我们创建并启动。但问题来了:安卓系统并不会返回给我们一个服务的示例,因此我们没办法与之进行通信。当然,我们可以通过网络或广播进行通讯,但最简单的当然还是直接拿一个对象去调用他的成员方法。这个时候我们就可以通过bingServiceonBind来实现。当我们调用bingService时,就需要服务返回一个实现了IBind的对象,这个对象会作为bingService的返回值交给调用者。如果我们把服务本身(也就是服务的this)包装到这个返回值里,不就可以拿到服务对象并和他交互了吗?确实如此,只不过别忘了这还有一个副作用:如果服务没有被创建,那在执行onBind之前,系统会“贴心”地帮我们创建服务。因此在设计时没办法简单的通过bind有没有连接上来检测服务的运行状态。

最后就是onDestroy了。我原本期待有一个onStop之类的重载,这样当用户决定停止服务时可以停止,然后接下来可以选择再次start,或者调用destroy被回收——这个onDestroy应该被用于回收资源才对。但很遗憾,这个onDestroy确实是服务被停止时才会调用的,你需要在这个函数里停止服务并释放资源。安卓系统保证在进行了这个调用后,不会再有其他针对这个service示例的调用了。也就是说服务被停止后,它就会被彻底销毁,不会占用任何资源,直到重启。

这个生命周期一开始觉得有些反人类,写代码的时候逐渐有些理解了,但时代如今我还是坚持应该加一个onStop重载,将停止服务和销毁服务分开。系统可以决定停止后立刻销毁,也可以决定等待一段时间再销毁,如果这期间服务被重新启动了,那不就免去了重新初始化资源的麻烦了吗?不过好在这种设计没有给我带来太大的困扰,所以我也就不多批评了。

蓝牙音频

在前台服务中我提到它的职责之一就是要不停的录音,然后把音频数据广播出去。但蓝牙拼音这块真的令我大费周章。没想到这么多年过去了,你说PC上JVM的蓝牙没有文档,支持不行,我念你不同的系统API设计不同,难以兼容,就不说什么了。怎么到了安卓上还是一个德行?

一开始我注意到,即便是我连接了蓝牙耳机,程序还是从手机自带的麦克风获取数据。我查阅了各种方法,Google给我推了一个BLE Audio的,但很明显,现在几乎没有耳机使用这种协议。我又查了查,最后发现蓝牙耳机所使用的协议无外乎两种:A2DP和SCO。前者对应音乐,后者对应语音,有时候也叫通话。如果你曾经在电脑上连接过蓝牙耳机,比如Windows上,你会注意到Windows提示“已连接的音乐,语音”,同时你的系统里多了两个音频输出,一个是立体声输出,一个是hands free免提输出。这个高音质的立体声输出就是A2DP协议,它提供了一种高带宽的单向通信,允许宿主向蓝牙耳机推送高质量、数据量大的音频数据。而后者的hands free则是用于通话的,它的音频质量不高,但提供了双向通信,允许宿主从蓝牙设备上获取麦克风输入。如果用过腾讯会议,并且腾讯会议恰巧识别到了这个hands free之后,我相信你会认识到这两种通道的不同的——如果你的电脑同时通过立体声输出和hands free输出向蓝牙耳机输出音频,那么hands free的音频流就会遮盖住立体声的流。

具体到我们这个APP来说,我们当然希望使用SCO来建立双向的音频流,不然骑车时我们的手机都放在离嘴比较远的地方,那恐怕被人听到的就只有呼啸的风声了。但通过AudioManager.startBluetoothSco()之后,我发现我必须使用USAGE_VOICE_COMMUNICATION才能在蓝牙耳机中听到我的APP的输出。如果我使用常规的USAGE_MEDIA,声音就会非常小,几乎为静音状态。一开始我以为和电脑上是一样的,我APP开启的SCO流盖过了A2DP流,但后来我意识到蓝牙耳机不能同时开这两个流,因此我需要想办法让系统把媒体流也输出到SCO上。经过各种搜索,最终我发现系统原本就是这样子的,但因为开始SCO模式后,系统自动进入了通话模式,因此会把其他音频的音量压得非常低。放音乐的话能够勉强听到,而语音通话则完全听不到,让我误以为是音频流被SCO给顶掉了。解决方法也非常简单,在创建了SCO流之后使用audioManager.mode = AudioManager.MODE_NORMAL把模式改回普通就好了。

但问题还没结束。使用USAGE_VOICE_COMMUNICATION的时候,系统默认会把通话流输出到听筒。如果没有蓝牙耳机的话,在骑车的时候没人想举着手机像打电话一样听人聊天。但我又不想额外研究外放,所以我搞了一个自动刷新音频IO的机制。如果存在SCO设备并且SCO已经启用,那么就自动使用通话流,否则使用媒体流。但问题来了:检测SCO状态是通过回调来做的,写音频流是在另一个线程,而初始化音频IO是在服务被调用onStartCommand时进行的。

我放佛能听见有人在喊:锁!加锁啊!锁当然是个好东西,无论是监视器锁还是可重入锁,但我总觉得锁这个东西挺费性能的,虽然客观上说有没有锁其实性能影响不大,但我喜欢小而美,所以能不用锁就不用锁。

主要还是加了锁就意味着程序员需要额外遵守写代码的规则(在使用资源时记得加锁)。这没有什么难的,但它开启了一种可能性——如果程序员忘记加锁,程序就会表现得不正常,这要是调试起来就会非常痛苦。当然,你可以搞一个函数来访问这个资源,但很丑陋,不是吗?

我们的问题在于,我们需要在SCO断开的时候,重新创建一个音频IO,让他使用媒体流而非通话流。最直白的设计无外乎如此:

加锁{
    关闭原来的音频流;
    创建新的音频流;
    启动新的音频流;
}

这样一来我们可以保证我们在替换变量时,没有人能够访问我们尚未初始化完成的音频流。如果要提高性能的话,我们可以使用ReentrantLock。当其他线程读取音频流时加锁,读出数据之后就释放锁。如果我们获取到了锁,在替换时读取的线程将被阻塞,等我们替换完成之后它读到的就是新的音频流了。完美。

但,读音频流的部分已经用try...catch包裹了一层了,现在要用synchronized再包一层,难看,太难看了。不够优雅。那怎么办呢?想想看并发三要素:原子性,可见性和有序性。我们需要原子性吗?很显然需要,我们当然不想让其他线程获取到我们还没有准备完毕的音频IO;那我们需要可见性吗?也需要,因为我们要让其他线程看到新的音频流,因为旧的已经被关闭了;最后,我们需要有序性吗?好像。。。没那么需要?我们这里的重点在于“替换”,我们需要用新的替换旧的,这个好像没什么顺序可言。

基于这一点观察,我们可以使用volatile关键字来实现这个功能。volatile能够保证内存可见性,也就是说,A线程修改了变量,那么B线程就能立刻看到这种修改。原子性则由赋值操作来保证——很显然变量赋值是原子的,不然这代码没法写了:你给一个变量赋值为8,如果这个操作不是原子的,那下一条只能只能看到这个值的低几位被写入了,那还玩什么?最后,有序性能够通过volatile带来的内存屏障来获得一定程度的保证。

前面说了,我们需要创建并初始化一个新的音频流,用来替换旧的,同时还要关闭旧的音频流释放资源。但如果新的音频流还没来得及初始化就被调用了,或者旧的音频流在关闭后仍被调用,那就坏事了。好在volatile的内存屏障能够保证读写之前的副作用一定发生在读写之前,而读写之后的副作用一定能发生在读写之后。这听起来像是废话,但考虑指令重排序的话就不是了。如果读者不了解指令重排序和内存屏障,还请移步谷歌自行搜索。

简单地说,如果我们在赋值发生前就创建好音频流并初始化,那么赋值发生时,volatile保证音频流一定是初始化的(否则JVM可能会把初始化的指令重新排序到赋值之后);而赋值发生之后,volatile保证其他线程获取到的音频流就已经是新的音频流了。这时再关闭旧的音频流,能够保证后续进程不会访问到已经被关闭的旧音频流。当然,有一个边界情况是一个线程已经拿到了旧的音频流,这时候我们替换并关闭了旧的流,那么读数据的线程就会出问题。这里我查询了音频流的实现,当这种错误发生时,错误是通过返回值返回的,因此不会出现异常。正常情况下返回值会给出实际读写的数据量,出现错误后将会返回小于0的错误代码,这种情况会被程序忽略。问题不大。最终设计如下:

获取旧的音频流;
创建并初始化新的音频流;
将新的音频流赋值给volatile变量;
关闭旧的音频流;

详情页

详情页就没有太多细节可以说。这个页面的主要功能就是绑定服务并获取一些状态,比如当前的节点列表。这个页面还允许用户调整自己的广播音量,同时还可以分别调整每一个节点的音量。为了防止骑行时误触,所有音量条默认都是锁定状态,需要用户先点击解锁按钮,然后才能调整。

总结

程序设计无非两点:偷懒和嘴硬。除非有利可图,不然这两点难以避免。

截至目前,我的APP已经开源到了GitHub,如果没有意外的话我会持续更新。不过还是那句话,我这个代码是开源的,有需求可以提issue,但我鼓励你自己实现并发起pull request。我没有收你一分钱,所以我没有义务实现任何人的愿望。

-全文完-


知识共享许可协议
【代码札记】在安卓中实现骑行时多人聊天天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code