Choreographer原理

Android 系統在 VSYNC 信號的指引下,有條不紊地進行者每一幀的渲染、合成操作,使我們可以享受穩定幀率的畫面。引入 VSYNC 之前的 Android 版本,渲染一幀相關的 Message ,中間是沒有間隔的,上一幀繪製完,下一幀的 Message 緊接着就開始被處理。這樣的問題就是,幀率不穩定,可能高也可能低,不穩定。

Choreographer 是 Android 4.1 新增的機制,主要是配合 VSYNC ,給上層 App 的渲染提供一個穩定的 Message 處理的時機,也就是 VSYNC 到來的時候 ,系統通過對 VSYNC 信號週期的調整,來控制每一幀繪製操作的時機。目前大部分手機都是 60Hz 的刷新率,也就是 16.6ms 刷新一次,系統爲了配合屏幕的刷新頻率,將 VSYNC 的週期也設置爲 16.6 ms,每個 16.6 ms,VSYNC 信號喚醒 Choreographer 來做 App 的繪製操作 ,這就是引入 Choreographer 的主要作用。

一、Choreographer 簡介

Choreographer 通過 Choreographer + SurfaceFlinger + Vsync + TripleBuffer 這一套從上到下的機制,保證了 Android App 可以以一個穩定的幀率運行(目前大部分是 60fps),減少幀率波動帶來的不適感。

1、負責接收和處理 App 的各種更新消息和回調,等到 VSYNC 到來的時候統一處理。比如集中處理 Input(主要是 Input 事件的處理) 、Animation(動畫相關)、Traversal(包括 measure、layout、draw 等操作) ,判斷卡頓掉幀情況,記錄 CallBack 耗時等。

2、負責請求和接收 VSYNC 信號。接收 VSYNC 事件回調(通過 FrameDisplayEventReceiver.onVsync());請求 VSYNC(FrameDisplayEventReceiver.scheduleVsync())。

二、原理解析

1、初始化

Choreographer構造方法
private Choreographer(Looper looper, int vsyncSource) {
        mLooper = looper;
        //初始化FrameHandler,與looper綁定
        mHandler = new FrameHandler(looper);
        // 初始化 DisplayEventReceiver(開啓VSYNC後將通過FrameDisplayEventReceiver接收VSYNC脈衝信號)
        mDisplayEventReceiver = USE_VSYNC
                ? new FrameDisplayEventReceiver(looper, vsyncSource)
                : null;
        mLastFrameTimeNanos = Long.MIN_VALUE;
        // 計算一幀的時間,Android手機屏幕採用60Hz的刷新頻率(這裏是納秒 ≈16000000ns 還是16ms)
        mFrameIntervalNanos = (long)(1000000000 / getRefreshRate());
        // 初始化CallbackQueue(CallbackQueue中存放要執行的輸入、動畫、遍歷繪製等任務)
        mCallbackQueues = new CallbackQueue[CALLBACK_LAST + 1];
        for (int i = 0; i <= CALLBACK_LAST; i++) {
            mCallbackQueues[i] = new CallbackQueue();
        }
        // b/68769804: For low FPS experiments.
        setFPSDivisor(SystemProperties.getInt(ThreadedRenderer.DEBUG_FPS_DIVISOR, 1));
    }
獲取Choreographer實例
    public static Choreographer getInstance() {
        return sThreadInstance.get();
    }

    // Thread local storage for the choreographer.
    private static final ThreadLocal<Choreographer> sThreadInstance =
            new ThreadLocal<Choreographer>() {
        @Override
        protected Choreographer initialValue() {
            Looper looper = Looper.myLooper();
            if (looper == null) {
                throw new IllegalStateException("The current thread must have a looper!");
            }
            Choreographer choreographer = new Choreographer(looper, VSYNC_SOURCE_APP);
            if (looper == Looper.getMainLooper()) {
                mMainInstance = choreographer;
            }
            return choreographer;
        }
    };


可以看出構造的實例是與線程綁定的。

2、FrameHandler

    private final class FrameHandler extends Handler {
        public FrameHandler(Looper looper) {
            super(looper);
        }

        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                // 如果啓用VSYNC機制,當VSYNC信號到來時觸發
                // 執行doFrame(),開始渲染下一幀的操作
                case MSG_DO_FRAME:
                    doFrame(System.nanoTime(), 0, new DisplayEventReceiver.VsyncEventData());
                    break;
				// 請求VSYNC信號,例如當前需要繪製任務時
                case MSG_DO_SCHEDULE_VSYNC:
                    doScheduleVsync();
                    break;
				// 處理 Callback(需要延遲的任務,最終還是執行上述兩個事件)
                case MSG_DO_SCHEDULE_CALLBACK:
                    doScheduleCallback(msg.arg1);
                    break;
            }
        }
    }

Choreographer 的所有任務最終都會發送到該 Looper 所在的線程。

3、Choreographer 初始化鏈

在 Activity 啓動過程,執行完 onResume() 後,會調用 Activity.makeVisible(),然後再調用到 addView(), 層層調用會進入如下方法:

點擊查看代碼
ActivityThread.handleResumeActivity(IBinder, boolean, boolean, String) (android.app) 
-->WindowManagerImpl.addView(View, LayoutParams) (android.view) 
   -->WindowManagerGlobal.addView(View, LayoutParams, Display, Window) (android.view) 
      -->ViewRootImpl.ViewRootImpl(Context, Display) (android.view) 
          public ViewRootImpl(Context context, Display display) { 
              ...... 
              mChoreographer = Choreographer.getInstance(); 
              ...... 
             }

4、FrameDisplayEventReceiver

VSYNC 的註冊、申請、接收都是通過 FrameDisplayEventReceiver 這個類,FrameDisplayEventReceiver 是 DisplayEventReceiver 的子類, 有三個比較重要的方法:

  • onVsync():Vsync 信號回調,系統native方法會調用。
  • scheduleVsync():請求 Vsync 信號,當應用需要繪製時,通過 scheduledVsync() 方法申請 VSYNC 中斷,來自 EventThread 的 VSYNC 信號就可以傳遞到 Choreographer。
  • run():執行 doFrame。

DisplayEventReceiver 是一個 abstract class,在 DisplayEventReceiver 的構造方法會通過 JNI 創建一個 IDisplayEventConnection 的 VSYNC 的監聽者。

    private final class FrameDisplayEventReceiver extends DisplayEventReceiver
            implements Runnable {
        private boolean mHavePendingVsync;
        private long mTimestampNanos;
        private int mFrame;
        private VsyncEventData mLastVsyncEventData = new VsyncEventData();

        public FrameDisplayEventReceiver(Looper looper, int vsyncSource) {
            super(looper, vsyncSource, 0);
        }

        // TODO(b/116025192): physicalDisplayId is ignored because SF only emits VSYNC events for
        // the internal display and DisplayEventReceiver#scheduleVsync only allows requesting VSYNC
        // for the internal display implicitly.
        @Override
        public void onVsync(long timestampNanos, long physicalDisplayId, int frame,
                VsyncEventData vsyncEventData) {
            try {
                if (Trace.isTagEnabled(Trace.TRACE_TAG_VIEW)) {
                    Trace.traceBegin(Trace.TRACE_TAG_VIEW,
                            "Choreographer#onVsync " + vsyncEventData.id);
                }
                // Post the vsync event to the Handler.
                // The idea is to prevent incoming vsync events from completely starving
                // the message queue.  If there are no messages in the queue with timestamps
                // earlier than the frame time, then the vsync event will be processed immediately.
                // Otherwise, messages that predate the vsync event will be handled first.
                long now = System.nanoTime();
                if (timestampNanos > now) {
                    Log.w(TAG, "Frame time is " + ((timestampNanos - now) * 0.000001f)
                            + " ms in the future!  Check that graphics HAL is generating vsync "
                            + "timestamps using the correct timebase.");
                    timestampNanos = now;
                }

                if (mHavePendingVsync) {
                    Log.w(TAG, "Already have a pending vsync event.  There should only be "
                            + "one at a time.");
                } else {
                    mHavePendingVsync = true;
                }

                mTimestampNanos = timestampNanos;
                mFrame = frame;
                mLastVsyncEventData = vsyncEventData;
                Message msg = Message.obtain(mHandler, this);
                msg.setAsynchronous(true);
                mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
            } finally {
                Trace.traceEnd(Trace.TRACE_TAG_VIEW);
            }
        }

        @Override
        public void run() {
            mHavePendingVsync = false;
            doFrame(mTimestampNanos, mFrame, mLastVsyncEventData);
        }
    }

可以看出,其最終是執行了run,然後調用doFrame繪製一幀。

5、Choreographer 處理一幀的邏輯

    void doFrame(long frameTimeNanos, int frame,
            DisplayEventReceiver.VsyncEventData vsyncEventData) {
        final long startNanos;
        final long frameIntervalNanos = vsyncEventData.frameInterval;
        try {
            if (Trace.isTagEnabled(Trace.TRACE_TAG_VIEW)) {
                Trace.traceBegin(Trace.TRACE_TAG_VIEW,
                        "Choreographer#doFrame " + vsyncEventData.id);
            }
            synchronized (mLock) {
                if (!mFrameScheduled) {
                    traceMessage
                            ("Frame not scheduled");
                    return; // no work to do
                }

                if (DEBUG_JANK && mDebugPrintNextFrameTimeDelta) {
                    mDebugPrintNextFrameTimeDelta = false;
                    Log.d(TAG, "Frame time delta: "
                            + ((frameTimeNanos - mLastFrameTimeNanos) * 0.000001f) + " ms");
                }
                //預期執行時間
                long intendedFrameTimeNanos = frameTimeNanos;
                //當前時間
                startNanos = System.nanoTime();
                final long jitterNanos = startNanos - frameTimeNanos;
                //超過的時間是否大於一幀的時間
                if (jitterNanos >= frameIntervalNanos) {
                    final long skippedFrames = jitterNanos / frameIntervalNanos;
                    //掉幀超過30幀就打印log
                    if (skippedFrames >= SKIPPED_FRAME_WARNING_LIMIT) {
                        Log.i(TAG, "Skipped " + skippedFrames + " frames!  "
                                + "The application may be doing too much work on its main thread.");
                    }
                    final long lastFrameOffset = jitterNanos % frameIntervalNanos;
                    if (DEBUG_JANK) {
                        Log.d(TAG, "Missed vsync by " + (jitterNanos * 0.000001f) + " ms "
                                + "which is more than the frame interval of "
                                + (frameIntervalNanos * 0.000001f) + " ms!  "
                                + "Skipping " + skippedFrames + " frames and setting frame "
                                + "time to " + (lastFrameOffset * 0.000001f) + " ms in the past.");
                    }
                    frameTimeNanos = startNanos - lastFrameOffset;
                }
                // 未知原因,居然小於最後一幀的時間,重新申請VSYNC信號
                if (frameTimeNanos < mLastFrameTimeNanos) {
                    if (DEBUG_JANK) {
                        Log.d(TAG, "Frame time appears to be going backwards.  May be due to a "
                                + "previously skipped frame.  Waiting for next vsync.");
                    }
                    traceMessage("Frame time goes backward");
                    scheduleVsyncLocked();
                    return;
                }

                if (mFPSDivisor > 1) {
                    long timeSinceVsync = frameTimeNanos - mLastFrameTimeNanos;
                    if (timeSinceVsync < (frameIntervalNanos * mFPSDivisor) && timeSinceVsync > 0) {
                        traceMessage("Frame skipped due to FPSDivisor");
                        scheduleVsyncLocked();
                        return;
                    }
                }

                mFrameInfo.setVsync(intendedFrameTimeNanos, frameTimeNanos, vsyncEventData.id,
                        vsyncEventData.frameDeadline, startNanos, vsyncEventData.frameInterval);
                mFrameScheduled = false;
                mLastFrameTimeNanos = frameTimeNanos;
                
                mLastFrameIntervalNanos = frameIntervalNanos;
                mLastVsyncEventData = vsyncEventData;
            }

            AnimationUtils.lockAnimationClock(frameTimeNanos / TimeUtils.NANOS_PER_MS);

            mFrameInfo.markInputHandlingStart();
            //執行input任務
            doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos, frameIntervalNanos);

            mFrameInfo.markAnimationsStart();
            //執行animation任務
            doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos, frameIntervalNanos);
            //執行Insert animation任務
            doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos,
                    frameIntervalNanos);

            mFrameInfo.markPerformTraversalsStart();
            //執行traversal任務,即測量繪製任務
            doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos, frameIntervalNanos);
            //執行commit任務
            doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos, frameIntervalNanos);
        } finally {
            AnimationUtils.unlockAnimationClock();
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }

        if (DEBUG_FRAMES) {
            final long endNanos = System.nanoTime();
            Log.d(TAG, "Frame " + frame + ": Finished, took "
                    + (endNanos - startNanos) * 0.000001f + " ms, latency "
                    + (startNanos - frameTimeNanos) * 0.000001f + " ms.");
        }
    }

可以看到執行任務的順序爲:
doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos, frameIntervalNanos);
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos, frameIntervalNanos);
doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos, frameIntervalNanos);
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos, frameIntervalNanos);
doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos, frameIntervalNanos);

我們看一下doCalbacks方法:

    void doCallbacks(int callbackType, long frameTimeNanos, long frameIntervalNanos) {
        CallbackRecord callbacks;
        synchronized (mLock) {
            // We use "now" to determine when callbacks become due because it's possible
            // for earlier processing phases in a frame to post callbacks that should run
            // in a following phase, such as an input event that causes an animation to start.
            final long now = System.nanoTime();
            callbacks = mCallbackQueues[callbackType].extractDueCallbacksLocked(
                    now / TimeUtils.NANOS_PER_MS);
            if (callbacks == null) {
                return;
            }
            mCallbacksRunning = true;

            // Update the frame time if necessary when committing the frame.
            // We only update the frame time if we are more than 2 frames late reaching
            // the commit phase.  This ensures that the frame time which is observed by the
            // callbacks will always increase from one frame to the next and never repeat.
            // We never want the next frame's starting frame time to end up being less than
            // or equal to the previous frame's commit frame time.  Keep in mind that the
            // next frame has most likely already been scheduled by now so we play it
            // safe by ensuring the commit time is always at least one frame behind.
            if (callbackType == Choreographer.CALLBACK_COMMIT) {
                final long jitterNanos = now - frameTimeNanos;
                Trace.traceCounter(Trace.TRACE_TAG_VIEW, "jitterNanos", (int) jitterNanos);
                if (jitterNanos >= 2 * frameIntervalNanos) {
                    final long lastFrameOffset = jitterNanos % frameIntervalNanos
                            + frameIntervalNanos;
                    if (DEBUG_JANK) {
                        Log.d(TAG, "Commit callback delayed by " + (jitterNanos * 0.000001f)
                                + " ms which is more than twice the frame interval of "
                                + (frameIntervalNanos * 0.000001f) + " ms!  "
                                + "Setting frame time to " + (lastFrameOffset * 0.000001f)
                                + " ms in the past.");
                        mDebugPrintNextFrameTimeDelta = true;
                    }
                    frameTimeNanos = now - lastFrameOffset;
                    mLastFrameTimeNanos = frameTimeNanos;
                }
            }
        }
        try {
            Trace.traceBegin(Trace.TRACE_TAG_VIEW, CALLBACK_TRACE_TITLES[callbackType]);
            for (CallbackRecord c = callbacks; c != null; c = c.next) {
                if (DEBUG_FRAMES) {
                    Log.d(TAG, "RunCallback: type=" + callbackType
                            + ", action=" + c.action + ", token=" + c.token
                            + ", latencyMillis=" + (SystemClock.uptimeMillis() - c.dueTime));
                }
                c.run(frameTimeNanos);
            }
        } finally {
            synchronized (mLock) {
                mCallbacksRunning = false;
                do {
                    final CallbackRecord next = callbacks.next;
                    recycleCallbackLocked(callbacks);
                    callbacks = next;
                } while (callbacks != null);
            }
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }
    }

CallbackRecord是一個鏈表,從上面可以看出其遍歷了鏈表,並且執行了CallbackRecord裏面的run方法。

6、CallbackQueue與CallbackRecord

private final CallbackQueue[] mCallbackQueues;

    private final class CallbackQueue {
        private CallbackRecord mHead;
		.
		.
		.
   }

mCallbackQueues是一個CallbackQueue的數組,裏面保存着CallbackQueue對象,而CallbackQueue裏面持有CallbackRecord對象。
CallbackQueue保存了通過postCallback()添加的任務,目前定義的任務類型有:

  • CALLBACK_INPUT:優先級最高,和輸入事件處理有關。
  • CALLBACK_ANIMATION:優先級其次,和 Animation 的處理有關
  • CALLBACK_INSETS_ANIMATION:優先級其次,和 Insets Animation 的處理有關
  • CALLBACK_TRAVERSAL:優先級最低,和 UI 繪製任務有關
  • CALLBACK_COMMIT:最後執行,和提交任務有關(在 API Level 23 添加)
    並且這些任務都是按順序添加進去的,當收到VSYNC信號時候,會按照順序執行這些任務。

CallbackRecord的數據結構如下

    private static final class
    CallbackRecord {
        public CallbackRecord next;
        public long dueTime;
        public Object action; // Runnable or FrameCallback
        public Object token;

        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
        public void run(long frameTimeNanos) {
            if (token == FRAME_CALLBACK_TOKEN) {
                // 通過postFrameCallback()或postFrameCallbackDelayed()添加的任務會執行這裏
                ((FrameCallback)action).doFrame(frameTimeNanos);
            } else {
                ((Runnable)action).run();
            }
        }
    }

可以看出CallbackRecord其實是一個鏈表結構,它的run方法會調用傳進來的Runnable的run方法或者FrameCallback的doFrame方法。

7、添加任務

    private void postCallbackDelayedInternal(int callbackType,
            Object action, Object token, long delayMillis) {
        if (DEBUG_FRAMES) {
            Log.d(TAG, "PostCallback: type=" + callbackType
                    + ", action=" + action + ", token=" + token
                    + ", delayMillis=" + delayMillis);
        }

        synchronized (mLock) {
            final long now = SystemClock.uptimeMillis();
            final long dueTime = now + delayMillis;
            mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);

            if (dueTime <= now) {
                scheduleFrameLocked(now);
            } else {
                Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_CALLBACK, action);
                msg.arg1 = callbackType;
                msg.setAsynchronous(true);
                mHandler.sendMessageAtTime(msg, dueTime);
            }
        }
    }

裏面會調用mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);

        @UnsupportedAppUsage
        public void addCallbackLocked(long dueTime, Object action, Object token) {
            CallbackRecord callback = obtainCallbackLocked(dueTime, action, token);
            CallbackRecord entry = mHead;
            if (entry == null) {
                mHead = callback;
                return;
            }
            if (dueTime < entry.dueTime) {
                callback.next = entry;
                mHead = callback;
                return;
            }
            while (entry.next != null) {
                if (dueTime < entry.next.dueTime) {
                    callback.next = entry.next;
                    break;
                }
                entry = entry.next;
            }
            entry.next = callback;
        }

其實就是取出數組裏對應任務鏈表,按照時間添加到鏈表中的對應位置。

三、調用棧

1、Animation 回調調用棧

一般接觸的多的是調用 View.postOnAnimation() 的時候,會使用到 CALLBACK_ANIMATION,View 的 postOnAnimation() 方法源碼如下:

public void postOnAnimation(Runnable action) {
    final AttachInfo attachInfo = mAttachInfo;
    // 判斷AttachInfo是否爲空
    if (attachInfo != null) {
        // 如果不爲null,直接調用Choreographer.postCallback()方法
        attachInfo.mViewRootImpl.mChoreographer.postCallback(
                Choreographer.CALLBACK_ANIMATION, action, null);
    } else {
        // 否則加入當前View的等待隊列
        getRunQueue().post(action);
    }
}

另外 Choreographer 的 FrameCallback 也是用的 CALLBACK_ANIMATION,Choreographer 的 postFrameCallbackDelayed() 方法源碼如下:

public void postFrameCallbackDelayed(Choreographer.FrameCallback callback, long delayMillis) { if (callback == null) { throw new IllegalArgumentException("callback must not be null"); } postCallbackDelayedInternal(CALLBACK_ANIMATION, callback, FRAME_CALLBACK_TOKEN, delayMillis);

2、ViewRootImpl 調用棧

    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
    void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
            mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
            notifyRendererOfFramePending();
            pokeDrawLockIfNeeded();
        }
    }

    final class TraversalRunnable implements Runnable {
        @Override
        public void run() {
            doTraversal();
        }
    }

    void doTraversal() {
        if (mTraversalScheduled) {
            mTraversalScheduled = false;
            mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

            if (mProfile) {
                Debug.startMethodTracing("ViewAncestor");
            }

            performTraversals();

            if (mProfile) {
                Debug.stopMethodTracing();
                mProfile = false;
            }
        }
    }

四、總結

  1. Choreographer 是線程單例的,而且必須要和一個Looper綁定,因爲其內部有一個Handler需要和Looper綁定,一般是 App 主線程的Looper` 綁定。
  2. DisplayEventReceiver 是一個 abstract class,其 JNI 的代碼部分會創建一個IDisplayEventConnection 的 VSYNC 監聽者對象。這樣,來自 AppEventThread 的 VSYNC 中斷信號就可以傳遞給 Choreographer 對象了。當 VSYNC 信號到來時,DisplayEventReceiver 的 onVsync() 方法將被調用。
  3. DisplayEventReceiver 還有一個 scheduleVsync` 函數。當應用需要繪製UI時,將首先申請一次 Vsync 中斷,然後再在中斷處理的 onVsync 函數去進行繪製。
  4. Choreographer 定義了一個 FrameCallback 接口,每當 VSYNC 到來時,其 doFrame() 函數將被調用。這個接口對 Android Animation 的實現起了很大的幫助作用。
  5. Choreographer 的主要功能是,當收到 VSYNC 信號時,去調用使用者通過 postCallback() 設置的回調函數。目前一共定義了五種類型的回調,它們分別是:
  6. CALLBACK_INPUT:處理輸入事件處理有關
  7. CALLBACK_ANIMATION:處理 Animation 的處理有關
  8. CALLBACK_INSETS_ANIMATION:處理 Insets Animation 的相關回調
  9. CALLBACK_TRAVERSAL:處理和 UI 等控件繪製有關
  10. CALLBACK_COMMIT:處理 Commit 相關回調
  11. ListView 的 Item 初始化(obtain\setup) 會在 Input 裏面也會在 Animation 裏面,這取決於 CALLBACK_INPUT、CALLBACK_ANIMATION 會修改 View 的屬性,所以要先與 CALLBACK_TRAVERSAL 執行。
  12. 每次執行完callback後會自動移除。

參考:https://henleylee.github.io/posts/2020/f831dca5.html,https://androidperformance.com/2019/10/22/Android-Choreographer/#/Choreographer-自身的掉幀計算邏輯

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章