Android 语音房应用内最小化实现方案(含完整代码)

适用对象:已经实现语音房基础功能(房间、麦位、推拉流),想加上”最小化成悬浮窗、用户切到其他页面也能继续聊”这一体验的 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 了,音频还能继续推流吗?

能。原因是:

  1. RTC 引擎实例由单例持有,引用链上有 Application Context 和静态对象,不会被 GC
  2. 音频采集运行在 Native 线程,不依赖 Activity
  3. 拉流回调通过 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

(0)

相关推荐