每幀1MB的圖片做幀動(dòng)畫會(huì)卡?不存在的!—— SurfaceView 滑動(dòng)窗口式幀復(fù)用

上篇用“SurfaceView逐幀解析 & 幀復(fù)用”優(yōu)化了幀動(dòng)畫內(nèi)存性能后,一個(gè)更復(fù)雜的問(wèn)題付出了水面:幀動(dòng)畫時(shí)間性能。這一篇試著讓每幀素材大小 1MB 的幀動(dòng)畫流暢播放的同時(shí)不讓內(nèi)存膨脹。在整個(gè)優(yōu)化過(guò)程中,綜合運(yùn)用了多線程、阻塞隊(duì)列、消息機(jī)制、滑動(dòng)窗口機(jī)制。也體悟到了計(jì)算機(jī)設(shè)計(jì)的中庸之道。

在此要感謝評(píng)論上一篇文章的掘友“小前鋒”,是你的提問(wèn)指引了我在這個(gè)方向上繼續(xù)探索。

(ps:粗斜體表示引導(dǎo)方案逐步進(jìn)化的關(guān)鍵點(diǎn))

SurfaceView逐幀解析 & 幀復(fù)用

簡(jiǎn)單回顧下上一篇的內(nèi)容:原生幀動(dòng)畫在播放前解析所有幀,對(duì)內(nèi)存壓力大。SurfaceView可以精細(xì)地控制幀動(dòng)畫每一幀的繪制,在每一幀繪制前才解析當(dāng)前幀,且解析后續(xù)幀時(shí)復(fù)用前幀內(nèi)存空間。遂整個(gè)過(guò)程在內(nèi)存只申請(qǐng)了一幀圖片大小的空間。下面羅列了一些關(guān)鍵代碼:

基類:定義繪制框架
public abstract class BaseSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
    ...
    //繪制線程
    private HandlerThread handlerThread;
    private Handler handler;
    
    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        startDrawThread();
    }
    
    private void startDrawThread() {
        handlerThread = new HandlerThread("SurfaceViewThread");
        handlerThread.start();
        handler = new Handler(handlerThread.getLooper());
        handler.post(new DrawRunnable());
    }
    
    private class DrawRunnable implements Runnable {
        @Override
        public void run() {
            try {
                canvas = getHolder().lockCanvas();
                //繪制一幀,包括解碼+繪制幀
                onFrameDraw(canvas);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                getHolder().unlockCanvasAndPost(canvas);
                onFrameDrawFinish();
            }
            //若onFrameDraw()執(zhí)行超時(shí),會(huì)導(dǎo)致下一幀的繪制被推后,預(yù)定的幀時(shí)間間隔不生效
            handler.postDelayed(this, frameDuration);
        }
    }
    
    protected abstract void onFrameDraw(Canvas canvas);
}

//幀動(dòng)畫繪制類:將繪制內(nèi)容具體化為一張Bitmap
public class FrameSurfaceView extends BaseSurfaceView {
    ...
    private BitmapFactory.Options options;
    
    @Override
    protected void onFrameDraw(Canvas canvas) {
        clearCanvas(canvas);
        if (!isStart()) {
            return;
        }
        if (!isFinish()) {
            //繪制一幀
            drawOneFrame(canvas);
        } else {
            onFrameAnimationEnd();
        }
    }

    private void drawOneFrame(Canvas canvas) {
        //解析幀
        frameBitmap = BitmapFactory.decodeResource(getResources(), bitmaps.get(bitmapIndex), options);
        //復(fù)用幀
        options.inBitmap = frameBitmap;
        //繪制幀
        canvas.drawBitmap(frameBitmap, srcRect, dstRect, paint);
        bitmapIndex++;
    }
    ...
}

對(duì)比圖片解析速度

對(duì)于素材在 100k 以下的幀動(dòng)畫,上一篇的逐幀解析方案完全能夠勝任。但如果素材是幾百k,時(shí)間性能就不如預(yù)期。

掘友“小前鋒”問(wèn):“你的方案有測(cè)試過(guò)大圖嗎?比如1024*768px”

在逐幀解析SurfaceView上試了下這個(gè)大小的幀動(dòng)畫,雖然播放過(guò)程很連續(xù),但 600ms 的幀動(dòng)畫被放成了 1s。因?yàn)轭A(yù)定義的每幀播放時(shí)間被解碼時(shí)間拉長(zhǎng)了。

有沒(méi)有比BitmapFactory.decodeResource()更快的解碼方式?

于是乎對(duì)比了各種圖片解碼的速度,其中包括BitmapFactory.decodeStream()、BitmapFactory.decodeResource()、并分別將圖片放到res/rawres/drawable、及assets,還在 GitHub 上發(fā)現(xiàn)了RapidDecoder這個(gè)庫(kù)(興奮不已!)。自定義了測(cè)量函數(shù)執(zhí)行時(shí)間的工具類:

public class MethodUtil {
    //測(cè)量并打印單次函數(shù)執(zhí)行耗時(shí)
    public static long time(Runnable runnable) {
        long start = SystemClock.elapsedRealtime();
        runnable.run();
        long end = SystemClock.elapsedRealtime();
        long span = end - start;
        Log.v("ttaylor", "MethodUtil.time()" + " time span = " + span + " ms");
        return span;
    }
}

public class NumberUtil {
    private static long total;
    private static int times;
    private static String tag;
    
    //統(tǒng)計(jì)并打印多次執(zhí)行時(shí)間的平均值
    public static void average(String tag, Long l) {
        if (!TextUtils.isEmpty(tag) && !tag.equals(NumberUtil.tag)) {
            reset();
            NumberUtil.tag = tag;
        }
        times++;
        total += l;
        int average = total / times ;
        Log.v("ttaylor", "Average.average() " + NumberUtil.tag + " average = " + average);
    }

    private static void reset() {
        total = 0;
        times = 0;
    }
}

經(jīng)多次測(cè)試取平均值,執(zhí)行時(shí)間最長(zhǎng)的是BitmapFactory.decodeResource(),最短的是BitmapFactory.decodeStream()解析assets圖片,后者只用了前者一半時(shí)間。而RapidDecoder庫(kù)的時(shí)間介于兩者之間(失望至極~),不過(guò)它提供了一種邊解碼邊繪制的技術(shù)號(hào)稱比先解碼再繪制要快,還沒(méi)來(lái)得及試。

雖然將解碼時(shí)間減半了,但解碼一張 1MB 圖片還是需要 60+ms,仍不能滿足時(shí)間性能要求。

獨(dú)立解碼線程

現(xiàn)在的矛盾是 圖片解析速度 慢于 圖片繪制速度,如果解碼和繪制在同一個(gè)線程串行的進(jìn)行,那解碼勢(shì)必會(huì)拖慢繪制效率。

可不可以將解碼圖片放在一個(gè)單獨(dú)的線程中進(jìn)行?

在上一篇FrameSurfaceView的基礎(chǔ)上新增了獨(dú)立解碼線程:

public class FrameSurfaceView extends BaseSurfaceView {
    ...
    //獨(dú)立解碼線程
    private HandlerThread decodeThread;
    //解碼算法寫在這里面
    private DecodeRunnable decodeRunnable;
    
    //播放幀動(dòng)畫時(shí)啟動(dòng)解碼線程
    public void start() {
        decodeThread = new HandlerThread(DECODE_THREAD_NAME);
        decodeThread.start();
        handler = new Handler(decodeThread.getLooper());
        handler.post(decodeRunnable);
    }
    
    private class DecodeRunnable implements Runnable {

        @Override
        public void run() {
            //在這里解碼
        }
    }
}

這樣一來(lái),基類中有獨(dú)立的繪制線程,而子類中有獨(dú)立的解碼線程,解碼速度不再影響繪制速度。

新的問(wèn)題來(lái)了:圖片被解碼后存放在哪里?

生產(chǎn)者 & 消費(fèi)者

存放解碼圖片的容器,會(huì)被兩個(gè)線程訪問(wèn),繪制線程從中取圖片(消費(fèi)者),解碼線程往里存圖片(生產(chǎn)者),需考慮線程同步。第一個(gè)想到的就是LinkedBlockingQueue,于是乎在FrameSurfaceView中新增了大小為 1 的阻塞隊(duì)列及存取操作:

public class FrameSurfaceView extends BaseSurfaceView {
    ...
    //解析隊(duì)列:存放已經(jīng)解析幀素材
    private LinkedBlockingQueue<Bitmap> decodedBitmaps = new LinkedBlockingQueue<>(1);
    //記錄已繪制的幀數(shù)
    private int frameIndex ;
    
    //存解碼圖片
    private void putDecodedBitmap(int resId, BitmapFactory.Options options) {
        Bitmap bitmap = decodeBitmap(resId, options);
        try {
            decodedBitmaps.put(bitmap);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
    //取解碼圖片
    private Bitmap getDecodedBitmap() {
        Bitmap bitmap = null;
        try {
            bitmap = decodedBitmaps.take();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return bitmap;
    }
    
    //解碼圖片
    private Bitmap decodeBitmap(int resId, BitmapFactory.Options options) {
        options.inScaled = false;
        InputStream inputStream = getResources().openRawResource(resId);
        return BitmapFactory.decodeStream(inputStream, null, options);
    }
    
    private void drawOneFrame(Canvas canvas) {
        //在繪制線程中取解碼圖片并繪制
        Bitmap bitmap = getDecodedBitmap();
        if (bitmap != null) {
            canvas.drawBitmap(bitmap, srcRect, dstRect, paint);
        }
        frameIndex++;
    }
    
    private class DecodeRunnable implements Runnable {
        private int index;
        private List<Integer> bitmapIds;
        private BitmapFactory.Options options;

        public DecodeRunnable(int index, List<Integer> bitmapIds, BitmapFactory.Options options) {
            this.index = index;
            this.bitmapIds = bitmapIds;
            this.options = options;
        }

        @Override
        public void run() {
            //在解碼線程中解碼圖片
            putDecodedBitmap(bitmapIds.get(index), options);
            index++;
            if (index < bitmapIds.size()) {
                handler.post(this);
            } else {
                index = 0;
            }
        }
    }
}
  • 繪制線程在每次繪制之前調(diào)用阻塞的take()從解析隊(duì)列的隊(duì)頭拿幀圖片,解碼線程不斷地調(diào)用阻塞的put()往解析隊(duì)列的隊(duì)尾存幀圖片。
  • 雖然assets目錄下的圖片解析速度最快,但res/raw目錄的速度和它相差無(wú)幾,為了簡(jiǎn)單起見(jiàn),這里使用了openRawResource讀取res/raw中的圖片。
  • 雖然解碼和繪制分別在不同線程,但如果存放解碼圖片容器大小為 1 ,繪制進(jìn)程必須等待解碼線程,繪制速度還是會(huì)被解碼速度拖累,看似互不影響的兩個(gè)線程,其實(shí)相互牽制。

滑動(dòng)窗口機(jī)制 & 預(yù)解析

為了讓速度不同的生產(chǎn)者和消費(fèi)者更流暢的協(xié)同工作,必須為速度較快的一方提供緩沖。

就好像 TCP 擁塞控制中的滑動(dòng)窗口機(jī)制,發(fā)送方產(chǎn)生報(bào)文的速度快于接收方消費(fèi)報(bào)文的速度,遂發(fā)送方不必等收到前一個(gè)報(bào)文的確認(rèn)再發(fā)送下一個(gè)報(bào)文。

對(duì)于當(dāng)前 case ,需要將存放圖片容器增大,并在幀動(dòng)畫開(kāi)始前預(yù)解析前幾幀存入解析隊(duì)列。

public class FrameSurfaceView extends BaseSurfaceView {
    ...
    //下一個(gè)該被解析的素材索引
    private int bitmapIdIndex;
    //幀動(dòng)畫素材容器
    private List<Integer> bitmapIds = new ArrayList<>();
    //大小為3的解析隊(duì)列
    private LinkedBlockingQueue<Bitmap> decodedBitmaps = new LinkedBlockingQueue<>(3);
    
    //傳入幀動(dòng)畫素材
    public void setBitmapIds(List<Integer> bitmapIds) {
        if (bitmapIds == null || bitmapIds.size() == 0) {
            return;
        }
        this.bitmapIds = bitmapIds;
        preloadFrames();
    }
    
    //預(yù)解析前幾幀
    private void preloadFrames() {
        //解析一幀并將圖片入解析隊(duì)列
        putDecodedBitmap(bitmapIds.get(bitmapIdIndex++), options);
        putDecodedBitmap(bitmapIds.get(bitmapIdIndex++), options);
    }
}

獨(dú)立解碼線程、滑動(dòng)窗口機(jī)制、預(yù)加載都已 code 完畢。運(yùn)行一把代碼(坐等驚喜~)。

居然流暢的播起來(lái)了!興奮的我忍不住播了好幾次。。。打開(kāi)內(nèi)存監(jiān)控一看(頭頂豎下三條線),一夜回到解放前:每播放一次,內(nèi)存中就會(huì)新增 N 個(gè)Bitmap對(duì)象(N為幀動(dòng)畫總幀數(shù))。

原來(lái)重構(gòu)過(guò)程中,將解碼時(shí)的幀復(fù)用邏輯去掉了。當(dāng)前 case 中,幀復(fù)用也變得復(fù)雜起來(lái)。

復(fù)用隊(duì)列

當(dāng)解碼和繪制是在一個(gè)線程中串行進(jìn)行,且只有一幀被復(fù)用,只需這樣寫代碼就能實(shí)現(xiàn)幀復(fù)用:

private void drawOneFrame(Canvas canvas) {
    frameBitmap = BitmapFactory.decodeResource(getResources(), bitmaps.get(bitmapIndex), options);
    //復(fù)用上一幀Bitmap的內(nèi)存
    options.inBitmap = frameBitmap;
    canvas.drawBitmap(frameBitmap, srcRect, dstRect, paint);
    bitmapIndex++;
}

而現(xiàn)在解碼和繪制并發(fā)進(jìn)行,且有多幀能被復(fù)用。這時(shí)就需要一個(gè)隊(duì)列來(lái)維護(hù)可被復(fù)用的幀。

當(dāng)繪制線程從解析隊(duì)列頭部取出幀圖片并完成繪制后,該幀就可以被復(fù)用了,應(yīng)該將其加入到復(fù)用隊(duì)列隊(duì)頭。而解碼線程在解碼新的一幀圖片之前,應(yīng)該從復(fù)用隊(duì)列的隊(duì)尾取出可復(fù)用的幀。

一幀圖片就這樣在兩個(gè)隊(duì)列之間轉(zhuǎn)圈。通過(guò)這樣一個(gè)周而復(fù)始的循環(huán),就可以將內(nèi)存占用控制在有限范圍內(nèi)(解碼隊(duì)列長(zhǎng)度*幀大?。P略鰪?fù)用隊(duì)列代碼如下:

public class FrameSurfaceView extends BaseSurfaceView {
    //復(fù)用隊(duì)列
    private LinkedBlockingQueue<Bitmap> drawnBitmaps = new LinkedBlockingQueue<>(3);
    
    //將已繪制圖片存入復(fù)用隊(duì)列
    private void putDrawnBitmap(Bitmap bitmap) {
        drawnBitmaps.offer(bitmap);
    }
    
    //從復(fù)用隊(duì)列中取圖片
    private LinkedBitmap getDrawnBitmap() {
        Bitmap bitmap = null;
        try {
            bitmap = drawnBitmaps.take();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return bitmap;
    }
    
    //復(fù)用上一幀解析下一幀并入解析隊(duì)列
    private void putDecodedBitmapByReuse(int resId, BitmapFactory.Options options) {
        Bitmap bitmap = getDrawnBitmap();
        options.inBitmap = bitmap;
        putDecodedBitmap(resId, options);
    }
    
    private void drawOneFrame(Canvas canvas) {
        Bitmap bitmap = getDecodedBitmap();
        if (bitmap != null) {
            canvas.drawBitmap(bitmap, srcRect, dstRect, paint);
        }
        //幀繪制完畢后將其存入復(fù)用隊(duì)列
        putDrawnBitmap(bitmap);
        frameIndex++;
    }
    
    private class DecodeRunnable implements Runnable {
        private int index;
        private List<Integer> bitmapIds;
        private BitmapFactory.Options options;

        public DecodeRunnable(int index, List<Integer> bitmapIds, BitmapFactory.Options options) {
            this.index = index;
            this.bitmapIds = bitmapIds;
            this.options = options;
        }

        @Override
        public void run() {
            //在解析線程復(fù)用上一幀并解析下一幀存入解析隊(duì)列
            putDecodedBitmapByReuse(bitmapIds.get(index), options);
            index++;
            if (index < bitmapIds.size()) {
                handler.post(this);
            } else {
                index = 0;
            }
        }
    }
}
  • 繪制幀完成后將其存入復(fù)用隊(duì)列時(shí)使用了不帶阻塞的offer(),這是為了避免慢速解析拖累快速繪制:假設(shè)復(fù)用隊(duì)列已滿,但解析線程還未完成當(dāng)前解析,此時(shí)完成了一幀的繪制,并正在向復(fù)用隊(duì)列存幀,若采用阻塞方法,則繪制線程因慢速解析而被阻塞。
  • 解析線程從復(fù)用隊(duì)列獲取復(fù)用幀時(shí)使用了阻塞的take(),這是為了避免快速解析導(dǎo)致內(nèi)存溢出:假設(shè)復(fù)用隊(duì)列為空,但繪制線程還未完成當(dāng)前幀的繪制,此時(shí)解析線程完成了一幀的解析,并正在向復(fù)用隊(duì)列取幀,若不采取阻塞方法,則解析線程復(fù)用幀失敗,一塊新的內(nèi)存被申請(qǐng)用于存放解析出來(lái)的下一幀。

滿懷期待運(yùn)行代碼并打開(kāi)內(nèi)存監(jiān)控~~,內(nèi)存沒(méi)有膨脹,播了好幾次也沒(méi)有!動(dòng)畫也很流暢!

正打算慶祝的時(shí)候,內(nèi)存監(jiān)控中的一個(gè)對(duì)象引起了我的注意。

僅僅是播放了5-6次動(dòng)畫,就產(chǎn)生了600+個(gè)實(shí)例,而Bitmap對(duì)象只有3個(gè)。

更蹊蹺的是600個(gè)對(duì)象的內(nèi)存占用和3個(gè)Bitmap的幾乎相等。

仔細(xì)觀察這600個(gè)對(duì)象,其中只有3個(gè)對(duì)象Retained size非常大,其余大小都是16k。

點(diǎn)開(kāi)這3個(gè)對(duì)象的成員后發(fā)現(xiàn),每個(gè)對(duì)象都持有1個(gè)Bitmap。

而且這個(gè)對(duì)象的名字叫LinkedBlockingQueue@Node。

真相大白!

在向阻塞隊(duì)列插入元素的時(shí)候,其內(nèi)部會(huì)新建一個(gè)Node結(jié)點(diǎn)用于包裹插入元素,以offer()為例:

public class LinkedBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, java.io.Serializable {
    public boolean offer(E e) {
        if (e == null) throw new NullPointerException();
        final AtomicInteger count = this.count;
        if (count.get() == capacity)
            return false;
        int c = -1;
        //新建結(jié)點(diǎn)
        Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        putLock.lock();
        try {
            if (count.get() < capacity) {
                enqueue(node);
                c = count.getAndIncrement();
                if (c + 1 < capacity)
                    notFull.signal();
            }
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
        return c >= 0;
    }
}

突然想到了 Android 中的消息隊(duì)列,消息被處理后放入消息池,構(gòu)建新消息時(shí)會(huì)先從池中獲取,以此實(shí)現(xiàn)消息的復(fù)用。消息機(jī)制中也維護(hù)了兩個(gè)隊(duì)列,一個(gè)是消息隊(duì)列,一個(gè)是消息回收隊(duì)列,兩個(gè)隊(duì)列之間形成循環(huán),和本文中的場(chǎng)景非常相似。

為啥消息隊(duì)列不會(huì)產(chǎn)生這么多冗余對(duì)象?

原因就在于LinkedBlockingQueue默默為我們包了一層結(jié)點(diǎn),但我們并沒(méi)有能力處理這層額外的結(jié)點(diǎn)。

抓狂中~~~,只要用LinkedBlockingQueue就必然會(huì)新建結(jié)點(diǎn)。。。要不就不用它吧。。。但不用它,實(shí)現(xiàn)生產(chǎn)者消費(fèi)者就比較麻煩。。。還是得用。。。

無(wú)奈之下,只能使用復(fù)制粘貼大法,重寫了一個(gè)自己的LinkedBlockingQueue并刪除那句new Node<E>(),為簡(jiǎn)單起見(jiàn),只列舉了其中的put(),代碼如下:

public class LinkedBlockingQueue {
    private final AtomicInteger count = new AtomicInteger();
    private final ReentrantLock takeLock = new ReentrantLock();
    private final Condition notEmpty = takeLock.newCondition();
    private final ReentrantLock putLock = new ReentrantLock();
    private final Condition notFull = putLock.newCondition();
    private final int capacity;
    private LinkedBitmap head;
    private LinkedBitmap tail;

    public LinkedBlockingQueue(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException();
        this.capacity = capacity;
    }

    public void put(LinkedBitmap bitmap) throws InterruptedException {
        if (bitmap == null) throw new NullPointerException();
        int c = -1;
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();
        try {
            while (count.get() == capacity) {
                notFull.await();
            }
            enqueue(bitmap);
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
    }
}

沒(méi)有了Node之后,Bitmap之間就無(wú)法串聯(lián)起來(lái),那就自己創(chuàng)建一個(gè)能串聯(lián)起來(lái)的Bitmap

public class LinkedBitmap {
    public Bitmap bitmap;
    //用于連接下一個(gè)Bitmap的指針
    public LinkedBitmap next;
}

將原本使用java.util.concurrent.LinkedBlockingQueue替換成自己的LinkedBlockingQueue(隱約覺(jué)得有更好的辦法,待熱心掘友點(diǎn)撥~),將原本使用Bitmap的地方替換成LinkedBitmap。大功告成??!源碼比較長(zhǎng)就不貼出來(lái)了(文末有鏈接)。

體悟中庸

原生幀動(dòng)畫采用提前解析:將全部素材以 Drawable 形式放在內(nèi)存,這是用空間換時(shí)間的做法,它擁有最好的時(shí)間性能和最差的內(nèi)存性能。

為了提高內(nèi)存性能采用逐幀解析,這是用時(shí)間換空間的做法,它擁有最好的內(nèi)存性能和最差的時(shí)間性能

顯然這兩種極端的方案都不是最好的方案。但是極端方案的價(jià)值在于它為最終的中庸方案定義了兩個(gè)邊界,讓我知道好在哪個(gè)位置獲取中點(diǎn)。

本文的滑動(dòng)窗口式幀復(fù)用就是在上一篇逐幀解析的基礎(chǔ)上犧牲了一些內(nèi)存性能換取了一個(gè)折中的內(nèi)存和時(shí)間性能。

題外話

對(duì)上一篇的代碼做了重構(gòu),但所有改動(dòng)都發(fā)生在子類,基類BaseSurfaceView保持原樣。這得益于模版方法設(shè)計(jì)模式,將不變的算法框架抽象出來(lái)定義在基類中,將變化的部分組織成抽象函數(shù),其實(shí)現(xiàn)延遲到子類。

talk is cheap, show me the code

上述列舉代碼段省略了一些和重點(diǎn)無(wú)關(guān)的細(xì)節(jié),詳細(xì)源碼可點(diǎn)擊這里[https://github.com/wisdomtl/FrameSurfaceView]

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容