适用对象:已经实现语音房基础功能(房间、麦位、推拉流),想加上”最小化成悬浮窗、用户切到其他页面也能继续聊”这一体验的 Android 开发者。
一、为什么语音房需要”应用内最小化”
1.1 典型场景
打开任意一款主流语聊产品(TT 语音、Soul、Yalla),你会发现一个共同细节:用户进入房间后,可以随时点左上角的”-“把房间收起来,变成一个悬浮的小气泡,然后去刷动态、看消息、翻个人主页。整个过程语音不中断、麦位状态不丢、点回气泡又能无缝回到房间。
这个交互满足的是真实需求:
- 在房间里聊得起劲,但需要查一下别人的资料
- 主播在直播,听众想去回个私信
- 用户想边听房间边浏览其他内容
如果没有最小化,用户就只有”留在房间”或”退出房间”两个选项,体验非常割裂。
1.2 应用内最小化 vs 系统级后台
这两个概念经常被混淆,先厘清:
| 维度 | 应用内最小化 | 系统级后台 |
|---|---|---|
| 用户操作 | 点击”-“按钮 | 按 Home 键 / 切到其他 App |
| UI 表现 | App 内悬浮小窗 | 通知栏 + 锁屏控件 |
| 技术核心 | View 复用 + Activity 解耦 | 前台 Service + 音频焦点 |
| 权限成本 | 无 | 通知权限、悬浮窗权限、自启动 |
本文聚焦”应用内最小化”,后面会简单提系统级后台扩展。
1.3 业内产品形态参考
观察几款产品后,可以总结出共同的设计语言:
- 悬浮窗尺寸约 60-80dp 圆形,显示房间封面
- 内圈一个旋转的小波纹动画,提示”正在通话”
- 长按或拖动可移动,自动贴边
- 单击恢复全屏房间,长按或叉叉关闭并退房
二、技术方案选型对比
实现”最小化”有三种主流思路,各有适用场景。
2.1 方案 A:Activity + 应用内悬浮 View
最小化时 finish 掉 RoomActivity,把一个悬浮 View 通过 ActivityLifecycleCallbacks 挂载到当前栈顶 Activity 的 DecorView 上。
- 优点:无需任何权限,用户体验流畅
- 缺点:仅限 App 内显示,退到桌面就消失
- 适用:绝大多数语聊场景
2.2 方案 B:WindowManager + SYSTEM_ALERT_WINDOW
通过 WindowManager.addView() 挂到系统级窗口,可以悬浮在任何 App 之上,包括桌面。
- 优点:真正的”全局悬浮”
- 缺点:需要悬浮窗权限,部分机型引导转化率低
- 适用:需要跨 App 持续可见的高强度场景(如视频会议)
2.3 方案 C:Fragment + 单 Activity 架构
把整个 App 改造成单 Activity + 多 Fragment,房间作为一个 Fragment 在容器内做大小切换。
- 优点:状态管理最简单,没有跨 Activity 通信问题
- 缺点:架构改造成本高,已有项目难以落地
- 适用:新项目从零搭建
2.4 选型建议
| 项目状态 | 推荐方案 |
|---|---|
| 已有多 Activity 项目,想加最小化 | 方案 A |
| 需要跨 App 悬浮 | 方案 B |
| 全新项目,技术选型自由 | 方案 C 或 A |
下文以方案 A为主线展开,因为它兼容性最好、改造成本最低、覆盖 90% 的需求。
三、核心实现:应用内悬浮窗方案
3.1 架构设计
┌─────────────────────────────────────────────┐
│ VoiceRoomManager │
│ (单例:持有 RTC 引擎 + 房间状态 + 麦位列表) │
└─────────────────┬───────────────────────────┘
│
┌─────────┴─────────┐
│ │
全屏态 最小化态
│ │
┌───────▼────────┐ ┌──────▼─────────────┐
│ RoomActivity │ │ FloatingWindow │
│ (UI 渲染) │ │ (悬浮 View) │
└────────────────┘ └────────────────────┘
核心思想:把”房间状态”和”房间 UI”解耦。Activity 和悬浮窗都是 UI 表现层,真正持有 RTC 引擎和业务状态的是单例 Manager。这样无论 UI 怎么切换,底层音频流都不受影响。
3.2 关键代码实现
3.2.1 单例管理房间状态
object VoiceRoomManager {
private var rtcEngine: RtcEngine? = null
var currentRoomId: String? = null
private set
var currentRoomInfo: RoomInfo? = null
private set
var isInRoom: Boolean = false
private set
private val seatList = mutableListOf<SeatInfo>()
private val listeners = mutableListOf<RoomStateListener>()
fun init(context: Context, appId: String) {
if (rtcEngine != null) return
rtcEngine = RtcEngine.create(context.applicationContext, appId)
rtcEngine?.setEventHandler(internalHandler)
}
fun joinRoom(roomId: String, userId: String, token: String) {
rtcEngine?.joinChannel(token, roomId, userId)
currentRoomId = roomId
isInRoom = true
notifyListeners { it.onRoomJoined(roomId) }
}
fun leaveRoom() {
rtcEngine?.leaveChannel()
currentRoomId = null
currentRoomInfo = null
isInRoom = false
seatList.clear()
notifyListeners { it.onRoomLeft() }
}
fun addListener(listener: RoomStateListener) {
if (!listeners.contains(listener)) listeners.add(listener)
}
fun removeListener(listener: RoomStateListener) {
listeners.remove(listener)
}
private fun notifyListeners(block: (RoomStateListener) -> Unit) {
listeners.toList().forEach(block)
}
}
要点:
rtcEngine用applicationContext初始化,避免持有 Activity Context- 监听器列表用
toList()防止回调中removeListener引发并发修改
3.2.2 最小化按钮逻辑
最小化的本质是:finish 掉 RoomActivity,但不调用 leaveRoom。
class RoomActivity : AppCompatActivity() {
private lateinit var binding: ActivityRoomBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityRoomBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.btnMinimize.setOnClickListener {
minimizeRoom()
}
binding.btnExit.setOnClickListener {
VoiceRoomManager.leaveRoom()
finish()
}
}
private fun minimizeRoom() {
FloatingWindowController.show(application)
finish()
overridePendingTransition(0, R.anim.scale_to_corner)
}
override fun onBackPressed() {
minimizeRoom()
}
}
注意 onBackPressed 也走最小化,符合用户预期——返回键不应该直接退房。
3.2.3 悬浮窗 View
class FloatingVoiceView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val binding = LayoutFloatingVoiceBinding.inflate(
LayoutInflater.from(context), this, true
)
private var downX = 0f
private var downY = 0f
private var lastX = 0f
private var lastY = 0f
private var isDragging = false
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
init {
VoiceRoomManager.currentRoomInfo?.let { bind(it) }
startWaveAnimation()
}
fun bind(room: RoomInfo) {
Glide.with(this).load(room.coverUrl).circleCrop().into(binding.ivCover)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
downX = event.rawX
downY = event.rawY
lastX = event.rawX
lastY = event.rawY
isDragging = false
}
MotionEvent.ACTION_MOVE -> {
val dx = event.rawX - lastX
val dy = event.rawY - lastY
if (!isDragging &&
(abs(event.rawX - downX) > touchSlop ||
abs(event.rawY - downY) > touchSlop)) {
isDragging = true
}
if (isDragging) {
translationX += dx
translationY += dy
lastX = event.rawX
lastY = event.rawY
}
}
MotionEvent.ACTION_UP -> {
if (isDragging) {
snapToEdge()
} else {
expandToRoom()
}
}
}
return true
}
private fun snapToEdge() {
val parentWidth = (parent as ViewGroup).width
val targetX = if (x + width / 2 < parentWidth / 2) {
0f
} else {
(parentWidth - width).toFloat()
}
animate().x(targetX).setDuration(200).start()
}
private fun expandToRoom() {
val ctx = context
val intent = Intent(ctx, RoomActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
}
ctx.startActivity(intent)
FloatingWindowController.hide()
}
private fun startWaveAnimation() {
binding.viewWave.animate()
.scaleX(1.4f).scaleY(1.4f).alpha(0f)
.setDuration(1200)
.withEndAction {
binding.viewWave.scaleX = 1f
binding.viewWave.scaleY = 1f
binding.viewWave.alpha = 1f
if (isAttachedToWindow) startWaveAnimation()
}.start()
}
}
3.2.4 跨 Activity 显示控制器
这是整个方案的关键:监听 Activity 生命周期,确保任何前台 Activity 上都能挂载悬浮窗。
object FloatingWindowController : Application.ActivityLifecycleCallbacks {
private var floatingView: FloatingVoiceView? = null
private var currentActivity: Activity? = null
private var isShowing = false
fun init(application: Application) {
application.registerActivityLifecycleCallbacks(this)
}
fun show(application: Application) {
if (isShowing) return
isShowing = true
currentActivity?.let { attachTo(it) }
}
fun hide() {
isShowing = false
detachFromCurrent()
}
private fun attachTo(activity: Activity) {
if (!isShowing) return
if (activity is RoomActivity) return // 房间页不显示悬浮窗
val decor = activity.window.decorView as ViewGroup
if (floatingView?.parent === decor) return
detachFromCurrent()
val view = FloatingVoiceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
dp2px(64), dp2px(64)
).apply {
gravity = Gravity.END or Gravity.BOTTOM
rightMargin = dp2px(12)
bottomMargin = dp2px(120)
}
}
decor.addView(view)
floatingView = view
}
private fun detachFromCurrent() {
floatingView?.let {
(it.parent as? ViewGroup)?.removeView(it)
}
floatingView = null
}
override fun onActivityResumed(activity: Activity) {
currentActivity = activity
if (isShowing) attachTo(activity)
}
override fun onActivityPaused(activity: Activity) {
if (currentActivity === activity) detachFromCurrent()
}
override fun onActivityDestroyed(activity: Activity) {
if (currentActivity === activity) currentActivity = null
}
// 其他生命周期方法留空
override fun onActivityCreated(a: Activity, b: Bundle?) {}
override fun onActivityStarted(a: Activity) {}
override fun onActivityStopped(a: Activity) {}
override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
}
在 Application.onCreate 里调用一次 FloatingWindowController.init(this)。
3.3 RTC 引擎在最小化期间的处理
这一节回答一个常见疑问:Activity 都 finish 了,音频还能继续推流吗?
能。原因是:
- RTC 引擎实例由单例持有,引用链上有 Application Context 和静态对象,不会被 GC
- 音频采集运行在 Native 线程,不依赖 Activity
- 拉流回调通过 Manager 路由,最小化期间所有事件继续触发,只是 UI 不刷新
需要做的几件事:
- 麦位状态变更:通过 Manager 的监听器派发,悬浮窗可以订阅(比如自己上麦时 wave 动画变红)
- 关键消息提示:被踢、网络断开等,用 Toast 或 Notification 提示,不能因为 UI 关了就丢
class FloatingVoiceView : FrameLayout, RoomStateListener {
override fun onAttachedToWindow() {
super.onAttachedToWindow()
VoiceRoomManager.addListener(this)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
VoiceRoomManager.removeListener(this)
}
override fun onSeatChanged(seat: SeatInfo) {
if (seat.userId == VoiceRoomManager.currentUserId) {
binding.viewWave.setBackgroundResource(
if (seat.isMicOpen) R.drawable.bg_wave_active
else R.drawable.bg_wave_idle
)
}
}
override fun onKickedOut(reason: String) {
VoiceRoomManager.leaveRoom()
FloatingWindowController.hide()
Toast.makeText(context, "您已被移出房间:$reason", Toast.LENGTH_LONG).show()
}
}
四、关键问题与解决
4.1 切换 Activity 时悬浮窗如何持续显示
通过 ActivityLifecycleCallbacks 实现”接力”:
- A Activity
onPause→ 从 A 的 DecorView 移除 - B Activity
onResume→ 挂载到 B 的 DecorView
这样视觉上是一个连贯的悬浮窗,实际上是不断地 detach + attach。坐标位置如果需要保持,把最后一次 translationX/Y 缓存到 Controller 中,挂载后恢复。
4.2 进入新 Activity 时悬浮窗被遮挡
DecorView 的层级是该 Activity 的最高层,但如果新 Activity 用了 Dialog 或 PopupWindow,可能会盖住悬浮窗。解决办法:
- 用
WindowManager.addView替代 DecorView 挂载(升级到方案 B) - 或在显示 Dialog 时主动暂时隐藏悬浮窗
4.3 音频焦点冲突
来电、其他音频 App 抢占焦点时要响应:
private fun requestAudioFocus() {
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setOnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS -> VoiceRoomManager.muteAll(true)
AudioManager.AUDIOFOCUS_GAIN -> VoiceRoomManager.muteAll(false)
}
}
.build()
am.requestAudioFocus(request)
}
4.4 内存泄漏防范
最容易踩的坑:
- 悬浮窗 View 持有 Activity Context,Activity 销毁时如果没 detach,泄漏整个 Activity
ActivityLifecycleCallbacks的currentActivity字段,必须在onDestroyed中清理
用 LeakCanary 跑一遍最小化 → 切 Activity → 退房的完整流程,确保无泄漏。
4.5 横竖屏切换时的位置保持
横竖屏切换会触发 Activity 重建。悬浮窗的位置是 translationX/Y,重建后会丢失。处理方式:
object FloatingWindowController {
private var lastX = -1f
private var lastY = -1f
fun saveOffset(x: Float, y: Float) {
lastX = x; lastY = y
}
private fun attachTo(activity: Activity) {
// ...
view.post {
if (lastX >= 0) view.translationX = lastX
if (lastY >= 0) view.translationY = lastY
}
}
}
五、扩展:支持系统级后台
如果产品上要求”按 Home 键回桌面后还能保持通话”,需要补两件事:
5.1 前台 Service
class VoiceRoomService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("语音房进行中")
.setContentText(VoiceRoomManager.currentRoomInfo?.name ?: "")
.setSmallIcon(R.drawable.ic_mic)
.setContentIntent(buildPendingIntent())
.build()
startForeground(NOTIF_ID, notification)
return START_STICKY
}
}
进入房间时 startForegroundService,退房时 stopSelf。Android 14+ 需要在 manifest 声明 FOREGROUND_SERVICE_MEDIA_PLAYBACK 类型。
5.2 全局悬浮窗权限引导
fun checkOverlayPermission(activity: Activity): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (!Settings.canDrawOverlays(activity)) {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${activity.packageName}")
)
activity.startActivityForResult(intent, REQ_OVERLAY)
return false
}
}
return true
}
5.3 厂商系统省电策略
小米、华为、OPPO 的”后台限制”会杀掉前台 Service。常见做法:
- 引导用户加入电池优化白名单
- 在通知栏显示”正在通话”持久通知
- 与系统电话同等优先级(声明
phoneCall通知类别)
六、示例结构与效果
完整可运行的示例工程结构:
app/
├── manager/VoiceRoomManager.kt
├── floating/
│ ├── FloatingWindowController.kt
│ ├── FloatingVoiceView.kt
│ └── layout_floating_voice.xml
├── ui/RoomActivity.kt
└── App.kt (init)
把上述代码片段拼到对应文件,在 App.onCreate 里调用:
class App : Application() {
override fun onCreate() {
super.onCreate()
VoiceRoomManager.init(this, BuildConfig.RTC_APP_ID)
FloatingWindowController.init(this)
}
}
七、常见问题 FAQ
Q:用户杀掉 App 后还能恢复房间吗?
A:不能。进程被杀后所有内存对象销毁,必须重新加入房间。如果产品需要”恢复上次房间”,可以在 SP 里持久化最后的 roomId,启动时检测、引导用户重新进入。
Q:悬浮窗在某些机型不显示?
A:方案 A 不依赖任何系统权限,理论上所有机型都能显示。如果不显示,先排查:
- DecorView 是否成功 addView(用 LayoutInspector 看)
- 当前 Activity 是不是 RoomActivity(被代码主动跳过)
- ActivityLifecycleCallbacks 是否注册(在 Application 而不是 Activity 里)
Q:最小化后如何展示当前正在说话的人?
A:RTC 引擎一般有”音量回调” API,每秒返回每个用户的音量值。在 Manager 中维护一个 speakingUserId,悬浮窗订阅后切换头像即可。
Q:用户在悬浮窗状态下收到了房间内的私聊消息怎么办?
A:建议在悬浮窗右上角显示一个红点,点击进入房间后定位到消息列表。不要直接弹 Toast 打扰用户当前的浏览。
至此,”应用内最小化”的完整方案就讲完了。核心思想再强调一次:业务状态由单例持有,UI 表现层(Activity / 悬浮窗)可以自由切换。理解了这一点,方案 B、C 也都是这个思路的变体。
本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/yinshipin/66767.html