zoukankan      html  css  js  c++  java
  • 音频处理(二) 音频输出

    Windows下的音频输出常用的3种方法:

    1. PlaySound:使用最简单直接,但是不够灵活,功能也非常单一,无法混音;

    2. WaveOut:早期的Windows系统中广泛应用的音频输出程序接口,功能比PlaySound较完善(WaveIn用于音频输入);

    3. DirectSound:现在Windows中主流的应用于音频输入输出的API,支持混音、独立音量控制、硬件加速、硬件仿真等强大的功能;

     

    PlaySound

       PlaySound的使用非常简单,下面是一个示例 (vs2013 vc++ Project):

    #include "stdafx.h"
    #include <Windows.h>
    #include <mmsystem.h>
    
    #pragma comment(lib, "winmm.lib")
    
    const char fPath1[] = "C:/Windows/Media/Ring09.wav";
    
    // 同步播放
    void PlaySync()
    {
        printf("Sync Start...
    ");
        PlaySoundA((char*)fPath1, NULL, SND_FILENAME|SND_SYNC);
        printf("Sync Complete!
    ");
    }
    
    // 异步播放
    void PlayAsync()
    {
        printf("Async Start...
    ");
        PlaySoundA((char*)fPath1, NULL, SND_FILENAME|SND_ASYNC);
        printf("Async Complete!
    ");
    }
    
    int main(int argc, char* argv[])
    {
        PlaySync();
        //PlayAsync();
    
        Sleep(3000);
        return 0;
    }

        使用PlaySound方法之前须添加mmsystem.h和Windows.h两个头文件,并将winmm.lib链接库添加到工程,这里是用#pragma添加的,上面使用了同步和异步两种方式播放一段wav音频;

        它们的区别是,同步方式下,PlaySound方法调用时会阻塞程序,直到这段音频播放结束,再返回往下继续执行;而异步方式下,调用PlaySound方法会立即返回,音频播放的同时,程序依然正常往下执行,这种情况下,上例如果没有Sleep方法休眠主线程,那么程序会直接结束,导致听不到完整的音乐。

        另外,PlaySound无法同时播放两个音频,当重复调用PlaySound方法时,已经在播放的音频会中断,转而播放新的那段音频;又或者第二次调用PlaySound会失败返回FALSE,而已经播放的音频不受影响,这一切要看PlaySound的第三个参数的设定;比如上例中,如果重复调用,已经在播放的音频会中断,转而播放新的音频;而如果加上SND_NOSTOP,那么重复调用播放同一段音频时,之前的音频不会中断,而再次调用的PlaySound会失败返回FALSE;

        SND_FILENAME表示采用文件名的方式加载音频资源,除此之外还有引用内存中已有音频资源的方式 (详见官方文档);

    WaveOut

         使用WaveOut进行音频输出大致分为几个步骤:创建缓冲区 — 读取音频文件 — 复制数据到缓冲区 — 播放,用一张图来说明音频播放的大致流程:

                                   

        首先是创建缓存区,这里要用到官方定义的一个结构 WAVEHDR,这个结构是专用来进行音频数据缓存块和设备之间的桥梁,这里通过它可以提交音频数据给设备;

    它的结构定义如下:

    /* wave data block header */
    typedef struct wavehdr_tag {
        LPSTR       lpData;                 /* 指向数据区起始地址 */
        DWORD       dwBufferLength;         /* 数据缓存大小(Byte) */
        DWORD       dwBytesRecorded;        /* used for input only */
        DWORD_PTR   dwUser;                 /* 开发者自由使用 */
        DWORD       dwFlags;                /* 标志位 */
        DWORD       dwLoops;                /* 循环计数器 */
        struct wavehdr_tag FAR *lpNext;     /* reserved for driver */
        DWORD_PTR   reserved;               /* reserved for driver */
    } WAVEHDR, *PWAVEHDR, NEAR *NPWAVEHDR, FAR *LPWAVEHDR;

        一般我们会创建3个以上的缓存块,循环地把数据写入、并按顺序提交给设备,那么设备就会按提交的顺序不断地播放已提交的那些缓存块音频数据,如上图;CPU控制硬盘读入数据,并复制到缓存区,然后按顺序提交一个个缓存块,播放完了的缓存块,可以继续读入再提交,循环往复;

    之所以选择3个以上缓存块,是为了避免播放间隙时间的产生,微小的间隙在音频播放中,观感是难以忍受的;

    创建代码:

    #define BlockSize   1024*10    // 数据块缓存大小(这个案例中必须是 BufferSize 的整数倍)
    #define BlockCount  12         // 数据块个数(不限,建议3个以上)
    
    WAVEHDR* Blocks = NULL;
    
    //
    // 创建缓存区
    // WAVEHDR* CreateBlocks() { unsigned char* buffer; DWORD totalBufferSize = (BlockSize + sizeof(WAVEHDR))*BlockCount; // 申请的内存空间 = 块结构内存 + 缓存空间 if ((buffer = (UCHAR*)malloc(totalBufferSize)) == NULL) { printf("Memory Malloc Error! "); return NULL; } memset(buffer, 0, totalBufferSize); Blocks = (WAVEHDR*)buffer; buffer += sizeof(WAVEHDR) * BlockCount; for (int i = 0; i < BlockCount; i++) { Blocks[i].dwBufferLength = BlockSize; Blocks[i].lpData = (char*)buffer; buffer += BlockSize; } return Blocks; }

    缓存块创建完成,然后可以读入wav音频数据了,首先读入wav头结构,分析音频信息,头结构的分析直接引用上一篇中ReadHeader()方法;

    //
    // 读取Wav文件头,并验证文件格式
    // 成功返回文件句柄,并重定位文件指针到数据区
    // 失败返回NULL
    //
    HANDLE ReadHeader(char* path)
    {
        HANDLE hFile = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
    
        if (hFile == INVALID_HANDLE_VALUE)
        {
            printf("Unable to Open File!");
            return NULL;
        }
        char buffer[512];
        DWORD readByte;
        if (ReadFile(hFile, buffer, sizeof(buffer), &readByte, NULL))
        {
            Riff_Header header;
            Fmt_Block fmt;
            Data_Block data;
            memcpy(&header, buffer, sizeof(Riff_Header));
            if (strncmp(header.szRiffId, "RIFF", 4) != 0) { CloseHandle(hFile); return NULL; }
    
            memcpy(&fmt, buffer + sizeof(Riff_Header), sizeof(Fmt_Block));
            if (strncmp(fmt.szFmtId, "fmt ", 4) != 0) { CloseHandle(hFile); return NULL; }
    
            memcpy(&data, buffer + sizeof(Riff_Header)+fmt.dwFmtSize+8, sizeof(Data_Block));
            if (strncmp(data.szDataId, "data", 4) != 0) { CloseHandle(hFile); return NULL; }
    
            memcpy(&wfx, &fmt.wFormatTag, sizeof(WAVEFORMATEX) - 2);
            wfx.cbSize = 0;
    
            // 重定位文件指针,到数据起始位置
            int headSize = sizeof(Riff_Header) + fmt.dwFmtSize + 8 + sizeof(Data_Block);
            headSize = (headSize / 8 + (headSize % 8 ? 1 : 0)) * 8;
            SetFilePointer(hFile, headSize, 0, FILE_BEGIN);
    
            return hFile;
        }
        return NULL;
    }
    ReadHeader

     头文件分析完毕,可以得到波形文件信息wfx,现在可以打开设备,并读入音频数据到缓存块,最后提交,这样就可以播放音频了;

    #define BufferSize    1024     // 读取文件的缓存大小
    
    const char testWave[] = "C:/Windows/Media/Ring02.wav";
    
    WAVEFORMATEX wfx;
    CRITICAL_SECTION wcSection;
    static volatile int freeCount; // 可用的缓存块数量,初始为BlockCount static int curIndex; // 当前要读入数据的缓存块的Index

    void CALLBACK WaveOutProc(HWAVEOUT, UINT, DWORD, DWORD, DWORD); int PlayWave(HANDLE hFile) { unsigned char buffer[BufferSize]; if (Blocks == NULL || hFile == NULL) return 1; freeCount = BlockCount; curIndex = 0; InitializeCriticalSection(&wcSection); HWAVEOUT hwo;
    // 打开设备
    if (waveOutOpen(&hwo, WAVE_MAPPER, &wfx, (DWORD_PTR)WaveOutProc, 0, CALLBACK_FUNCTION) != MMSYSERR_NOERROR) { printf("Unable to Open Mapper Device!"); return 2; } WAVEHDR* current = &Blocks[curIndex]; printf("Play Wave Start... "); while (1) { DWORD readByte; if (!ReadFile(hFile, buffer, BufferSize, &readByte, NULL)) break; if (readByte == 0) break; if (readByte < BufferSize) memset(buffer + readByte, 0, BufferSize - readByte); memcpy(current->lpData + current->dwUser, buffer, BufferSize); current->dwUser += BufferSize; if (current->dwUser < BlockSize) continue; // 必须先填满当前缓存块 waveOutPrepareHeader(hwo, current, sizeof(WAVEHDR)); // 准备数据块 waveOutWrite(hwo, current, sizeof(WAVEHDR)); // 把数据块提交给设备 (播放),立即返回 EnterCriticalSection(&wcSection); freeCount--; LeaveCriticalSection(&wcSection); while (freeCount == 0) Sleep(10); // 当所有的数据都准备好,且没有释放时,等待 curIndex = (curIndex + 1) % BlockCount; current = &Blocks[curIndex]; current->dwUser = 0; } while (freeCount < BlockCount) Sleep(100); // 等待所有数据块播放完 printf("Finish Play Wave! "); DeleteCriticalSection(&wcSection); waveOutClose(hwo); return 0; }
    //
    // 设备回调方法,三种情况下调用:
    // 当设备开启、关闭、播放一个缓存块完成时
    //
    void CALLBACK WaveOutProc(HWAVEOUT hwo, UINT msg, DWORD dwInstance, DWORD dwParam1, DWORD dwParam2)
    {
        if (msg != WOM_DONE) return;  // 过滤设备开启、关闭消息
    
        WAVEHDR* pwh = (WAVEHDR*)dwParam1;
        waveOutUnprepareHeader(hwo, pwh, sizeof(WAVEHDR));  // 释放播放完的块
    
        EnterCriticalSection(&wcSection);
        freeCount++;
        LeaveCriticalSection(&wcSection);
    }

    要点说明:

    1. 上面的打开设备方法中,参数WaveOutProc是一个回调方法,就是设备的反馈消息,包括设备的打开、关闭、缓存块播放完成消息;

    2. wcSection是一个用于数据同步的信息,通过wcSection中的访问计数,可以在多个线程同时访问EnterCriticalSection和LeaveCriticalSection之间的数据时,避免数据并发冲突;

        如上例中,通过wcSection计数可以记录freeCount的访问个数,避免多线程(WaveOutProc方法)同时修改freeCount导致的错误;

    3. 有一点需要知道,数据的读取并复制到缓存区并提交,这一过程的速度远远大于音频播放的速度,所有缓存块在程序一运行就迅速填充满了,之后就是等待释放一个,填充一个,依次进行;

    4. 这里通过freeCount的值来判断有无数据块可用,curIndex始终表示最晚提交的缓存块编号,那么(curIndex+1)%BlockCount就是最早提交的那个编号,因为所有缓存块是循环使用的;

    只要判定 freeCount > 0成立,那么一定是(curIndex+1)%BlockCount播放完成了,可以继续复制数据到其中,再次提交;

     

    那么,上面就是数据块的创建、读入数据、提交数据的过程,下面是调用它们:

    #include "stdafx.h"
    #include <Windows.h>
    #include <mmsystem.h>
    #include "WavStruct.h"

    #pragma comment(lib, "winmm.lib")

    int
    main(int argc, char* argv[]) { HANDLE hFile = ReadHeader((char*)testWave); if (hFile == NULL) return 0; CreateBlocks(); PlayWave(hFile); free(Blocks); CloseHandle(hFile); return 0; }

        运行,播放,无问题; 

     

    DirectSound

        WaveOut 原生API无法直接同时播放多路wav音频,即无法进行混音,若要使用WaveOut实现混音功能,就只能在准备数据缓存之前,进行混音算法,手动将多路音频数据糅合到一起;这样也可以达成混音的效果,当然混音质量就取决于所采用的混音算法了;

        而DirectSound也可以实现混音,而且混音功能封装在其内部,完全不必由我们手动进行;

        单从使用DirectSound来看,过程和WaveOut类似,也需要我们解码得到Wave数据,然后把Wave数据复制到缓冲区;区别是,WaveOut只有一个缓冲区(虽然其中分为若干块),而DirectSound一般有很多个缓冲区,它们各自是独立的,只要把它们同时播放,就会自动实现混音,互不干扰!

        DirectSound缓冲区分一主、多副缓冲区,一般我们不操作主缓冲区,只是用若干个次缓冲区,次缓冲区多个音频数据会自动混音,最终的音频数据存放在主缓冲区进行播放,只是混音的过程不需我们来操作,它是DS内部完成的 (也可以手动);

        WaveOut的缓冲区由我们指定大小并创建,而DS中,我们只是指定大小,由DS内部开辟内存,我们可以获得地址;

    步骤1. 首先初始化:

    //
    // 初始化DS,成功则返回 0
    //
    int DSInit()
    {
        // 创建 DirectSound
        if (DirectSoundCreate8(0, &lpds8, 0) != DS_OK)
        {
            printf("Create DirectSound Failed!
    ");
            return 1;
        }
        // 设置应用协作级别,要实现混音必须设置为 Priority
        if (lpds8->SetCooperativeLevel(hwnd, DSSCL_PRIORITY) != DS_OK)
        {
            printf("Set CooperativeLevel Failed!
    ");
            return 2;
        }
        return 0;
    }

    步骤2. 读取音频数据,为了方便,这里依然使用无压缩的Wav格式,不需要解码;

    读取Wav文件的方法,沿用之前的ReadHeader()方法,只是稍作修改;

    //
    // 读取Wav文件头,并验证文件格式
    // 成功返回文件句柄,并重定位文件指针到数据区
    // 失败返回NULL
    //
    HANDLE ReadHeader(char* path, WAVEFORMATEX* pwfx)
    {
        HANDLE hFile = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
    
        if (hFile == INVALID_HANDLE_VALUE)
        {
            printf("Unable to Open File!");
            return NULL;
        }
        char buffer[512];
        DWORD readByte;
        if (ReadFile(hFile, buffer, sizeof(buffer), &readByte, NULL))
        {
            Riff_Header header;
            Fmt_Block fmt;
            Data_Block data;
            memcpy(&header, buffer, sizeof(Riff_Header));
            if (strncmp(header.szRiffId, "RIFF", 4) != 0) { CloseHandle(hFile); return NULL; }
    
            memcpy(&fmt, buffer + sizeof(Riff_Header), sizeof(Fmt_Block));
            if (strncmp(fmt.szFmtId, "fmt ", 4) != 0) { CloseHandle(hFile); return NULL; }
    
            memcpy(&data, buffer + sizeof(Riff_Header) + fmt.dwFmtSize + 8, sizeof(Data_Block));
            if (strncmp(data.szDataId, "data", 4) != 0) { CloseHandle(hFile); return NULL; }
    
            memcpy(pwfx, &fmt.wFormatTag, sizeof(WAVEFORMATEX) - 2);
            pwfx->cbSize = 0;
    
            // 重定位文件指针,到数据起始位置
            int headSize = sizeof(Riff_Header) + fmt.dwFmtSize + 8 + sizeof(Data_Block);
            headSize = (headSize / 8 + (headSize % 8 ? 1 : 0)) * 8;
            SetFilePointer(hFile, headSize, 0, FILE_BEGIN);
    
            return hFile;
        }
        return NULL;
    }
    ReadHeader

    CreateWaveBuffer()方法中,读取Wav文件成功后,创建了一个缓冲区,将用于存放音频数据,

    //
    // 打开Wav文件,读取文件头
    // 并创建与之对应的 DS 缓存区
    //
    HANDLE CreateWaveBuffer(char* path, LPDIRECTSOUNDBUFFER* ppdsBuffer)
    {
        WAVEFORMATEX wfx;
        HANDLE hFile = ReadHeader((char*)path, &wfx);
        if (hFile == NULL)
        {
            printf("File Read Failed!
    ");
            return NULL;
        }
        DSBUFFERDESC dsDesc;
        memset(&dsDesc, 0, sizeof(DSBUFFERDESC));
        dsDesc.dwSize = sizeof(DSBUFFERDESC);
        dsDesc.lpwfxFormat = &wfx;
        dsDesc.dwBufferBytes = BlockSize * MaxAudio;
        dsDesc.dwFlags = DSBCAPS_GLOBALFOCUS | DSBCAPS_CTRLPOSITIONNOTIFY | DSBCAPS_GETCURRENTPOSITION2;
    
        // 创建副缓存区
        if (lpds8->CreateSoundBuffer(&dsDesc, ppdsBuffer, 0) != DS_OK)
        {
            printf("Create lpdsBuffer01 Failed!
    ");
            CloseHandle(hFile);
            return NULL;
        }
        return hFile;
    }

    步骤3. 播放音频,这是一个创建线程CreateThread()方法的参数,它的调用在后面可以看到;

    //
    // 线程:播放音频
    //
    DWORD WINAPI PlayWave(void* lpv)
    {
        LPDIRECTSOUNDBUFFER lpdsBuffer;
        HANDLE hFile = CreateWaveBuffer((char*)lpv, &lpdsBuffer);
    if(hFile == NULL) return 1;
    // 创建通知字段,当播放到该位置时,触发事件通知 DSBPOSITIONNOTIFY dsPosNotify[MaxAudio]; HANDLE hevent[MaxAudio]; for (int i = 0; i < MaxAudio; i++) { hevent[i] = CreateEventA(NULL, FALSE, FALSE, NULL); dsPosNotify[i].dwOffset = (i+1) * BlockSize-1; dsPosNotify[i].hEventNotify = hevent[i]; } LPDIRECTSOUNDNOTIFY lpdsNotify = NULL; if (lpdsBuffer->QueryInterface(IID_IDirectSoundNotify, (void**)&lpdsNotify) != DS_OK) { printf("QueryInterface lpdsNotify Failed!"); return 2; } lpdsNotify->SetNotificationPositions(MaxAudio, dsPosNotify); lpdsNotify->Release(); void* lpBuffer1; void* lpBuffer2; DWORD dwLen1; DWORD dwLen2; // 首次读入数据 HRESULT hr = lpdsBuffer->Lock(0, BlockSize*MaxAudio, (void**)&lpBuffer1, &dwLen1, &lpBuffer2, &dwLen2, DSBLOCK_ENTIREBUFFER); if (hr >= 0) { ReadFile(hFile, lpBuffer1, dwLen1, 0, NULL); lpdsBuffer->Unlock(lpBuffer1, dwLen1, NULL, 0); } //设置起始位置,播放 lpdsBuffer->SetCurrentPosition(0); lpdsBuffer->Play(0, 0, DSBPLAY_LOOPING); DWORD index; bool flag = true; while (1) { index = WaitForMultipleObjects(MaxAudio, hevent, FALSE, INFINITE); // 有标记的通知事件触发时,返回一个index,代表hevent的编号 printf("index = %d ", index); if (index >= 0) flag = FillBuffer(hFile, index, lpdsBuffer); if (!flag) break; } WaitForMultipleObjects(MaxAudio, hevent, TRUE, INFINITE); lpdsBuffer->Stop(); CloseHandle(hFile); return 0; } // // 补充数据到缓冲区的方法 // bool FillBuffer(HANDLE hFile, int index, LPDIRECTSOUNDBUFFER lpdsBuffer01) { bool flag = true; VOID* lockBuffer1 = NULL; VOID* lockBuffer2 = NULL; DWORD dwSize1; DWORD dwSize2; HRESULT hr; hr = lpdsBuffer01->Lock(index*BlockSize, BlockSize, &lockBuffer1, &dwSize1, &lockBuffer2, &dwSize2, 0); if (hr == DSERR_BUFFERLOST) { lpdsBuffer01->Restore(); hr = lpdsBuffer01->Lock(index*BlockSize, BlockSize, &lockBuffer1, &dwSize1, &lockBuffer2, &dwSize2, 0); } //printf("hr = %d, index = %d, address1 = 0x%X, Size = 0x%X, address2 = 0x%X ", hr, index, lockBuffer1, dwSize1, lockBuffer2); DWORD dwReadByte = 0; if (hr >= 0) { if (lockBuffer1 != NULL) { ReadFile(hFile, lockBuffer1, BlockSize, &dwReadByte, NULL); if (dwReadByte < BlockSize) { memset((char*)lockBuffer1 + dwReadByte, 0, BlockSize - dwReadByte); flag = FALSE; } } if (lockBuffer2 != NULL) { ReadFile(hFile, lockBuffer2, BlockSize, &dwReadByte, NULL); if (dwReadByte < BlockSize) { memset((char*)lockBuffer2 + dwReadByte, 0, BlockSize - dwReadByte); flag = FALSE; } } hr = lpdsBuffer01->Unlock(lockBuffer1, dwSize1, lockBuffer2, dwSize2); return flag; } }

    以上就是播放单个wav文件的方法主体,现在只要调用它们就行了,可以传入多个文件路径同时调用它们,在此之前有些说明;

    说明:

    (1) 在PlayWave()方法中,需要设置播放事件触发通知位置,就是说,在这个缓冲区,设置多个点,播放到这些点的时候,就会触发一个通知,返回点的编号,从而我们可以知道播放的位置,然后重新读取数据填充到已播放的缓冲区域,这样缓冲区就可以不断循环播放;

            dsPosNotify[i].dwOffset = (i+1) * BlockSize-1;
            dsPosNotify[i].hEventNotify = hevent[i];

         在这个循环中,i = 0时,设置的缓冲偏移是 BlockSize - 1,而整个缓冲区的大小是BlockSize*MaxAudio,这是在CreateWaveBuffer中设定的;就是说,整个缓冲区平均分成 MaxAudio 个小区,第一个小区的起始offset是0,终点offset是BlockSize-1,第二个、第三个依次类推;

        因此,这里通知位置的设定为各个小区的终点offset,不能比它大,大了就是越界了会得不到通知,就不知道播放到了哪里;可以比它小,但最好不要太小,以免一个小区没播完,就重新写入;

    (2) 设定完播放位置,就可以开始读取音频数据到缓存区,然后播放,Play方法这里必须设定为DSBPLAY_LOOPING循环播放缓存区;

    (3) WaitForMultipleObjects()方法是一个win32方法,根据之前播放事件通知位置绑定的HANDLE组,触发一个预设点,就会返回一个Index,它就是HANDLE组的下标,即编号,由此,我们可以得到当前播放位置,需要填充数据的位置就是BlockSize*Index,大小是小区大小BlockSize;

        这个方法第三个参数是bool类型,为 false 时,表示等待任一位置触发时返回;为 true 时,表示等待所有触发点全部触发,再返回;注意上面代码它的两种调用,由此可见,这也是控制程序执行进度的手段;

                                                               

    步骤4. 调用播放方法;

    #include "stdafx.h"
    #include <dsound.h>
    #include <mmsystem.h>
    #include "WaveStructs.h"
    
    #pragma comment(lib, "dsound.lib")
    #pragma comment(lib, "dxguid.lib")
    
    
    #define BlockSize     32*1024   // 缓存区每个小区的大小(可以设定)
    #define MaxAudio      4         //缓存区的小区个数(可以设定为2个以上)
    
    const char testWave2[] = "C:/Windows/Media/Ring02.wav";
    const char testWave5[] = "C:/Windows/Media/Ring05.wav";
    
    HWND hwnd;
    
    int main(int argc, char* argv[])
    {
        SetConsoleTitleA("DSTest");
        hwnd = FindWindowA(NULL, "DSTest");
        
        if (DSInit() == 0)
        {
            CreateThread(NULL, 0, PlayWave, (void*)testWave2, 0, NULL);
            CreateThread(NULL, 0, PlayWave, (void*)testWave5, 0, NULL);
    
            getchar();
        }
        return 0;
    }

        使用DS必须先添加dsound.h,另外还需mmsystem.h头文件,添加dsound.lib, dxguid.lib到项目中;

        这里播放了两个实例wav文件,还可以再加几个,多个音频可以同时播放互不影响。

      (完)

  • 相关阅读:
    Geohash
    Go加密解密之RSA[转]
    在MACOS上实现交叉编译
    [转]MySQL与MongoDB的操作对比
    CentOS 6 使用 yum 安装MongoDB及服务器端配置
    Ubuntu下PHP的扩展
    golang 图片处理,剪切,base64数据转换,文件存储
    性能测试应用领域
    性能测试用例、策略和方法
    性能测试类型
  • 原文地址:https://www.cnblogs.com/mwwf-blogs/p/7511183.html
Copyright © 2011-2022 走看看