3.C:录音组件落地(VoiceRecorder + Overlay + Presenter + Window 子类)

完整支撑 Phase 3.E prepareaudio handler 的录音子链路:用户按住 H5 麦克风按钮
→ Native 录音 → 松手停止 → 触发完成回调。

## 新增模块

- `RecordingAwareWindow`:UIWindow 子类,提供 touch 旁路监听通道
  (static touchObserver closure)。默认行为与 UIWindow 完全一致,仅
  RecordingPresenter 注册 observer 后才触发额外行为。daoqi msext
  CustomWindow.m + nScreenTouch 通知同款思路,改用 closure 注入
- `VoiceRecorder`:AVAudioRecorder + AudioSession + 0.1s 计时器封装。
  ObservableObject + @Published(暴露 averagePower / elapsed 给 SwiftUI)。
  录音参数 8kHz/16bit/mono PCM 严格对齐 daoqi(AMR-NB 编码硬要求)。
  60s 上限对齐 daoqi kDefaultMaxRecordTime
- `RecordingOverlay`:SwiftUI 浮层(UI 内部实现,按 CLAUDE.md「原则 B 原生
  内部自由」与 daoqi 不一致)。半透明黑底胶囊 + SF Symbols 麦克风 + 音量
  光晕动画 + 倒计时(剩 10s 显示秒数,对齐 daoqi UI 逻辑)
- `RecordingPresenter`:组装入口。挂载 overlay 到 hostVC 全屏覆盖 +
  注册 touchObserver。提供 `start(on:fileName:onFinish:)` 单一 API +
  `cancelActive()` 强制取消

## SceneDelegate 改动

UIWindow → RecordingAwareWindow 实例化。变量类型仍为 UIWindow?
(多态),sendEvent 走子类版本。零行为变化(observer 未注册时)。

## 为何必须走 UIWindow 子类

H5 麦克风按钮按下触发 prepareaudio → 录音浮层在按住状态下中途弹出。
iOS touch 事件流是 view hit-test 链,**in-flight touch 不传给中途加入
的 view**,浮层 view 自身收不到 touchesEnded,用户松手时检测不到。

唯一可靠路径是 UIWindow.sendEvent 旁路截获(UIWindow 接收所有 touch
event 无论 in-flight 还是新 touch)。daoqi msext 也是这条路。

## Combine 引入

VoiceRecorder 用 ObservableObject + @Published 给 SwiftUI 数据绑定,
需要 import Combine。CLAUDE.md「避免 Combine」精神是禁用 Publisher 链
替代 async/await,不是禁止 ObservableObject(iOS 15.6 无 @Observable
宏可替代)。已在 VoiceRecorder.swift 注释说明。

## 验证

BuildProject 通过 0 错误,全部新文件 XcodeRefreshCodeIssuesInFile 0 诊断。
This commit is contained in:
joywayer
2026-06-24 01:29:04 +08:00
parent 5b38456c66
commit c398a8dd58
5 changed files with 431 additions and 1 deletions
+4 -1
View File
@@ -13,7 +13,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
// 使用 RecordingAwareWindow(UIWindow 子类)以便录音浮层旁路监听 touch
// 事件。默认行为与 UIWindow 完全一致,仅在 RecordingPresenter 注册
// touchObserver 后才有额外行为。详见 Source/Audio/RecordingAwareWindow.swift
let window = RecordingAwareWindow(windowScene: windowScene)
// 用 UINavigationController 承载大厅,便于 Phase 6 push 子游戏 / Phase 7 push 弹层。
// 栈深约束 ≤ 3(Design §2.4.3),由 AppCoordinator 在 push 处守门。
@@ -0,0 +1,43 @@
//
// RecordingAwareWindow.swift
// ylgamehall
//
// UIWindow 子类:提供 touch 事件的旁路监听通道,供录音浮层用。
//
// ## 为何需要
//
// H5 麦克风按钮按下时触发 prepareaudio → 录音浮层在「按住状态下」中途
// 弹出。iOS 的 touch 事件流走 view hit-test 链,**in-flight touch 不会
// 传给中途加入的 view**,新弹浮层 view 收不到 touchesEnded,导致用户松
// 手时 Native 检测不到。
//
// 必须在 UIWindow 层旁路截获 touch,绕开 view hit-test 链的不确定性。
// daoqi msext `CustomWindow.m` 同款思路(msext 用 NotificationCenter
// post `nScreenTouch` 通知,新外壳改用 closure 注入)。
//
import UIKit
/// 录音感知 UIWindow。
///
/// **默认行为完全不变**:未设置 `touchObserver` 时,`sendEvent` 直接调
/// `super`,所有 touch 事件按正常 hit-test 链分发。设置 `touchObserver`
/// 后,window 在每次 `.touches` event 时把 event 转给 observer,**触发
/// 时机在 `super.sendEvent` 之后,不阻塞业务 view 的事件分发**。
public final class RecordingAwareWindow: UIWindow {
/// 全局 touch event 观察 closure。
///
/// - 不为 nil 时,每次 `.touches` event 触发该 closure
/// - 录音组件在 start 时设置、stop / cancel 时清空
/// - **必须主线程访问**:UIWindow.sendEvent 由 UIKit 主线程调,
/// 读写 closure 在主线程串行,无需加锁
public static var touchObserver: ((UIEvent) -> Void)?
public override func sendEvent(_ event: UIEvent) {
super.sendEvent(event)
if event.type == .touches {
Self.touchObserver?(event)
}
}
}
@@ -0,0 +1,92 @@
//
// RecordingOverlay.swift
// ylgamehall
//
// 录音浮层 SwiftUI 视图(**UI 内部实现**,不影响 H5 契约)。
//
// 按 CLAUDE.md 「原则 B 原生内部自由」,UI 设计与 daoqi 不一致:
// - daoqi 是 ChatVoiceRecorderVC.xib 125×130 老式弹窗 + UIImageView 波形 +
// "录音剩下:Ns秒" 文本
// - 新外壳改用现代 SwiftUI:半透明黑底胶囊 + SF Symbols 麦克风 + 光晕动画 +
// 倒计时文本
//
import SwiftUI
struct RecordingOverlay: View {
@ObservedObject var recorder: VoiceRecorder
/// 剩余秒数 (0 ~ maxDuration)
private var remaining: TimeInterval {
max(0, VoiceRecorder.maxDuration - recorder.elapsed)
}
/// 倒计时提示,仅最后 10 秒显示秒数(对齐 daoqi
/// ChatVoiceRecorderVC.m:233-236 「剩 10s 显示倒计时」逻辑)。
private var hint: String {
if remaining < 10 {
"录音剩下:\(Int(ceil(remaining)))秒"
} else {
"松开发送"
}
}
private var hintColor: Color {
remaining < 10 ? .yellow : .white.opacity(0.85)
}
var body: some View {
ZStack {
// 全屏背景:阻止下层 H5 接收点击。touch 事件由
// RecordingAwareWindow.touchObserver 旁路捕获,与本 view 的
// hit-test 无关;这里只是视觉上的"接管屏幕"
Color.black.opacity(0.001)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
VStack(spacing: 12) {
MicrophoneWaveform(power: recorder.averagePower)
.frame(width: 72, height: 72)
Text(hint)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(hintColor)
}
.padding(.horizontal, 24)
.padding(.vertical, 20)
.background(
.black.opacity(0.7),
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
)
}
.ignoresSafeArea()
}
}
/// 麦克风波形指示:根据 averagePower 缩放光晕圆。
private struct MicrophoneWaveform: View {
let power: Float // -160 ~ 0 dB
/// dB 映射到 [0, 1]:截断 [-50, 0],等距归一化。
/// -50 dB 视作静音底噪,0 dB 视作峰值。
private var level: CGFloat {
let clamped = max(-50, min(0, power))
return CGFloat((clamped + 50) / 50)
}
var body: some View {
ZStack {
Circle()
.fill(.white.opacity(0.15 + Double(level) * 0.25))
.scaleEffect(0.7 + level * 0.5)
.animation(.easeOut(duration: 0.15), value: level)
Image(systemName: "mic.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(.white)
.frame(width: 36, height: 36)
}
}
}
@@ -0,0 +1,104 @@
//
// RecordingPresenter.swift
// ylgamehall
//
// 录音 UI 呈现器:组装 VoiceRecorder + RecordingOverlay + RecordingAwareWindow
// 三方协作,对外暴露单一 `start` 入口。
//
// 内部职责:
// 1. 创建 VoiceRecorder 启动录音
// 2. 把 RecordingOverlay 挂载到 hostVC.view 全屏覆盖
// 3. 注册 RecordingAwareWindow.touchObserver 监听松手
// 4. 触发松手 / 60s 自动停 → recorder.stop → onFinish → 清理 overlay +
// observer
//
// 调用方(Phase 3.E prepareaudio handler):
// ```swift
// let fileName = VoiceRecorder.currentTimeString()
// try RecordingPresenter.start(on: hostVC, fileName: fileName) { wavURL, _ in
// // 录音完成;调用方负责后续转码 / 上传 / 反向 callback
// }
// ```
//
import UIKit
import SwiftUI
public enum RecordingPresenter {
/// 当前进行中会话;同时只能有一个录音
private static var active: ActiveSession?
/// 启动录音 + 显示浮层 + 注册触摸监听。
///
/// 若已有进行中会话,会先 `cancel()` 旧的再开新的(防御性,正常流程下
/// 不应发生——H5 prepareaudio 不会在录音中再次调用)。
///
/// - Throws: VoiceRecorder.RecorderError,调用方应捕获并向用户提示
public static func start(
on hostVC: UIViewController,
fileName: String,
onFinish: @escaping (URL, TimeInterval) -> Void
) throws {
// 防御:清理任何遗留会话
cancelActive()
let recorder = VoiceRecorder()
try recorder.start(fileName: fileName) { wavURL, duration in
// 完成回调:清理 UI + observer,转发给业务
Self.teardown()
onFinish(wavURL, duration)
}
// 挂载 overlay 到 hostVC.view 全屏覆盖
let overlayHost = UIHostingController(rootView: RecordingOverlay(recorder: recorder))
overlayHost.view.backgroundColor = .clear
hostVC.addChild(overlayHost)
overlayHost.view.translatesAutoresizingMaskIntoConstraints = false
hostVC.view.addSubview(overlayHost.view)
NSLayoutConstraint.activate([
overlayHost.view.topAnchor.constraint(equalTo: hostVC.view.topAnchor),
overlayHost.view.bottomAnchor.constraint(equalTo: hostVC.view.bottomAnchor),
overlayHost.view.leadingAnchor.constraint(equalTo: hostVC.view.leadingAnchor),
overlayHost.view.trailingAnchor.constraint(equalTo: hostVC.view.trailingAnchor),
])
overlayHost.didMove(toParent: hostVC)
active = ActiveSession(recorder: recorder, overlayHost: overlayHost)
// 注册触摸观察:任一 touch ended/cancelled → 停止录音
RecordingAwareWindow.touchObserver = { [weak recorder] event in
guard let recorder, let touches = event.allTouches else { return }
let anyEnded = touches.contains {
$0.phase == .ended || $0.phase == .cancelled
}
if anyEnded {
recorder.stop()
}
}
}
/// 外部强制取消(如 H5 业务退出 / 应用切到后台)。
public static func cancelActive() {
active?.recorder.cancel()
teardown()
}
private static func teardown() {
active?.removeOverlay()
active = nil
RecordingAwareWindow.touchObserver = nil
}
private struct ActiveSession {
let recorder: VoiceRecorder
let overlayHost: UIHostingController<RecordingOverlay>
func removeOverlay() {
overlayHost.willMove(toParent: nil)
overlayHost.view.removeFromSuperview()
overlayHost.removeFromParent()
}
}
}
+188
View File
@@ -0,0 +1,188 @@
//
// VoiceRecorder.swift
// ylgamehall
//
// 录音业务对象。管理 AVAudioRecorder + AudioSession + 实时音量/时长状态。
//
// - 不负责 UI(RecordingOverlay 通过 @ObservedObject 订阅 @Published 状态)
// - 不负责触摸捕获(RecordingAwareWindow.touchObserver 实现,由
// RecordingPresenter 串接)
//
// 契约:docs/H5-Native-Contract.md §3.1 [4]prepareaudio 的录音子链路。
//
import AVFoundation
import Combine // ObservableObject / @Published:SwiftUI 数据绑定标准用法。
// 项目最低 iOS 15.6,无 iOS 17+ `@Observable` 宏可替代。
// CLAUDE.md「避免 Combine」指禁止用 Publisher 链替代 async/await,
// 不禁止 ObservableObject。
import os.log
/// 录音状态机 + AVAudioRecorder 包装。
public final class VoiceRecorder: ObservableObject {
private static let log = Logger(subsystem: "ylgamehall", category: "VoiceRecorder")
/// 最大录音时长 (秒),对齐 daoqi VoiceRecorderBaseVC.h:15 `kDefaultMaxRecordTime`。
public static let maxDuration: TimeInterval = 60
/// 录音参数(8kHz / 16bit / mono PCM)。对齐 daoqi
/// VoiceRecorderBaseVC.m:116-128 `getAudioRecorderSettingDict`。
/// AMR-NB 编码硬要求 8kHz mono;改动会让 amrFileCodec.mm `ReadPCMFrame`
/// 拿不到完整 160 sample 帧,转码失败。
private static let recorderSettings: [String: Any] = [
AVSampleRateKey: 8000.0 as Float,
AVFormatIDKey: UInt32(kAudioFormatLinearPCM),
AVLinearPCMBitDepthKey: 16,
AVNumberOfChannelsKey: 1,
]
private var recorder: AVAudioRecorder?
private var timer: Timer?
private var startTime: Date?
private var onFinishCallback: ((URL, TimeInterval) -> Void)?
private var recordingFileURL: URL?
/// 实时音量峰值 (~-160 ~ 0 dB);UI 用作波形动画。
@Published public private(set) var averagePower: Float = -160
/// 已录制秒数(0 ~ maxDuration)。
@Published public private(set) var elapsed: TimeInterval = 0
public init() {}
public enum RecorderError: Error, Sendable {
case sessionFailed(String)
case recorderCreateFailed(String)
case startFailed
}
/// 启动录音。调用前应已获得麦克风权限(由 prepareaudio handler 保证)。
///
/// - Parameters:
/// - fileName: wav 文件名(不含 .wav 扩展),对齐 daoqi `getCurrentTimeString` 产物
/// - onFinish: 完成回调 (wavURL, durationSec)。durationSec 是 `Date.now -
/// startTime`,**不**用作 `getaudiourl.time` 字段;Phase 3.E
/// prepareaudio 实现里会用 AVAudioPlayer 重读 wav duration 再 round
/// 取整秒,对齐 daoqi NewRootVC.m:1892-1893 行为
public func start(
fileName: String,
onFinish: @escaping (URL, TimeInterval) -> Void
) throws {
// AudioSession 配置(对齐 daoqi ChatVoiceRecorderVC.m:122-126)
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playAndRecord, options: [.defaultToSpeaker])
try session.setActive(true)
} catch {
throw RecorderError.sessionFailed(error.localizedDescription)
}
let wavURL = Self.wavFileURL(named: fileName)
try? FileManager.default.removeItem(at: wavURL)
let r: AVAudioRecorder
do {
r = try AVAudioRecorder(url: wavURL, settings: Self.recorderSettings)
} catch {
throw RecorderError.recorderCreateFailed(error.localizedDescription)
}
r.isMeteringEnabled = true
guard r.prepareToRecord(), r.record() else {
throw RecorderError.startFailed
}
self.recorder = r
self.recordingFileURL = wavURL
self.onFinishCallback = onFinish
self.startTime = Date()
self.elapsed = 0
self.averagePower = -160
startTimer()
Self.log.debug("start file=\(fileName, privacy: .public)")
}
/// 停止录音并触发 onFinish。已停止状态下重复调用是 no-op。
public func stop() {
guard let r = recorder, r.isRecording else { return }
let actualDuration = elapsed
r.stop()
let wavURL = recordingFileURL
let cb = onFinishCallback
cleanup()
if let wavURL, let cb {
Self.log.debug("stop duration=\(actualDuration, privacy: .public)s")
cb(wavURL, actualDuration)
}
}
/// 取消录音并删除文件,**不**触发 onFinish。
public func cancel() {
guard let r = recorder else { return }
if r.isRecording { r.stop() }
if let url = recordingFileURL {
try? FileManager.default.removeItem(at: url)
}
cleanup()
Self.log.debug("cancel")
}
private func cleanup() {
stopTimer()
try? AVAudioSession.sharedInstance().setActive(false)
recorder = nil
recordingFileURL = nil
onFinishCallback = nil
startTime = nil
}
// MARK: - 计时器(0.1s tick,更新音量峰值 + 检测 60s 上限)
private func startTimer() {
let t = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.tick() }
}
RunLoop.main.add(t, forMode: .common)
self.timer = t
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func tick() {
guard let r = recorder, r.isRecording, let start = startTime else { return }
r.updateMeters()
averagePower = r.averagePower(forChannel: 0)
elapsed = Date().timeIntervalSince(start)
if elapsed >= Self.maxDuration {
stop()
}
}
// MARK: - 沙盒文件路径
/// 录音 wav 写入路径。对齐 daoqi VoiceRecorderBaseVC.m:62-64
/// `getCacheDirectory`(实际返回 NSDocumentDirectory[0],不是 Caches/)。
private static func wavFileURL(named name: String) -> URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(name)
.appendingPathExtension("wav")
}
// MARK: - 公用工具
/// 生成 `yyyyMMddHHmmss` 时间戳字符串。对齐 daoqi
/// VoiceRecorderBaseVC.m:50-54 `getCurrentTimeString`。
public static func currentTimeString() -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyyMMddHHmmss"
return f.string(from: Date())
}
}