摘要:什么樣的對象容易找到靜態(tài)變量和單例。在一個進程之內(nèi),靜態(tài)變量和單例變量是相對不容易發(fā)生變化的,因此非常容易定位,而普通的對象則要么無法標志,要么容易改變。
前言
為了實現(xiàn) App 的快速迭代更新,基于 H5 Hybrid 的解決方案有很多,由于 webview 本身的性能問題,也隨之出現(xiàn)了很多基于 JS 引擎實現(xiàn)的原生渲染的方案,例如 React Native、weex 等,而國內(nèi)一線大廠基本上主要還是 Android 插件化解決大部分的更新問題,對于部分是采用 webview 或者 React Native 這種方案,而對于 Android 插件化采用的技術(shù)對于 Android Framewrok 的理解要求很高,真正實現(xiàn)落地的方案都還是有難度,對于非 Android Native 開發(fā)的人員更是有技術(shù)門檻。插件化可以很好的解決 Android 運行的一些問題,本文站在學(xué)習(xí)者的角度去嘗試理解插件化到底解決了什么問題。
插件化框架如下是主流的插件化框架之間的對比:
特性 | DynamicLoadApk | DynamicAPK | Small | DroidPlugin | VirtualAPK |
---|---|---|---|---|---|
支持四大組件 | 只支持 Activity | 只支持 Activity | 只支持 Activity | 全支持 | 全支持 |
無需在宿主 manifest 中預(yù)注冊 | √ | × | √ | √ | √ |
插件可以依賴宿主 | √ | √ | √ | × | √ |
支持 PendingIntent | × | × | × | √ | √ |
Android 特性支持 | 大部分 | 大部分 | 大部分 | 幾乎全部 | 幾乎全部 |
兼容性適配 | 一般 | 一般 | 中等 | 高 | 高 |
插件構(gòu)建 | 無 | 部署 aapt | Gradle 插件 | 無 | Gradle 插件 |
代理模式是為一個對象提供一個代用品或占位符,以便控制對它的訪問。使用代理可以屏蔽內(nèi)部實現(xiàn)細節(jié),后續(xù)內(nèi)部有變動對于外部調(diào)用者來說是封閉的,符合開放-封閉原則。用戶可以放心地請求代理,他只關(guān)心是否能得到想要的結(jié)果。在任何使用本體的地方都可以替換成使用代理,從而實現(xiàn)實現(xiàn)和調(diào)用松耦合。
不用代理模式:
使用代理模式:
靜態(tài)代理例如我們有兩個接口:
// Subject1.java public interface Subject1 { void method1(); void method2(); } // Subject2.java public interface Subject2 { void method1(); void method2(); void method3(); }
我們分別實現(xiàn)這兩個接口:
// RealSubject1.java public class RealSubject1 implements Subject1 { @Override public void method1() { Logger.i(RealSubject1.class, "我是RealSubject1的方法1"); } @Override public void method2() { Logger.i(RealSubject1.class, "我是RealSubject1的方法2"); } } // RealSubject2.java public class RealSubject2 implements Subject2 { @Override public void method1() { Logger.i(RealSubject2.class, "我是RealSubject2的方法1"); } @Override public void method2() { Logger.i(RealSubject2.class, "我是RealSubject2的方法2"); } @Override public void method3() { Logger.i(RealSubject2.class, "我是RealSubject2的方法3"); } }
如果不使用代理模式,我們一般會直接實例化 RealSubject1 和 RealSubject2 類對象。使用代理,我們一般都需要建立一個代理類。在 Java 等語言中,代理和本體都需要顯式地實現(xiàn)同一個接口,一方面接口保證了它們會擁 有同樣的方法,另一方面,面向接口編程迎合依賴倒置原則,通過接口進行向上轉(zhuǎn)型,從而避開 編譯器的類型檢查,代理和本體將來可以被替換使用。
/** * 靜態(tài)代理類(為了保持行為的一致性,代理類和委托類通常會實現(xiàn)相同的接口) * ProxySubject1.java */ public class ProxySubject1 implements Subject1 { private Subject1 subject1; public ProxySubject1(Subject1 subject1) { this.subject1 = subject1; } @Override public void method1() { Logger.i(ProxySubject1.class, "我是代理,我會在執(zhí)行實體方法1之前先做一些預(yù)處理的工作"); subject1.method1(); } @Override public void method2() { Logger.i(ProxySubject1.class, "我是代理,我會在執(zhí)行實體方法2之前先做一些預(yù)處理的工作"); subject1.method2(); } }
使用代理后我們對 RealSubject1 的操作換成對 ProxySubject1 對象的操作,如下:
ProxySubject1 proxySubject1 = new ProxySubject1(new RealSubject1()); proxySubject1.method1(); proxySubject1.method2(); 結(jié)果: [ProxySubject1] : 我是代理,我會在執(zhí)行實體方法1之前先做一些預(yù)處理的工作 [RealSubject1] : 我是RealSubject1的方法1 [ProxySubject1] : 我是代理,我會在執(zhí)行實體方法2之前先做一些預(yù)處理的工作 [RealSubject1] : 我是RealSubject1的方法2 [ProxySubject2] : 我是代理,我會在執(zhí)行實體方法1之前先做一些預(yù)處理的工作 [RealSubject2] : 我是RealSubject2的方法1 [ProxySubject2] : 我是代理,我會在執(zhí)行實體方法2之前先做一些預(yù)處理的工作 [RealSubject2] : 我是RealSubject2的方法2
顯然當我們想代理 RealSubject2 按照這種方式我們?nèi)匀恍枰⒁粋€類去處理,這也是靜態(tài)代理的局限性。如果寫一個代理類就能對上面兩個都能代理就好了,動態(tài)代理就解決了這個問題。
動態(tài)代理在 java 的動態(tài)代理機制中,有兩個重要的類或接口,一個是 InvocationHandler(Interface)、另一個則是 Proxy(Class),這一個類和接口是實現(xiàn)我們動態(tài)代理所必須用到的。
動態(tài)代理的步驟:
寫一個 InvocationHandler 的實現(xiàn)類,并實現(xiàn) invoke 方法,return method.invoke(...);。
/** * @param proxy 指代我們所代理的那個真實對象 * @param method 指代的是我們所要調(diào)用真實對象的某個方法的Method對象 * @param args 指代的是調(diào)用真實對象某個方法時接受的參數(shù) */ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
每一個動態(tài)代理類都必須要實現(xiàn) InvocationHandler 這個接口,并且每個代理類的實例都關(guān)聯(lián)到了一個 handler,當我們通過代理對象調(diào)用一個方法的時候,這個方法的調(diào)用就會被轉(zhuǎn)發(fā)為由 InvocationHandler 這個接口的 invoke 方法來進行調(diào)用。
使用 Proxy 類的 newProxyInstance 方法生成一個代理對象。例如: 生成 Subject1 的代理對象,注意第三個參數(shù)中要將一個實體對象傳入。
/** * @param loader 一個ClassLoader對象,定義了由哪個ClassLoader對象來對生成的代理對象進行加載 * @param interfaces 一個Interface對象的數(shù)組,表示的是我將要給我需要代理的對象提供一組什么接口,如果我提供了一組接口給它,那么這個代理對象就宣稱實現(xiàn)了該接口(多態(tài)),這樣我就能調(diào)用這組接口中的方法了 * @param h 一個InvocationHandler對象,表示的是當我這個動態(tài)代理對象在調(diào)用方法的時候,會關(guān)聯(lián)到哪一個InvocationHandler對象上 */ public static Object newProxyInstance(ClassLoader loader, Class>[] interfaces, InvocationHandler h) throws IllegalArgumentException
例如:
Proxy.newProxyInstance( Subject1.class.getClassLoader(), new Class[] {Subject1.class}, new DynamicProxyHandler(new RealSubject1()) );
Proxy 這個類的作用就是用來動態(tài)創(chuàng)建一個代理對象的類,它提供了許多的方法,但是我們用的最多的就是 newProxyInstance 這個方法。
使用動態(tài)代理完成上述靜態(tài)代理中的功能:
public class DynamicProxyHandler implements InvocationHandler { private Object object; public DynamicProxyHandler(Object object) { this.object = object; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Logger.i(DynamicProxyHandler.class, "我正在動態(tài)代理[" + object.getClass().getSimpleName() + "]的[" + method.getName() + "]方法"); return method.invoke(object, args); } /** * 調(diào)用Proxy.newProxyInstance即可生成一個代理對象 * * @param object * @return */ public static Object newProxyInstance(Object object) { // 傳入被代理對象的classloader實現(xiàn)的接口, 還有DynamicProxyHandler的對象即可。 return Proxy.newProxyInstance(object.getClass().getClassLoader(), object.getClass().getInterfaces(), new DynamicProxyHandler(object)); } }
動態(tài)代理調(diào)用如下:
Subject1 dynamicProxyHandler1 = (Subject1) DynamicProxyHandler.newProxyInstance(new RealSubject1()); dynamicProxyHandler1.method1(); dynamicProxyHandler1.method2();初識 Hook 機制
上述我們對一個方法的調(diào)用采用了動態(tài)代理的辦法,如果我們自己創(chuàng)建代理對象,然后把原始對象替換為我們的代理對象,那么就可以在這個代理對象為所欲為了,修改參數(shù),替換返回值,我們稱之為 Hook。下面我們 Hook 掉 startActivity 這個方法,使得每次調(diào)用這個方法之前輸出一條日志;當然,這個輸入日志有點點弱,只是為了展示原理;只要你想,你想可以替換參數(shù),攔截這個 startActivity 過程,使得調(diào)用它導(dǎo)致啟動某個別的 Activity,指鹿為馬!
首先我們得找到被 Hook 的對象,我稱之為 Hook 點;什么樣的對象比較好 Hook 呢?自然是容易找到的對象。什么樣的對象容易找到?靜態(tài)變量和單例。在一個進程之內(nèi),靜態(tài)變量和單例變量是相對不容易發(fā)生變化的,因此非常容易定位,而普通的對象則要么無法標志,要么容易改變。我們根據(jù)這個原則找到所謂的 Hook 點。
對于 startActivity 過程有兩種方式:Context.startActivity 和 Activity.startActivity。這里暫不分析其中的區(qū)別,以 Activity.startActivity 為例說明整個過程的調(diào)用棧。
Activity 中的 startActivity 最終都是由 startActivityForResult 來實現(xiàn)的。
Activity#startActivityForResult:
public void startActivityForResult(@RequiresPermission Intent intent, int requestCode, @Nullable Bundle options) { // 一般的 Activity 其 mParent 為 null,mParent 常用在 ActivityGroup 中,ActivityGroup 已廢棄 if (mParent == null) { options = transferSpringboardActivityOptions(options); // 這里會啟動新的Activity,核心功能都在 mMainThread.getApplicationThread() 中完成 Instrumentation.ActivityResult ar = mInstrumentation.execStartActivity( this, mMainThread.getApplicationThread(), mToken, this, intent, requestCode, options); if (ar != null) { mMainThread.sendActivityResult( mToken, mEmbeddedID, requestCode, ar.getResultCode(), ar.getResultData()); } if (requestCode >= 0) { mStartedActivity = true; } cancelInputsAndStartExitTransition(options); } else { if (options != null) { mParent.startActivityFromChild(this, intent, requestCode, options); } else { // Note we want to go through this method for compatibility with // existing applications that may have overridden it. mParent.startActivityFromChild(this, intent, requestCode); } } }
可以發(fā)現(xiàn),真正打開 activity 的實現(xiàn)在 Instrumentation 的 execStartActivity 方法中。
Instrumentation#execStartActivity:
public ActivityResult execStartActivity( Context who, IBinder contextThread, IBinder token, Activity target, Intent intent, int requestCode, Bundle options) { // 核心功能在這個whoThread中完成,其內(nèi)部scheduleLaunchActivity方法用于完成activity的打開 IApplicationThread whoThread = (IApplicationThread) contextThread; Uri referrer = target != null ? target.onProvideReferrer() : null; if (referrer != null) { intent.putExtra(Intent.EXTRA_REFERRER, referrer); } if (mActivityMonitors != null) { synchronized (mSync) { final int N = mActivityMonitors.size(); for (int i=0; i= 0 ? am.getResult() : null; } break; } } } } try { intent.migrateExtraStreamToClipData(); intent.prepareToLeaveProcess(who); // 這里才是真正打開 Activity 的地方,核心功能在 whoThread 中完成。 int result = ActivityManager.getService() .startActivity(whoThread, who.getBasePackageName(), intent, intent.resolveTypeIfNeeded(who.getContentResolver()), token, target != null ? target.mEmbeddedID : null, requestCode, 0, null, options); // 這個方法是專門拋異常的,它會對結(jié)果進行檢查,如果無法打開activity, // 則拋出諸如ActivityNotFoundException類似的各種異常 checkStartActivityResult(result, intent); } catch (RemoteException e) { throw new RuntimeException("Failure from system", e); } return null; }
如果我們想深入了解 Activity 啟動過程我們需要接著 Android 源碼看下去,但是對于本文中我們初步了解 Hook 機制足以。
我們的目的是替換掉系統(tǒng)默認邏輯,對于 Activity#startActivityForResult 的方法里面核心邏輯就是 mInstrumentation 屬性的 execStartActivity 方法,而這里的 mInstrumentation 屬性在 Activity 類中恰好是一個單例,在 Activity 類的 attach 方法里面被賦值,我們可以在 attach 之后使用反射機制對 mInstrumentation 屬性進行重新賦值。attach() 方法調(diào)用完成后,就自然而然的調(diào)用了 Activity 的 onCreate() 方法了。
我們需要修改 mInstrumentation 這個字段為我們的代理對象,我們使用靜態(tài)代理實現(xiàn)這個代理對象。這里我們使用 EvilInstrumentation 作為代理對象。
public class EvilInstrumentation extends Instrumentation { private Instrumentation instrumentation; public EvilInstrumentation(Instrumentation instrumentation) { this.instrumentation = instrumentation; } public ActivityResult execStartActivity( Context who, IBinder contextThread, IBinder token, Activity target, Intent intent, int requestCode, Bundle options) { StringBuilder sb = new StringBuilder(); sb.append("who = [").append(who).append("], ") .append("contextThread = [").append(contextThread).append("], ") .append("token = [").append(token).append("], ") .append("target = [").append(target).append("], ") .append("intent = [").append(intent).append("], ") .append("requestCode = [").append(requestCode).append("], ") .append("options = [").append(options).append("]");; Logger.i(EvilInstrumentation.class, "執(zhí)行了startActivity, 參數(shù)如下: " + sb.toString()); try { Method execStartActivity = Instrumentation.class.getDeclaredMethod( "execStartActivity", Context.class, IBinder.class, IBinder.class, Activity.class, Intent.class, int.class, Bundle.class); return (ActivityResult) execStartActivity.invoke(instrumentation, who, contextThread, token, target, intent, requestCode, options); } catch (Exception e) { e.printStackTrace(); } return null; } }
采用反射直接修改 Activity 中的 mInstrumentation 屬性,從而實現(xiàn)偷梁換柱——用代理對象替換原始對象。
// 拿到原始的 mInstrumentation字段 Field mInstrumentationField = Activity.class.getDeclaredField("mInstrumentation"); mInstrumentationField.setAccessible(true); // 創(chuàng)建代理對象 Instrumentation originalInstrumentation = (Instrumentation) mInstrumentationField.get(activity); mInstrumentationField.set(activity, new EvilInstrumentation(originalInstrumentation));
這段 Hook 的邏輯放在 Activity 的 onCreate 里面即可生效。
對于 Context 類的 startActivity 方法的 Hook 實現(xiàn)可以參考 weishu 大神的 Android 插件化原理解析——Hook 機制之動態(tài)代理,本文也是基于 weishu 大神的文章在學(xué)習(xí)過程記錄的內(nèi)容。
Activity 啟動過程上述例子中我們只是完成了一個最基礎(chǔ)的 Hook 功能,然而大部分插件化框架提供了十分豐富的功能,例如:插件化支持首先要解決的一點就是插件里的 Activity 并未在宿主程序的 AndroidMainfest.xml 注冊。常規(guī)方法肯定無法直接啟動插件的 Activity,這個時候就需要去了解 Activity 的啟動流程。
完整的流程如下:
注: 可以在 http://androidxref.com/ 在線查看 Android 源碼。
上圖列出的是啟動一個 Activity 的主要過程,具體步驟如下:
Activity 調(diào)用 startActivity,實際會調(diào)用 Instrumentation 類的 execStartActivity 方法,Instrumentation 是系統(tǒng)用來監(jiān)控 Activity 運行的一個類,Activity 的整個生命周期都有它的影子。
通過跨進程的 Binder 調(diào)用,進入到 ActivityManagerService 中,其內(nèi)部會處理 Activity 棧。之后又通過跨進程調(diào)用進入到需要調(diào)用的 Activity 所在的進程中。
ApplicationThread 是一個 Binder 對象,其運行在 Binder 線程池中,內(nèi)部包含一個 H 類,該類繼承于類 Handler。ApplicationThread 將啟動需要調(diào)用的 Activity 的信息通過 H 對象發(fā)送給主線程。
主線程拿到需要調(diào)用的 Activity 的信息后,調(diào)用 Instrumentation 類的 newActivity 方法,其內(nèi)通過 ClassLoader 創(chuàng)建 Activity 實例。
下面介紹如何通過 hook 的方式啟動插件中的 Activity,需要解決以下兩個問題:
插件中的 Activity 沒有在 AndroidManifest 中注冊,如何繞過檢測。
如何構(gòu)造 Activity 實例,同步生命周期。
我們這里使用最簡單的一種實現(xiàn)方式:先在 Manifest 中預(yù)埋 StubActivity,啟動時 hook 上圖第 1 步,將 Intent 替換成 StubActivity。
// StubActivity.java public class StubActivity extends Activity { public static final String TARGET_COMPONENT = "TARGET_COMPONENT"; }
我們上面在 EvilInstrumentation 類里面實現(xiàn)了 execStartActivity 方法,現(xiàn)在我們在這里再加一些額外的邏輯。
public ActivityResult execStartActivity(Context who, IBinder contextThread, IBinder token, Activity target, Intent intent, int requestCode, Bundle options) { StringBuilder sb = new StringBuilder(); sb.append("who = [").append(who).append("], ") .append("contextThread = [").append(contextThread).append("], ") .append("token = [").append(token).append("], ") .append("target = [").append(target).append("], ") .append("intent = [").append(intent).append("], ") .append("requestCode = [").append(requestCode).append("], ") .append("options = [").append(options).append("]");; Logger.i(EvilInstrumentation.class, "執(zhí)行了startActivity, 參數(shù)如下: " + sb.toString()); // 在此處先將 intent 原本的 Component 保存起來, 然后創(chuàng)建一個新的 intent。 // 使用 StubActivity 并替換掉原本的 Activity, 以達通過 AMS 驗證的目的,然后等 AMS 驗證通過后再將其還原。 Intent replaceIntent = new Intent(target, StubActivity.class); replaceIntent.putExtra(StubActivity.TARGET_COMPONENT, intent); intent = replaceIntent; try { Method execStartActivity = Instrumentation.class.getDeclaredMethod( "execStartActivity", Context.class, IBinder.class, IBinder.class, Activity.class, Intent.class, int.class, Bundle.class); return (ActivityResult) execStartActivity.invoke(instrumentation, who, contextThread, token, target, intent, requestCode, options); } catch (Exception e) { e.printStackTrace(); } return null; }
通過這種"移花接木"的方式繞過 AMS 驗證,但是這里我們并沒有完成對我們原本需要真正打開的 Activity 的創(chuàng)建。這里我們需要監(jiān)聽 Activity 的創(chuàng)建過程,然后在適當?shù)倪m合將原本需要打開的 Activity 還原回來。
在 ActivityThread 類中有一個重要的消息處理的方法 sendMessage。
2644 private void sendMessage(int what, Object obj, int arg1, int arg2, boolean async) { 2645 if (DEBUG_MESSAGES) Slog.v( 2646 TAG, "SCHEDULE " + what + " " + mH.codeToString(what) 2647 + ": " + arg1 + " / " + obj); 2648 Message msg = Message.obtain(); 2649 msg.what = what; 2650 msg.obj = obj; 2651 msg.arg1 = arg1; 2652 msg.arg2 = arg2; 2653 if (async) { 2654 msg.setAsynchronous(true); 2655 } 2656 mH.sendMessage(msg); 2657 }
最終都會落實到 mH.sendMessage(msg); 的調(diào)用,繼續(xù)追蹤這個 mH 對象,我們會發(fā)現(xiàn)是 H 對象的實例化對象。
final H mH = new H();
private class H extends Handler { public void handleMessage(Message msg) { 1585 if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what)); 1586 switch (msg.what) { 1587 case LAUNCH_ACTIVITY: { 1588 Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStart"); 1589 final ActivityClientRecord r = (ActivityClientRecord) msg.obj; 1590 1591 r.packageInfo = getPackageInfoNoCheck( 1592 r.activityInfo.applicationInfo, r.compatInfo); 1593 handleLaunchActivity(r, null, "LAUNCH_ACTIVITY"); 1594 Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER); 1595 } break; ... } }
我們知道 Handler 消息機制用于同進程的線程間通信, Handler 是工作線程向 UI 主線程發(fā)送消息,工作線程通過 mHandler 向其成員變量 MessageQueue 中添加新 Message,主線程一直處于 loop() 方法內(nèi),當收到新的 Message 時按照一定規(guī)則分發(fā)給相應(yīng)的 handleMessage() 方法來處理。
類似于對上述 mInstrumentation 實例化對象 hook 一樣,這里我們可以對 mH 對象進行 hook。
/** * 將替換的activity在此時還原回來 */ public static void doHandlerHook() { try { Class> activityThreadClass = Class.forName("android.app.ActivityThread"); Method currentActivityThread = activityThreadClass.getDeclaredMethod("currentActivityThread"); Object activityThread = currentActivityThread.invoke(null); Field mHField = activityThreadClass.getDeclaredField("mH"); mHField.setAccessible(true); Handler mH = (Handler) mHField.get(activityThread); Field mCallbackField = Handler.class.getDeclaredField("mCallback"); mCallbackField.setAccessible(true); mCallbackField.set(mH, new ActivityThreadHandlerCallback(mH)); } catch (Exception e) { e.printStackTrace(); } }
對于 Handler.Callback 的 hook 實現(xiàn)如下:
public class ActivityThreadHandlerCallback implements Handler.Callback { private Handler mBaseHandler; public ActivityThreadHandlerCallback(Handler mBaseHandler) { this.mBaseHandler = mBaseHandler; } @Override public boolean handleMessage(Message msg) { Logger.i(ActivityThreadHandlerCallback.class, "接受到消息了msg:" + msg); if (msg.what == 100) { try { Object obj = msg.obj; Field intentField = obj.getClass().getDeclaredField("intent"); intentField.setAccessible(true); Intent intent = (Intent) intentField.get(obj); Intent targetIntent = intent.getParcelableExtra(StubActivity.TARGET_COMPONENT); intent.setComponent(targetIntent.getComponent()); Log.e("intentField", targetIntent.toString()); } catch (NoSuchFieldException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } } mBaseHandler.handleMessage(msg); return true; } }
我們設(shè)置在 handleMessage 里面還原我們最開始替換的 Activity,至此我們就實現(xiàn)了對于 startActivity 的完整 hook,但是這個過程中仍然存在很多問題,我們需要進一步去深入探索才能去理解和更好實現(xiàn)插件化框架的內(nèi)容。
學(xué)習(xí)案例本文學(xué)習(xí)案例地址:android-plugin-framework
參考Android 博客周刊專題之#插件化開發(fā)#
VirtualAPK Wiki
DroidPlugin Wiki
understand-plugin-framework
Android 插件化原理解析——Hook 機制之動態(tài)代理
Android 源碼分析-Activity 的啟動過程
APP 的啟動過程
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://systransis.cn/yun/71647.html
閱讀 3380·2021-11-04 16:10
閱讀 3877·2021-09-29 09:43
閱讀 2714·2021-09-24 10:24
閱讀 3385·2021-09-01 10:46
閱讀 2523·2019-08-30 15:54
閱讀 603·2019-08-30 13:19
閱讀 3252·2019-08-29 17:19
閱讀 1068·2019-08-29 16:40