Android實現錄屏直播(三)MediaProjection + VirtualDisplay + librtmp + MediaCodec實現視頻編碼並推流到rtmp服務器

請尊重分享成果,轉載請註明出處,本文來自Coder包子哥,原文連接:http://blog.csdn.net/zxccxzzxz/article/details/55230272html

Android實現錄屏直播(一)ScreenRecorder的簡單分析java

Android實現錄屏直播(二)需求才是硬道理之產品功能調研git

看到有網友在後臺私信和詢問錄屏這部分推流相關的問題,感受這篇博客早該寫完了。事實上除了繁忙的工做加上春節假期一會兒拖了近一個月之久。近期更新了Demo,加入了視頻幀推流,須要的朋友能夠看看Demo。github

不管是音頻仍是視頻編碼,咱們都須要原始的數據源,拿視頻舉例子,實際錄屏直播也只是將屏幕的每個視圖間接的獲取充當原始視頻幀,和攝像頭獲取視頻幀原理區別不大。現在Android設備幾乎都已經知足硬編碼的條件了(雖然有好有壞),因此咱們僞裝曾經那些兼容性問題都不存在。算法

咱們向服務器發送視頻數據的時候,還須要先發送視頻參數的數據給服務器以區分咱們的視頻源的格式、類型及FLV封裝的一些關鍵信息(File Header / File TAG等如sps / pps等相關的meta data)給解碼器。(PS: 關於FLV格式封裝等視頻編解碼的分析推薦看看雷博的相關博文)MediaCodec的細節能夠自行查閱官方API。shell

而且推薦這些對我極爲有用的文章資料,感謝這些做者的無私分享:服務器

做者自行封裝及實現的一個Android實施濾鏡、RTMP推流的類庫。代碼結構須要花點時間理解和讀懂,值得深刻學習其中的實現,由於使用MediaProjection / VirtualDisplay 來進行錄屏的話,官方並不提供幀率的控制,這須要用到OpenGL ES將VirtualDisplay中的surface進行繪製到MediaCodec中的surface。然而在這個庫中做者已經實現了所有的操做,本篇文章也會圍繞該庫進行大體的分析網絡

在找到上面的類庫以前,仍是這位做者給出的思路纔可以一點點往OpenGL ES這個坑裏跳,越入越深,差點沒爬出來。因爲做者不方便放出源碼,我只能經過他的描述一點點的實現。而且StackOverFlow中網友fadden也給出了相關的思路:controlling-frame-rate-of-virtualdisplay多線程

Google官方給的Demo,基本涵蓋了OpenGL的各種用法,好好看看吧。併發

一個Android MediaCodec的超詳細的博客,實例Demo很明確。

問題的原因來自工做中某些需求所引發的,接下來我一一描述。

需求一 幀率控制

好不容易開發完成錄屏直播,結果在低碼率或者網絡波動大的狀況下,不少機型(尤爲是小米)在60幀滿幀的條件打出來的視頻是那樣的酸爽,動態的畫面簡直眼瞎。老闆要求改幀率!下降幀率到30看看什麼狀況,最後實際選擇使用了15FPS。

在快速滑動屏幕或者畫面變換頻繁的狀況下改善視頻模糊的作法:(參見https://github.com/lakeinchina/librestreaming/issues/11)

  • 提升碼率BPS
  • 下降幀率
  • 調小關鍵幀間隔(MediaFormat.KEY_I_FRAME_INTERVAL)
  • 提升AVCProfile(Android默認使用的是BaseLine模式)
  • 編碼器的好壞也有關係

需求二 穩定性(保活)

性能比起Bilibili仍是要差一些,這裏挖個坑,以後再填。

後期設定的方案是將推流放到remote service當中,該service爲前臺獨立進程的service,對主進程的依賴性減弱一些(雖然APP在被殺死的時候也可能被殺死)

經過開啓遠程服務並與APP的進程進行進程間通訊(IPC),尋求保活的方式花了一段時間,最後對MIUI的系統機制仍是無果,Debug的時候發現MIUI擁有一個PowerKeeper,一旦觸發就會對任何後臺進程的APP(聽說有白名單)進行KillApplication操做,在個人壓力測試下,無一應用倖免(包括優化得極其穩定的Bilibili,GooglePlay錄屏APP排行第一的AZ ScreenRecorder)。

近期涉及到的技術知識點:

  • Java多線程併發(線程之間的通訊,併發時鎖的使用)
  • OpenGL ES(對Surface與Surface之間的數據傳遞)
  • Android消息機制的深刻理解(Handler內部實現及熟練運用)

首先推薦看看這些對我極爲有用的文章資料,感謝這些做者的無私分享:

librestreaming

做者自行封裝及實現的一個Android實施濾鏡、RTMP推流的類庫。代碼結構須要花點時間理解和讀懂,值得深刻學習其中的實現,由於使用MediaProjection / VirtualDisplay 來進行錄屏的話,官方並不提供幀率的控制,這須要用到OpenGL ES將VirtualDisplay中的surface進行繪製到MediaCodec中的surface。然而在這個庫中做者已經實現了所有的操做,本篇文章也會圍繞該庫進行大體的分析

屏幕錄製(二)——幀率控制

在找到上面的類庫以前,仍是這位做者給出的思路纔可以一點點往OpenGL ES這個坑裏跳,越入越深,差點沒爬出來。因爲做者不方便放出源碼,我只能經過他的描述一點點的實現。而且StackOverFlow中網友fadden也給出了相關的思路:controlling-frame-rate-of-virtualdisplay

grafika

Google官方給的Demo,基本涵蓋了OpenGL的各種用法,好好看看吧。

Android MediaCodec stuff

一個Android MediaCodec的超詳細的博客,實例Demo很明確。

Updated 3.12

以前阿里雲搞活動,12塊買了個1核2G / 1M 半年的服務器,正好一直閒置沒用,爲了完成這篇博客我也真是夠拼的了,先按照上述連接搭建一個基於Nginx + RTMP協議的流媒體服務器。搭服務器的目的是爲了完成推流的操做,畢竟不想用公司的資源來進行私人的活動。

不少朋友都問推流何時纔有,那麼今天我就完完整整的將錄屏推流這塊完善,Android客戶端的Demo + 推流服務器的步驟實現,時間有限,只注重實現,代碼質量以後重構。

醜話再說在前頭,我本着一顆開源分享和學習的心來寫博客和Demo,認爲好的點贊、評論你們隨意,可是本人能力和精力有限,這本屬於一個Demo,若是認爲太爛沒參考價值,那麼還請留點口德,默默關閉本頁便可,有問題提出來我會回覆並以改正,請求勿噴,謝謝~

Demo結構

大概將會包含幾個部分:

  1. 錄屏 : 客戶端實現(官方API,目前僅支持Android 5.0以上)
  2. 原始幀的格式轉換,分辨率、方向轉換:libyuv,這裏沒有使用librestreaming中的封裝方法。(錄屏在使用OpenGL繪製以前直接使用的是API配置的參數,故暫時跳過該步驟
  3. FLV的格式封裝: 套用librestreaming中的算法(Java),實際個人項目中是經過一種不巧妙地算法將sps / pps 硬拼湊出來的。雖然也是一樣的原理,但我的更推薦該庫的處理方式,代碼清晰易懂。
  4. rtmp推流:前期先準備好相應的librtmp的源碼,構建項目的同時進行引用編譯成靜態庫,本次直接引用librestreaming中的代碼稍做修改,有關librtmp更詳細的內容可參見雷博的文章: http://blog.csdn.net/leixiaohua1020/article/details/42104945

錄屏推流的流程:

客戶端原始幀編碼爲H264裸流(MediaCodec,Android API) —> 再封裝爲FLV格式的視頻流(Java) —> 按照rtmp流媒體協議經過librtmp(JNI + C)推流到RTMP流媒體服務器 —> 客戶端播放器解碼觀看

注意:

Demo省略了librestreaming中的OpenGL處理幀率的過程,也就意味着咱們使用的是MediaCodec直接編碼後的數據,並無OpenGL繪製VirtualDisplay映射給MediaCodec.createInputSurface中Surface的這個過程,目的是將流程簡單化。OpenGL方面的使用以後我也會介紹說明。

能夠看到實現錄屏到本地的ScreenRecorder直接經過下面的方法進行音視頻寫入,而推流的話須要作以修改。

mMuxer.writeSampleData(mVideoTrackIndex, encodedData, mBufferInfo);

FLV Header的獲取(SPS PPS)

FLV的頭文件信息發送給服務器後,就能夠將咱們的關鍵幀發送,注意流媒體服務器解析的時候首先要先獲得第一幀關鍵幀纔會開始解析後面的視頻幀,因此咱們還須要在編碼器獲取IDR幀的時候進行發送。MediaCodec的INFO_OUTPUT_FORMAT_CHANGED這個狀態能夠獲取sps / pps,再將數據處理包裝後打到FLV的TAG中。

private void sendAVCDecoderConfigurationRecord(long tms, MediaFormat format) {
    byte[] AVCDecoderConfigurationRecord = Packager.H264Packager.generateAVCDecoderConfigurationRecord(format);
    int packetLen = Packager.FLVPackager.FLV_VIDEO_TAG_LENGTH +
            AVCDecoderConfigurationRecord.length;
    byte[] finalBuff = new byte[packetLen];
    Packager.FLVPackager.fillFlvVideoTag(finalBuff,
            0,
            true,
            true,
            AVCDecoderConfigurationRecord.length);
    System.arraycopy(AVCDecoderConfigurationRecord, 0,
            finalBuff, Packager.FLVPackager.FLV_VIDEO_TAG_LENGTH, AVCDecoderConfigurationRecord.length);
    RESFlvData resFlvData = new RESFlvData();
    resFlvData.droppable = false;
    resFlvData.byteBuffer = finalBuff;
    resFlvData.size = finalBuff.length;
    resFlvData.dts = (int) tms;
    resFlvData.flvTagType = RESFlvData.FLV_RTMP_PACKET_TYPE_VIDEO;
    resFlvData.videoFrameType = RESFlvData.NALU_TYPE_IDR;
    dataCollecter.collect(resFlvData, RESRtmpSender.FROM_VIDEO);
}
public static byte[] generateAVCDecoderConfigurationRecord(MediaFormat mediaFormat) {
    ByteBuffer SPSByteBuff = mediaFormat.getByteBuffer("csd-0");
    SPSByteBuff.position(4);
    ByteBuffer PPSByteBuff = mediaFormat.getByteBuffer("csd-1");
    PPSByteBuff.position(4);
    int spslength = SPSByteBuff.remaining();
    int ppslength = PPSByteBuff.remaining();
    int length = 11 + spslength + ppslength;
    byte[] result = new byte[length];
    SPSByteBuff.get(result, 8, spslength);
    PPSByteBuff.get(result, 8 + spslength + 3, ppslength);
    /**
     * UB[8]configurationVersion
     * UB[8]AVCProfileIndication
     * UB[8]profile_compatibility
     * UB[8]AVCLevelIndication
     * UB[8]lengthSizeMinusOne
     */
    result[0] = 0x01;
    result[1] = result[9];
    result[2] = result[10];
    result[3] = result[11];
    result[4] = (byte) 0xFF;
    /**
     * UB[8]numOfSequenceParameterSets
     * UB[16]sequenceParameterSetLength
     */
    result[5] = (byte) 0xE1;
    ByteArrayTools.intToByteArrayTwoByte(result, 6, spslength);
    /**
     * UB[8]numOfPictureParameterSets
     * UB[16]pictureParameterSetLength
     */
    int pos = 8 + spslength;
    result[pos] = (byte) 0x01;
    ByteArrayTools.intToByteArrayTwoByte(result, pos + 1, ppslength);

    return result;
}

在librestreaming中Packager.java這個類主要就是作了上面這些事。僅兩個方法實現,做者代碼邏輯清晰易懂,這裏就很少說了,再次感謝Lake哥!

能夠看到音視頻編碼線程共用同一個RESFlvDataCollecter接口,負責監聽編碼線程的一舉一動,從接口中將音視頻編碼幀送到同一個幀隊列,發送線程取數據時取到什麼就把數據餵給RtmpStreamingSender發送出去。

dataCollecter = new RESFlvDataCollecter() {
    @Override
    public void collect(RESFlvData flvData, int type) {
        rtmpSender.feed(flvData, type);
    }
};

在librestreaming中使用了HandlerThread爲WorkHandler提供Looper,經過Handler的消息隊列循環機制來控制數據的發送,實際也能夠自定義線程,手動管理視頻幀收發隊列,但涉及到了併發搶佔資源的問題,更推薦Handler這種方式,而且Handler處理除了效率高、邏輯清晰,易管理以外還有一個好處,若是使用OpenGL繪製Surface時,正好能夠Handler處理其中的異步操做。

不過在Demo中我修改成了一個普通的Runnable任務,run()中循環處理frameQueue中的數據。代碼以下:

public class RtmpStreamingSender implements Runnable {
    private static final int MAX_QUEUE_CAPACITY = 50;
    private AtomicBoolean mQuit = new AtomicBoolean(false);
    private LinkedBlockingDeque<RESFlvData> frameQueue = new LinkedBlockingDeque<>(MAX_QUEUE_CAPACITY);
    private final Object syncWriteMsgNum = new Object();
    private FLvMetaData fLvMetaData;
    private RESCoreParameters coreParameters; 
    private volatile int state;

    private long jniRtmpPointer = 0;
    private int maxQueueLength = 150;
    private int writeMsgNum = 0;
    private String rtmpAddr = null;

    private static class STATE {
        private static final int START = 0;
        private static final int RUNNING = 1;
        private static final int STOPPED = 2;
    }

    public RtmpStreamingSender() {
        coreParameters = new RESCoreParameters();
        coreParameters.mediacodecAACBitRate = 32 * 1024;
        coreParameters.mediacodecAACSampleRate = 44100;
        coreParameters.mediacodecAVCFrameRate = 20;
        coreParameters.videoWidth = 1280;
        coreParameters.videoHeight = 720;
        fLvMetaData = new FLvMetaData(coreParameters);
    }

    @Override
    public void run() {
        while (!mQuit.get()) {
            if (frameQueue.size() > 0) {
                switch (state) {
                    case STATE.START:
                        LogTools.d("RESRtmpSender,WorkHandler,tid=" + Thread.currentThread().getId());
                        if (TextUtils.isEmpty(rtmpAddr)) {
                            LogTools.e("rtmp address is null!");
                            break;
                        }
                        jniRtmpPointer = RtmpClient.open(rtmpAddr, true);
                        final int openR = jniRtmpPointer == 0 ? 1 : 0;
                        String serverIpAddr = null;
                        if (openR == 0) {
                            serverIpAddr = RtmpClient.getIpAddr(jniRtmpPointer);
                            LogTools.d("server ip address = " + serverIpAddr);
                        }
                        if (jniRtmpPointer == 0) {
                            break;
                        } else {
                            byte[] MetaData = fLvMetaData.getMetaData();
                            RtmpClient.write(jniRtmpPointer,
                                    MetaData,
                                    MetaData.length,
                                    RESFlvData.FLV_RTMP_PACKET_TYPE_INFO, 0);
                            state = STATE.RUNNING;
                        }
                        break;
                    case STATE.RUNNING:
                        synchronized (syncWriteMsgNum) {
                            --writeMsgNum;
                        }
                        if (state != STATE.RUNNING) {
                            break;
                        }
                        RESFlvData flvData = frameQueue.pop();
                        if (writeMsgNum >= (maxQueueLength * 2 / 3) && flvData.flvTagType == RESFlvData.FLV_RTMP_PACKET_TYPE_VIDEO && flvData.droppable) {
                            LogTools.d("senderQueue is crowded,abandon video");
                            break;
                        }
                        final int res = RtmpClient.write(jniRtmpPointer, flvData.byteBuffer, flvData.byteBuffer.length, flvData.flvTagType, flvData.dts);
                        if (res == 0) {
                            if (flvData.flvTagType == RESFlvData.FLV_RTMP_PACKET_TYPE_VIDEO) {
                                LogTools.d("video frame sent = " + flvData.size);
                            } else {
                                LogTools.d("audio frame sent = " + flvData.size);
                            }
                        } else {
                            LogTools.e("writeError = " + res);
                        }
                        break;
                    case STATE.STOPPED:
                        if (state == STATE.STOPPED || jniRtmpPointer == 0) {
                            break;
                        }
                        final int closeR = RtmpClient.close(jniRtmpPointer);
                        serverIpAddr = null;
                        LogTools.e("close result = " + closeR);
                        break;
                }
            }
        }
    }

    public void sendStart(String rtmpAddr) {
        synchronized (syncWriteMsgNum) {
            writeMsgNum = 0;
        }
        this.rtmpAddr = rtmpAddr;
        state = STATE.START;
    }

    public void sendStop() {
        synchronized (syncWriteMsgNum) {
            writeMsgNum = 0;
        }
        state = STATE.STOPPED;
    }

    public void sendFood(RESFlvData flvData, int type) {
        synchronized (syncWriteMsgNum) {
            //LAKETODO optimize
            if (writeMsgNum <= maxQueueLength) {
                frameQueue.add(flvData);
                ++writeMsgNum;
            } else {
                LogTools.d("senderQueue is full,abandon");
            }
        }
    }

    public final void quit() {
        mQuit.set(true);
    }
}

RtmpStreamingSender.java這個類的大部分方法都引入了librestreaming中RESRtmpSender.java類中的代碼實現,只是將其改成了以前所說的線程循環機制,並無用Handler。

RTMP package的封裝與使用

RtmpClient中包含了jni的native方法,能夠看到有對應了screenrecorderrtmp.h中的幾個方法:

public static native long open(String url, boolean isPublishMode);
public static native int read(long rtmpPointer, byte[] data, int offset, int size);
public static native int write(long rtmpPointer, byte[] data, int size, int type, int ts);
public static native int close(long rtmpPointer);
public static native String getIpAddr(long rtmpPointer);

咱們可根據RtmpClient.java這個Jni入口類生成 jni的c文件screenrecorderrtmp.h,再到項目的java目錄,使用如下命令在同級目錄下建立一個jni/screenrecorderrtmp.h 文件。

javah -d jni net.yrom.screenrecorder.rtmp.RtmpClient

接着對應h文件編寫c的rtmp推流代碼,screenrecorderrtmp.c 以下:

#include <jni.h>
#include <screenrecorderrtmp.h>
#include <malloc.h>
#include "rtmp.h"

JNIEXPORT jlong JNICALL Java_net_yrom_screenrecorder_rtmp_RtmpClient_open
 (JNIEnv * env, jobject thiz, jstring url_, jboolean isPublishMode) {
    const char *url = (*env)->GetStringUTFChars(env, url_, 0);
    LOGD("RTMP_OPENING:%s",url);
    RTMP* rtmp = RTMP_Alloc();
    if (rtmp == NULL) {
        LOGD("RTMP_Alloc=NULL");
        return NULL;
    }

    RTMP_Init(rtmp);
    int ret = RTMP_SetupURL(rtmp, url);

    if (!ret) {
        RTMP_Free(rtmp);
        rtmp=NULL;
        LOGD("RTMP_SetupURL=ret");
        return NULL;
    }
    if (isPublishMode) {
        RTMP_EnableWrite(rtmp);
    }

    ret = RTMP_Connect(rtmp, NULL);
    if (!ret) {
        RTMP_Free(rtmp);
        rtmp=NULL;
        LOGD("RTMP_Connect=ret");
        return NULL;
    }
    ret = RTMP_ConnectStream(rtmp, 0);

    if (!ret) {
        ret = RTMP_ConnectStream(rtmp, 0);
        RTMP_Close(rtmp);
        RTMP_Free(rtmp);
        rtmp=NULL;
        LOGD("RTMP_ConnectStream=ret");
        return NULL;
    }
    (*env)->ReleaseStringUTFChars(env, url_, url);
    LOGD("RTMP_OPENED");
    return rtmp;
}


/*
 * Class:     net_yrom_screenrecorder_rtmp_RtmpClient
 * Method:    read
 * Signature: (J[BII)I
 */
JNIEXPORT jint JNICALL Java_net_yrom_screenrecorder_rtmp_RtmpClient_read
(JNIEnv * env, jobject thiz,jlong rtmp, jbyteArray data_, jint offset, jint size) {

    char* data = malloc(size*sizeof(char));

    int readCount = RTMP_Read((RTMP*)rtmp, data, size);

    if (readCount > 0) {
        (*env)->SetByteArrayRegion(env, data_, offset, readCount, data);  // copy
    }
    free(data);

    return readCount;
}
/*
 * Class:     net_yrom_screenrecorder_rtmp_RtmpClient
 * Method:    write
 * Signature: (J[BIII)I
 */
JNIEXPORT jint JNICALL Java_net_yrom_screenrecorder_rtmp_RtmpClient_write
(JNIEnv * env, jobject thiz,jlong rtmp, jbyteArray data, jint size, jint type, jint ts) {
    LOGD("start write");
    jbyte *buffer = (*env)->GetByteArrayElements(env, data, NULL);
    RTMPPacket *packet = (RTMPPacket*)malloc(sizeof(RTMPPacket));
    RTMPPacket_Alloc(packet, size);
    RTMPPacket_Reset(packet);
    if (type == RTMP_PACKET_TYPE_INFO) { // metadata
        packet->m_nChannel = 0x03;
    } else if (type == RTMP_PACKET_TYPE_VIDEO) { // video
        packet->m_nChannel = 0x04;
    } else if (type == RTMP_PACKET_TYPE_AUDIO) { //audio
        packet->m_nChannel = 0x05;
    } else {
        packet->m_nChannel = -1;
    }

    packet->m_nInfoField2  =  ((RTMP*)rtmp)->m_stream_id;

    LOGD("write data type: %d, ts %d", type, ts);

    memcpy(packet->m_body,  buffer,  size);
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
    packet->m_hasAbsTimestamp = FALSE;
    packet->m_nTimeStamp = ts;
    packet->m_packetType = type;
    packet->m_nBodySize  = size;
    int ret = RTMP_SendPacket((RTMP*)rtmp, packet, 0);
    RTMPPacket_Free(packet);
    free(packet);
    (*env)->ReleaseByteArrayElements(env, data, buffer, 0);
    if (!ret) {
        LOGD("end write error %d", sockerr);
        return sockerr;
    }else
    {
        LOGD("end write success");
        return 0;
    }
}
/*
 * Class:     net_yrom_screenrecorder_rtmp_RtmpClient
 * Method:    close
 * Signature: (J)I
 */
JNIEXPORT jint JNICALL Java_net_yrom_screenrecorder_rtmp_RtmpClient_close
 (JNIEnv * env,jlong rtmp, jobject thiz) {
    RTMP_Close((RTMP*)rtmp);
    RTMP_Free((RTMP*)rtmp);
    return 0;
 }
/*
 * Class:     net_yrom_screenrecorder_rtmp_RtmpClient
 * Method:    getIpAddr
 * Signature: (J)Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_net_yrom_screenrecorder_rtmp_RtmpClient_getIpAddr
    (JNIEnv * env,jobject thiz,jlong rtmp) {
    if(rtmp!=0){
        RTMP* r= (RTMP*)rtmp;
        return (*env)->NewStringUTF(env, r->ipaddr);
    }else {
        return (*env)->NewStringUTF(env, "");
    }
}

更多的請看Demo源碼,說下目前未實現的功能和問題:

  • 音頻編碼尚未,只有視頻的部分。考慮到音頻能夠經過FFmpeg等lib進行軟編碼,也能夠MediaCodec硬編,須要的話能夠自行加入
  • 有必定延遲,發送隊列須要優化
  • 錄屏的API有個坑,就是在屏幕內容沒有變化的時候,是不會刷新繪製幀率的,也就是若是最後一幀是主頁,而此時主頁沒有任何UI變化,那麼MediaCodec會中止編碼工做,直到屏幕上有任何UI變化(包括一像素)則繼續編碼。遇到這個問題當時想了一些辦法感受太複雜,因此我則在懸浮窗加了個閃爍的提示條,只要在錄屏過程當中提示條就會閃動,這樣就巧妙的避免了上述問題的發生
  • 不可幀率控制,其實Demo大部分仍是引用的是librestreaming的代碼,由於時間有限,就不作過多的開發,仍是推薦使用和理解該庫的原理,並不複雜,而且已經實現了幀率控制,丟幀等處理。對OpenGL不熟悉的朋友能夠先忽略這部分的實現,只看原始幀採集,編碼器編碼,FLV封裝,推流這幾個部分便可

RTMP服務器

參照Nginx + rtmp搭建了流媒體服務器用來測試,Demo中鍵入如下地址即可推流,yourstramingkey自定義,

rtmp://59.110.159.133/live/<yourstramingkey>
例如:rtmp://59.110.159.133/live/test

效果圖

demo

源碼

傳送門:GitHub

更多參考文章:

相關文章
相關標籤/搜索