關于Android視頻播放

一忽舟、流媒體

什么是流媒體技術?
簡單的說,就是邊下載,邊播放萧诫。
也就是說斥难,客戶端在播放前,無需下載整個媒體文件帘饶,而是在播放緩存區(qū)已下載的媒體數(shù)據(jù)同時哑诊,持續(xù)不斷的接收媒體流的剩余部分。

更專業(yè)一點的定義是:
流媒體技術的主要特點是以“流(Streaming)”的形式在基于IP協(xié)議的互聯(lián)網(wǎng)中進行多媒體數(shù)據(jù)的實時及刻、連續(xù)傳播镀裤。

常用的流媒體協(xié)議有哪些?

 RTSP (RTP, SDP), RTMP
 HTTP progressive streaming
 HLS - HTTP live streaming (M3U8)

RTSP/RTP流媒體協(xié)議

什么是RTSP/RTP流媒體協(xié)議?

RTSP/RTP是目前最流行缴饭、使用最廣泛的實時流媒體協(xié)議暑劝,它實際上由一組標準化協(xié)議構(gòu)成:

1.jpg

其中,RTSP是Real Time Streaming Protocol(實時流媒體協(xié)議)颗搂,RTP是Real Time Transport Protocol(實時傳輸協(xié)議)担猛。
RTSP是一種雙向?qū)崟r數(shù)據(jù)傳輸協(xié)議,它允許客戶端向服務器端發(fā)送請求丢氢,如回放傅联、快進、倒退等操作

HTTP漸進下載流媒體播放(HTTP progressive streaming)

什么是HTTP progressive streaming?

1.基于HTTP的漸進下載疚察,是在下載完成后再播放的模式基礎上做了一些小的改進蒸走。
2.客戶端在開始播放前僅需等待一段較短的時間用于下載和緩沖該媒體文件最前面一部分的數(shù)據(jù),之后便可以一邊下載一邊播放貌嫡。開始播放前的緩沖通常需要幾十秒甚至上百秒的時間比驻。
3.只有滿足特定封裝條件的媒體文件格式才支持漸進下載播放,例如編碼參數(shù)必須放在文件的起始部位岛抄,音視頻文件完全按照時間順序交織等别惦。

HTTP Live Streaming協(xié)議

什么是HTTP Live Streaming?

最初是蘋果公司針對其移動設備開發(fā)的流媒體協(xié)議。
讓內(nèi)容提供者通過普通Web服務器向客戶端提供接近實時的音視頻流媒體服務弦撩,包括直播和點播步咪。
支持將同一節(jié)目編碼為不同碼率的多個替換流,客戶端可以根據(jù)帶寬變化在替換流之間智能切換益楼。

二猾漫、解碼

我們播放的視頻文件都是經(jīng)過壓縮的,因為這樣有利于節(jié)約存儲空間感凤。那么在播放過程悯周,就需要進行一個反射的解壓縮過程。

軟解碼和硬解碼

解碼分為硬解碼和軟解碼兩種

區(qū)別

舉個栗子陪竿,CPU 相當于公司的 CEO 禽翼,GPU相當于公司技術總監(jiān)屠橄、產(chǎn)品經(jīng)理之類,來了一個需求闰挡,如果采用軟解碼锐墙,那就是讓 CEO 去畫原型,去一線寫代碼长酗,這期間還要忙著各種大小的事物處理溪北,如果采用硬解碼,那就是CEO 朝技術總監(jiān)夺脾、產(chǎn)品經(jīng)理發(fā)指令之拨,讓他們?nèi)ネ瓿梢患拢⑶叶ㄆ诓樵兺瓿傻某潭取?/p>

硬解碼:就是調(diào)用GPU的專門模塊進行解碼咧叭,由顯卡核心GPU來對視頻進行解碼工作蚀乔。
軟解碼:通過軟件讓CPU來對視頻進行解碼處理。

優(yōu)缺點

網(wǎng)上看到一句話菲茬,“硬解碼是將原來全部交由CPU來處理的視頻數(shù)據(jù)的一部分交由GPU來做吉挣,而GPU的并行運算能力要遠遠高于CPU,這樣可以大大的降低對CPU的負載婉弹,CPU的占用率較低了之后就可以同時運行一些其他的程序了听想。”

兩者優(yōu)缺點:


2.jpg

上面對比中一個是功耗一個是總功耗马胧,這個也很容易理解,GPU的電路更復雜衔峰,并行運算能力要遠遠高于CPU佩脊,于是耗電量就更高,GPU功耗大垫卤,但運行速度提升更多威彰,功耗 = 功率 * 時間,所以就算功率乘個4穴肘,但是時間除以個10歇盼,總耗能還是降低。

選擇:說不上那個好评抚,各有優(yōu)點豹缀。我感覺硬解更好一些。過于占用CPU太耗性能慨代。

三邢笙、播放器

目前播放器比較火熱的有Android系統(tǒng)自帶的MediaPalyer,還有google的ExoPlayer

3.1我們先說一下MediaPlayer

MediaPlayer處于Android多媒體包下"android.media.MediaPlayer",僅有一個無參的構(gòu)造函數(shù)侍匙,雖然Android平臺僅為我們提供了一個無參的構(gòu)造函數(shù)氮惯,但是為了方便我們初始化,還為我們提供了幾個靜態(tài)的`create()方法用于完成MediaPlayer初始化的工作。(常用的兩個)

static MediaPlayer create(Context context,int resid):通過給定的Id來創(chuàng)建一個MediaPlayer實例妇汗。
static MediaPlayer create(Context context,Uri uri):通過給定的Uri來創(chuàng)建一個MediaPlayer實例帘不。
還有一些重載的create方法。

MediaPlayer具體方法介紹:

void setDataSource(String path) 通過一個具體的路徑來設置MediaPlayer的數(shù)據(jù)源杨箭,path可以是本地的一個路徑寞焙,也可以是一個網(wǎng)絡路徑
void setDataSource(Context context, Uri uri) 通過給定的Uri來設置MediaPlayer的數(shù)據(jù)源,這里的Uri可以是網(wǎng)絡路徑或是一個ContentProvider的Uri告唆。
void setDataSource(MediaDataSource dataSource) 通過提供的MediaDataSource來設置數(shù)據(jù)源
void setDataSource(FileDescriptor fd) 通過文件描述符FileDescriptor來設置數(shù)據(jù)源
int getCurrentPosition() 獲取當前播放的位置
int getAudioSessionId() 返回音頻的session ID
int getDuration() 得到文件的時間
TrackInfo[] getTrackInfo() 返回一個track信息的數(shù)組
boolean isLooping () 是否循環(huán)播放
boolean isPlaying() 是否正在播放
void pause () 暫停
void start () 開始
void stop () 停止
void prepare() 同步的方式裝載流媒體文件棺弊。
void prepareAsync() 異步的方式裝載流媒體文件。
void reset() 重置MediaPlayer至未初始化狀態(tài)擒悬。
void release () 回收流媒體資源模她。
void seekTo(int msec) 指定播放的位置(以毫秒為單位的時間)
void setAudioStreamType(int streamtype) 指定流媒體類型
void setLooping(boolean looping) 設置是否單曲循環(huán)
void setNextMediaPlayer(MediaPlayer next) 當 當前這個MediaPlayer播放完畢后,MediaPlayer next開始播放
void setWakeMode(Context context, int mode):設置CPU喚醒的狀態(tài)懂牧。
setOnBufferingUpdateListener(MediaPlayer.OnBufferingUpdateListener listener) 網(wǎng)絡流媒體的緩沖變化時回調(diào)
setOnCompletionListener(MediaPlayer.OnCompletionListener listener) 網(wǎng)絡流媒體播放結(jié)束時回調(diào)
setOnErrorListener(MediaPlayer.OnErrorListener listener) 發(fā)生錯誤時回調(diào)
setOnPreparedListener(MediaPlayer.OnPreparedListener listener):當裝載流媒體完畢的時候回調(diào)侈净。

在使用MediaPlayer播放一段流媒體的時候,需要使用prepare()或prepareAsync()方法把流媒體裝載進MediaPlayer僧凤,才可以調(diào)用start()方法播放流媒體畜侦。

在使用start()播放流媒體之前,需要裝載流媒體資源躯保。這里最好使用prepareAsync()用異步的方式裝載流媒體資源旋膳。因為流媒體資源的裝載是會消耗系統(tǒng)資源的,在一些硬件不理想的設備上途事,如果使用prepare()同步的方式裝載資源验懊,可能會造成UI界面的卡頓,這是非常影響用于體驗的尸变。因為推薦使用異步裝載的方式义图,為了避免還沒有裝載完成就調(diào)用start()而報錯的問題,需要綁定MediaPlayer.setOnPreparedListener()事件召烂,它將在異步裝載完成之后回調(diào)碱工。異步裝載還有一個好處就是避免裝載超時引發(fā)ANR((Application Not Responding)錯誤。

            mediaPlayer = new MediaPlayer();
            mediaPlayer.setDataSource(path);
            mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
            
            // 通過異步的方式裝載媒體資源
            mediaPlayer.prepareAsync();
            mediaPlayer.setOnPreparedListener(new OnPreparedListener() {                    
                @Override
                public void onPrepared(MediaPlayer mp) {
                    // 裝載完畢回調(diào)
                    mediaPlayer.start();
                }
            });

使用完MediaPlayer需要回收資源奏夫。MediaPlayer是很消耗系統(tǒng)資源的怕篷,所以在使用完MediaPlayer,不要等待系統(tǒng)自動回收酗昼,最好是主動回收資源匙头。

          mediaPlayer.stop();
          mediaPlayer.release();
          mediaPlayer = null;
      }

3.2 ExoPalyer
簡要說明:

ExoPlayer is an application level media player for Android. It provides an alternative to Android’s MediaPlayer API for playing audio and video both locally and over the Internet. ExoPlayer supports features not currently supported by Android’s MediaPlayer API, including DASH and SmoothStreaming adaptive playbacks. Unlike the MediaPlayer API, ExoPlayer is easy to customize and extend, and can be updated through Play Store application updates.
來自google翻譯:
ExoPlayer是Android的應用程序級媒體播放器。 它提供了Android的MediaPlayer API的替代品仔雷,用于在本地和互聯(lián)網(wǎng)上播放音頻和視頻蹂析。 ExoPlayer支持Android MediaPlayer API目前不支持的功能舔示,包括DASH和SmoothStreaming自適應回放。 與MediaPlayer API不同电抚,ExoPlayer易于定制和擴展惕稻,并可通過Play Store應用程序更新進行更新。
綜上所述蝙叛,大概就是MediaPlayer不如ExoPlayer俺祠,google推薦使用ExoPlayer。

使用

3.2.1.添加依賴

implementation 'com.google.android.exoplayer:exoplayer:2.X.X'

整個ExoPlayer庫包括5個子庫借帘,依賴了整個ExoPlayer庫和依賴5個子庫是等效的蜘渣。
- exoplayer-core:核心功能 (必要)
- exoplayer-dash:支持DASH內(nèi)容
- exoplayer-ui:用于ExoPlayer的UI組件和相關的資源。
- exoplayer-hls:支持HLS內(nèi)容
- exoplayer-smoothstreaming:支持SmoothStreaming內(nèi)容

3.2.2.創(chuàng)建一個SimpleExoPlayer實例肺然,SimpleExoPlayer是ExoPlayer接口的一個默認的通用實現(xiàn)蔫缸。


private void initializePlayer() {
        if (player==null){
            player = ExoPlayerFactory.newSimpleInstance(
                    new DefaultRenderersFactory(this),
                    new DefaultTrackSelector(), new DefaultLoadControl());

            playerView.setPlayer(player);

            player.setPlayWhenReady(playWhenReady);
            player.seekTo(currentWindow, playbackPosition);
        }

        Uri uri = Uri.parse(getString(R.string.media_url_mp4));
        MediaSource mediaSource = buildMediaSource(uri);
        player.prepare(mediaSource, false, true);
    }

3.2.3.最后我們要做資源釋放

ExoPlayer相較于MediaPlayer有很多很多的優(yōu)點:
支持動態(tài)的自適應流HTTP(DASH) 和 平滑流,任何目前MediaPlayer支持的視頻格式(同時它還支持HTTP直播了(HLS),MP4,MP3,WebM,M4A,MPEG-TS 和 AAC).
支持高級的HLS特性
支持自定義和擴治你的使用場景际起。ExoPlayer專門為此設計拾碌;
便于隨著App的升級而升級。因為ExoPlayer是一個包含在你的應用中的庫街望,對于你使用哪個版本有完全的控制權校翔,并且你可以簡單的跟隨應用的升級而升級;
更少的適配性問題灾前。

缺點:
ExoPlayer的音頻和視頻組件依賴Android的 MediaCodec接口防症,該接口發(fā)布于Android4.1(API 等級16)。因此它不能工作于之前的Android版本哎甲。

3.3 MediaPlayer和TextureView

使用MediaPlayer 要配合SurfaceView或者TextureView來使用告希。來用他們渲染視圖。
打個比方來說的話 MediaPlayer就像電腦的主機一樣烧给,而SurfaceView和TextureView就如同顯示器一樣。

TextureView優(yōu)缺點:

  • 優(yōu)點

動畫支持良好喝噪,可以獲取視頻截圖

視圖不可見時可以保留當前幀不黑屏

  • 缺點

必須開啟硬件加速础嫡,否則無畫面,占用內(nèi)存比SurfaceView高酝惧,在5.0以前在主線程渲染榴鼎,5.0以后有單獨的渲染線程。

SurfaceView優(yōu)缺點:

  • 優(yōu)點

可以在一個獨立的線程中進行繪制晚唇,不會影響主線程使用雙緩沖機制巫财,播放視頻時畫面更流暢

  • 缺點

由于是獨立的一層View,更像是獨立的一個Window哩陕,不能加上動畫平项、平移赫舒、縮放;

四闽瓢、一些思考
之前我們接入過騰訊視頻
直接用騰訊寫好的Demo 叫SuperBasePlayer 使用過程中是非常痛苦接癌,因為他的功能比較多。但是沒有按照業(yè)務功能進行拆分扣讼。所有全部蹂在一起缺猛。這樣如果做功能修改或者解碼器更換⊥址或者我們不接騰訊接別的sdk改動就會很大荔燎。所以就想因為我們現(xiàn)在做的項目都是組件化。所以視頻這塊是否可以組件化開發(fā)销钝。把每個功能單獨開發(fā)有咨,然后自由拼接。將解碼器與視圖分離曙搬。

3.png

實現(xiàn)

4.1 接收者組管理(ReceiverGroup)

ReceiverGroup的目的就是對眾多接收者進行統(tǒng)一的管理摔吏,事件下發(fā)。

在ReceiverGroup中包含Cover和Receiver纵装,提供了Receiver的添加征讲、移除、銷毀等操作橡娄。

public interface IReceiverGroup {
    void setOnReceiverGroupChangeListener(OnReceiverGroupChangeListener onReceiverGroupChangeListener);

    void addReceiver(String key, IReceiver receiver);

    void removeReceiver(String key);

    void clearReceivers();
}


**4.2 BaseCover **
定義一個視圖基類

//請求暫停
void requestPause(Bundle bundle);
//請求恢復播放
void requestResume(Bundle bundle);
//請求seek
void requestSeek(Bundle bundle);
//請求停止
void requestStop(Bundle bundle);
//請求重置
void requestReset(Bundle bundle);
//請求重試
void requestRetry(Bundle bundle);
//請求重播
void requestReplay(Bundle bundle);

------------------------------------------------------------

//必須實現(xiàn)的方法诗箍。用于初始化視圖布局。
abstract View onCreateCoverView(Context context);

------------------------------------------------------------

//設置視圖的顯示或隱藏
void setCoverVisibility(int visibility); 

4.2.1 CoVer
寫各種CoVer
LoadingCover
ErrorCover
ControllerCover
GestureCover
·················
根據(jù)自己的需要些一寫Cover

通過ReceiverGroup 我們把Cover添加到視圖上

ReceiverGroup receiverGroup = new ReceiverGroup(groupValue);
receiverGroup.addReceiver(KEY_LOADING_COVER, new LoadingCover(context));
receiverGroup.addReceiver(KEY_CONTROLLER_COVER, new ControllerCover(context));
receiverGroup.addReceiver(KEY_GESTURE_COVER, new GestureCover(context));
receiverGroup.addReceiver(KEY_ERROR_COVER, new ErrorCover(context));
videoView.setReceiverGroup(receiverGroup);

我們也可以移除不想要的Cover

4.3事件生產(chǎn)者(EventProducer)
顧名思義挽唉,就是它是產(chǎn)生事件的源滤祖。比如系統(tǒng)網(wǎng)絡狀態(tài)發(fā)生了變化,發(fā)出了通知瓶籽,然后各個應用根據(jù)自己的情況來調(diào)整顯示或設置等匠童。又或者電池電量的變化和低電量預警通知事件等。

網(wǎng)絡變化事件

public class NetworkEventProducer extends BaseEventProducer {
    //...
    private Handler mHandler = new Handler(Looper.getMainLooper()){
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            switch (msg.what){
                case MSG_CODE_NETWORK_CHANGE:
                    int state = (int) msg.obj;
                    //...將網(wǎng)絡狀態(tài)發(fā)送出去
                    getSender().sendInt(InterKey.KEY_NETWORK_STATE, state);
                    PLog.d(TAG,"onNetworkChange : " + state);
                    break;
            }
        }
    };
    //...
    public NetworkEventProducer(Context context){
        //...
    }
    //...
    public static class NetChangeBroadcastReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            //...
            //post state message
        }
        //...
    }
}

4.4 BaseVideoView
寫一個基類 來實現(xiàn)我們寫的功能塑顺。

  • 初始化BaseVideoView
    BaseVideoView可以寫在在布局中
 <com.kk.taurus.playerbase.widget.BaseVideoView
        android:id="@+id/videoView"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:layout_margin="2dp"
        android:background="#000000"
        />

我們也還可以通過java代碼創(chuàng)建

BaseVideoView videoView = new BaseVideoView(context);

  • 為BaseVideoView設置一個ReceiverGroup
ReceiverGroup receiverGroup = new ReceiverGroup(groupValue);
receiverGroup.addReceiver(KEY_LOADING_COVER, new LoadingCover(context));
receiverGroup.addReceiver(KEY_CONTROLLER_COVER, new ControllerCover(context));
receiverGroup.addReceiver(KEY_ERROR_COVER, new ErrorCover(context));
videoView.setReceiverGroup(receiverGroup);
  • 設置事件監(jiān)聽器汤求、事件處理器
videoView.setOnPlayerEventListener(new OnPlayerEventListener() {
    @Override
    public void onPlayerEvent(int eventCode, Bundle bundle) {
        //...
    }
});
videoView.setOnReceiverEventListener(new OnReceiverEventListener() {
    @Override
    public void onReceiverEvent(int eventCode, Bundle bundle) {
        //...
    }
});
videoView.setOnErrorEventListener(new OnErrorEventListener() {
    @Override
    public void onErrorEvent(int eventCode, Bundle bundle) {
        //...
    }
});
videoView.setEventHandler(new OnVideoViewEventHandler(){
    @Override
    public void onAssistHandle(BaseVideoView assist, int eventCode, Bundle bundle) {
        super.onAssistHandle(assist, eventCode, bundle);
        //...
    }
});
  • 設置數(shù)據(jù)啟動播放
videoView.setDataSource(new DataSource("http://url..."));
videoView.start();
//如果需要定點播放,請調(diào)用下面的方法并傳入時間點(毫秒值)
//如下严拒,從15秒處起播
videoView.start(15000);
  • 也可以設置 倍速播放 需要看解碼器是否支持

  • 暫停與恢復播放

//暫停
videoView.pause();
//恢復
videoView.resume();
  • 銷毀播放器
videoView.stopPlayback();

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末扬绪,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子裤唠,更是在濱河造成了極大的恐慌挤牛,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,123評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件种蘸,死亡現(xiàn)場離奇詭異墓赴,居然都是意外死亡竞膳,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,031評論 2 384
  • 文/潘曉璐 我一進店門竣蹦,熙熙樓的掌柜王于貴愁眉苦臉地迎上來顶猜,“玉大人,你說我怎么就攤上這事痘括〕ふ” “怎么了?”我有些...
    開封第一講書人閱讀 156,723評論 0 345
  • 文/不壞的土叔 我叫張陵纲菌,是天一觀的道長挠日。 經(jīng)常有香客問我,道長翰舌,這世上最難降的妖魔是什么嚣潜? 我笑而不...
    開封第一講書人閱讀 56,357評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮椅贱,結(jié)果婚禮上懂算,老公的妹妹穿的比我還像新娘。我一直安慰自己庇麦,他們只是感情好计技,可當我...
    茶點故事閱讀 65,412評論 5 384
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著山橄,像睡著了一般垮媒。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上航棱,一...
    開封第一講書人閱讀 49,760評論 1 289
  • 那天睡雇,我揣著相機與錄音,去河邊找鬼饮醇。 笑死它抱,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的朴艰。 我是一名探鬼主播观蓄,決...
    沈念sama閱讀 38,904評論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼呵晚!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起沫屡,我...
    開封第一講書人閱讀 37,672評論 0 266
  • 序言:老撾萬榮一對情侶失蹤饵隙,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后沮脖,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體金矛,經(jīng)...
    沈念sama閱讀 44,118評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡芯急,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,456評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了驶俊。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片娶耍。...
    茶點故事閱讀 38,599評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖饼酿,靈堂內(nèi)的尸體忽然破棺而出榕酒,到底是詐尸還是另有隱情,我是刑警寧澤故俐,帶...
    沈念sama閱讀 34,264評論 4 328
  • 正文 年R本政府宣布想鹰,位于F島的核電站,受9級特大地震影響药版,放射性物質(zhì)發(fā)生泄漏辑舷。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,857評論 3 312
  • 文/蒙蒙 一槽片、第九天 我趴在偏房一處隱蔽的房頂上張望何缓。 院中可真熱鬧,春花似錦还栓、人聲如沸碌廓。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,731評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽氓皱。三九已至,卻和暖如春勃刨,著一層夾襖步出監(jiān)牢的瞬間波材,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,956評論 1 264
  • 我被黑心中介騙來泰國打工身隐, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留廷区,地道東北人。 一個月前我還...
    沈念sama閱讀 46,286評論 2 360
  • 正文 我出身青樓贾铝,卻偏偏與公主長得像隙轻,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子垢揩,可洞房花燭夜當晚...
    茶點故事閱讀 43,465評論 2 348