當一個安卓開發(fā)玩抖音玩瘋了之后(一)

滴勘伺,滴滴勿负!

暗號

本篇文章將介紹自己總結(jié)的短視頻錄制的相關內(nèi)容栏笆,主要分為三個部分:

  • 攝像頭內(nèi)容錄制
  • 音頻錄制
  • 視頻合成

先上效果圖

  • 錄制過程


    錄制過程截圖
  • 錄制結(jié)果


    錄制結(jié)果截圖

1.攝像頭內(nèi)容錄制

錄制

錄制流程大致如上圖所示鲁驶。

渲染關鍵代碼

新建外部紋理

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        mTextureId = GLUtils.createTextureObject(GLES11Ext.GL_TEXTURE_EXTERNAL_OES);
        mSurfaceTexture = new SurfaceTexture(mTextureId);
        ...
    }

新建了外部紋理之后俱两,傳入 Camera

mCamera.setPreviewTexture(mSurfaceTexture);
mCamera.startPreview();

GLSurfaceView 渲染時,請求 SurfaceTexture 更新痛侍,獲取最新的內(nèi)容

    @Override
    public void onDrawFrame(GL10 gl) {
        if (mFilter == null) {
            return;
        }
        float matrix[] = new float[16];
        if (mSurfaceTexture != null) {
            //請求刷新最新內(nèi)容
            mSurfaceTexture.updateTexImage();
        }
        mSurfaceTexture.getTransformMatrix(matrix);

        if (mFrameListener != null) {
            //通知MediaCodec刷新畫面
            mFrameListener.onFrameAvailable(new VideoFrameData(mFilter,
                    matrix, mSurfaceTexture.getTimestamp(), mTextureId));
        }
        mFilter.init();
        if (mOldFilter != null) {
            mOldFilter.release();
            mOldFilter = null;
        }
        mSurfaceTexture.getTransformMatrix(mMatrix);
        //繪制預覽內(nèi)容
        mFilter.draw(mTextureId, mMatrix);
    }

mFilter 中包含 OpenGL 相關的著色器程序

著色器代碼如下:

    /**
     * 默認代碼
     */
    private static final String FRAGMENT_CODE =
            "#extension GL_OES_EGL_image_external : require\n" +
                    "precision mediump float;\n" +
                    "varying vec2 vTextureCoord;\n" +
                    "uniform samplerExternalOES uTexture;\n" +
                    "void main() {\n" +
                    "    gl_FragColor = texture2D(uTexture, vTextureCoord);\n" +
                    "}\n";
    /**
     * 默認代碼
     */
    private static final String VERTEX_CODE =
            "uniform mat4 uTexMatrix;\n" +
                    "attribute vec2 aPosition;\n" +
                    "attribute vec4 aTextureCoord;\n" +
                    "varying vec2 vTextureCoord;\n" +
                    "void main() {\n" +
                    "    gl_Position = vec4(aPosition,0.0,1.0);\n" +
                    "    vTextureCoord = (uTexMatrix * aTextureCoord).xy;\n" +
                    "}\n";

外部紋理和普通紋理不同朝氓,需要在片段著色器代碼頭部聲明拓展。

#extension GL_OES_EGL_image_external : require

著色器代碼比較簡單恋日,不包含濾鏡相關的內(nèi)容膀篮,直接使用相機的紋理繪制一個矩形。

錄制關鍵代碼

內(nèi)容錄制編碼使用 MediaCodec + MediaMuxer 的組合來實現(xiàn)岂膳。MediaCodec 在初始化時誓竿,我們可以從中獲取一個 Surface,用來往里面填充內(nèi)容谈截。

        MediaFormat format = MediaFormat.createVideoFormat(C.VideoParams.MIME_TYPE,
                configuration.getVideoWidth(),
                configuration.getVideoHeight());
        //設置參數(shù)
        format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
                MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
        format.setInteger(MediaFormat.KEY_BIT_RATE, C.VideoParams.BIT_RATE);
        format.setInteger(MediaFormat.KEY_FRAME_RATE, C.VideoParams.SAMPLE_RATE);
        format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, C.VideoParams.I_FRAME_INTERVAL);
        MediaCodec encoder = MediaCodec.createEncoderByType(C.VideoParams.MIME_TYPE);
        encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        inputSurface = encoder.createInputSurface();

獲取 inputSurface 之后筷屡,我們新建一個 EGLSurface,到這里編碼器的初始化就完成了,當有新的內(nèi)容時簸喂,通知編碼器來刷新毙死。之前我們獲取了GLSurfaceView 的 GL 上下文,當收到新內(nèi)容通知時喻鳄,我們把 GL 環(huán)境切到編碼器的線程扼倘,然后繪制,最后調(diào)用 swapBuffers 方法把繪制的內(nèi)容填充到inputSurface 中,這就是所謂的離屏渲染(聽著很高大上再菊,后面講解短視頻后期制作時也會用到這個)爪喘。

這里不使用 EOS 紋理也是可以的,我們可以通過 Camera 的setPreviewCallback 方法監(jiān)聽相機的每一幀數(shù)據(jù)纠拔,然后將 YUV 數(shù)據(jù)轉(zhuǎn)換成ARGB 數(shù)據(jù)秉剑,再轉(zhuǎn)成紋理交給 OpenGL 渲染即可。

最后新建 MediaMuxer

muxer = new MediaMuxer(configuration.getFileName(),
                MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);

此部分內(nèi)容參考 grafika 實現(xiàn)

微笑

視頻變速

視頻變速相對來說比較容易稠诲,在編碼之后侦鹏,我們從 MediaCodec 的緩沖區(qū)中獲取本次編碼內(nèi)容的 ByteBuffer 和 BufferInfo ,前者是編碼后的內(nèi)容,后者是本次內(nèi)容的信息臀叙,包括時間戳略水,大小等。我們通過改變視頻的時間戳匹耕,就可以達到視頻變速的要求聚请。比如要加快視頻的速度荠雕,那么只需要將視頻的時間戳間隔縮小一定的倍數(shù)即可稳其。放慢操作和這個相反,只需要把時間戳間隔放大一定的倍數(shù)即可炸卑。

音頻錄制

音頻的錄制我們需要使用到 AudioRecord 這個大殺器既鞠,大致流程圖如下。

音頻流程圖

音頻錄制比較簡單盖文,參考官方文檔即可嘱蛋。這里需要開啟兩條線程,因為目前使用的編碼是同步模式五续,如果是在一條線程里處理數(shù)據(jù)洒敏,會導致麥克風的數(shù)據(jù)丟失。

關鍵代碼如下:

初始化AudioRecord
指定單聲道模式疙驾,采樣率為 44100凶伙,每個采樣點 16 比特

 int bufferSize = AudioRecord.getMinBufferSize(
                configuration.getSampleRate(), C.AudioParams.CHANNEL,
                C.AudioParams.BITS_PER_SAMPLE);
 recorder = new AudioRecord(
                MediaRecorder.AudioSource.MIC, configuration.getSampleRate(),
                C.AudioParams.CHANNEL, C.AudioParams.BITS_PER_SAMPLE, bufferSize);

初始化MediaCodec

        MediaFormat audioFormat = MediaFormat.createAudioFormat(C.AudioParams.MIME_TYPE,
                C.AudioParams.SAMPLE_RATE, C.AudioParams.CHANNEL_COUNT);
        audioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE,
                MediaCodecInfo.CodecProfileLevel.AACObjectLC);
        audioFormat.setInteger(MediaFormat.KEY_CHANNEL_MASK, C.AudioParams.CHANNEL);
        audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, C.AudioParams.BIT_RATE);
        audioFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, C.AudioParams.CHANNEL_COUNT);
        audioFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 1024 * 4);
        encoder = MediaCodec.createEncoderByType(C.AudioParams.MIME_TYPE);
        encoder.configure(audioFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        bufferInfo = new MediaCodec.BufferInfo();
        mStream = new BufferedOutputStream(new FileOutputStream(configuration.getFileName()));

音頻編碼

讀取音頻數(shù)據(jù)

 byte[] buffer = new byte[configuration.getSamplePerFrame()];
 int bytes = recorder.read(buffer, 0, buffer.length);
 if (bytes > 0) {
     encode(buffer, bytes);
 }

塞進MediaCodec緩沖區(qū)

    private void onEncode(byte[] data, int length) {
        final ByteBuffer[] inputBuffers = encoder.getInputBuffers();
        while (true) {
            final int inputBufferIndex = encoder.dequeueInputBuffer(BUFFER_TIME_OUT);
            if (inputBufferIndex >= 0) {
                final ByteBuffer inputBuffer = inputBuffers[inputBufferIndex];
                inputBuffer.clear();
                inputBuffer.position(0);
                if (data != null) {
                    inputBuffer.put(data, 0, length);
                }
                if (length <= 0) {
                    encoder.queueInputBuffer(inputBufferIndex, 0, 0,
                            getTimeUs(), MediaCodec.BUFFER_FLAG_END_OF_STREAM);
                    break;
                } else {
                    encoder.queueInputBuffer(inputBufferIndex, 0, length,
                            getTimeUs(), 0);
                }
                break;
            }
        }
    }

取出編碼后的數(shù)據(jù)并寫入文件

    private void drain() {
        bufferInfo = new MediaCodec.BufferInfo();
        ByteBuffer[] encoderOutputBuffers = encoder.getOutputBuffers();
        int encoderStatus = encoder.dequeueOutputBuffer(bufferInfo, C.BUFFER_TIME_OUT);
        while (encoderStatus >= 0) {
            ByteBuffer encodedData = encoderOutputBuffers[encoderStatus];
            int outSize = bufferInfo.size;
            encodedData.position(bufferInfo.offset);
            encodedData.limit(bufferInfo.offset + bufferInfo.size);
            byte[] data = new byte[outSize + 7];
            addADTSHeader(data, outSize + 7);
            encodedData.get(data, 7, outSize);
            try {
                mStream.write(data, 0, data.length);
            } catch (IOException e) {
                LogUtil.e(e);
            }
            if (duration >= configuration.getMaxDuration()) {
                stop();
            }
            encoder.releaseOutputBuffer(encoderStatus, false);
            encoderStatus = encoder.dequeueOutputBuffer(bufferInfo, C.BUFFER_TIME_OUT);
        }
    }

aac文件對內(nèi)容格式有要求,需要在每一幀的內(nèi)容頭部添加內(nèi)容它碎,代碼如下:

    private void addADTSHeader(byte[] packet, int length) {
        int profile = 2; // AAC LC
        int freqIdx = 4; // 44.1KHz
        int chanCfg = 1; // CPE
        // fill in A D T S data
        packet[0] = (byte) 0xFF;
        packet[1] = (byte) 0xF9;
        packet[2] = (byte) (((profile - 1) << 6) + (freqIdx << 2) + (chanCfg >> 2));
        packet[3] = (byte) (((chanCfg & 3) << 6) + (length >> 11));
        packet[4] = (byte) ((length & 0x7FF) >> 3);
        packet[5] = (byte) (((length & 7) << 5) + 0x1F);
        packet[6] = (byte) 0xFC;
    }

音頻變速

一開始調(diào)研短視頻方案的時候函荣,對于音頻變速這方面,想了很多個方案:

  • 音頻和視頻使用 MediaMuxer 合成扳肛,指定變速速率傻挂,在錄制結(jié)束時使用ffmpeg 進行變速
  • 視頻和音頻分開錄制,視頻實時變速錄制挖息,音頻在錄制結(jié)束時使用 ffmpeg 變速金拒,然后再使用 ffmpeg 合并到視頻中
  • 音頻和視頻分開錄制,音頻實時變速套腹,視頻實時變速绪抛,錄制完成后轿衔,使用ffmpeg 合成

最終我選擇了第三個方案,前兩個方案的死因如下:

  • 效率差睦疫,ffmpeg 如果要對視頻進行變速害驹,效率很低,一個視頻如果要放慢三倍蛤育,最久的時候要十幾秒宛官,并且因為使用的是軟編,對 cpu 占用率比較高瓦糕,會導致 UI 卡頓,
  • 音頻變速耗時比視頻變速要少底洗,但是對用戶來說,還是可以感知的到的咕娄,所以這個方案也 pass亥揖。(主要是達不到抖音的效果)

第三個方案需要使用一個第三方庫——SoundTouch,它可以改變音頻的音調(diào)和速度圣勒。SoundTouch 由 C++ 實現(xiàn)费变,因此我們需要用 NDK 工具把它集成到工程當中。集成的方法參照官方文檔即可圣贸。官方的例子中主要給出了處理 wav 文件的方法挚歧,接下來我介紹一下如何使用這個庫實時處理 pcm 數(shù)據(jù)(通過實時處理PCM 數(shù)據(jù),我們還可以弄個變聲功能噢)吁峻。

SoundTouch 使用

新建類—— SoundTouch

public class SoundTouch {
    private native final void setTempo(long handle, float tempo);

    private native final void setPitchSemiTones(long handle, float pitch);

    private native final void putBytes(long handle, byte[] input, int offset, int length);

    private native final int getBytes(long handle, byte[] output, int length);

    private native final static long newInstance();

    private native final void deleteInstance(long handle);

    private native final void flush(long handle);

    private long handle = 0;

    public SoundTouch() {
        handle = newInstance();
    }

    public void putBytes(byte[] input) {
        this.putBytes(handle, input, 0, input.length);
    }

    public int getBytes(byte[] output) {
        return this.getBytes(handle, output, output.length);
    }


    public void close() {
        deleteInstance(handle);
        handle = 0;
    }

    public void flush() {
        this.flush(handle);
    }

    public void setTempo(float tempo) {
        setTempo(handle, tempo);
    }


    public void setPitchSemiTones(float pitch) {
        setPitchSemiTones(handle, pitch);
    }

    static {
        System.loadLibrary("soundtouch");
    }

}

主要有四個方法

  • setTempo —— 設置音頻變速 大于1為加速滑负,小于1為減速
  • setPitchSemiTones —— 設置音頻聲調(diào)
  • putBytes —— 將 pcm 數(shù)據(jù)添加到 SoundTouch 管道中
  • getBytes —— 從 SoundTouch 管道中取出處理過的 pcm 數(shù)據(jù)

新建對應的 cpp 文件,關鍵代碼如下:

void Java_com_netease_soundtouch_SoundTouch_setTempo(JNIEnv *env, jobject thiz, jlong handle, jfloat tempo)
{
    SoundTouch *ptr = (SoundTouch *)handle;
    ptr->setTempo(tempo);
}
void Java_com_netease_soundtouch_SoundTouch_setPitchSemiTones(JNIEnv *env, jobject thiz, jlong handle, jfloat pitch)
{
    SoundTouch *ptr = (SoundTouch *)handle;
    ptr->setPitchSemiTones(pitch);
}
void Java_com_netease_soundtouch_SoundTouch_putBytes(JNIEnv *env, jobject thiz, jlong handle, jbyteArray input, jint offset, jint length)
{
    SoundTouch *soundTouch = (SoundTouch *)handle;
    jbyte *data;
    data = env->GetByteArrayElements(input, JNI_FALSE);
    soundTouch->putSamples((SAMPLETYPE *)data, length/2);
    env->ReleaseByteArrayElements(input, data, 0);
}
jint Java_com_netease_soundtouch_SoundTouch_getBytes(JNIEnv *env, jobject thiz, jlong handle, jbyteArray output, jint length)
{
    int receiveSamples = 0;
    int maxReceiveSamples = length/2;
    SoundTouch *soundTouch = (SoundTouch *)handle;
    jbyte *data;
    data = env->GetByteArrayElements(output, JNI_FALSE);
    receiveSamples = soundTouch->receiveSamples((SAMPLETYPE *)data,
                                                maxReceiveSamples);
    env->ReleaseByteArrayElements(output, data, 0);
    return receiveSamples;
}

處理 pcm 數(shù)據(jù)

    //在將pcm導入MediaCodec之前用含,先由SoundTouch處理一遍
    private void encode(final byte[] data, final int length) {
        encodeHandler.post(new Runnable() {
            @Override
            public void run() {
                if (soundTouch != null) {
                    soundTouch.putBytes(data);
                    while (true) {
                        //如果是用MediaMuxer來生成音頻矮慕,我們每次只能寫入一幀數(shù)據(jù),那么這里緩沖區(qū)就不能用4096啄骇,只能用1024
                        byte[] modified = new byte[4096];
                        int count = soundTouch.getBytes(modified);
                        if (count > 0) {
                            onEncode(modified, count * 2);
                            drain();
                        } else {
                            break;
                        }
                    }
                } else {
                    onEncode(data, length);
                    drain();
                }
            }
        });
    }

音頻和視頻合成

錄制完視頻和音頻之后痴鳄,我們需要將音頻和視頻進行合成,這一步直接使用FFMPEG 工具即可肠缔,命令行如下:

ffmpeg -y -i audioFile -ss 0 -t duration -i videoFile -acodec copy -vcodec copy output

其中夏跷,audioFile 為我們的 aac 文件的路徑,videoFile 為 mp4 文件的路徑明未,output 為最終生成的 mp4 文件的路徑槽华,duration 為音頻文件的長度,使用MediaExtractor 獲取即可趟妥。

ffmpeg 不會自動幫我們創(chuàng)建文件猫态,在合成之前,需要先創(chuàng)建output文件

執(zhí)行完這個命令后,音頻和視頻就合成完畢了亲雪,15秒的視頻勇凭,合成一次大概只需要100ms左右。我們只需要在每小段視頻錄制完畢時合成一次即可义辕,對用戶來說沒什么影響虾标。視頻的碼率越高,合成所需要的時間越久灌砖。

視頻合成

多段視頻拼接使用 ffmpeg 即可璧函,無需重新解碼,我們在點擊 app 中的下一步按鈕時進行視頻的拼接基显。關鍵代碼如下:

    public static VideoCommand mergeVideo(List<String> videos, String output) {
        String appDir = StorageUtil.getExternalStoragePath() + File.separator;
        String fileName = "ffmpeg_concat.txt";
        FileUtils.writeTxtToFile(videos, appDir, fileName);
        VideoCommand cmd = new VideoCommand();
        cmd.append("ffmpeg").append("-y").append("-f").append("concat").append("-safe")
                .append("0").append("-i").append(appDir + fileName)
                .append("-c").append("copy").append(output);
        return cmd;
    }

命令行為:

ffmpeg -y -f concat -safe 0 -i concatFile -c copy output

其中蘸吓,concatFile 是一個 txt 文件,內(nèi)容為我們要拼接的文件的路徑列表撩幽,output 為最終輸出的 mp4 文件库继。

總結(jié)

整個短視頻的錄制方案大概就是如此,關于視頻錄制方面窜醉,因為沒有具體線上項目實踐過宪萄,所以可能會存在機型不兼容的情況,大家如果有更好的方案酱虎,歡迎在評論區(qū)提出來噢雨膨,一起探討下擂涛。有些地方講解不對或者覺得不清楚的读串,歡迎大家在評論區(qū)指出。后面會發(fā)關于短視頻后期處理的文章撒妈,敬請關注恢暖!

滴,滴滴狰右!

參考資料

1.MediaCodec - Android Developer
2.AudioRecord - Android Developer
3.SoundTouch Audio Processing Library
4.FFMPEG —— A complete, cross-platform solution to record, convert and stream audio and video.
5.Google-Grafika
6.MP4音視頻同步原理

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末杰捂,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子棋蚌,更是在濱河造成了極大的恐慌嫁佳,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,126評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件谷暮,死亡現(xiàn)場離奇詭異蒿往,居然都是意外死亡,警方通過查閱死者的電腦和手機湿弦,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評論 2 382
  • 文/潘曉璐 我一進店門瓤漏,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事蔬充〉悖” “怎么了?”我有些...
    開封第一講書人閱讀 152,445評論 0 341
  • 文/不壞的土叔 我叫張陵饥漫,是天一觀的道長榨呆。 經(jīng)常有香客問我,道長庸队,這世上最難降的妖魔是什么愕提? 我笑而不...
    開封第一講書人閱讀 55,185評論 1 278
  • 正文 為了忘掉前任,我火速辦了婚禮皿哨,結(jié)果婚禮上浅侨,老公的妹妹穿的比我還像新娘。我一直安慰自己证膨,他們只是感情好如输,可當我...
    茶點故事閱讀 64,178評論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著央勒,像睡著了一般不见。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上崔步,一...
    開封第一講書人閱讀 48,970評論 1 284
  • 那天稳吮,我揣著相機與錄音,去河邊找鬼井濒。 笑死灶似,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的瑞你。 我是一名探鬼主播酪惭,決...
    沈念sama閱讀 38,276評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼者甲!你這毒婦竟也來了春感?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,927評論 0 259
  • 序言:老撾萬榮一對情侶失蹤虏缸,失蹤者是張志新(化名)和其女友劉穎鲫懒,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體刽辙,經(jīng)...
    沈念sama閱讀 43,400評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡窥岩,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,883評論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了扫倡。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片谦秧。...
    茶點故事閱讀 37,997評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡竟纳,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出疚鲤,到底是詐尸還是另有隱情锥累,我是刑警寧澤,帶...
    沈念sama閱讀 33,646評論 4 322
  • 正文 年R本政府宣布集歇,位于F島的核電站桶略,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏诲宇。R本人自食惡果不足惜际歼,卻給世界環(huán)境...
    茶點故事閱讀 39,213評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望姑蓝。 院中可真熱鬧鹅心,春花似錦、人聲如沸纺荧。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,204評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽宙暇。三九已至输枯,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間占贫,已是汗流浹背桃熄。 一陣腳步聲響...
    開封第一講書人閱讀 31,423評論 1 260
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留型奥,地道東北人瞳收。 一個月前我還...
    沈念sama閱讀 45,423評論 2 352
  • 正文 我出身青樓,卻偏偏與公主長得像桩引,于是被迫代替她去往敵國和親缎讼。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,722評論 2 345