• qt6 多媒体开发代码分析(三、音频采集)


     QAudioSource *audioSource; //用于采集原始音频

    QAudioSink   *audioSink;   //用于播放原始音频

    几个概念:

    音频的采样频率是指每秒钟对信号进行采样的次数,单位为赫兹(Hz)。采样频率决定了数字音频中能够表示的最高频率范围。

    常见的音频采样频率有 8 kHz、16 kHz、44.1 kHz、48 kHz 等。其中,CD音质标准为44.1 kHz,广播和视频制作常用的采样频率为48 kHz。

    较低的采样频率会导致高频信号无法还原,造成音频质量损失;而较高的采样频率可以更准确地还原原始音频信号,但会占用更多的存储空间。

    选择适当的采样频率需要考虑信号的频率范围和所需的音频质量。一般来说,对于人耳可接受的音频,采样频率在 20 kHz 以上就能较好地还原信号。

    音频采样点格式是指用于描述数字音频样本值的数据类型和位数。通常情况下,一次采样会生成一个或多个采样点,每个采样点的值用此格式进行编码。

    常见的采样点格式有以下几种:

    1. 整数:采样点用有符号或无符号的整数表示,其中最常见的是16位或24位的整数。这种格式比较简单,但可能会出现量化误差。

    2. 浮点数:采样点以浮点数格式表示。浮点数表示方法可以更精确地还原原始音频信号,避免了量化误差问题,因此在音频编辑和处理中广泛应用。

    3. DSD:一种基于脉冲密度调制(Pulse Density Modulation,简称PDM)的数字声音编码方式,具有高采样率和分辨率,被广泛应用于SACD等高保真数字音频系统。

    在选择采样点格式时需要根据所需的音质和存储空间要求进行平衡。一般来说,采用更高的采样点格式能够更好地还原音频信号,但会占用更多的存储空间。

    音频每采样点字节数是指描述单个采样点所需的字节数。它是由采样点格式和通道数两个因素决定的,通常以字节为单位进行计算。

    例如,16位整数格式的单声道(即只有一个通道)音频,每个采样点需要 2 个字节(16位=2个字节)来描述;而双声道音频则需要 4 个字节(即每个采样点包含左右两个通道,每个通道使用2个字节)。同样地,24位整数格式的单声道音频,每个采样点需要 3 个字节,而双声道则需要 6 个字节。

    需要注意的是,除了采样点本身的字节数外,还需要考虑采样率和通道数等因素对于音频数据总大小的影响。同时,不同的采样格式可能存在字节对齐等问题,也需要进行相应的处理。

    音频采样中的每帧字节数是指描述一帧音频数据所需的字节数,它由采样点格式、通道数和每个通道中的采样点数三个因素决定。

    一帧音频数据包含多个采样点,其数量等于每个通道中的采样点数。例如,对于一个双声道、每秒采样率为 44100 次、16 位整数格式的音频文件,每帧的字节数就是 4 × 2 = 8 字节。其中 4 表示每个采样点需要占用 2 个字节(16 位),同时每帧中有两个通道,因此需要乘以 2。

    帧(Frame)是一种音频数据的组织形式,通常每帧包含多个采样点,用于描述一段时间内的连续音频数据。在数字音频处理中,帧的大小与数据传输、存储和处理等方面都有重要的应用。在具体实现时,采样格式的字节对齐方式也会对帧大小产生影响,需注意相应的处理。

    1. #ifndef MAINWINDOW_H
    2. #define MAINWINDOW_H
    3. #include
    4. #include
    5. #include
    6. #include "tmydevice.h"
    7. QT_BEGIN_NAMESPACE
    8. namespace Ui { class MainWindow; }
    9. QT_END_NAMESPACE
    10. class MainWindow : public QMainWindow
    11. {
    12. Q_OBJECT
    13. private:
    14. const qint64 m_bufferSize=10000; //缓冲区大小,字节数
    15. bool m_isWorking=false; //是否正在采集或播放
    16. QLineSeries *lineSeries; //曲线序列
    17. QAudioSource *audioSource; //用于采集原始音频
    18. TMyDevice *myDevice; //用于显示的IODevice
    19. QAudioSink *audioSink; //用于播放原始音频
    20. QFile sinkFileDevice; //用于audioSink的文件设备
    21. void iniChart(); //初始化图表
    22. void closeEvent(QCloseEvent *event); //close事件处理函数
    23. public:
    24. MainWindow(QWidget *parent = nullptr);
    25. ~MainWindow();
    26. private slots:
    27. //自定义槽函数
    28. void do_IODevice_update(qint64 blockSize);
    29. void do_sink_stateChanged(QAudio::State state);
    30. void on_actStart_triggered();
    31. void on_actStop_triggered();
    32. // void on_actDeviceTest_triggered();
    33. void on_btnGetFile_clicked();
    34. void on_chkBoxSaveToFile_clicked(bool checked);
    35. void on_actTest_triggered();
    36. void on_comboSampFormat_currentIndexChanged(int index);
    37. void on_spinChanCount_valueChanged(int arg1);
    38. void on_actPlayFile_triggered();
    39. void on_actPreferFormat_triggered();
    40. private:
    41. Ui::MainWindow *ui;
    42. };
    43. #endif // MAINWINDOW_H

    这是一个名为MainWindow的类的头文件 mainwindow.h。它继承自 QMainWindow 类,并包含了一些私有成员变量和函数来管理主窗口的操作和状态。

    以下是该类的一些重要成员:

    • m_bufferSize:表示缓冲区大小的常量,以字节为单位。
    • m_isWorking:表示当前是否正在进行采集或播放操作的布尔变量。
    • lineSeries:一个指向 QLineSeries 类的指针,用于绘制曲线图的序列。
    • audioSource:用于采集原始音频的 QAudioSource 对象。
    • myDevice:用于显示的 TMyDevice 对象。
    • audioSink:用于播放原始音频的 QAudioSink 对象。
    • sinkFileDevice:用于 audioSink 的文件设备的 QFile 对象。

    此外,该类还有一些私有函数,用于初始化图表、处理窗口关闭事件等。

    头文件中还定义了一些槽函数,用于响应与用户界面的交互操作,例如开始采集、停止采集、选取文件等。

    总而言之,mainwindow.h 定义了 MainWindow 类,负责管理主窗口的功能和状态,并与用户界面进行交互。

    1. #include "mainwindow.h"
    2. #include "ui_mainwindow.h"
    3. #include
    4. void MainWindow::iniChart()
    5. {//创建图表
    6. QChart *chart = new QChart;
    7. chart->setTitle("音频输入原始信号");
    8. ui->chartView->setChart(chart);
    9. lineSeries= new QLineSeries(); //创建序列
    10. chart->addSeries(lineSeries);
    11. lineSeries->setUseOpenGL(true); //使用OpenGL加速
    12. QValueAxis *axisX = new QValueAxis; //X坐标轴
    13. axisX->setRange(0, m_bufferSize); //X数据范围
    14. axisX->setLabelFormat("%g");
    15. axisX->setTitleText("Samples");
    16. QValueAxis *axisY = new QValueAxis; //Y坐标轴
    17. axisY->setRange(0, 256); //UInt8采样,数据范围0~255
    18. axisY->setTitleText("Audio Level");
    19. chart->addAxis(axisX,Qt::AlignBottom);
    20. chart->addAxis(axisY,Qt::AlignLeft);
    21. lineSeries->attachAxis(axisX);
    22. lineSeries->attachAxis(axisY);
    23. chart->legend()->hide(); //隐藏图例
    24. }
    25. MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent),
    26. ui(new Ui::MainWindow)
    27. {
    28. ui->setupUi(this);
    29. iniChart(); //创建图表
    30. QAudioDevice device=QMediaDevices::defaultAudioInput(); //默认音频输入设备
    31. if (device.isNull())
    32. {
    33. ui->actStart->setEnabled(false);
    34. ui->groupBoxDevice->setTitle("音频输入设置(无设备)");
    35. QMessageBox::information(this,"提示","无音频输入设备");
    36. return;
    37. }
    38. ui->comboDevices->addItem(device.description()); //只添加默认音频输入设备
    39. //首选音频格式
    40. QAudioFormat audioFormat =device.preferredFormat(); //音频输入设备的首选音频格式
    41. ui->comboSampFormat->setCurrentIndex(audioFormat.sampleFormat()); //采样点格式
    42. ui->spinSampRate->setValue(audioFormat.sampleRate()); //采样频率
    43. int minRate=device.minimumSampleRate();
    44. int maxRate=device.maximumSampleRate();
    45. ui->labSampRateRange->setText(QString::asprintf("范围: %d~%d",minRate,maxRate));
    46. ui->spinSampRate->setRange(minRate, maxRate);
    47. ui->spinChanCount->setValue(audioFormat.channelCount()); //通道个数
    48. int minChan=device.minimumChannelCount();
    49. int maxChan=device.maximumChannelCount();
    50. ui->labChanCountRange->setText(QString::asprintf("范围:%d~%d",minChan,maxChan));
    51. ui->spinChanCount->setRange(minChan, maxChan);
    52. ui->spinBytesPerSamp->setValue(audioFormat.bytesPerSample()); //每个采样点的字节数
    53. ui->spinBytesPerFrame->setValue(audioFormat.bytesPerFrame()); //每帧字节数
    54. }
    55. MainWindow::~MainWindow()
    56. {
    57. delete ui;
    58. }
    59. void MainWindow::do_IODevice_update(qint64 blockSize)
    60. {
    61. float time=audioSource->processedUSecs()/1000; //ms
    62. QString str=QString::asprintf("已录制时间 =%.1f 秒", time/1000);
    63. ui->labBufferSize->setText(str);
    64. ui->labBlockSize->setText(QString("实际数据块字节数=%1").arg(blockSize));
    65. }
    66. void MainWindow::do_sink_stateChanged(QAudio::State state)
    67. {
    68. if (state == QAudio::IdleState) //播放结束后的空闲状态
    69. {
    70. sinkFileDevice.close(); //关闭文件
    71. audioSink->stop(); //停止播放
    72. audioSink->deleteLater(); //在主循环里删除对象
    73. ui->actPlayFile->setEnabled(true);
    74. m_isWorking=false; //表示没有设备在工作了
    75. }
    76. }
    77. void MainWindow::on_actStart_triggered()
    78. {//“开始采集”按钮
    79. if(ui->comboSampFormat->currentIndex()==0)
    80. {
    81. QMessageBox::critical(this,"错误","请设置采样点格式");
    82. return;
    83. }
    84. bool saveToFile=ui->chkBoxSaveToFile->isChecked(); //是否保存到文件
    85. QString fileName= ui->editFileName->text().trimmed();
    86. if ((saveToFile) && (fileName.isEmpty()))
    87. {
    88. QMessageBox::critical(this,"错误","请设置要保存的文件");
    89. return;
    90. }
    91. //设置音频格式
    92. QAudioFormat daqFormat;
    93. daqFormat.setSampleRate(ui->spinSampRate->value()); //采样频率
    94. daqFormat.setChannelCount(ui->spinChanCount->value()); //通道个数
    95. int index=ui->comboSampFormat->currentIndex();
    96. daqFormat.setSampleFormat(QAudioFormat::SampleFormat(index)); //采样点格式
    97. audioSource = new QAudioSource(daqFormat, this);
    98. audioSource->setBufferSize(m_bufferSize); //设置缓冲区大小,如10000
    99. audioSource->setVolume(1); //设置录音音量, 0~1
    100. myDevice = new TMyDevice(this);
    101. connect(myDevice,&TMyDevice::updateBlockSize,this, &MainWindow::do_IODevice_update);
    102. bool showWave=ui->chkBoxShowWave->isChecked(); //是否实时显示曲线
    103. myDevice->openDAQ(m_bufferSize,showWave,lineSeries,saveToFile, fileName);
    104. audioSource->start(myDevice); //以流设备作为参数,开始录入音频输入数据
    105. m_isWorking=true; //表示有设置在工作,不允许关闭窗口
    106. ui->actStart->setEnabled(false);
    107. ui->actStop->setEnabled(true);
    108. ui->actPlayFile->setEnabled(false); //"播放文件"按钮
    109. }
    110. void MainWindow::on_actStop_triggered()
    111. {//"停止采集"按钮
    112. audioSource->stop(); //停止采集
    113. myDevice->closeDAQ(); //IO设备停止记录
    114. delete myDevice; //删除对象
    115. delete audioSource; //删除对象
    116. m_isWorking=false; //表示没有设备在工作了
    117. ui->actStart->setEnabled(true);
    118. ui->actStop->setEnabled(false);
    119. ui->actPlayFile->setEnabled(ui->chkBoxSaveToFile->isChecked()); //"播放文件"按钮
    120. }
    121. void MainWindow::on_btnGetFile_clicked()
    122. {//"数据文件" 按钮
    123. QString curPath=QDir::currentPath();//获取系统当前目录
    124. QString dlgTitle="选择输出文件"; //对话框标题
    125. QString filter="原始音频数据文件(*.raw);;所有文件(*.*)"; //文件过滤器
    126. QString selectedFile=QFileDialog::getSaveFileName(this,dlgTitle,curPath,filter);
    127. if (!selectedFile.isEmpty())
    128. {
    129. ui->editFileName->setText(selectedFile);
    130. QFileInfo fileInfo(selectedFile);
    131. QDir::setCurrent(fileInfo.absolutePath());
    132. }
    133. }
    134. void MainWindow::on_chkBoxSaveToFile_clicked(bool checked)
    135. {//"数据记录到文件" CheckBox
    136. ui->btnGetFile->setEnabled(checked);
    137. ui->editFileName->setEnabled(checked);
    138. }
    139. void MainWindow::on_actTest_triggered()
    140. {//"测试音频格式"按钮
    141. QAudioFormat daqFormat;
    142. daqFormat.setSampleRate(ui->spinSampRate->value()); //采样频率
    143. daqFormat.setChannelCount(ui->spinChanCount->value()); //通道数
    144. int index=ui->comboSampFormat->currentIndex();
    145. daqFormat.setSampleFormat(QAudioFormat::SampleFormat(index)); //采样点格式
    146. QAudioDevice device=QMediaDevices::defaultAudioInput(); //默认音频输入设备
    147. if (device.isFormatSupported(daqFormat))
    148. QMessageBox::information(this,"提示","默认设备支持所选格式 ");
    149. else
    150. QMessageBox::critical(this,"提示","默认设备不支持所选格式 ");
    151. }
    152. void MainWindow::on_comboSampFormat_currentIndexChanged(int index)
    153. {//"采样点格式" 下拉列表框
    154. switch(index) //采样点格式, 更新" 每采样点字节数" SpinBox的显示值
    155. {
    156. case 0: //Unknow
    157. case 1: //UInt8
    158. ui->spinBytesPerSamp->setValue(1); break;
    159. case 2: //Int16
    160. ui->spinBytesPerSamp->setValue(2); break;
    161. case 3: //Int32
    162. case 4: //Float
    163. ui->spinBytesPerSamp->setValue(4);
    164. }
    165. int bytes=ui->spinChanCount->value() * ui->spinBytesPerSamp->value();
    166. ui->spinBytesPerFrame->setValue(bytes); //更新每帧字节数
    167. bool canShowWave= (index==1) && (ui->spinChanCount->value() ==1); //是否可以显示曲线
    168. ui->chkBoxShowWave->setEnabled(canShowWave); //只有UInt8, 通道数为1 时才能显示曲线
    169. if (!canShowWave)
    170. ui->chkBoxShowWave->setChecked(false); //不能显示曲线
    171. }
    172. void MainWindow::on_spinChanCount_valueChanged(int arg1)
    173. {//"通道数" SpinBox
    174. ui->spinBytesPerFrame->setValue( arg1 * (ui->spinBytesPerSamp->value())); //计算每帧字节数
    175. bool canShowWave= (arg1==1) && (ui->comboSampFormat->currentIndex() ==1);
    176. ui->chkBoxShowWave->setEnabled(canShowWave); //只有UInt8, 通道数为1 时才能显示曲线
    177. if (!canShowWave)
    178. ui->chkBoxShowWave->setChecked(false); //不能显示曲线
    179. }
    180. void MainWindow::closeEvent(QCloseEvent *event)
    181. {
    182. if (m_isWorking)
    183. {
    184. QMessageBox::information(this,"提示","正在采集或播放音频,不允许退出");
    185. event->ignore();
    186. }
    187. else
    188. event->accept();
    189. }
    190. void MainWindow::on_actPlayFile_triggered()
    191. {//"播放文件"按钮
    192. QString filename =ui->editFileName->text().trimmed();
    193. if (filename.isEmpty() || !QFile::exists(filename)) //检查文件
    194. {
    195. QMessageBox::critical(this,"错误","文件名为空,或文件不存在");
    196. return;
    197. }
    198. sinkFileDevice.setFileName(filename); //文件IO设备设置文件
    199. if ( !sinkFileDevice.open(QIODeviceBase::ReadOnly)) //以只读方式打开
    200. {
    201. QMessageBox::critical(this,"错误","打开文件时出现错误,无法播放");
    202. return;
    203. }
    204. QAudioFormat format; //使用界面上的音频格式参数
    205. format.setSampleRate(ui->spinSampRate->value());
    206. format.setChannelCount(ui->spinChanCount->value());
    207. int index=ui->comboSampFormat->currentIndex();
    208. format.setSampleFormat(QAudioFormat::SampleFormat(index));
    209. QAudioDevice audioDevice= QMediaDevices::defaultAudioOutput(); //默认的音频输出设备
    210. if (!audioDevice.isFormatSupported(format)) //是否支持此音频格式参数
    211. {
    212. QMessageBox::critical(this,"错误","播放设备不支持此音频格式设置,无法播放");
    213. return;
    214. }
    215. audioSink =new QAudioSink(format, this); //创建audioSink
    216. connect(audioSink, &QAudioSink::stateChanged,this, &MainWindow::do_sink_stateChanged);
    217. audioSink->start(&sinkFileDevice); //开始播放
    218. m_isWorking=true; //表示有设备在工作,不能关闭窗口
    219. ui->actPlayFile->setEnabled(false);
    220. }
    221. void MainWindow::on_actPreferFormat_triggered()
    222. {//"首先音频格式"按钮, 显示默认音频输入设备的首选音频格式
    223. QAudioFormat audioFormat =QMediaDevices::defaultAudioInput().preferredFormat();
    224. ui->spinSampRate->setValue(audioFormat.sampleRate()); //采样频率
    225. ui->comboSampFormat->setCurrentIndex(audioFormat.sampleFormat()); //采样点格式
    226. ui->spinChanCount->setValue(audioFormat.channelCount()); //通道个数
    227. ui->spinBytesPerSamp->setValue(audioFormat.bytesPerSample()); //每个采样点的字节数
    228. ui->spinBytesPerFrame->setValue(audioFormat.bytesPerFrame()); //每帧字节数
    229. }

    这段代码是一个音频录制和播放的程序,采用了Qt框架。下面是对代码中各部分功能的说明:

    1. iniChart()函数:该函数用于创建图表并初始化相关设置,包括设置标题、坐标轴范围和样式等。

    2. MainWindow类的构造函数:初始化界面并调用iniChart()函数创建图表,然后获取默认音频输入设备的信息并显示在界面上。

    3. do_IODevice_update()函数:在录制过程中更新界面上的录制时间和数据块字节数。

    4. do_sink_stateChanged()函数:当播放设备状态改变时触发,用于处理播放结束后的操作,包括关闭文件、停止播放和删除对象等。

    5. on_actStart_triggered()函数:当点击“开始采集”按钮时触发,根据用户设置的音频格式和保存文件选项进行录制设置,并启动音频输入设备进行录制。

    6. on_actStop_triggered()函数:当点击“停止采集”按钮时触发,停止音频输入设备的录制和关闭相关对象。

    7. on_btnGetFile_clicked()函数:当点击“数据文件”按钮时触发,用于选择保存录制数据的文件路径。

    8. on_chkBoxSaveToFile_clicked()函数:当点击“数据记录到文件”复选框时触发,根据复选框状态设置相关控件的可用性。

    9. on_actTest_triggered()函数:当点击“测试音频格式”按钮时触发,根据用户设置的音频格式和默认音频输入设备的支持情况弹出相应的提示信息。

    10. on_comboSampFormat_currentIndexChanged()函数:当选择采样点格式的下拉列表框的选项发生变化时触发,根据选项来更新每个采样点的字节数,并根据当前选择的采样点格式和通道数设置曲线显示的可用性。

    11. on_spinChanCount_valueChanged()函数:当通道数的值改变时触发,根据新值更新每帧字节数,并根据当前选择的采样点格式和通道数设置曲线显示的可用性。

    12. closeEvent()函数:在窗口关闭事件发生时触发,如果有设备正在工作,则弹出提示信息并阻止窗口关闭。

    13. on_actPlayFile_triggered()函数:当点击“播放文件”按钮时触发,根据用户设置的文件名和音频格式进行播放设置,并开始播放指定的音频文件。

    14. on_actPreferFormat_triggered()函数:当点击“首选音频格式”按钮时触发,获取默认音频输入设备的首选音频格式并显示在界面上。

    这段代码实现了录制和播放音频的基本功能,并提供了图表显示、文件保存和音频格式设置等附加功能。

    流媒体:

    流媒体在线播放系统是车载系统中常见的应用,它通过网络接收音视频数据流,并进行解码和播放。以下是实现该项目的一般步骤和涉及的概念:

    1. 获取音视频媒体信息:需要从服务器获取音视频媒体文件的相关信息,例如文件路径、格式、编码等。

    2. 数据传输:使用网络传输协议(如HTTP、RTSP等)将音视频数据以数据流的形式从服务器传输到流媒体播放器客户端。

    3. 音频解码:使用开源的高精度MPEG音频解码库(如libmad)对接收到的音频数据进行解码。libmad支持MPEG-1标准,提供24-bit的PCM输出,适合在嵌入式硬件平台上使用。

    4. 音频播放:将解码后的PCM音频数据写入音频设备(如声卡)进行播放。可以使用相关的音频库(如ALSA)来实现音频设备的控制和数据写入。

    5. 视频解码:如果需要支持视频播放,需要使用开源的视频解码库(如FFmpeg)对接收到的视频数据进行解码。FFmpeg支持众多的视频格式和编码方式。

    6. 视频显示:将解码后的视频数据渲染到车载系统的显示设备上。可以使用相关的图形库(如Qt)来实现视频的显示功能。

    在实现流媒体在线播放系统的过程中,涉及到以下概念:

    • 音频编解码:将音频数据从一种格式转换为另一种格式,使其可在播放器端进行处理和播放。常用的音频编解码格式包括MP3、AAC等。

    • 视频编解码:将视频数据从一种格式转换为另一种格式,以便在播放器端进行处理和显示。常用的视频编解码格式包括H.264、VP9等。

    • 数据传输协议:选择适合车载系统的网络传输协议,如HTTP或RTSP。这些协议负责在服务器和播放器之间传输音视频数据流。

    • 音频设备控制:通过音频库(如ALSA)控制音频设备,包括初始化设备、设置音量、写入音频数据等操作。

    • 图像渲染:使用图形库(如Qt)在车载系统的显示设备上渲染视频图像,以实现视频的显示功能。

    通过合理选择和应用上述概念和工具,可以实现一个基于Linux系统的流媒体在线播放系统。

    ps:MPEG代表“Moving Picture Experts Group”,即动态图像专家组。它是一种用于压缩和编码数字音频和视频数据的标准。

    MPEG标准由国际标准化组织(ISO)和国际电工委员会(IEC)联合制定和管理。其目标是通过采用高效的压缩算法,将音频和视频数据压缩成较小的文件大小,同时保持高质量的播放效果。

    MPEG标准涵盖了多个不同的技术层次。其中,MPEG-1是第一个广泛应用的标准,最初用于压缩和编码VCD(Video CD)格式的视频。MPEG-2标准更进一步,支持更高质量的视频压缩,并被广泛用于DVD、数字电视和广播等领域。MPEG-4则引入了更多的功能和灵活性,使其适用于网络流媒体、视频会议和移动通信等领域。

    MPEG标准定义了压缩算法、编码格式和文件容器格式等方面的规范,以确保在不同设备和平台上能够正确解码和播放压缩后的音视频数据。通过使用MPEG标准,可以实现高效的媒体数据传输和存储,从而在带宽和存储资源有限的情况下提供更好的音视频体验。

  • 相关阅读:
    php字符串加密,js使用CryptoJS
    S7-200 SMART PLC 子程序功能块(阀门控制)
    mysql的分组group by
    Kafka保证消息幂等以及解决方案
    【Kingbase FlySync】界面化管控平台:2.配置数据库同步之KES>KES
    开源博客项目Blog .NET Core源码学习(3:数据库操作方式)
    小型ATC显示系统mini ATC Display
    Python中2种常用数据可视化库:Bokeh和Altair
    vscode常用插件
    【JavaScript】DOM查询(子节点、父节点、兄弟节点)源码详解
  • 原文地址:https://blog.csdn.net/wh_xia_jun/article/details/133915823