摘要:所以,從體驗上考慮,這個情況并不屬于問題。一般情況下,這個節(jié)點占據(jù)了除了通知欄的所有區(qū)域。通知給對象的消息,都會被這個內(nèi)部對象進行處理通過執(zhí)行處理消息在通知給對象顯示的時候,對象將給對象發(fā)送一條消息,并在的函數(shù)中執(zhí)行。
歡迎大家前往云+社區(qū),獲取更多騰訊海量技術實踐干貨哦~
作者:QQ音樂技術團隊題記
Toast 作為 Android 系統(tǒng)中最常用的類之一,由于其方便的api設計和簡潔的交互體驗,被我們所廣泛采用。但是,伴隨著我們開發(fā)的深入,Toast 的問題也逐漸暴露出來。
本系列文章將分成兩篇:
第一篇,我們將分析 Toast 所帶來的問題
第二篇,將提供解決 Toast 問題的解決方案
(注:本文源碼基于Android 7.0)
上一篇 [[Android] Toast問題深度剖析(一)] 筆者解釋了:
Toast 系統(tǒng)如何構(gòu)建窗口(通過系統(tǒng)服務NotificationManager來生成系統(tǒng)窗口)
Toast 異常出現(xiàn)的原因(系統(tǒng)調(diào)用 Toast的時序紊亂)
而本篇的重點,在于解決我們第一章所說的 Toast 問題。
2.解決思路基于第一篇的知識,我們知道,Toast 的窗口屬于系統(tǒng)窗口,它的生成和生命周期依賴于系統(tǒng)服務 NotificationManager。一旦 NotificationManager 所管理的窗口生命周期跟我們本地的進程不一致,就會發(fā)生異常。那么,我們能不能不使用系統(tǒng)的窗口,而使用自己的窗口,并且由我們自己控制生命周期呢?事實上, SnackBar 就是這樣的方案。不過,如果不使用系統(tǒng)類型的窗口,就意味著你的Toast 界面,無法在其他應用之上顯示。(比如,我們經(jīng)??吹降囊粋€場景就是你在你的應用出調(diào)用了多次 Toast.show函數(shù),然后退回到桌面,結(jié)果發(fā)現(xiàn)桌面也會彈出 Toast,就是因為系統(tǒng)的 Toast 使用了系統(tǒng)窗口,具有高的層級)不過在某些版本的手機上,你的應用可以申請權限,往系統(tǒng)中添加 TYPE_SYSTEM_ALERT 窗口,這也是一種系統(tǒng)窗口,經(jīng)常用來作為浮層顯示在所有應用程序之上。不過,這種方式需要申請權限,并不能做到讓所有版本的系統(tǒng)都能正常使用。
如果我們從體驗的角度來看,當用戶離開了該進程,就不應該彈出另外一個進程的 Toast 提示去干擾用戶的。Android 系統(tǒng)似乎也意識到了這一點,在新版本的系統(tǒng)更新中,限制了很多在桌面提示窗口相關的權限。所以,從體驗上考慮,這個情況并不屬于問題。
“那么我們可以選擇哪些窗口的類型呢?”
使用子窗口: 在 Android 進程內(nèi),我們可以直接使用類型為子窗口類型的窗口。在 Android 代碼中的直接應用是 PopupWindow 或者是 Dialog 。這當然可以,不過這種窗口依賴于它的宿主窗口,它可用的條件是你的宿主窗口可用
采用 View 系統(tǒng): 使用 View 系統(tǒng)去模擬一個 Toast 窗口行為,做起來不僅方便,而且能更加快速的實現(xiàn)動畫效果,我們的 SnackBar 就是采用這套方案。這也是我們今天重點講的方案
“如果采用 View 系統(tǒng)方案,那么我要往哪個控件中添加我的 Toast 控件呢?”
在Android進程中,我們所有的可視操作都依賴于一個 Activity 。 Activity 提供上下文(Context)和視圖窗口(Window) 對象。我們通過 Activity.setContentView 方法所傳遞的任何 View對象 都將被視圖窗口( Window) 中的 DecorView 所裝飾。而在 DecorView 的子節(jié)點中,有一個 id 為 android.R.id.content 的 FrameLayout 節(jié)點(后面簡稱 content 節(jié)點) 是用來容納我們所傳遞進去的 View 對象。一般情況下,這個節(jié)點占據(jù)了除了通知欄的所有區(qū)域。這就特別適合用來作為 Toast 的父控件節(jié)點。
“我什么時機往這個content節(jié)點中添加合適呢?這個 content 節(jié)點什么時候被初始化呢?”
根據(jù)不同的需求,你可能會關注以下兩個時機:
Content 節(jié)點生成
Content 內(nèi)容顯示
實際我們只需要將我們的 Toast 添加到 Content 節(jié)點中,只要滿足第一條即可。如果你是為了完成性能檢測,測量或者其他目的,那么你可能更關心第二條。 那么什么情況下 Content 節(jié)點生成呢?剛才我們說了,Content 節(jié)點包含在我們的 DecorView 控件中,而 DecorView 是由 Activity 的 Window對象所持有的控件。Window 在 Android 中的實現(xiàn)類是 PhoneWindow,(這部分代碼有興趣可以自行閱讀) 我們來看下源碼:
//code PhoneWindow.java @Override public void setContentView(int layoutResID) { if (mContentParent == null) { //mContentParent就是我們的 content 節(jié)點 installDecor();//生成一個DecorView } else { mContentParent.removeAllViews(); } mLayoutInflater.inflate(layoutResID, mContentParent); final Callback cb = getCallback(); if (cb != null && !isDestroyed()) { cb.onContentChanged(); } }
PhoneWindow 對象通過 installDecor 函數(shù)生成 DecorView 和 我們所需要的 content 節(jié)點(最終會存到 mContentParent) 變量中去。但是, setContentView 函數(shù)需要我們主動調(diào)用,如果我并沒有調(diào)用這個 setContentView 函數(shù),installDecor 方法將不被調(diào)用。那么,有沒有某個時刻,content 節(jié)點是必然生成的呢?當然有,除了在 setContentView 函數(shù)中調(diào)用installDecor外,還有一個函數(shù)也調(diào)用到了這個,那就是:
//code PhoneWindow.java @Override public final View getDecorView() { if (mDecor == null) { installDecor(); } return mDecor; }
而這個函數(shù),將在 Activity.findViewById 的時候調(diào)用:
//code Activity.java public View findViewById(@IdRes int id) { return getWindow().findViewById(id); } //code Window.java public View findViewById(@IdRes int id) { return getDecorView().findViewById(id); }
因此,只要我們只要調(diào)用了 findViewById 函數(shù),一樣可以保證 content 被正常初始化。這樣我們解釋了第一個”就緒”(Content 節(jié)點生成)。我們再來看下第二個”就緒”,也就是 Android 界面什么時候顯示呢?相信你可能迫不及待的回答不是 onResume 回調(diào)的時候么?實際上,在 onResume 的時候,根本還沒處理跟界面相關的事情。我們來看下 Android 進程是如何處理 resume 消息的:
(注: AcitivityThread 是 Android 進程的入口類, Android 進程處理 resume 相關消息將會調(diào)用到 AcitivityThread.handleResumeActivity 函數(shù))
//code AcitivityThread.java void handleResumeActivity(...) { ... ActivityClientRecord r = performResumeActivity(token, clearHide); // 之后會調(diào)用call onResume ... View decor = r.window.getDecorView(); //調(diào)用getDecorView 生成 content節(jié)點 decor.setVisibility(View.INVISIBLE); .... if (r.activity.mVisibleFromClient) { r.activity.makeVisible();//add to WM 管理 } ... } //code Activity.java void makeVisible() { if (!mWindowAdded) { ViewManager wm = getWindowManager(); wm.addView(mDecor, getWindow().getAttributes()); mWindowAdded = true; } mDecor.setVisibility(View.VISIBLE); }
Android 進程在處理 resume 消息的時候,將走以下的流程:
調(diào)用 performResumeActivity 回調(diào) Activity 的 onResume 函數(shù)
調(diào)用 Window 的 getDecorView 生成 DecorView 對象和 content 節(jié)點
將DecorView納入 WindowManager (進程內(nèi)服務)的管理
調(diào)用 Activity.makeVisible 顯示當前 Activity
按照上述的流程,在 Activity.onResume 回調(diào)之后,才將控件納入本地服務 WindowManager 的管理中。也就是說, Activity.onResume 根本沒有顯示任何東西。我們不妨寫個代碼驗證一下:
//code DemoActivity.java public DemoActivity extends Activity { private View view ; @Override protected void onCreate( Bundle savedInstanceState) { super.onCreate(savedInstanceState); view = new View(this); this.setContentView(view); } @Override protected void onResume() { super.onResume(); Log.d("cdw","onResume :" +view.getHeight());// 有高度是顯示的必要條件 } }
這里,我們通過在 onResume 中獲取高度的方式驗證界面是否被繪制,最終我們將輸出日志:
D cdw : onResume :0
那么,界面又是在什么時候完成的繪制呢?是不是在 WindowManager.addView 之后呢?我們在 onResume之后會調(diào)用Activity.makeVisible,里面會調(diào)用 WindowManager.addView。因此我們在onResume 里post一個消息就可以檢測WindowManager.addView 之后的情況:
@Override protected void onResume() { super.onResume(); this.runOnUiThread(new Runnable() { @Override public void run() { Log.d("cdw","onResume :" +view.getHeight()); } }); } //控制臺輸出: 01-02 21:30:27.445 2562 2562 D cdw : onResume :0
從結(jié)果上看,我們在 WindowManager.addView 之后,也并沒有繪制界面。那么,Android的繪制是什么時候開始的?又是到什么時候結(jié)束?
在 Android 系統(tǒng)中,每一次的繪制都是通過一個 16ms 左右的 VSYNC 信號控制的,這種信號可能來自于硬件也可能來自于軟件模擬。每一次非動畫的繪制,都包含:測量,布局,繪制三個函數(shù)。而一般觸發(fā)這一事件的的動作有:
View 的某些屬性的變更
View 重新布局Layout
增刪 View 節(jié)點
當調(diào)用 WindowManager.addView 將空間添加到 WM 服務管理的時候,會調(diào)用一次Layout請求,這就觸發(fā)了一次 VSYNC 繪制。因此,我們只需要在 onResume 里 post 一個幀回調(diào)就可以檢測繪制開始的時間:
@Override protected void onResume() { super.onResume(); Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { @Override public void doFrame(long frameTimeNanos) { //TODO 繪制開始 } }); }
我們先來看下 View.requestLayout 是怎么觸發(fā)界面重新繪制的:
//code View.java public void requestLayout() { .... if (mParent != null) { ... if (!mParent.isLayoutRequested()) { mParent.requestLayout(); } } }
View 對象調(diào)用 requestLayout 的時候會委托給自己的父節(jié)點處理,這里之所以不稱為父控件而是父節(jié)點,是因為除了控件外,還有 ViewRootImpl 這個非控件類型作為父節(jié)點,而這個父節(jié)點會作為整個控件樹的根節(jié)點。按照我們上面說的委托的機制,requestLayout 最終將會調(diào)用到 ViewRootImpl.requestLayout。
//code ViewRootImpl.java @Override public void requestLayout() { if (!mHandlingLayoutInLayoutRequest) { checkThread(); mLayoutRequested = true; scheduleTraversals();//申請繪制請求 } } void scheduleTraversals() { if (!mTraversalScheduled) { mTraversalScheduled = true; .... mChoreographer.postCallback( Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);//申請繪制 .... } }
ViewRootImpl 最終會將 mTraversalRunnable 處理命令放到 CALLBACK_TRAVERSAL 繪制隊列中去:
final class TraversalRunnable implements Runnable { @Override public void run() { doTraversal();//執(zhí)行布局和繪制 } } void doTraversal() { if (mTraversalScheduled) { mTraversalScheduled = false; ... performTraversals(); ... } }
mTraversalRunnable 命令最終會調(diào)用到 performTraversals() 函數(shù):
private void performTraversals() { final View host = mView; ... host.dispatchAttachedToWindow(mAttachInfo, 0);//attachWindow ... getRunQueue().executeActions(attachInfo.mHandler);//執(zhí)行某個指令 ... childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width); childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height); host.measure(childWidthMeasureSpec, childHeightMeasureSpec);//測量 .... host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());//布局 ... draw(fullRedrawNeeded);//繪制 ... }
performTraversals 函數(shù)實現(xiàn)了以下流程:
調(diào)用 dispatchAttachedToWindow 通知子控件樹當前控件被 attach 到窗口中
執(zhí)行一個命令隊列 getRunQueue
執(zhí)行 meausre 測量指令
執(zhí)行 layout 布局函數(shù)
執(zhí)行繪制 draw
這里我們看到一句方法調(diào)用:
getRunQueue().executeActions(attachInfo.mHandler);
這個函數(shù)將執(zhí)行一個延時的命令隊列,在 View 對象被 attach 到 View樹之前,通過調(diào)用 View.post 函數(shù),可以將執(zhí)行消息命令加入到延時執(zhí)行隊列中去:
//code View.java public boolean post(Runnable action) { Handler handler; AttachInfo attachInfo = mAttachInfo; if (attachInfo != null) { handler = attachInfo.mHandler; } else { // Assume that post will succeed later ViewRootImpl.getRunQueue().post(action); return true; } return handler.post(action); }
getRunQueue().executeActions 函數(shù)執(zhí)行的時候,會將該命令消息延后一個UI線程消息執(zhí)行,這就保證了執(zhí)行的這個命令消息發(fā)生在我們的繪制之后:
//code RunQueue.java void executeActions(Handler handler) { synchronized (mActions) { ... for (int i = 0; i < count; i++) { final HandlerAction handlerAction = actions.get(i); handler.postDelayed(handlerAction.action, handlerAction.delay);//推遲一個消息 } } }
所以,我們只需要在視圖被 attach 之前通過一個 View 來拋出一個命令消息,就可以檢測視圖繪制結(jié)束的時間點:
//code DemoActivity.java @Override protected void onResume() { super.onResume(); Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { @Override public void doFrame(long frameTimeNanos) { start = SystemClock.uptimeMillis(); log("繪制開始:height = "+view.getHeight()); } }); } @Override protected void onCreate( Bundle savedInstanceState) { super.onCreate(savedInstanceState); view = new View(this); view.post(new Runnable() { @Override public void run() { log("繪制耗時:"+(SystemClock.uptimeMillis()-start)+"ms"); log("繪制結(jié)束后:height = "+view.getHeight()); } }); this.setContentView(view); } //控制臺輸出: 01-03 23:39:27.251 27069 27069 D cdw : --->繪制開始:height = 0 01-03 23:39:27.295 27069 27069 D cdw : --->繪制耗時:44ms 01-03 23:39:27.295 27069 27069 D cdw : --->繪制結(jié)束后:height = 1232
我們帶著我們上面的知識儲備,來看下SnackBar是如何做的呢:
3.SnackbarSnackBar 系統(tǒng)主要依賴于兩個類:
SnackBar 作為門面,與業(yè)務程序交互
SnackBarManager 作為時序管理器, SnackBar 與 SnackBarManager 的交互,通過 Callback 回調(diào)對象進行
SnackBarManager 的時序管理跟 NotifycationManager 的很類似不再贅述
SnackBar 通過靜態(tài)方法 make 靜態(tài)構(gòu)造一個 SnackBar:
public static Snackbar make(@NonNull View view, @NonNull CharSequence text, @Duration int duration) { Snackbar snackbar = new Snackbar(findSuitableParent(view)); snackbar.setText(text); snackbar.setDuration(duration); return snackbar; }
這里有一個關鍵函數(shù) findSuitableParent ,這個函數(shù)的目的就相當于我們上面的 findViewById(R.id.content) 一樣,給 SnackBar 所定義的 Toast 控件找一個合適的容器:
private static ViewGroup findSuitableParent(View view) { ViewGroup fallback = null; do { if (view instanceof CoordinatorLayout) { return (ViewGroup) view; } else if (view instanceof FrameLayout) { if (view.getId() == android.R.id.content) {//把 `Content` 節(jié)點作為容器 ... return (ViewGroup) view; } else { // It"s not the content view but we"ll use it as our fallback fallback = (ViewGroup) view; } } ... } while (view != null); // If we reach here then we didn"t find a CoL or a suitable content view so we"ll fallback return fallback; }
我們發(fā)現(xiàn),除了包含 CoordinatorLayout 控件的情況, 默認情況下, SnackBar 也是找的 Content 節(jié)點。找到的這個父節(jié)點,作為 Snackbar 構(gòu)造器的形參:
private Snackbar(ViewGroup parent) { mTargetParent = parent; mContext = parent.getContext(); ... LayoutInflater inflater = LayoutInflater.from(mContext); mView = (SnackbarLayout) inflater.inflate( R.layout.design_layout_snackbar, mTargetParent, false); ... }
Snackbar 將生成一個 SnackbarLayout 控件作為 Toast 控件。最后當時序控制器 SnackBarManager 回調(diào)返回的時候,通知 SnackBar 顯示,即將 SnackBar.mView 增加到 mTargetParent 控件中去。
這里有人或許會有疑問,這里使用強引用,會不會造成一段時間內(nèi)的內(nèi)存泄漏呢?
假如你現(xiàn)在彈了 10 個 Toast ,每個 Toast 的顯示時間是 2s 。也就是說你的最后一個 SnackBar 將被 SnackBarManager 持有至少 20s。而 SnackBar 中又存在有父控件 mTargetParent 的強引用。相當于在這20s內(nèi), 你的mTargetParent 和它所持有的 Context (一般是 Activity)無法釋放
這個其實是不會的,原因在于 SnackBarManager 在管理這種回調(diào) callback 的時候,采用了弱引用。
private static class SnackbarRecord { final WeakReferencecallback; .... }
但是,我們從 SnackBar 的設計可以看出,SnackBar無法定制具體的樣式: SnackBar 只能生成 SnackBarLayout 這種控件和布局,可能并不滿足你的業(yè)務需求。當然你也可以變更 SnackBarLayout 也能達到目的。不過,有了上面的知識儲備,我們完全可以寫一個自己的 Snackbar。
4.基于Toast的改法從第一篇文章我們知道,我們直接在 Toast.show 函數(shù)外增加 try-catch 是沒有意義的。因為 Toast.show 實際上只是發(fā)了一條命令給 NotificationManager 服務。真正的顯示需要等 NotificationManager 通知我們的 TN 對象 show 的時候才能觸發(fā)。NotificationManager 通知給 TN 對象的消息,都會被 TN.mHandler 這個內(nèi)部對象進行處理
//code Toast.java private static class TN { final Runnable mHide = new Runnable() {// 通過 mHandler.post(mHide) 執(zhí)行 @Override public void run() { handleHide(); mNextView = null; } }; final Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { IBinder token = (IBinder) msg.obj; handleShow(token);// 處理 show 消息 } }; }
在NotificationManager 通知給 TN 對象顯示的時候,TN 對象將給 mHandler 對象發(fā)送一條消息,并在 mHandler 的 handleMessage 函數(shù)中執(zhí)行。 當NotificationManager 通知 TN 對象隱藏的時候,將通過 mHandler.post(mHide) 方法,發(fā)送隱藏指令。不論采用哪種方式發(fā)送的指令,都將執(zhí)行 Handler 的 dispatchMessage(Message msg) 函數(shù):
//code Handler.java public void dispatchMessage(Message msg) { if (msg.callback != null) { handleCallback(msg);// 執(zhí)行 post(Runnable)形式的消息 } else { ... handleMessage(msg);// 執(zhí)行 sendMessage形式的消息 } } 因此,我們只需要在 dispatchMessage 方法體內(nèi)加入 try-catch 就可以避免 Toast 崩潰對應用程序的影響: public void dispatchMessage(Message msg) { try { super.dispatchMessage(msg); } catch(Exception e) {} }
因此,我們可以定義一個安全的 Handler 裝飾器:
private static class SafelyHandlerWarpper extends Handler { private Handler impl; public SafelyHandlerWarpper(Handler impl) { this.impl = impl; } @Override public void dispatchMessage(Message msg) { try { super.dispatchMessage(msg); } catch (Exception e) {} } @Override public void handleMessage(Message msg) { impl.handleMessage(msg);//需要委托給原Handler執(zhí)行 } }
由于 TN.mHandler 對象復寫了 handleMessage 方法,因此,在 Handler 裝飾器里,需要將 handleMessage 方法委托給 TN.mHandler 執(zhí)行。定義完裝飾器之后,我們就可以通過反射往我們的 Toast 對象中注入了:
public class ToastUtils { private static Field sField_TN ; private static Field sField_TN_Handler ; static { try { sField_TN = Toast.class.getDeclaredField("mTN"); sField_TN.setAccessible(true); sField_TN_Handler = sField_TN.getType().getDeclaredField("mHandler"); sField_TN_Handler.setAccessible(true); } catch (Exception e) {} } private static void hook(Toast toast) { try { Object tn = sField_TN.get(toast); Handler preHandler = (Handler)sField_TN_Handler.get(tn); sField_TN_Handler.set(tn,new SafelyHandlerWarpper(preHandler)); } catch (Exception e) {} } public static void showToast(Context context,CharSequence cs, int length) { Toast toast = Toast.makeText(context,cs,length); hook(toast); toast.show(); } }
我們再用第一章中的代碼測試一下:
public void showToast(View view) { ToastUtils.showToast(this,"hello", Toast.LENGTH_LONG); try { Thread.sleep(10000); } catch (InterruptedException e) {} }
等 10s 之后,進程正常運行,不會因為 Toast 的問題而崩潰。
相關閱讀[Android] Toast問題深度剖析(一)
Android基礎:Fragment,看這篇就夠了
Android圖像處理 - 高斯模糊的原理及實現(xiàn)
此文已由作者授權云加社區(qū)發(fā)布,轉(zhuǎn)載請注明文章出處
文章版權歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://www.ezyhdfw.cn/yun/70997.html
摘要:模仿的功能掘金本模仿了的功能。國內(nèi)曾經(jīng)出現(xiàn)的團購類網(wǎng)站有多家,到四年多以后的現(xiàn)在,美團已經(jīng)是成為國內(nèi)最大的本地生活服務平臺,不管怎餓了么移動的架構(gòu)演進掘金引言時代演進,技術也隨之發(fā)展。 模仿 Smartisan OS 的 BigBang 功能 ??? - Android - 掘金 本 Demo 模仿了 Smartisan OS 的 BigBang 功能。App 打開會從剪切板讀取文字并...
摘要:中和的交互方式在進行交互之前需要我們對進行設置開啟對的支持。定義和相關的交互類和方法,對于方法通過注解進行標注。向添加該,同時為其指定一個名稱,該名稱將會在文件中使用。傳遞的數(shù)據(jù)中有一個端口號,通過這個端口號作為標示,來調(diào)用相應的方法。 隨著H5性能的提升,在我們移動應用開發(fā)的過程中,我們會越來越多的在我們的App頁面內(nèi)嵌入H5頁面,使得App變的更加動態(tài)靈活。而H5頁面往往并不是獨立...
摘要:在代碼中的直接應用是或者是。就像一個控制器,統(tǒng)籌視圖的添加與顯示,以及通過其他回調(diào)方法,來與以及進行交互。創(chuàng)建需要通過創(chuàng)建,通過將加載其中,并將交給,進行視圖繪制以及其他交互。創(chuàng)建機制分析實例的創(chuàng)建中執(zhí)行,從而生成了的實例。 目錄介紹 01.Window,View,子Window 02.什么是Activity 03.什么是Window 04.什么是DecorView 05.什么是Vi...
摘要:不努力不奮斗,可能就會在基層一輩子止步不前。不過,只一句,如果你還在做這一行,還是一名程序猿媛,想走上坡路的你,也許我這到手的十幾家一線互聯(lián)網(wǎng)公司性能優(yōu)化項目實戰(zhàn)可能會對你有所幫助。 ...
閱讀 2923·2021-11-22 11:56
閱讀 3634·2021-11-15 11:39
閱讀 956·2021-09-24 09:48
閱讀 824·2021-08-17 10:14
閱讀 1405·2019-08-30 15:55
閱讀 2806·2019-08-30 15:55
閱讀 1395·2019-08-30 15:44
閱讀 2843·2019-08-30 10:59