LeakCanary-隐藏Icon、Toast、Notify

在Android中,要检测App的内存泄漏,众所周知有个Square公司开源神器——LeakCanary。
LeakCanary的使用方便简单,使用只需要3行代码即可:
1)在build.gradle文件中,添加依赖(版本号可自行选择):

debugCompile 'com.squareup.leakcanary:leakcanary-android:1.5.4'
releaseCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.5.4'

2)在Application中,执行:

RefWatcher mRefWatcher = LeakCanary.install(this);

mRefWatcher可以用于检测你想检测的内容,比如用于检测Fragment。
LeakCanary的更多具体使用方法,网上有很多详细的内容,可以自行搜索查看。
大家应该知道使用LeakCanary后,设备上会有一个Leak的Icon,发现泄漏后,会出现一个Toast提示,并在通知栏中会展示一个Leak的Notify信息,但是由于某些原因,我们需要隐藏掉这些外露的信息,目标是:可以在debug和release包中都能检测内存泄漏,发现泄漏后,可以做到获取泄漏信息时,用户是无感知的。

解决问题一:希望在debug和release包中都能检测内存泄漏

虽然LeakCanary提供了release的版本,但是release版本为了App的性能,会跳过检查,所以LeakCanary的内存泄漏检测是在Debug包中才能产生效果。
在build.gradle中,引入的2行代码,分别代表,在debug版本中,引入leakcanary-android:1.5.4,在release版本中,引入leakcanary-android-no-op:1.5.4,所以要想实现想要的效果,只需要将两行代码缩减并修改成一行:

compile 'com.squareup.leakcanary:leakcanary-android:1.5.4'

也就是不再区分debug版本和release版本,直接引入LeakCanary用于检测内存泄漏的版本,Over!(注意:有可能导致App的性能变差,需要额外关注)

解决问题二:希望能够隐藏Leak的Icon

想要隐藏Leak的Icon,首先要知道Icon是怎么来的。
首先,LeakCanary的使用手册中,有告诉我们,如果需要更换Leak的Icon,可以替换图标文件:

res/
  drawable-hdpi/
    __leak_canary_icon.png
  drawable-mdpi/
    __leak_canary_icon.png
  drawable-xhdpi/
    __leak_canary_icon.png
  drawable-xxhdpi/
    __leak_canary_icon.png
  drawable-xxxhdpi/
    __leak_canary_icon.png
 
<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string name="__leak_canary_display_activity_label">MyLeaks</string>
</resources>

但是可惜,我们要的不是替换Leak的Icon,而是直接隐藏Leak的Icon。

在网上查阅资料,看到有位大神提供的建议,将DisplayLeakActivity隐藏,链接:https://gist.github.com/lennykano/2bb061c9cff85b225590,无法翻墙的小伙伴请看下面这部分代码:

<activity
 
    android:enabled="false"
 
    android:icon="@drawable/leak_canary_icon"
 
    android:label="@string/__leak_canary_display_activity_label"
 
    android:name="com.squareup.leakcanary.internal.DisplayLeakActivity"
 
    android:taskAffinity="com.squareup.leakcanary"
 
    android:theme="@style/__LeakCanary.Base">
 
    <intent-filter tools:node="remove">
 
        <action android:name="android.intent.action.MAIN"/>
 
        <category android:name="android.intent.category.LAUNCHER"/>
 
    </intent-filter>
 
</activity>

实践后,发现这部分的代码确实可以让App找不到DisplayLeakActivity,所以也确实可以隐藏Icon,但是正是由于App需要DisplayLeakActivity,却又找不到它,所以引发了Crash问题,报错就是找不到DisplayLeakActivity。所以该方法不可行。
走投无路后,将LeakCanary的代码down下来,希望能在源码中,找到隐藏Icon的方法。
首先想到,既然有大神提供了在Mainfest.xml中,隐藏DisplayLeakActivity,那么在源码的这个文件下,就一定有对这个Activity的某些定义:

<activity
    android:theme="@style/leak_canary_LeakCanary.Base"
    android:name=".internal.DisplayLeakActivity"
    android:process=":leakcanary"
    android:enabled="false"
    android:label="@string/leak_canary_display_activity_label"
    android:icon="@mipmap/leak_canary_icon"
    android:taskAffinity="com.squareup.leakcanary.${applicationId}"
    >
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
</activity>

可以看到,Activity的定义中,定义了它的Icon,也定义了它的label,这就是Leak Icon的由来,同时Activity是在leakcanary进程中(不在我们的App进程中),所以展示不受影响。
既然我们希望可以隐藏Icon,所以最直接的方法,就是通过 tools:node="remove" 方法,移除掉Activity的定义,从而达到隐藏Activity的目的,也就是上面大神提供的那个方法,然而并不可行。
所以只能往它的上一步查找:屌用Activity的地方,可以想象,如果我们将所有屌用Activity的部分注释掉,那么也可以达到我们想要的效果。
查找后,发现整份源码中,只有2个部分屌用到了DisplayLeakActivity,并且它的入口都在同一份java文件中(这是非常幸运的一件事情,感谢Square公司大神们的代码架构非常好):

public final class LeakCanary {
    ...
    public static void enableDisplayLeakActivity(Context context) {
        setEnabled(context, DisplayLeakActivity.class, true);
    }
    ...
    /**
    * If you build a {@link RefWatcher} with a {@link AndroidHeapDumper} that has a custom {@link
    * LeakDirectoryProvider}, then you should also call this method to make sure the activity in
    * charge of displaying leaks can find those on the file system.
    */
    public static void setDisplayLeakActivityDirectoryProvider(LeakDirectoryProvider leakDirectoryProvider) {
        DisplayLeakActivity.setLeakDirectoryProvider(leakDirectoryProvider);
    }
    ...
}

所以自然而然的,冒出来的第一个想法就是:继承LeakCanary,修改这两部分代码。但是可以看到,LeakCanary类是final类型,无法继承,所以只能放弃继承的想法。
但是我们可以重写一个MyLeakCanary,内容和LeakCanary一样,在MyLeakCanary中,修改这两部分的代码,在外部屌用LeakCanary.install(this);的部分,修改成MyLeakCanary.install(this);,似乎也是可以达到我们想要的效果。
所以重新建立一个com.squareup.leakcanary包名,新建一个LeakCanaryWithoutDisplay类,将LeakCanary的内容全部复制过来,按照我们想要的修改,所以修改后变成:

public final class LeakCanaryWithoutDisplay {
 
    
    public interface LeakCanaryCallBack {
        void onAnalysisResult(String result);
    }

    private static LeakCanaryCallBack sLeakCanaryCallBack;

    public static LeakCanaryCallBack getLeakCanaryCallBack() {
        return sLeakCanaryCallBack;
    }
    /**
     * Builder to create a customized {@link RefWatcher} with appropriate Android defaults.
     */
    public static AndroidRefWatcherBuilderWithoutToast refWatcher(Context context) {
        return new AndroidRefWatcherBuilderWithoutToast(context);
    }

    public static void enableDisplayLeakActivity(Context context) {
        setEnabled(context, DisplayLeakActivity.class, false);
    }

    private LeakCanaryWithoutDisplay() {
        throw new AssertionError();
    }
    ...
}

而setDisplayLeakActivityDirectoryProvider方法,是在AndroidRefWatcherBuilder文件中屌用的。
所以,新建一个MyAndroidRefWatcherBuilder,将AndroidRefWatcherBuilder的内容全部复制过来,修改:

public final class MyAndroidRefWatcherBuilder extends RefWatcherBuilder<AndroidRefWatcherBuilderWithoutToast> {
    ...
        /**
     * Sets the maximum number of heap dumps stored. This overrides any call to {@link
     * #heapDumper(HeapDumper)} as well as any call to
     * {@link LeakCanary#setDisplayLeakActivityDirectoryProvider(LeakDirectoryProvider)})}
     *
     * @throws IllegalArgumentException if maxStoredHeapDumps < 1.
     */
    public AndroidRefWatcherBuilderWithoutToast maxStoredHeapDumps(int maxStoredHeapDumps) {
        LeakDirectoryProvider leakDirectoryProvider =
                new DefaultLeakDirectoryProvider(context, maxStoredHeapDumps);
//        LeakCanary.setDisplayLeakActivityDirectoryProvider(leakDirectoryProvider);//将这行注释掉,不再屌用即可
        return heapDumper(new AndroidHeapDumperWithoutToast(context, leakDirectoryProvider));
    }
    ...
}

至此,得益于Square公司大神们优秀的代码架构,我们将2个文件重新定义一份后,在屌用的地方,将LeakCanary替换成LeakCanaryWithoutDisplay,将AndroidRefWatcherBuilder替换成AndroidRefWatcherBuilderWithoutToast,就可以成功实现Leak Icon的隐藏了。

// 安装LeakCanary
AndroidRefWatcherBuilderWithoutToast refBuilder = LeakCanaryWithoutDisplay.refWatcher(ContextUtil.getApplication());
refBuilder.buildAndInstall();
LeakCanaryWithoutDisplay.enableDisplayLeakActivity(ContextUtil.getContext());

解决问题三:希望发现泄漏后,不再显示Toast和Notify

在解决完问题二后,解决问题的思路就大体形成了:
1、找到展示(Toast、Notify、Activity)的源码部分(方法)
2、查看屌用该方法的类
3、重写一份该类,注释(修改)其中屌用的方法块
有了思路,查看源码后,就可以发现,Toast展示的方法是在AndroidHeapDumper.java:

public final class AndroidHeapDumper implements HeapDumper {
    ...
    @SuppressWarnings("ReferenceEquality") // Explicitly checking for named null.
    @Override
    public File dumpHeap() {
        ...
        FutureResult<Toast> waitingForToast = new FutureResult<>();
        showToast(waitingForToast);//发现泄漏后,显示Toast
        ...
    }
    ...
}

而Notify的展示,是定义在:DisplayLeakService.java

public class DisplayLeakService extends AbstractAnalysisResultService {
 
    @Override
    protected final void onHeapAnalyzed(HeapDump heapDump, AnalysisResult result) {
        ...
        // New notification id every second.
        int notificationId = (int) (SystemClock.uptimeMillis() / 1000);
        showNotification(this, contentTitle, contentText, pendingIntent, notificationId);//发现泄漏后,通知栏展示Notify
        ...
    }
}

所以相应的,就是重写这两份类、重写屌用这两个方法的类、修改LeakCanary安装时屌用的类,具体的就不再一一细说。
最终文件
最终一共重写了4份源码文件:
1、AndroidHeapDumperWithoutToast.java
2、AndroidRefWatcherBuilderWithoutToast.java
3、DisplayLeakServiceWithoutNotification.java
4、LeakCanaryWithoutDisplay.java
以下是修改的具体内容。

public final class AndroidHeapDumperWithoutToast implements HeapDumper {
 
    final Context context;
    private final LeakDirectoryProvider leakDirectoryProvider;
    private final Handler mainHandler;
 
    public AndroidHeapDumperWithoutToast(Context context, LeakDirectoryProvider leakDirectoryProvider) {
        this.leakDirectoryProvider = leakDirectoryProvider;
        this.context = context.getApplicationContext();
        mainHandler = new Handler(Looper.getMainLooper());
    }
 
 
    @SuppressWarnings("ReferenceEquality") // Explicitly checkinnamed null.
    @Override
    public File dumpHeap() {
        Log.e("TAG-AndroidHeapDumper", "AndroidHeapDumperWithoutToast-dumpHeap");
        File heapDumpFile = leakDirectoryProvider.newHeapDumpFile();
 
        if (heapDumpFile == RETRY_LATER) {
            return RETRY_LATER;
        }
 
        FutureResult<Toast> waitingForToast = new FutureResult<>();
        showToast(waitingForToast);
 
        if (!waitingForToast.wait(5, SECONDS)) {
            CanaryLog.d("Did not dump heap, too much time waiting for Toast.");
            return RETRY_LATER;
        }
 
        Toast toast = waitingForToast.get();
        try {
            Debug.dumpHprofData(heapDumpFile.getAbsolutePath());
            cancelToast(toast);
            return heapDumpFile;
        } catch (Exception e) {
            CanaryLog.d(e, "Could not dump heap");
            // Abort heap dump
            return RETRY_LATER;
        }
    }
 
    private void showToast(final FutureResult<Toast> waitingForToast) {
        mainHandler.post(new Runnable() {
            @Override
            public void run() {
                Log.e("TAG-AndroidHeapDumper", "AndroidHeapDumperWithoutToast-showToast");
                final Toast toast = new Toast(context);
                toast.setGravity(Gravity.CENTER_VERTICAL, 0, 0);
                toast.setDuration(Toast.LENGTH_LONG);
                LayoutInflater inflater = LayoutInflater.from(context);
                toast.setView(inflater.inflate(R.layout.leak_canary_heap_dump_toast, null));
//                toast.show();
                // Waiting for Idle to make sure Toast gets rendered.
                Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
                    @Override
                    public boolean queueIdle() {
                        waitingForToast.set(toast);
                        return false;
                    }
                });
            }
        });
    }
 
    private void cancelToast(final Toast toast) {
        mainHandler.post(new Runnable() {
            @Override
            public void run() {
                toast.cancel();
            }
        });
    }
}
public final class AndroidRefWatcherBuilderWithoutToast extends RefWatcherBuilder<AndroidRefWatcherBuilderWithoutToast> {
 
    private static final long DEFAULT_WATCH_DELAY_MILLIS = SECONDS.toMillis(5);
 
    private final Context context;
 
    AndroidRefWatcherBuilderWithoutToast(Context context) {
        this.context = context.getApplicationContext();
    }
 
    /**
     * Sets a custom {@link AbstractAnalysisResultService} to listen to analysis results. This
     * overrides any call to {@link #heapDumpListener(HeapDump.Listener)}.
     */
    public AndroidRefWatcherBuilderWithoutToast listenerServiceClass(
            Class<? extends AbstractAnalysisResultService> listenerServiceClass) {
        return heapDumpListener(new ServiceHeapDumpListener(context, listenerServiceClass));
    }
 
    /**
     * Sets a custom delay for how long the {@link RefWatcher} should wait until it checks if a
     * tracked object has been garbage collected. This overrides any call to {@link
     * #watchExecutor(WatchExecutor)}.
     */
    public AndroidRefWatcherBuilderWithoutToast watchDelay(long delay, TimeUnit unit) {
        return watchExecutor(new AndroidWatchExecutor(unit.toMillis(delay)));
    }
 
    /**
     * Sets the maximum number of heap dumps stored. This overrides any call to {@link
     * #heapDumper(HeapDumper)} as well as any call to
     * {@link LeakCanary#setDisplayLeakActivityDirectoryProvider(LeakDirectoryProvider)})}
     *
     * @throws IllegalArgumentException if maxStoredHeapDumps < 1.
     */
    public AndroidRefWatcherBuilderWithoutToast maxStoredHeapDumps(int maxStoredHeapDumps) {
        LeakDirectoryProvider leakDirectoryProvider =
                new DefaultLeakDirectoryProvider(context, maxStoredHeapDumps);
//        LeakCanary.setDisplayLeakActivityDirectoryProvider(leakDirectoryProvider);
        return heapDumper(new AndroidHeapDumperWithoutToast(context, leakDirectoryProvider));
    }
 
    /**
     * Creates a {@link RefWatcher} instance and starts watching activity references (on ICS+).
     */
    public RefWatcher buildAndInstall() {
        RefWatcher refWatcher = build();
        if (refWatcher != DISABLED) {
            LeakCanary.enableDisplayLeakActivity(context);
            ActivityRefWatcher.install((Application) context, refWatcher);
        }
        return refWatcher;
    }
 
    @Override
    protected boolean isDisabled() {
        return LeakCanary.isInAnalyzerProcess(context);
    }
 
    @Override
    protected HeapDumper defaultHeapDumper() {
        LeakDirectoryProvider leakDirectoryProvider = new DefaultLeakDirectoryProvider(context);
        return new AndroidHeapDumperWithoutToast(context, leakDirectoryProvider);
    }
 
    @Override
    protected DebuggerControl defaultDebuggerControl() {
        return new AndroidDebuggerControl();
    }
 
    @Override
    protected HeapDump.Listener defaultHeapDumpListener() {
        return new ServiceHeapDumpListener(context, DisplayLeakServiceWithoutNotification.class);
    }
 
    @Override
    protected ExcludedRefs defaultExcludedRefs() {
        return AndroidExcludedRefs.createAppDefaults().build();
    }
 
    @Override
    protected WatchExecutor defaultWatchExecutor() {
        return new AndroidWatchExecutor(DEFAULT_WATCH_DELAY_MILLIS);
    }
}
public class DisplayLeakServiceWithoutNotification extends AbstractAnalysisResultService {
 
    @Override
    protected final void onHeapAnalyzed(HeapDump heapDump, AnalysisResult result) {
        Log.e("TAG-viky", "DisplayLeakServiceWithoutNotification-onHeapAnalyzed");
        String leakInfo = leakInfo(this, heapDump, result, true);
        CanaryLog.d("%s", leakInfo);
        if (LeakCanaryWithoutDisplay.getLeakCanaryCallBack() != null) {
            LeakCanaryWithoutDisplay.getLeakCanaryCallBack().onAnalysisResult(leakInfo);
        }
 
        boolean shouldSaveResult = result.leakFound || result.failure != null;
        if (shouldSaveResult) {
            heapDump = renameHeapdump(heapDump);
        }
 
        // New notification id every second.
        afterDefaultHandling(heapDump, result, leakInfo);
    }
 
    private HeapDump renameHeapdump(HeapDump heapDump) {
        String fileName =
                new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss_SSS'.hprof'", Locale.US).format(new Date());
 
        File newFile = new File(heapDump.heapDumpFile.getParent(), fileName);
        boolean renamed = heapDump.heapDumpFile.renameTo(newFile);
        if (!renamed) {
            CanaryLog.d("Could not rename heap dump file %s to %s", heapDump.heapDumpFile.getPath(),
                    newFile.getPath());
        }
        return new HeapDump(newFile, heapDump.referenceKey, heapDump.referenceName,
                heapDump.excludedRefs, heapDump.watchDurationMs, heapDump.gcDurationMs,
                heapDump.heapDumpDurationMs);
    }
 
    /**
     * You can override this method and do a blocking call to a server to upload the leak trace and
     * the heap dump. Don't forget to check {@link AnalysisResult#leakFound} and {@link
     * AnalysisResult#excludedLeak} first.
     */
    protected void afterDefaultHandling(HeapDump heapDump, AnalysisResult result, String leakInfo) {
    }
}
public final class LeakCanaryWithoutDisplay {
 
    public interface LeakCanaryCallBack {
        void onAnalysisResult(String result);
    }
 
    private static LeakCanaryCallBack sLeakCanaryCallBack;
 
    public static LeakCanaryCallBack getLeakCanaryCallBack() {
        return sLeakCanaryCallBack;
    }
    /**
     * Builder to create a customized {@link RefWatcher} with appropriate Android defaults.
     */
    public static AndroidRefWatcherBuilderWithoutToast refWatcher(Context context) {
        return new AndroidRefWatcherBuilderWithoutToast(context);
    }
 
    public static void enableDisplayLeakActivity(Context context) {
        setEnabled(context, DisplayLeakActivity.class, false);
    }
 
    private LeakCanaryWithoutDisplay() {
        throw new AssertionError();
    }
}

屌用这些文件的地方,也需要修改:

// 安装LeakCanary
AndroidRefWatcherBuilderWithoutToast refBuilder = LeakCanaryWithoutDisplay.refWatcher(ContextUtil.getApplication());
refBuilder.listenerServiceClass(LeakUploadService.class);
refBuilder.maxStoredHeapDumps(20);
refBuilder.buildAndInstall();
LeakCanaryWithoutDisplay.enableDisplayLeakActivity(ContextUtil.getContext());
public class LeakUploadService extends DisplayLeakServiceWithoutNotification {
 
    @Override
    protected void afterDefaultHandling(HeapDump heapDump, AnalysisResult result, String leakInfo) {
        if (!result.leakFound || result.excludedLeak){
            return;
        }
        // 下面是处理泄漏数据的代码块
        Log.e("TAG-leakInfo", "leakInfo = " + leakInfo);
        File dumpFile = heapDump.heapDumpFile;
        if (dumpFile.exists()) {
            Log.e("TAG-leakInfo", "dumpFile path = " + dumpFile.getAbsolutePath());
        }
        ...
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,050评论 25 707
  • 请允许我借鉴前辈们的东西~~~~ 感激不尽~~~~~ 以下为Android 框架排行榜 么么哒~ Android...
    嗯_新阅读 1,981评论 3 32
  • 一.榜单介绍 排行榜包括四大类: 单一框架:仅提供路由、网络层、UI层、通信层或其他单一功能的框架 混合开发框架:...
    伟子男阅读 5,235评论 0 161
  • 附上原文作者连接:作者:金诚 一.榜单介绍 排行榜包括四大类: 单一框架:仅提供路由、网络层、UI层、通信层或其他...
    这个美嘉不姓陈阅读 2,247评论 1 35
  • 从小学到大学我通常都是最普通的那一波人,颜值一般,家庭背景一般。校花从来都是属于我高攀不起的存在,感觉像是生活在同...
    好久不来居然被注册阅读 313评论 0 0