ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从原理到实践:实时音频频域降噪模块的Python实现

从原理到实践:实时音频频域降噪模块的Python实现 很多人第一次做播客或者录制视频旁白的时候都会被同一件事搞崩溃明明屋里很安静可录出来的音频里总有一层“嗞——”的底噪像一层磨砂玻璃糊在人声上。有人第一反应是换麦克风、换声卡折腾一圈发现改善有限有人选择录完以后用离线工具整体降噪但等到真的要发片、要连麦、要做直播的时候离线处理完全帮不上忙。我当时面临的情况更具体需要在Windows端写一个能实时处理麦克风输入的模块让输出的声音里没有底噪同时不能有明显的回声感、机器人声延迟还得低到人耳察觉不出来。查了一圈现成的商业方案要么太贵要么闭源不好嵌入要么延迟高得离谱。最后决定自己动手写一个实时降噪模块。这篇文章把整个项目的思路、代码结构和踩坑过程完整拆开讲适合正在做语音录制工具、直播伴侣、本地语音交互、或者单纯想搞清楚“降噪到底是怎么一回事”的朋友参考。我会把频域降噪的核心原理讲清楚直接给出一份可运行的Python实现再沿着实时化的路径把分块处理、参数调优和实际移植中的问题逐个击破。1. 底噪从哪里来先搞清楚我们要消掉的是什么降噪不是把声音“变小”那么简单。如果只是把音量压下去人声和噪音会一起衰减效果相当于什么都没干。所以在动手写代码之前必须把底噪的成分拆开看。1.1 环境噪声、设备底噪和量化噪声的差别录音底噪的来源大致可以分成三类。第一类是环境噪声比如电脑风扇声、空调低频嗡嗡声、室外的交通声这类噪声通常在中低频段比较集中而且相对稳定第二类是设备底噪来自麦克风前置放大器的电流声、USB声卡的共地干扰往往表现为50Hz或100Hz附近的工频噪声以及宽频带的微弱嘶嘶声第三类是量化噪声这是ADC把模拟信号转成数字信号时产生的通常在低电平信号时比较明显听起来像一层极细微的沙沙声。这三类噪声混在一起就成了我们常说的“底噪”。但注意不同类型的噪声在频谱上的表现差别很大。环境噪声往往是窄带的、有特定峰值设备底噪往往全频段都有能量量化噪声则是典型的白噪声特征在全频域内能量分布均匀。1.2 为什么时域处理解决不了底噪问题有人可能会想噪声和语音叠加在一起能不能在时域上做个高通或者低通滤波把噪声滤掉问题在于语音本身的频率范围非常宽男性声带基频能达到100Hz以下齿音又集中在6kHz以上而噪声往往是宽频的时域滤波器在滤掉噪声的同时一定会切掉语音的频段。更麻烦的是底噪在频域上是动态变化的服务器风扇转速稍快一点1kHz附近的噪声能量就完全变了固定参数滤波器根本跟不上这种变化。这就是为什么所有正经的降噪方案都要在频域里做。我们在频域能清楚看到哪些频段是噪声主导哪些频段是语音主导然后只衰减噪声主导的频段保留语音主导的频段。这种思路就是谱减法、维纳滤波以及各类深度学习降噪的共同基础。1.3 降噪模块的核心KPI降噪量、失真相容度、延迟硬指标写模块之前一定要有可量化的验收标准不然调参就成了玄学。我给自己定的是三个硬指标环境噪声降低20dB以上语音失真尽可能小至少不能出现明显的“水声”和机械感从声音进入麦克风到从耳机/扬声器输出端到端延迟低于40毫秒。前两个指标决定“能不能用”第三个指标决定“能不能实时”。人在听自己说话时如果延迟超过60毫秒就会有明显的不适应感超过100毫秒就基本没法正常说话了。40毫秒听起来不难但要给分帧、加窗、FFT、降噪处理、IFFT、重叠加法这些步骤留出计算时间还要加一层系统缓冲实际能分给算法的时间窗口非常紧。2. 频域降噪的数学直觉谱减法与维纳滤波的等价性网上讲降噪的文章很多但大多数直接丢公式不讲为什么。我尽量用直觉加推导的方式说清楚。2.1 以帧为单位走通“分帧-FFT-增益-ISTFT”的思路真实的音频是非平稳信号你说话的响度、音调在几十毫秒内就在变。但如果我们只取很短一段信号比如20毫秒这段信号可以近似认为是平稳的频谱特征基本不变。这就是“短时平稳假设”。降噪流程的骨架如下把连续音频切成20到30毫秒的帧相邻帧之间留50%的重叠对每一帧加窗一般用汉宁窗后做FFT得到频域幅度和相位在频域里根据噪声底数和当前帧频谱估计出一个增益值对每个频点做衰减把处理后的频域信号用IFFT还原回时域再通过重叠相加的方式拼接成连续的音频流。这里的核心就是“算增益”。增益的取值范围在0到1之间0表示这个频点被完全消除1表示不动它。对噪声主导的频点给接近0的增益对语音主导的频点给接近1的增益其他频点根据信噪比做一个平滑过渡。2.2 谱减法的迭代过程与残余“音乐噪声”的成因最原始的谱减法思路特别直白用带噪信号的功率谱减去估计出的噪声功率谱差值就是干净语音的功率谱。用公式写就是 [|S|^2 |X|^2 - |N|^2]如果减完是负数就置成0。理论上没问题实操中很快会暴露一个致命缺陷噪声是随机波动的减掉一个平均值后总会有部分帧的噪声瞬时能量高于平均值减去均值后剩余的正能量会成为一个个孤立的小峰听感上就像背景里有一阵一阵的“音乐声”或者“水泡声”。这是谱降噪的老大难问题几乎所有传统降噪算法都会遇到。要抑制音乐噪声两个手段最有效。第一是“谱减法的下溢保护”给增益设一个下限不让任何一个频点彻底归零这样残余噪声至少是平整的不容易被感知成音乐噪声第二是时间平滑对增益的更新速率做限制避免增益值逐帧剧烈跳变。2.3 从维纳滤波器视角理解为什么开方号和过减系数有效从统计信号处理的角度看最优的频域增益其实是维纳滤波器它给出的增益是[ G_k \frac{\xi_k}{1 \xi_k} ]其中(\xi_k)是第k个频点的先验信噪比。这个公式告诉我们信噪比高的频点增益趋近于1信噪比低的频点增益趋近于0。整个降噪过程本质上就是做“带噪信号的信噪比估计”。谱减法里的过减系数(\alpha)和增益下限(\beta)本质上就是对信噪比估计误差的补偿。过减系数大于1时相当于高估了噪声衰减更激进但同时失真风险更大增益下限则是故意留一点噪声当“底垫”用可接受的残余噪声换更低的失真。这个权衡贯穿整个调参过程。3. 第一个能跑的降噪模块Python高频验证版在写实时版之前我建议所有人都先做一版离线验证。用Python把算法逻辑跑通耳朵听效果眼睛看频谱确认参数方向正确再考虑移植到实时框架。直接一上来写实时版本排查问题的复杂度会翻几倍。3.1 环境准备与wavfile读取链路这一步只需要三个库numpy做数组运算scipy做信号处理辅助soundfile读音频。实测下来soundfile对各类wav编码的兼容性比自带的wave模块强很多对采样率、位深各异的文件都不容易翻车。import numpy as np import soundfile as sf # 读取录音文件返回数据和采样率 data, sr sf.read(noisy_voice.wav) # 如果录的是立体声先合成单声道做处理 if data.ndim 1: data data.mean(axis1) print(f采样率: {sr}Hz时长: {len(data)/sr:.2f}s)3.2 噪声底数估计取前几帧静音段做平均谱减法的关键输入是噪声功率谱它从哪来最简单可靠的方法是取音频开头一段没有语音的“静音段”比如前200到400毫秒把这段的频谱平均作为噪声底数估计。但这里有个大坑麦克风的底噪不是恒定不变的。如果你录制时安静后期处理时房间背景噪声变了固定噪声底数就不匹配了。实时模块里我们通常会用递归平均的方式持续追踪最小噪声底数这个后面细说。离线验证阶段先固定底数把主链路跑通。import numpy as np def estimate_noise_floor(data, sr, frame_len1024, hop512, init_seconds0.3): 用音频开头若干秒作为静音段估计噪声功率谱 frame_size frame_len n_init_frames max(1, int(sr * init_seconds / hop)) noise_psd np.zeros(frame_size // 2 1, dtypenp.float64) count 0 for i in range(n_init_frames): start i * hop frame data[start:start frame_size] if len(frame) frame_size: break win np.hanning(frame_size) frame frame * win spec np.fft.rfft(frame) noise_psd np.abs(spec) ** 2 count 1 noise_psd / max(count, 1) return noise_psd3.3 计算频点增益与频谱衰减有了噪声功率谱就可以计算每一帧的增益了。实践中完全照搬谱减法公式的很少大家用的都是改良版。我最常用的一个表达式是SNR相关的软增益[ G_k \max\left( \beta, 1 - \frac{\alpha \cdot N_psd_k}{|X_k|^2} \right) ]其中(\beta)是增益下限通常取0.05到0.15(\alpha)是过减系数取值1到3之间。这个表达式的意思是当前帧信号功率远大于噪声功率时增益接近1信号功率和噪声功率差不多甚至更低时增益会被限制在(\beta)附近也就是只保留一点点残余噪声当背景。注意这里用的是功率谱幅度的平方而不是幅度谱两者算出的增益形态差异明显功率谱形式衰减更柔和更适合语音处理。def wiener_like_gain(spec_mag2, noise_psd, alpha2.0, beta0.1): 按频点计算增益带下限保护 gain 1.0 - alpha * noise_psd / (spec_mag2 1e-10) gain np.clip(gain, beta, 1.0) return gain def process_frame(frame, noise_psd, alpha2.0, beta0.1): frame_size len(frame) win np.hanning(frame_size) # 加窗后被FFT保存相位只处理幅度 spec np.fft.rfft(frame * win) mag np.abs(spec) phase np.angle(spec) mag2 mag ** 2 gain wiener_like_gain(mag2, noise_psd, alpha, beta) # 对gain做一阶时间平滑抑制音乐噪声 processed_mag mag * gain new_spec processed_mag * np.exp(1j * phase) return np.fft.irfft(new_spec, nframe_size) * win这段代码里有两处细节值得解释一下。保存相位是必须的因为人耳对相位失真不敏感保留原始相位可以省去重构相位的巨大开销对gain做时间平滑是降音乐噪声的关键——增益本身不能跳变太快否则会产生类似颤音的效果。3.4 重叠相加法拼接音频帧分帧处理完的短帧怎么拼回连续音频最稳妥的办法是重叠相加法。因为我们用的是50%重叠的汉宁窗两帧重叠部分各占一半加窗后的两个半帧叠加正好恢复原始幅度因此输出相当于每个时点都有两帧的能量互补。def denoise_streaming(data, noise_psd, frame_len1024, hop512, alpha2.0, beta0.1): n_frames (len(data) - frame_len) // hop 1 output np.zeros_like(data) window_sum np.zeros_like(data) win np.hanning(frame_len) for i in range(n_frames): start i * hop frame data[start:start frame_len] processed process_frame(frame, noise_psd, alpha, beta) output[start:start frame_len] processed window_sum[start:start frame_len] win # 防止除零 window_sum[window_sum 1e-8] 1.0 output / window_sum return output这一步最容易出问题的就是拼接处的“咔哒”声。如果用矩形窗或者不使用重叠相加帧与帧交界处会有不连续听起来就是密集的爆音。必须用带重叠的窗口函数并在输出端做归一化。4. 从离线到实时分块处理与流水线架构改造离线版本能跑通之后真正的难点才开始。实时处理意味着你不能等整段音频收齐再处理而是要在一个固定大小的缓冲块到达后立刻处理完并输出。这里有几个和离线版完全不同的设计决策。4.1 为什么必须用块处理而不是逐点滑动如果每来一个样本就处理一次FFT的重复计算量太大而且每次计算只推进一个样本CPU负担完全不可接受。常规做法是麦克风回调函数每次收到一小块PCM数据比如480帧或512帧把这小块数据送入处理管线处理完立刻写回输出设备。这里出现了一个关键参数块大小。块越小端到端延迟越低但FFT在短块上的频率分辨率越差。拿16kHz采样率、512点FFT为例频率分辨率是16000/512约31.25Hz低频频段会显得很糙拿1024点FFT分辨率提升到15.6Hz但一次要攒64毫秒的数据延迟立刻超出目标。实线里的解法是折中FFT长度定1024点但处理块大小定512点利用50%的重叠保证连续性。这也是前面离线版本用hop512帧长的原因。4.2 模拟回调驱动的环形缓冲处理框架为了模拟真实回调驱动的流程我写了一个以块为单位的环形缓冲。麦克风回调函数每来一个新块就把它和缓冲区里上一块的数据拼成1024点的一帧做FFT处理处理完只取后半段512点输出前半段因为和上一块重叠已经被输出过了。这个设计等同于把重叠部分提前缓存让每个输出样本只被处理一次后被输出不引入额外的算法延迟延迟主要由缓冲深度和FFT长度决定。class RealtimeDenoiser: def __init__(self, frame_len1024, hop512, sr16000, alpha2.0, beta0.1): self.frame_len frame_len self.hop hop self.sr sr self.win np.hanning(frame_len) self.buffer np.zeros(frame_len) self.noise_psd None self.alpha alpha self.beta beta self.history [] def feed(self, chunk): 输入一个hop长度的PCM块返回降噪后的hop长度块 # 把新旧数据拼接成一帧 self.buffer[:self.hop] self.buffer[self.hop:] self.buffer[self.hop:] chunk # 如果还没有噪声底数用当前缓存的前几帧初始化 if self.noise_psd is None: self.noise_psd np.abs(np.fft.rfft(self.buffer * self.win, nself.frame_len)) ** 2 frame self.buffer * self.win spec np.fft.rfft(frame, nself.frame_len) mag np.abs(spec) phase np.angle(spec) mag2 mag ** 2 gain wiener_like_gain(mag2, self.noise_psd, self.alpha, self.beta) processed mag * gain * np.exp(1j * phase) out_frame np.fft.irfft(processed, nself.frame_len) * self.win # 只输出后半段 return out_frame[self.hop:]这段代码里噪声底数的初始化其实很粗糙直接拿第一帧当噪声谱了。实际使用中应该用前几帧甚至前几百毫秒的数据来估计。更实用的做法是先把系统跑起来等用户说开始后前一段时间不做降噪只统计噪声底数后面再切换成降噪模式。4.3 音频设备的流式接入用sounddevice打通输入输出验证实时流程音频库我推荐sounddevice因为它基于PortAudio跨平台支持Windows、macOS和Linux而且回调模型和我们要的块处理模型高度匹配。安装就是一行命令pip install sounddevice numpy接好输入输出设备后核心就是定义一个音频回调函数。这个函数会被音频驱动以非常高的优先级反复调用内部不能有任何阻塞操作也不能申请大块内存所有计算必须在线程回调上下文中立刻完成。import sounddevice as sd import numpy as np sr 16000 frame_len 1024 hop 512 denoiser RealtimeDenoiser(frame_len, hop, sr) def audio_callback(indata, outdata, frames, time_info, status): if status: print(f音频状态异常: {status}) # indata是麦克风捕获的PCM块outdata是我们要填写的输出 in_chunk indata[:, 0].copy() # 取单声道 out_chunk denoiser.feed(in_chunk) outdata[:, 0] out_chunk def start_realtime_denoise(): with sd.Stream(device(input_device_idx, output_device_idx), sampleratesr, blocksizehop, dtypefloat32, channels1, callbackaudio_callback): print(开始实时降噪按CtrlC停止) while True: pass实测中第一个坑就是blocksize和hop的匹配问题。如果你用blocksize512但回调里FFT长度是1024两个块才能拼成一帧那处理结果就会对应到不同的时间点上听感上会有错误的对齐和延迟感。更好的做法是让音频设备每次回调就提供1024个样本块处理和FFT长度正好一致就不用额外的缓存了。4.4 实时版的延迟预算从麦克风到扬声器到底经历了什么延迟感知这件事有很多人误判。端到端延迟不光是算法处理时间还包括输入设备缓冲、操作系统调度、输出设备排队、扬声器物理发声等一串时间。用blocksize512采样点16kHz采样率下音频驱动一次回调要攒32毫秒的数据才触发这32毫秒就已经占了预算的大半。所以算法侧FFT处理不能再用1024点否则累计延迟直接超标。如果目标是40毫秒端到端算法侧能支配的只有10毫秒左右。FFT处理512点需要约2毫秒还剩下8毫秒用于其他环节的缓冲。结论就是实时降噪模块必须和音频设备配置联动设计算法延迟只是整个链路的一环。5. 调参和音质打磨音乐噪声、爆音与动态噪声的博弈算法跑通之后真正的“主观音质调校”阶段才刚刚开始。频谱图画出来好看不等于耳朵听着舒服。把这一部分单独拿出来讲是因为几乎所有第一次做降噪的人都会卡在这里。5.1 噪声底数更新策略最小统计法与递归平均之前离线版用固定噪声底数实时版如果还这么做场景稍微一变化就翻车。比如原本房间很安静突然有人开了一下门底噪上升了再过一会儿噪音退去底数又该回落。固定底数要么降噪不足要么把短暂的突发声音误判成噪声消掉。实践中常用的是最小统计法思路持续跟踪每个频点的功率谱最小值认为噪声不会在短时间内大幅上升因此某个频点在一段观察窗口内出现的最小功率近似等于当前噪声功率。这个思路简单效果好但实现时要注意“跟踪速度与误判的平衡”——跟得太快正常语音的长音容易被当噪声驯服跟得太慢环境突变后噪声会长时间残留。一种折中实现是递归平均加噪声概率加权def update_noise_psd(noise_psd, frame_psd, prob_speech, alpha_d0.98): # 语音概率越低越积极更新噪声底数 alpha alpha_d (1 - alpha_d) * prob_speech updated alpha * noise_psd (1 - alpha) * frame_psd return np.minimum(noise_psd, updated * 1.2) # 限制上升速度这个更新函数里prob_speech就是当前帧是语音的概率估计。一个粗略但有效的估计方式是用频带信噪比如果整个帧里绝大多数频点信噪比很低认为当前是静音帧prob_speech接近0如果很多频点能量明显高于噪声底数认为有语音活动prob_speech接近1。语音概率高的帧不更新噪声底数避免把人声特征学进噪声模型里。5.2 增益时间平滑参数的本质失真和残余噪声的取舍增益平滑的两个参数——噪声底数估计的平滑系数和增益本身的平滑系数——共同决定了降噪的风格。把增益时间常数调小比如增益变化跟随每一帧降噪猛但说话声中会夹杂不稳定的“气喘声”把时间常数调大声音变稳但噪声跟着语音一起起落形成“背景呼吸”的效果。两个方向的阈值需要配合试听决定不存在理论最优值。我常用的一组起始参数是噪声底数平滑系数0.98增益平滑系数0.85过减系数1.8增益下限0.1。在这个基础上如果觉得音乐噪声明显把过减系数降到1.5如果觉得底噪压制不够把增益下限往0.05调。5.3 从波形和频谱两个维度观察降噪效果调试降噪模块光靠耳朵不够必须同时看波形和频谱。这里分享一个非常实用的观察法录一段有明确静音段的语音处理前静音段在波形上是一条带毛边的水平线对应底噪设备噪声处理后静音段应该明显变平但不能完全变成一条直线——完全平坦往往意味着增益下限设成了0这种音质听着“死”反而容易让人疲劳。频谱图上观察持续的音节如“啊——”的谐波是否完整保留同时看非谐波区域的谱线是否被打薄。如果谐波之间原本平直的噪声带变成了断断续续的小尖峰说明音乐噪声还没压住如果谐波本身变糊了说明过减系数太大语音细节被误伤。6. 实战调试记录我在跑实时降噪时遇到过的三类顽固问题这一章写几个我实际排查了很久的问题。如果你不是做这个方向的可能想不到问题会出现在这么奇怪的地方。6.1 爆音问题的完整排查链路为什么处理后声音一卡一卡的现象是降噪后的声音每隔几十毫秒就有一声“噗”像细碎的爆米花声。最开始我怀疑是FFT处理的问题检查了窗口、正弦因子、重叠相加归一化都没毛病。后来用正弦波测试信号过整个链路逐步加注释定位才发现问题根本不在算法里而在音频回调的数据格式上。我一开始的代码用float64类型做计算输出时转成float32写回音频设备但音频回调的输入输出块是共享内存如果在计算过程中引用了缓冲区内部的引用而不是它的副本后续处理时原缓冲区可能已经被底层驱动修改了。这是一个非常隐蔽的共享内存竞争只会在实时运行时报错在离线仿真中根本不可能触发。解决办法是在回调函数里对输入数据做一次copy()保证算法的数据在任何情况下都是稳定的快照。6.2 当大声说话时出现“金属声”过减系数与相位重置的联合效应另一个高频问题是说话声音一变大音色就发“脆”、发“金属”。原因有两个叠加在一起就产生了化学反应。第一个原因是过减系数过大高频段衰减过度导致齿音和气流声被削掉音色失去自然光泽第二个原因是当某个频点幅度很低、接近零时相位信息变得极不稳定IFFT之后会产生类似振铃的时域失真听感上就是金属声。解决办法是给幅度加一个很小的“地板值”避免频谱出现病态的值同时把过减系数从2.5调低到1.6高频段再加一个轻微的低通保护齿音部分就不会显得炸裂。6.3 环境噪声剧烈波动时的“水声”现象与对策做直播的朋友可能会遇到这种情况房间外有人走动噪声一会儿大一会儿小。这时候如果噪声底数更新得慢底噪压不干净更新得快人声的尾音又容易产生“水声”——一种像在水下说话的不自然回响。最终让我稳定下来的方案是“双时间常数”用较长的平滑窗口跟踪稳态底噪时间常数约2秒同时用较短的窗口跟踪突发噪声的增量时间常数约0.3秒最终噪声底数取两者的最大值。这样既能快速响应突发噪声又不会因为突发噪声掉了就马上把底数抬上去。def dual_time_constant_noise(slow_psd, fast_psd, frame_psd, slow_alpha0.995, fast_alpha0.97): new_slow slow_alpha * slow_psd (1 - slow_alpha) * frame_psd new_fast fast_alpha * fast_psd (1 - fast_alpha) * frame_psd combined np.maximum(new_slow, new_fast) return new_slow, new_fast, combined7. 进一步提高把传统降噪能力当做深度学习的基线写完这个传统频域降噪模块我心里其实很清楚它的上限。对于稳态噪声它已经非常好用但对非稳态噪声、多人语音重叠、强混响场景它只能做有限的压制。这几年深度学习降噪模型突飞猛进像RNNoise、DeepFilterNet、CleanUNet这些开源方案已经能实现远超传统方案的效果而且不少模型已经能跑到实时。但做传统降噪的价值并不会因此消失。如果你要把深度学习降噪嵌入到一个实时录音工具里文本里的分帧、加窗、块处理、噪声底数估计、延迟控制这些工程能力几乎百分之百都要复用。深度学习模型只是替换了增益计算那一小段前后链路一点都不会变。还有一点很实际很多边缘设备、嵌入式主控根本没有足够的算力跑神经网络模型传统频谱降噪的几十KB代码和几MB算力占用仍然是这类设备上最靠谱的降噪方案。7.1 传统方法可以做到的边界仍然是稳态噪声的主场实测中这套基于谱减法的实现能稳定处理风扇声、白噪声、空调低频声这类稳态噪声20dB的降噪量在参数调优后可以稳定达标。但如果你想处理的是键盘敲击声、狗叫、开门声这类瞬态噪声它基本无能为力——因为算法默认这些是“语音的一部分”不会做针对性处理。如果想要瞬态噪声抑制一个相对低成本的改进是结合瞬态检测检测到高频能量的突然脉冲时动态增强该帧的衰减系数。这个方法不能完全消除瞬态噪声但能把它的响度从“吓一跳”降低到“可忽略”。7.2 把模块嵌入到自己的录音软件里的工程建议如果你看完这些准备动手做一个自己的降噪模块最后提几条工程上的建议。实时音频处理最怕的就是算法线程被调度打断务必确保回调函数极简所有复杂的控制逻辑都放到主线程在线程优先级设置上Windows平台可以尝试把音频回调线程优先级调到MMCSS的Pro Audio级别Linux下则尽量用SCHED_FIFO调度策略。真到了部署环节C/C实现会比Python更稳但前期验证用Python能大幅节约开发成本。另外降噪模块永远不要做得“太干净”。适度保留一点点环境底噪能让人声听起来自然得多。我调完这个模块后回头再听原始录音最大的感受不是算法多高效而是对“噪声永远无法完全消除只能控制到不干扰信息传递的程度”这句话有了更具象的理解。
返回列表