Android進階Handler應(yīng)用線上卡頓監(jiān)控詳解
引言
在上一篇文章中# Android進階寶典 -- KOOM線上APM監(jiān)控最全剖析,我詳細介紹了對于線上App內(nèi)存監(jiān)控的方案策略,其實除了內(nèi)存指標之外,經(jīng)常有用戶反饋卡頓問題,其實這種問題是最難定位的,因為不像Crash有完整的堆棧信息,而且卡頓問題可能轉(zhuǎn)瞬即逝,那么如何健全完整的線上卡頓監(jiān)控,可能就需要我們對于Android系統(tǒng)的消息處理有一個清晰的認知。
1 Handler消息機制
這里我不會完整的從Handler源碼來分析Android的消息體系,而是從Handler自身的特性引申出線上卡頓監(jiān)控的策略方案。
1.1 方案確認
首先當我們啟動一個App的時候,是由AMS通知zygote進程fork出主進程,其中主進程的入口就是ActivityThread的main方法,在這個方法中開啟Loop死循環(huán)來處理系統(tǒng)消息。
Looper.loop();
在ActivityThread中,有一個內(nèi)部類ApplicationThread,這個類是system_server的一個代理對象,負責(zé)App主進程與system_server進程的通信(如果對這塊有疑問的,可以看之前的文章都有詳細的介紹)。
private class ApplicationThread extends IApplicationThread.Stub { private static final String DB_INFO_FORMAT = " %8s %8s %14s %14s %s"; @Override public final void bindApplication(String processName, ApplicationInfo appInfo, ProviderInfoList providerList, ComponentName instrumentationName, ProfilerInfo profilerInfo, Bundle instrumentationArgs, IInstrumentationWatcher instrumentationWatcher, IUiAutomationConnection instrumentationUiConnection, int debugMode, boolean enableBinderTracking, boolean trackAllocation, boolean isRestrictedBackupMode, boolean persistent, Configuration config, CompatibilityInfo compatInfo, Map services, Bundle coreSettings, String buildSerial, AutofillOptions autofillOptions, ContentCaptureOptions contentCaptureOptions, long[] disabledCompatChanges, SharedMemory serializedSystemFontMap) { if (services != null) { if (false) { // Test code to make sure the app could see the passed-in services. for (Object oname : services.keySet()) { if (services.get(oname) == null) { continue; // AM just passed in a null service. } String name = (String) oname; // See b/79378449 about the following exemption. switch (name) { case "package": case Context.WINDOW_SERVICE: continue; } if (ServiceManager.getService(name) == null) { Log.wtf(TAG, "Service " + name + " should be accessible by this app"); } } } // Setup the service cache in the ServiceManager ServiceManager.initServiceCache(services); } setCoreSettings(coreSettings); AppBindData data = new AppBindData(); data.processName = processName; data.appInfo = appInfo; data.providers = providerList.getList(); data.instrumentationName = instrumentationName; data.instrumentationArgs = instrumentationArgs; data.instrumentationWatcher = instrumentationWatcher; data.instrumentationUiAutomationConnection = instrumentationUiConnection; data.debugMode = debugMode; data.enableBinderTracking = enableBinderTracking; data.trackAllocation = trackAllocation; data.restrictedBackupMode = isRestrictedBackupMode; data.persistent = persistent; data.config = config; data.compatInfo = compatInfo; data.initProfilerInfo = profilerInfo; data.buildSerial = buildSerial; data.autofillOptions = autofillOptions; data.contentCaptureOptions = contentCaptureOptions; data.disabledCompatChanges = disabledCompatChanges; data.mSerializedSystemFontMap = serializedSystemFontMap; sendMessage(H.BIND_APPLICATION, data); } }
我們可以看到,每個方法的最后,其實都是調(diào)用了sendMessage方法,通過Handler發(fā)送消息;為啥會用到Handler呢,是因為App進程與system_server進程通信是通過Binder實現(xiàn)的,Binder會開辟Binder線程池,那么此時這個方法的調(diào)用是在子線程中完成,像bindApplication最終需要調(diào)用Application的onCreate方法,但這個方法是在主線程中,因此需要Handler完成線程切換。
所以整個App消息體系都是通過Handler來支持起來的,看下圖
因為Android對于消息的時效性要求非常高,需要一個高速執(zhí)行的狀態(tài),一旦有消息執(zhí)行耗時造成阻塞就會產(chǎn)生卡頓,所以通過Handler來監(jiān)聽消息的執(zhí)行速度,通過設(shè)定閾值判斷是否產(chǎn)生卡頓,從而獲取堆棧消息來定位問題。
1.2 Looper源碼
我們先去看下Looper源碼,看如何處理分發(fā)消息的
public static void loop() { final Looper me = myLooper(); if (me == null) { throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread."); } if (me.mInLoop) { Slog.w(TAG, "Loop again would have the queued messages be executed" + " before this one completed."); } me.mInLoop = true; // Make sure the identity of this thread is that of the local process, // and keep track of what that identity token actually is. Binder.clearCallingIdentity(); final long ident = Binder.clearCallingIdentity(); // Allow overriding a threshold with a system prop. e.g. // adb shell 'setprop log.looper.1000.main.slow 1 && stop && start' final int thresholdOverride = SystemProperties.getInt("log.looper." + Process.myUid() + "." + Thread.currentThread().getName() + ".slow", 0); me.mSlowDeliveryDetected = false; /**在這里開啟死循環(huán)*/ for (;;) { if (!loopOnce(me, ident, thresholdOverride)) { return; } } }
在Looper的loop方法中,開啟一個死循環(huán),然后調(diào)用了loopOnce方法
private static boolean loopOnce(final Looper me, final long ident, final int thresholdOverride) { /**第一步,從MessagQueue中取出消息*/ Message msg = me.mQueue.next(); // might block if (msg == null) { // No message indicates that the message queue is quitting. return false; } // This must be in a local variable, in case a UI event sets the logger /**這里關(guān)注下這個打點信息*/ final Printer logging = me.mLogging; if (logging != null) { logging.println(">>>>> Dispatching to " + msg.target + " " + msg.callback + ": " + msg.what); } try { /**第二步,調(diào)用Handler的dispatchMessage方法*/ msg.target.dispatchMessage(msg); if (observer != null) { observer.messageDispatched(token, msg); } dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0; } catch (Exception exception) { if (observer != null) { observer.dispatchingThrewException(token, msg, exception); } throw exception; } finally { ThreadLocalWorkSource.restore(origWorkSource); if (traceTag != 0) { Trace.traceEnd(traceTag); } } //...... /**消息執(zhí)行完成的打點*/ if (logging != null) { logging.println("<<<<< Finished to " + msg.target + " " + msg.callback); } return true; }
這里我們需要關(guān)注的有兩個點:
(1)看消息是如何被分發(fā)執(zhí)行的,在注釋中,我標注了關(guān)鍵的二步;
(2)從消息被執(zhí)行之前,到消息執(zhí)行之后,有兩處打點信息分別為:Dispatching to和Finished to,這個就是代表消息執(zhí)行的整個過程,如果我們能夠拿到這兩段之間的耗時,是不是就可以完成我們的方案策略。
通過源碼我們可以看到,這個Printer是我們可以自定義傳入的,那也就是說,我們可以在我們自定義的Printer中插入計時的代碼,就可以監(jiān)控每個消息執(zhí)行的耗時了。
public void setMessageLogging(@Nullable Printer printer) { mLogging = printer; }
1.3 Blockcanary原理分析
所以根據(jù)上面的源碼分析,業(yè)內(nèi)有一款適用于卡頓監(jiān)控的組件 - Blockcanary
implementation 'com.github.markzhai:blockcanary-android:1.5.0'
使用方式:
BlockCanary.install(this, BlockCanaryContext()).start()
所以我們看一下Blockcanary的源碼,它的思想就是我們提到的通過setMessageLogging方法注入自己的代碼。
public void start() { if (!mMonitorStarted) { mMonitorStarted = true; Looper.getMainLooper().setMessageLogging(mBlockCanaryCore.monitor); } }
在start方法中,就是調(diào)用了setMessageLogging方法,傳入了一個Printer對象,這個實現(xiàn)類就是LooperMonitor,其中需要實現(xiàn)println方法.
class LooperMonitor implements Printer { @Override public void println(String x) { if (mStopWhenDebugging && Debug.isDebuggerConnected()) { return; } /** mPrintingStarted 默認false */ if (!mPrintingStarted) { mStartTimestamp = System.currentTimeMillis(); mStartThreadTimestamp = SystemClock.currentThreadTimeMillis(); mPrintingStarted = true; startDump(); } else { final long endTime = System.currentTimeMillis(); mPrintingStarted = false; if (isBlock(endTime)) { notifyBlockEvent(endTime); } stopDump(); } } private boolean isBlock(long endTime) { return endTime - mStartTimestamp > mBlockThresholdMillis; } }
我們知道,在Looper的loop方法中,會調(diào)用兩次print方法,所以在第一次調(diào)用println方法的時候,會記錄一個系統(tǒng)時間;第二次進入的時候,會再次記一次系統(tǒng)時間,前后兩次時間差如果超過一個閾值mBlockThresholdMillis,那么認為是發(fā)生了卡頓。
private void notifyBlockEvent(final long endTime) { final long startTime = mStartTimestamp; final long startThreadTime = mStartThreadTimestamp; final long endThreadTime = SystemClock.currentThreadTimeMillis(); HandlerThreadFactory.getWriteLogThreadHandler().post(new Runnable() { @Override public void run() { mBlockListener.onBlockEvent(startTime, endTime, startThreadTime, endThreadTime); } }); }
如果發(fā)生了卡頓,那么就會將堆棧信息記錄到文件當中,但是這樣處理真的能夠幫助到我們嗎?
1.4 Handler監(jiān)控的缺陷
當然Blockcanary確實能夠幫助我們確認卡頓發(fā)生的一個大致范圍,但是我們看下面的圖
當方法B執(zhí)行完成之后觸發(fā)了卡頓閾值,這個時候堆棧當中存在方法A的堆棧信息和方法B的堆棧信息,那么我們會認為因為方法B的原因產(chǎn)生了卡頓嗎?其實不然,如果堆棧信息中也包含了其他方法,那么Handler監(jiān)控其實也只是給出了一個大粒度的范圍,分析起來還是會有問題。
2 字節(jié)碼插樁實現(xiàn)方法耗時監(jiān)控
基于前面我們對于Blockcanary的分析,其存在的一個重大弊端就是無法獲取細顆粒度的數(shù)據(jù),例如每個方法執(zhí)行的耗時,當打印出堆棧信息之后,附加上每個方法的耗時,這樣就能準確地定位出耗時方法的存在。
private fun funcA() { funcB() } private fun funcB() { Thread.sleep(400) funcC() } private fun funcC() { funcD() } private fun funcD() { Thread.sleep(100) }
例如還是以500ms為卡頓閾值,那么當執(zhí)行方法A的時候,系統(tǒng)檢測到了卡頓的發(fā)生,如果給到一個堆棧信息如下:
D方法 耗時100ms
C方法 耗時100ms
B方法 耗時400ms
A方法 耗時500ms
這樣是不是就一目了然了,顯然是方法B中有一個非常耗時的操作,那么如何獲取每個方法執(zhí)行的時間呢?
private fun funcA() { val startTime = System.currentTimeMillis() funcB() val deltaTime = System.currentTimeMillis() - startTime }
上述這種方式可以獲取方法耗時,如果我們僅在測試階段想測試某個方法耗時可以這么做,但是工程中成千上萬的方法,如果靠自己手動這么添加豈不是要累死,所以就需要字節(jié)碼插樁來幫忙在每個方法中加入上述代碼邏輯。
2.1 字節(jié)碼插樁流程
如果有看過Android進階寶典 -- 從字節(jié)碼插樁技術(shù)了解美團熱修復(fù)這篇文章的伙伴,可能對于字節(jié)碼插樁有些了解了。其實字節(jié)碼插樁,就是在class文件中寫代碼。
因為不管是Java還是Kotlin最終都會編譯成class字節(jié)碼,而我們?nèi)粘i_發(fā)中肯定是在Java(Kotlin)層上寫代碼,而字節(jié)碼插樁則是在class文件上寫代碼。
因此整個字節(jié)碼插樁的流程就是
其中難點就在于解析出class文件中包含的信息之后,需要嚴格按照class字節(jié)碼的規(guī)則來進行修改,只要有一個地方改錯了,那么生成的.class文件就無法使用,所以如果要我們自己修改顯然是很難,因此各路Android大佬考慮到這個問題,就開源出很多框架提供給我們使用。
2.2 引入ASM實現(xiàn)字節(jié)碼插樁
首先,我們先引入ASM依賴
implementation 'org.ow2.asm:asm:9.1' implementation 'org.ow2.asm:asm-util:9.1' implementation 'org.ow2.asm:asm-commons:9.1'
我們可以根據(jù)2.1小節(jié)的這個流程圖,利用ASM中的工具完成字節(jié)碼插樁。
public class TestFunctionRunTime { public TestFunctionRunTime() { } public void funA() throws InterruptedException { Thread.sleep(2000); } }
例如,我們想在funA中插入計算耗時的方法,那么首先需要得到這個類的class文件
fun transform() { //IO操作,獲取文件流 val fis = FileInputStream("/storage/emulated/0/TestFunctionRunTime.class") //用于讀取class文件中信息 val cr = ClassReader(fis) val cw = ClassWriter(ClassWriter.COMPUTE_MAXS) //開始分析字節(jié)碼 cr.accept( MyClassVisitor(Opcodes.ASM9, cw), ClassReader.SKIP_FRAMES or ClassReader.SKIP_DEBUG ) }
首先,獲取class文件這里我作為示例直接通過IO加載某個路徑下的class文件,通過ASM中提供的ClassReader和ClassWriter來讀取class中的文件信息,然后調(diào)用ClassReader的accept方法,開始分析class文件。
class MyClassVisitor(api: Int, classVisitor: ClassVisitor) : ClassVisitor(api, classVisitor) { override fun visitMethod( access: Int, name: String?, descriptor: String?, signature: String?, exceptions: Array<out String>? ): MethodVisitor { /**這里假設(shè)就對一個方法插樁*/ return if (name == "funA") { val methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions) MyMethodVisitor(api, methodVisitor, access, name, descriptor) } else { super.visitMethod(access, name, descriptor, signature, exceptions) } } override fun visitField( access: Int, name: String?, descriptor: String?, signature: String?, value: Any? ): FieldVisitor { return super.visitField(access, name, descriptor, signature, value) } override fun visitAnnotation(descriptor: String?, visible: Boolean): AnnotationVisitor { return super.visitAnnotation(descriptor, visible) } }
因為在一個類中,會存在很多屬性,例如變量、方法、注解等等,所以在ASM中的ClassVisitor類中,提供了這些屬性的訪問權(quán)利,例如visitMethod可以訪問方法,假如我們想要對funA進行插樁,那么就需要做一些自定義的操作,這里就可以使用ASM提供的AdviceAdapter來完成方法執(zhí)行過程中代碼的插入。
class MyMethodVisitor( val api: Int, val methodVisitor: MethodVisitor, val mAccess: Int, val methodName: String, val descriptor: String? ) : AdviceAdapter(api, methodVisitor, mAccess, methodName, descriptor) { /**當方法開始執(zhí)行的時候*/ override fun onMethodEnter() { super.onMethodEnter() } /**當方法執(zhí)行結(jié)束的時候*/ override fun onMethodExit(opcode: Int) { super.onMethodExit(opcode) } }
假設(shè)我們對于每個方法,都插入以下兩行代碼,那么我們在操作字節(jié)碼的時候,需要看一下當這個方法被編譯成字節(jié)碼之后,是什么樣的。
public void funA() throws InterruptedException { Long startTime = System.currentTimeMillis(); Thread.sleep(2000L); Log.e("TestFunctionRunTime", "duration=>" + (System.currentTimeMillis() - startTime)); }
插入代碼之前的字節(jié)碼如下:
public funA()V throws java/lang/InterruptedException L0 LINENUMBER 18 L0 LDC 2000 INVOKESTATIC java/lang/Thread.sleep (J)V L1 LINENUMBER 19 L1 RETURN L2 LOCALVARIABLE this Lcom/lay/mvi/net/TestFunctionRunTime; L0 L2 0 MAXSTACK = 2 MAXLOCALS = 1
插入代碼之后的字節(jié)碼如下:
public funA()V throws java/lang/InterruptedException L0 LINENUMBER 17 L0 INVOKESTATIC java/lang/System.currentTimeMillis ()J INVOKESTATIC java/lang/Long.valueOf (J)Ljava/lang/Long; ASTORE 1 L1 LINENUMBER 18 L1 LDC 2000 INVOKESTATIC java/lang/Thread.sleep (J)V L2 LINENUMBER 19 L2 LDC "TestFunctionRunTime" NEW java/lang/StringBuilder DUP INVOKESPECIAL java/lang/StringBuilder.<init> ()V LDC "duration=>" INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder; INVOKESTATIC java/lang/System.currentTimeMillis ()J ALOAD 1 INVOKEVIRTUAL java/lang/Long.longValue ()J LSUB INVOKEVIRTUAL java/lang/StringBuilder.append (J)Ljava/lang/StringBuilder; INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String; INVOKESTATIC android/util/Log.e (Ljava/lang/String;Ljava/lang/String;)I POP L3 LINENUMBER 20 L3 RETURN L4 LOCALVARIABLE this Lcom/lay/mvi/net/TestFunctionRunTime; L0 L4 0 LOCALVARIABLE startTime Ljava/lang/Long; L1 L4 1 MAXSTACK = 6 MAXLOCALS = 2 }
首先我們看如果按照我們這種加代碼的方式,當然沒問題,但是在進行插樁的時候,將會寫很多的字節(jié)碼指令,看下面的代碼,我僅僅貼出L2代碼塊就需要這么多,寫的多通常就會出問題。
visitLdcInsn(methodName) visitTypeInsn(NEW, "java/lang/StringBuilder") visitInsn(DUP) visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false) visitLdcInsn(""duration=>"") visitMethodInsn( INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder", false ) visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false) visitVarInsn(ALOAD, 1) visitMethodInsn(INVOKEVIRTUAL, "java/lang/Long", "longValue", "()J", false) visitInsn(LSUB) visitMethodInsn( INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder", false ) visitMethodInsn( INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String", false ) visitMethodInsn( INVOKEVIRTUAL, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false ) visitInsn(POP)
所以簡單一點就是封裝一個方法,因為這個插樁是在編譯時將代碼插入,所以不影響
object AppMethodTrace { private var startTime: Long = 0L fun start() { startTime = System.currentTimeMillis() } fun end(funcName: String) { val endTime = System.currentTimeMillis() Log.e("AppMethodTrace", "$funcName 耗時為${endTime - startTime}") } }
看這樣就變得非常簡便了,而且寫起來也是非常清晰
public funA()V throws java/lang/InterruptedException L0 LINENUMBER 17 L0 GETSTATIC com/lay/mvi/net/AppMethodTrace.INSTANCE : Lcom/lay/mvi/net/AppMethodTrace; INVOKEVIRTUAL com/lay/mvi/net/AppMethodTrace.start ()V L1 LINENUMBER 18 L1 LDC 2000 INVOKESTATIC java/lang/Thread.sleep (J)V L2 LINENUMBER 19 L2 GETSTATIC com/lay/mvi/net/AppMethodTrace.INSTANCE : Lcom/lay/mvi/net/AppMethodTrace; LDC "funA" INVOKEVIRTUAL com/lay/mvi/net/AppMethodTrace.end (Ljava/lang/String;)V L3 LINENUMBER 20 L3 RETURN L4 LOCALVARIABLE this Lcom/lay/mvi/net/TestFunctionRunTime; L0 L4 0 MAXSTACK = 2 MAXLOCALS = 1
那么通過onMethodEnter和onMethodExit兩個方法的處理,就可以完成對字節(jié)碼插入的操作。
class MyMethodVisitor( val api: Int, val methodVisitor: MethodVisitor, val mAccess: Int, val methodName: String, val descriptor: String? ) : AdviceAdapter(api, methodVisitor, mAccess, methodName, descriptor) { /**當方法開始執(zhí)行的時候*/ override fun onMethodEnter() { super.onMethodEnter() visitFieldInsn( GETSTATIC, "com/lay/mvi/net/AppMethodTrace", "INSTANCE", "Lcom/lay/mvi/net/AppMethodTrace" ) visitMethodInsn(INVOKEVIRTUAL, "com/lay/mvi/net/AppMethodTrace", "start", "()V", false) } /**當方法執(zhí)行結(jié)束的時候*/ override fun onMethodExit(opcode: Int) { super.onMethodExit(opcode) visitFieldInsn( GETSTATIC, "com/lay/mvi/net/AppMethodTrace", "INSTANCE", "Lcom/lay/mvi/net/AppMethodTrace" ) /**方法名可以動態(tài)拿到*/ visitLdcInsn(methodName) visitMethodInsn( INVOKEVIRTUAL, "com/lay/mvi/net/AppMethodTrace", "end", "(Ljava/lang/String;)V", false ) } }
最終,通過分析處理字節(jié)碼之后,將修改后的字節(jié)碼重新輸出到新的文件,在實際的應(yīng)用開發(fā)中,是需要覆蓋之前的字節(jié)碼文件的。
//輸出結(jié)果 val bytes = cw.toByteArray() val fos = FileOutputStream("/storage/emulated/0/TestFunctionRunTimeTransform.class") fos.write(bytes) fos.flush() fos.close()
如果伙伴們第一次使用,建議還是熟悉所有的字節(jié)碼指令以及ASM的API,這樣我們在寫的時候就非常迅速了。
2.3 Blockcanary的優(yōu)化策略
通過前面我們對于Blockcanary的了解,通過Handler雖然能夠獲取卡頓時的堆棧信息,但是無法獲取到方法的執(zhí)行耗時,所以通過ASM字節(jié)碼插樁統(tǒng)計方法耗時配合Handler,就能夠精確地定位到卡頓的方法,有時間的伙伴們可以去看下騰訊的Matrix。
最后還要啰嗦一下,其實對于字節(jié)碼插樁,像美團的熱修復(fù)框架采用的字節(jié)碼插樁技術(shù)就是ASM,但方式并不是只有這一種,像Javassist、kotlinpoet/javapoet都具備插樁的能力;我們在做線上卡頓監(jiān)控的時候,其實就是在做一個系統(tǒng),所以不能從一個點出發(fā),像運用到系統(tǒng)能力之外,同樣也會使用到三方框架作為輔助手段,目的就是為了能夠達到快速定位、快速響應(yīng)的能力。
以上就是Android進階Handler應(yīng)用線上卡頓監(jiān)控詳解的詳細內(nèi)容,更多關(guān)于Android Handler線上卡頓監(jiān)控的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android系統(tǒng)添加自定義鼠標樣式通過按鍵切換實例詳解
在本篇文章里小編給大家整理的是關(guān)于Android系統(tǒng)添加自定義鼠標樣式通過按鍵切換實例詳解內(nèi)容,有需要的朋友們可以學(xué)習(xí)下。2019-11-11Android中SparseArray性能優(yōu)化的使用方法
這篇文章主要為大家詳細介紹了Android中SparseArray性能優(yōu)化的使用方法,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2016-04-04Android?Jetpack組件ViewModel基本用法詳解
這篇文章主要為大家介紹了Android?Jetpack組件ViewModel基本用法詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-01-01Android使用xUtils3.0實現(xiàn)文件上傳
這篇文章主要為大家詳細介紹了Android使用xUtils3.0實現(xiàn)文件上傳的相關(guān)資料,具有一定的參考價值,感興趣的小伙伴們可以參考一下2016-11-11