Android教你如何發(fā)現(xiàn)APP卡頓的實(shí)現(xiàn)
最近部門(mén)打算優(yōu)化下 APP 在低端機(jī)上的卡頓情況,既然想優(yōu)化,就必須獲取卡頓情況,那么如何獲取卡頓情況就是本文目的。
一般主線(xiàn)程過(guò)多的 UI 繪制、大量的 IO 操作或是大量的計(jì)算操作占用 CPU,導(dǎo)致 App 界面卡頓。只要我們能在發(fā)生卡頓的時(shí)候,捕捉到主線(xiàn)程的堆棧信息和系統(tǒng)的資源使用信息,即可準(zhǔn)確分析卡頓發(fā)生在什么函數(shù),資源占用情況如何。那么問(wèn)題就是如何有效檢測(cè) Android 主線(xiàn)程的卡頓發(fā)生?
用 adb 系統(tǒng)工具觀察 App 的卡頓數(shù)據(jù)情況,試圖重現(xiàn)場(chǎng)景來(lái)定位問(wèn)題。
常用的方式是使用 adb SurfaceFlinger 服務(wù)和 adb gfxinfo 功能,在自動(dòng)化操作 app 的過(guò)程中,使用 adb 獲取數(shù)據(jù)來(lái)監(jiān)控 app 的流暢情況,發(fā)現(xiàn)出現(xiàn)出現(xiàn)卡頓的時(shí)間段,尋找出現(xiàn)卡頓的場(chǎng)景和操作。
方式1:adb shell dumpsysSurfaceFlinger
使用 ‘a(chǎn)db shell dumpsysSurfaceFlinger' 命令即可獲取最近 127 幀的數(shù)據(jù),通過(guò)定期執(zhí)行 adb 命令,獲取幀數(shù)來(lái)計(jì)算出幀率 FPS。
方式2:adb shell dumpsys gfxinfo
使用 ‘a(chǎn)db shell dumpsys gfxinfo' 命令即可獲取最新 128 幀的繪制信息,詳細(xì)包括每一幀繪制的 Draw,Process,Execute 三個(gè)過(guò)程的耗時(shí),如果這三個(gè)時(shí)間總和超過(guò) 16.6ms 即認(rèn)為是發(fā)生了卡頓。
已有的兩種方案比較適合衡量回歸卡頓問(wèn)題的修復(fù)效果和判斷某些特定場(chǎng)景下是否有卡頓情況,然而,這樣的方式有幾個(gè)明顯的不足:
- 一般很難構(gòu)造實(shí)際用戶(hù)卡頓的環(huán)境來(lái)重現(xiàn);
- 這種方式操作起來(lái)比較麻煩,需編寫(xiě)自動(dòng)化用例,無(wú)法覆蓋大量的可疑場(chǎng)景,測(cè)試重現(xiàn)耗時(shí)耗人力;
- 無(wú)法衡量靜態(tài)頁(yè)面的卡頓情況;
- 出現(xiàn)卡頓的時(shí)候app無(wú)法及時(shí)獲取運(yùn)行狀態(tài)和信息,開(kāi)發(fā)定位困難。
隨著對(duì)Android 源碼的深入研究,也有了其他兩種比較方便的方式,并且這兩種方式侵入性小,占用內(nèi)存低,能夠更好的用在實(shí)際場(chǎng)景中:
- 利用UI線(xiàn)程的Looper打印的日志匹配;
- 使用Choreographer.FrameCallback
利用 UI 線(xiàn)程的 Looper 打印的日志匹配
Android 主線(xiàn)程更新 UI。如果界面1秒鐘刷新少于 60 次,即 FPS 小于 60,用戶(hù)就會(huì)產(chǎn)生卡頓感覺(jué)。簡(jiǎn)單來(lái)說(shuō),Android 使用消息機(jī)制進(jìn)行 UI 更新,UI 線(xiàn)程有個(gè) Looper,在其 loop方法中會(huì)不斷取出 message,調(diào)用其綁定的 Handler 在 UI 線(xiàn)程執(zhí)行。如果在 handler 的 dispatchMesaage 方法里有耗時(shí)操作,就會(huì)發(fā)生卡頓。
下面來(lái)看下 Looper.loop( ) 的源碼
public static void loop() {
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}
final MessageQueue queue = me.mQueue;
// 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);
boolean slowDeliveryDetected = false;
for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}
// This must be in a local variable, in case a UI event sets the logger
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
// Make sure the observer won't change while processing a transaction.
final Observer observer = sObserver;
final long traceTag = me.mTraceTag;
long slowDispatchThresholdMs = me.mSlowDispatchThresholdMs;
long slowDeliveryThresholdMs = me.mSlowDeliveryThresholdMs;
if (thresholdOverride > 0) {
slowDispatchThresholdMs = thresholdOverride;
slowDeliveryThresholdMs = thresholdOverride;
}
final boolean logSlowDelivery = (slowDeliveryThresholdMs > 0) && (msg.when > 0);
final boolean logSlowDispatch = (slowDispatchThresholdMs > 0);
final boolean needStartTime = logSlowDelivery || logSlowDispatch;
final boolean needEndTime = logSlowDispatch;
if (traceTag != 0 && Trace.isTagEnabled(traceTag)) {
Trace.traceBegin(traceTag, msg.target.getTraceName(msg));
}
final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
final long dispatchEnd;
Object token = null;
if (observer != null) {
token = observer.messageDispatchStarting();
}
long origWorkSource = ThreadLocalWorkSource.setUid(msg.workSourceUid);
try {
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);
}
}
if (logSlowDelivery) {
if (slowDeliveryDetected) {
if ((dispatchStart - msg.when) <= 10) {
Slog.w(TAG, "Drained");
slowDeliveryDetected = false;
}
} else {
if (showSlowLog(slowDeliveryThresholdMs, msg.when, dispatchStart, "delivery",
msg)) {
// Once we write a slow delivery log, suppress until the queue drains.
slowDeliveryDetected = true;
}
}
}
if (logSlowDispatch) {
showSlowLog(slowDispatchThresholdMs, dispatchStart, dispatchEnd, "dispatch", msg);
}
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
// Make sure that during the course of dispatching the
// identity of the thread wasn't corrupted.
final long newIdent = Binder.clearCallingIdentity();
if (ident != newIdent) {
Log.wtf(TAG, "Thread identity changed from 0x"
+ Long.toHexString(ident) + " to 0x"
+ Long.toHexString(newIdent) + " while dispatching to "
+ msg.target.getClass().getName() + " "
+ msg.callback + " what=" + msg.what);
}
msg.recycleUnchecked();
}
}
代碼中兩處標(biāo)紅的地方,就是 msg.target.dispatchMessage(msg) 的執(zhí)行前后索打印的 log。通過(guò)測(cè)量處理時(shí)間就能檢測(cè)到部分UI線(xiàn)程是否有耗時(shí)的操作。注意到這行執(zhí)行代碼的前后,有兩個(gè) logging.println 函數(shù),如果設(shè)置了logging,會(huì)分別打印出 ”>>>>> Dispatching to “ 和 ”<<<<< Finished to “ 這樣的日志,這樣我們就可以通過(guò)兩次log的時(shí)間差值,來(lái)計(jì)算 dispatchMessage 的執(zhí)行時(shí)間,從而設(shè)置閾值判斷是否發(fā)生了卡頓。
那么如何設(shè)置 logging 呢?
我們看下面的代碼:
/**
* Control logging of messages as they are processed by this Looper. If
* enabled, a log message will be written to <var>printer</var>
* at the beginning and ending of each message dispatch, identifying the
* target Handler and message contents.
*
* @param printer A Printer object that will receive log messages, or
* null to disable message logging.
*/
public final class Looper {
private Printer mLogging;
public void setMessageLogging(@Nullable Printer printer) {
mLogging = printer;
}
}
public interface Printer {
void println(String x);
}
Looper 的 mLogging 是私有的,并且提供了 setMessageLogging(@Nullable Printer printer) 方法,所以我們可以自己實(shí)現(xiàn)一個(gè) Printer,在通過(guò) setMessageLogging() 方法傳入即可,代碼如下:
public class BlockDetectByPrinter {
public static void start() {
Looper.getMainLooper().setMessageLogging(new Printer() {
private static final String START = ">>>>> Dispatching";
private static final String END = "<<<<< Finished";
@Override
public void println(String x) {
if (x.startsWith(START)) {
LogMonitor.getInstance().startMonitor();
}
if (x.startsWith(END)) {
LogMonitor.getInstance().removeMonitor();
}
}
});
}
}
設(shè)置了logging后,loop方法會(huì)回調(diào) logging.println 打印出每次消息執(zhí)行的時(shí)間日志:”>>>>> Dispatching to “和”<<<<< Finished to “。BlockDetectByPrinter 的使用則在Application 的 onCreate 方法中調(diào)用 BlockDetectByPrinter.start() 即可。
我們可以簡(jiǎn)單實(shí)現(xiàn)一個(gè) LogMonitor 來(lái)記錄卡頓時(shí)候主線(xiàn)程的堆棧信息。當(dāng)匹配到 >>>>> Dispatching 時(shí),執(zhí)行 startMonitor,會(huì)在 200ms(設(shè)定的卡頓閾值)后執(zhí)行任務(wù),這個(gè)任務(wù)負(fù)責(zé)在子線(xiàn)程(非UI線(xiàn)程)打印UI線(xiàn)程的堆棧信息。如果消息低于 200ms 內(nèi)執(zhí)行完成,就可以匹配到 <<<<< Finished 日志,那么在打印堆棧任務(wù)啟動(dòng)前執(zhí)行 removeMonitor 取消了這個(gè)任務(wù),則認(rèn)為沒(méi)有卡頓的發(fā)生;如果消息超過(guò) 200ms 才執(zhí)行完畢,此時(shí)認(rèn)為發(fā)生了卡頓,并打印 UI 線(xiàn)程的堆棧信息。
LogMonitor如何實(shí)現(xiàn)?
public class LogMonitor {
private static final String TAG = "LogMonitor";
private static LogMonitor sInstance = new LogMonitor();
private HandlerThread mLogThread = new HandlerThread("log");
private Handler mIoHandler;
private static final long TIME_BLOCK = 200L;
private LogMonitor() {
mLogThread.start();
mIoHandler = new Handler(mLogThread.getLooper());
}
private static Runnable mLogRunnable = new Runnable() {
@Override
public void run() {
StringBuilder sb = new StringBuilder();
StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();
for (StackTraceElement s : stackTrace) {
sb.append(s.toString() + "\n");
}
Log.e(TAG, sb.toString());
}
};
public static LogMonitor getInstance() {
return sInstance;
}
public boolean isMonitor() {
return mIoHandler.hasCallbacks(mLogRunnable);
}
public void startMonitor() {
mIoHandler.postDelayed(mLogRunnable, TIME_BLOCK);
}
public void removeMonitor() {
mIoHandler.removeCallbacks(mLogRunnable);
}
}
這里我們使用 HandlerThread 來(lái)構(gòu)造一個(gè) Handler,HandlerThread 繼承自 Thread,實(shí)際上就一個(gè) Thread,只不過(guò)比普通的 Thread 多了一個(gè) Looper,對(duì)外提供自己這個(gè) Looper 對(duì)象的 getLooper 方法,然后創(chuàng)建 Handler 時(shí)將 HandlerThread 中的 looper 對(duì)象傳入。這樣我們的 mIoHandler 對(duì)象就是與 HandlerThread 這個(gè)非 UI 線(xiàn)程綁定的了,它處理耗時(shí)操作將不會(huì)阻塞UI。如果UI線(xiàn)程阻塞超過(guò) 200ms,就會(huì)在子線(xiàn)程中執(zhí)行 mLogRunnable,打印出 UI 線(xiàn)程當(dāng)前的堆棧信息,如果處理消息沒(méi)有超過(guò) 1000ms,則會(huì)實(shí)時(shí)的 remove 掉這個(gè)mLogRunnable 任務(wù)。
發(fā)生卡頓時(shí)打印出堆棧信息的大致內(nèi)容如下,開(kāi)發(fā)可以通過(guò) log 定位耗時(shí)的地方。
2020-10-30 14:26:13.823 30359-30415/com.example.myproxyplugin E/LogMonitor: java.lang.Thread.sleep(Native Method)
java.lang.Thread.sleep(Thread.java:443)
java.lang.Thread.sleep(Thread.java:359)
com.example.myproxyplugin.MainActivity$1.run(MainActivity.java:22)
android.os.Handler.handleCallback(Handler.java:900)
android.os.Handler.dispatchMessage(Handler.java:103)
android.os.Looper.loop(Looper.java:219)
android.app.ActivityThread.main(ActivityThread.java:8347)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:513)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1055)
優(yōu)點(diǎn):用戶(hù)使用 app 或者測(cè)試過(guò)程中都能從app層面來(lái)監(jiān)控卡頓情況,一旦出現(xiàn)卡頓能記錄 app 狀態(tài)和信息, 只要dispatchMesaage執(zhí)行耗時(shí)過(guò)大都會(huì)記錄下來(lái),不再有前面兩種adb方式面臨的問(wèn)題與不足。
缺點(diǎn):需另開(kāi)子線(xiàn)程獲取堆棧信息,會(huì)消耗少量系統(tǒng)資源。
在實(shí)際實(shí)現(xiàn)中,不同手機(jī)不同 Android 系統(tǒng)甚至是不同的 ROM 版本,Loop 函數(shù)不一定都能打印出 ”>>>>> Dispatching to “ 和 ”<<<<< Finished to “ 這樣的日志,導(dǎo)致該方式無(wú)法進(jìn)行。
優(yōu)化的策略:我們知道 Loop 函數(shù)開(kāi)始和結(jié)束必會(huì)執(zhí)行 println 打印日志,所以?xún)?yōu)化版本將卡頓的判斷改為,Loop輸出第一句 log 時(shí)當(dāng)作 startMonitor,輸出下一句log時(shí)當(dāng)作end時(shí)刻來(lái)解決這個(gè)問(wèn)題。
其實(shí) Looper 中有個(gè) Observer 接口可以很好的完成這個(gè)任務(wù),只是因?yàn)楸粯?biāo)記為 hide 了,所以我們不能使用,不過(guò)可以知道下。
Observer 接口提供了三個(gè)方法,分別是監(jiān)聽(tīng)任務(wù)開(kāi)始,結(jié)束,發(fā)生錯(cuò)誤的回調(diào)。
/** {@hide} */
public interface Observer {
/**
* Called right before a message is dispatched.
*
* <p> The token type is not specified to allow the implementation to specify its own type.
*
* @return a token used for collecting telemetry when dispatching a single message.
* The token token must be passed back exactly once to either
* {@link Observer#messageDispatched} or {@link Observer#dispatchingThrewException}
* and must not be reused again.
*
*/
Object messageDispatchStarting();
/**
* Called when a message was processed by a Handler.
*
* @param token Token obtained by previously calling
* {@link Observer#messageDispatchStarting} on the same Observer instance.
* @param msg The message that was dispatched.
*/
void messageDispatched(Object token, Message msg);
/**
* Called when an exception was thrown while processing a message.
*
* @param token Token obtained by previously calling
* {@link Observer#messageDispatchStarting} on the same Observer instance.
* @param msg The message that was dispatched and caused an exception.
* @param exception The exception that was thrown.
*/
void dispatchingThrewException(Object token, Message msg, Exception exception);
}
利用Choreographer.FrameCallback監(jiān)控卡頓
Choreographer.FrameCallback 官方文檔鏈接(https://developer.android.com/reference/android/view/Choreographer.FrameCallback.html)
我們知道, Android 系統(tǒng)每隔 16ms 發(fā)出 VSYNC 信號(hào),來(lái)通知界面進(jìn)行重繪、渲染,每一次同步的周期為16.6ms,代表一幀的刷新頻率。SDK 中包含了一個(gè)相關(guān)類(lèi),以及相關(guān)回調(diào)。理論上來(lái)說(shuō)兩次回調(diào)的時(shí)間周期應(yīng)該在 16ms,如果超過(guò)了 16ms 我們則認(rèn)為發(fā)生了卡頓,利用兩次回調(diào)間的時(shí)間周期來(lái)判斷是否發(fā)生卡頓(這個(gè)方案是 Android 4.1 API 16 以上才支持)。
這個(gè)方案的原理主要是通過(guò) Choreographer 類(lèi)設(shè)置它的 FrameCallback 函數(shù),當(dāng)每一幀被渲染時(shí)會(huì)觸發(fā)回調(diào) FrameCallback, FrameCallback 回調(diào) void doFrame (long frameTimeNanos) 函數(shù)。一次界面渲染會(huì)回調(diào) doFrame 方法,如果兩次 doFrame 之間的間隔大于 16.6ms 說(shuō)明發(fā)生了卡頓。
public class FPSFrameCallback implements Choreographer.FrameCallback {
private static final String TAG = "FPS_TEST";
private long mLastFrameTimeNanos = 0;
private long mFrameIntervalNanos;
public FPSFrameCallback(long lastFrameTimeNanos) {
mLastFrameTimeNanos = lastFrameTimeNanos;
// 1s 60 幀
mFrameIntervalNanos = (long) (1000000000 / 60.0);
}
@Override
public void doFrame(long frameTimeNanos) {
//初始化時(shí)間
if (mLastFrameTimeNanos == 0) {
mLastFrameTimeNanos = frameTimeNanos;
}
final long jitterNanos = frameTimeNanos - mLastFrameTimeNanos;
if (jitterNanos >= mFrameIntervalNanos) {
final long skippedFrames = jitterNanos / mFrameIntervalNanos;
if (skippedFrames > 30) {
Log.i(TAG, "Skipped " + skippedFrames + " frames! "
+ "The application may be doing too much work on its main thread.");
}
}
mLastFrameTimeNanos = frameTimeNanos;
//注冊(cè)下一幀回調(diào)
Choreographer.getInstance().postFrameCallback(this);
}
}
本質(zhì)和 log 沒(méi)太多區(qū)別,但是這個(gè)更加通用些,不會(huì)因?yàn)闄C(jī)型系統(tǒng)原因出現(xiàn)不可用的問(wèn)題。
示例
下面進(jìn)入實(shí)戰(zhàn),看看代碼層面是如何實(shí)現(xiàn)的。
MainActivity 代碼如下:
public class MainActivity extends AppCompatActivity {
Handler handler = new Handler(Looper.getMainLooper());
private final Runnable runnable = new Runnable() {
@Override
public void run() {
try {
Thread.sleep(600);
handler.postDelayed(runnable, 500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Choreographer.getInstance().postFrameCallback(new FPSFrameCallback(System.nanoTime()));
BlockDetectByPrinter.start();
}
@Override
protected void onResume() {
super.onResume();
handler.postDelayed(runnable, 500);
}
}
收集到的堆棧信息如下:
2020-10-30 14:26:13.823 30359-30415/com.example.myproxyplugin E/LogMonitor: java.lang.Thread.sleep(Native Method)
java.lang.Thread.sleep(Thread.java:443)
java.lang.Thread.sleep(Thread.java:359)
com.example.myproxyplugin.MainActivity$1.run(MainActivity.java:22)
android.os.Handler.handleCallback(Handler.java:900)
android.os.Handler.dispatchMessage(Handler.java:103)
android.os.Looper.loop(Looper.java:219)
android.app.ActivityThread.main(ActivityThread.java:8347)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:513)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1055)
對(duì)于 FPS log 可以看到如下信息:
I/Choreographer: Skipped 64 frames! The application may be doing too much work on its main thread.
I/FPS_TEST: Skipped 65 frames! The application may be doing too much work on its main thread.
如果你要把上面的方法用到自己的APP 中,那么還需要很多操作,具體可以閱讀參考文獻(xiàn)的內(nèi)容。
參考文章
到此這篇關(guān)于Android教你如何發(fā)現(xiàn)APP卡頓的實(shí)現(xiàn)的文章就介紹到這了,更多相關(guān)Android APP卡頓內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Android編程實(shí)現(xiàn)通話(huà)錄音功能的方法
這篇文章主要介紹了Android編程實(shí)現(xiàn)通話(huà)錄音功能的方法,結(jié)合實(shí)例形式較為詳細(xì)的分析了Android廣播接收機(jī)制實(shí)現(xiàn)錄音功能的操作技巧,需要的朋友可以參考下2017-06-06
Android實(shí)現(xiàn)檢測(cè)實(shí)體按鍵事件并屏蔽
這篇文章主要介紹了Android實(shí)現(xiàn)檢測(cè)實(shí)體按鍵事件并屏蔽 ,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-08-08
android中soap協(xié)議使用(ksoap調(diào)用webservice)
kSOAP是如何調(diào)用ebservice的呢,首先要使用SoapObject,這是一個(gè)高度抽象化的類(lèi),完成SOAP調(diào)用??梢哉{(diào)用它的addProperty方法填寫(xiě)要調(diào)用的webservice方法的參數(shù)2014-02-02
解決android viewmodel 數(shù)據(jù)刷新異常的問(wèn)題
這篇文章主要介紹了解決android viewmodel 數(shù)據(jù)刷新異常的問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2020-03-03
Android啟動(dòng)頁(yè)面定時(shí)跳轉(zhuǎn)的三種方法
這篇文章主要介紹了Android啟動(dòng)頁(yè)面定時(shí)跳轉(zhuǎn)的三種方法,實(shí)現(xiàn)打開(kāi)一個(gè)Android手機(jī)APP的歡迎界面后跳轉(zhuǎn)到指定界面的效果,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-11-11
Android RecyclerView區(qū)分視圖類(lèi)型的Divider的實(shí)現(xiàn)
本篇文章主要介紹了Android RecyclerView區(qū)分視圖類(lèi)型的Divider的實(shí)現(xiàn),小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-04-04
Android自定義實(shí)現(xiàn)一個(gè)車(chē)牌字母選擇鍵盤(pán)
這篇文章主要為大家詳細(xì)介紹了Android如何自定義實(shí)現(xiàn)一個(gè)車(chē)牌字母選擇鍵盤(pán),文中的示例代碼講解詳細(xì),感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2023-06-06
Android:“萬(wàn)能”Activity重構(gòu)篇
本文主要介紹了mvp以及每一層,以及使用mvp來(lái)重構(gòu)“萬(wàn)能”Activity,其實(shí)每一層需要注意的東西還有很多,比如model層是最難寫(xiě)的一層。具有很好的參考價(jià)值,下面跟著小編一起來(lái)看下吧2017-01-01
Android 模擬器(emulator-5554...)出現(xiàn)錯(cuò)誤解決辦法
這篇文章主要介紹了Android 模擬器出現(xiàn)錯(cuò)誤解決辦法的相關(guān)資料,如:Unable to get view server version from device,F(xiàn)ailed to install helloworld.apk on device 'emulator-5554': timeout,這種常見(jiàn)錯(cuò)誤,解決辦法,需要的朋友可以參考下2016-11-11

