Android內(nèi)存泄漏監(jiān)控

1. 內(nèi)存泄漏

為什么會出現(xiàn)內(nèi)存泄漏?因為在GC垃圾回收時會利用GC Root可達(dá)性分析算法去遍歷哪些對象正在被引用摧阅。如果一個對象該銷毀時卻被另一個更長生命周期的對象引用,則會發(fā)生該銷毀的對象無法被回收,導(dǎo)致內(nèi)存泄漏郭毕。
在Java中苍匆,有四種對象引用:強引用可達(dá)性分析算法中此引用不會被回收刘急;軟引用可達(dá)性分析算法如果此時內(nèi)存溢出時,這種引用的對象會被回收浸踩;弱引用可達(dá)性分析算法叔汁,對于這種引用對象會將不在引用鏈之內(nèi)則會將其回收;虛引用則是一種標(biāo)志作用民轴,在回收時會調(diào)用finalize()方法攻柠。

2. 內(nèi)存泄漏常見場景

2.1 單例模式中使用Context

當(dāng)在一個單例對象中持有Activity的Context引用時,該引用會持有整個應(yīng)用程序的生命周期后裸,這可能會導(dǎo)致內(nèi)存泄漏瑰钮。

public class MySingleton {
    private Context mContext;
    private static MySingleton sInstance;

    private MySingleton(Context context) {
        mContext = context;
    }

    public static MySingleton getInstance(Context context) {
        if (sInstance == null) {
            sInstance = new MySingleton(context);
        }
        return sInstance;
    }

    // ... some other methods ...
}

正確代碼:

public class MySingleton {
    private Context mContext;
    private static MySingleton sInstance;

    private MySingleton(Context context) {
        mContext = context.getApplicationContext(); //使用ApplicationContext代替Activity或Application的Context
    }

    public static MySingleton getInstance(Context context) {
        if (sInstance == null) {
            sInstance = new MySingleton(context);
        }
        return sInstance;
    }

    // ... some other methods ...
}

2.2 非靜態(tài)內(nèi)部類持有外部類引用

當(dāng)一個非靜態(tài)內(nèi)部類實例化時,它會持有一個對外部類實例的引用微驶,如果該內(nèi)部類的實例長時間存在浪谴,則可能導(dǎo)致外部類實例的生命周期過長开睡,從而導(dǎo)致內(nèi)存泄漏。

示例代碼:

public class MyActivity extends Activity {
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            // ... handle message ...
        }
    };

    // ... some other methods ...
}

正確代碼:

public class MyActivity extends Activity {
    private static class MyHandler extends Handler {
        private WeakReference<MyActivity> mActivity;

        public MyHandler(MyActivity activity) {
            mActivity = new WeakReference<MyActivity>(activity);
        }

        @Override
        public void handleMessage(Message msg) {
            MyActivity activity = mActivity.get();
            if (activity != null) {
                // ... handle message ...
            }
        }
    }

    private MyHandler mHandler = new MyHandler(this);

    // ... some other methods ...
}

2.3 注冊廣播接收器未注銷

當(dāng)應(yīng)用程序注冊廣播接收器時苟耻,如果不及時注銷篇恒,它將一直存在,從而導(dǎo)致內(nèi)存泄漏凶杖。

正確代碼:

public class MyActivity extends Activity {
    private BroadcastReceiver mReceiver;
    private boolean mIsReceiverRegistered = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    mReceiver = new MyBroadcastReceiver();
    IntentFilter filter = new IntentFilter();
    filter.addAction(Intent.ACTION_SCREEN_ON);
    filter.addAction(Intent.ACTION_SCREEN_OFF);
    mIsReceiverRegistered = true;
    registerReceiver(mReceiver, filter); //注冊廣播接收器
}

 @Override
protected void onDestroy() {
    super.onDestroy();
    if (mIsReceiverRegistered) { //判斷廣播接收器是否已注冊
        unregisterReceiver(mReceiver); //注銷廣播接收器
        mIsReceiverRegistered = false;
    }
}

// ... some other methods ...
}

2.4 匿名內(nèi)部類持有外部類引用

當(dāng)一個匿名內(nèi)部類實例化時胁艰,它會持有一個對外部類實例的引用,如果該內(nèi)部類的實例長時間存在智蝠,則可能導(dǎo)致外部類實例的生命周期過長腾么,從而導(dǎo)致內(nèi)存泄漏。

示例代碼:

public class MyActivity extends Activity {
    private Button mButton;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        new Thread().start();
    }

    // ... some other methods ...
}

2.5 Handler導(dǎo)致的內(nèi)存泄漏

當(dāng)使用Handler時杈湾,如果在處理消息時解虱,持有Activity或Fragment的引用,則可能導(dǎo)致內(nèi)存泄漏漆撞。

示例代碼:

public class MyActivity extends Activity {
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            // ... handle message ...
        }
    };

    // ... some other methods ...
}

正確代碼:

public class MyActivity extends Activity {
    private static class MyHandler extends Handler {
        private WeakReference<MyActivity> mActivity;

        public MyHandler(MyActivity activity) {
            mActivity = new WeakReference<MyActivity>(activity);
        }

        @Override
        public void handleMessage(Message msg) {
            MyActivity activity = mActivity.get();
            if (activity != null) {
                // ... handle message ...
            }
        }
    }

    private MyHandler mHandler = new MyHandler(this);

    // ... some other methods ...
}

2.6 資源沒有正確釋放導(dǎo)致的內(nèi)存泄漏

當(dāng)使用資源(如Bitmap殴泰、Cursor等)時,如果沒有正確釋放浮驳,則可能導(dǎo)致內(nèi)存泄漏悍汛。

示例代碼:

public class MyActivity extends Activity {
    private Bitmap mBitmap;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.my_image);
    }

    // ... some other methods ...
}

正確代碼:

public class MyActivity extends Activity {
    private Bitmap mBitmap;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.my_image);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (mBitmap != null) {
            mBitmap.recycle();
            mBitmap = null;
        }
    }

    // ... some other methods ...
}

3. 內(nèi)存泄漏監(jiān)控

雖然對常見的內(nèi)存泄漏場景有認(rèn)識,但是還是需要對內(nèi)存泄漏進行自動監(jiān)控抹恳,目前有的檢測工具有:leakcanarymatrix的Resource Canary员凝。其使用過程不再講述。

3.1 檢測內(nèi)存泄漏

無論是使用Leakcanary還是Matrix工具奋献,它們檢測代碼是否內(nèi)存泄露都是一樣的思路:
通過 registerActivityLifecycleCallbacks在每個Activity銷毀onDestroy的時候健霹,通過弱引用WeakReference去持有Activity,然后通過間隔預(yù)定的時間手動調(diào)用GC瓶蚂,并通過弱引用的get()方法去查看Activity在內(nèi)存中是否被回收糖埋,如果沒有被回收則判斷為內(nèi)存泄露。當(dāng)然手動調(diào)用GC并一定會做回收操作窃这,像Matrix就通過多次的GC判斷瞳别,才認(rèn)為內(nèi)存泄露。

 class ActivityRefWatcher(val application: Application?) {

    private var handlerThread: HandlerThread? = null
    private var handler: Handler? = null
    private var lastTriggeredTime: Long = 0
    private val maxRedetectTimes = 2
    private val lock = java.lang.Object()

    private val destroyedActivityInfos: ConcurrentLinkedQueue<DestroyedActivityInfo> by lazy { ConcurrentLinkedQueue() }

    private val retryableTaskExecutor: RetryableTaskExecutor by lazy {
        RetryableTaskExecutor(GC_TIME, handlerThread)
    }

    init {
        handlerThread =
            HandlerThreadUtil.getNewHandlerThread("ActivityRefWatcher", Thread.NORM_PRIORITY)
        handler = HandlerThreadUtil.getDefaultHandler()
    }

    fun start() {
        stopDetect()
        application?.registerActivityLifecycleCallbacks(removedActivityMonitor)
        scheduleDetectProcedure()
    }

    fun stop() {
        stopDetect()
        handler?.removeCallbacksAndMessages(null)
    }

    private val removedActivityMonitor: ActivityLifecycleCallbacks =
        object : EmptyActivityLifecycleCallback() {
            override fun onActivityDestroyed(activity: Activity) {
                // 弱引用Activity杭攻,并收集相關(guān)信息
                pushDestroyedActivityInfo(activity)
                // 2s 后開始觸發(fā)gc
                handler?.postDelayed({ triggerGc() }, delayTime)
            }
        }


    private val scanDestroyedActivitiesTask: RetryableTaskExecutor.RetryableTask =
        object : RetryableTaskExecutor.RetryableTask {

            override fun execute(): RetryableTaskExecutor.RetryableTask.Status {
                return checkDestroyedActivities()
            }
        }

    private fun scheduleDetectProcedure() {
        retryableTaskExecutor.executeInBackground(scanDestroyedActivitiesTask)
    }

    private fun stopDetect() {
        application?.unregisterActivityLifecycleCallbacks(removedActivityMonitor)
        unscheduleDetectProcedure()
    }

    private fun unscheduleDetectProcedure() {
        retryableTaskExecutor.clearTasks()
        destroyedActivityInfos.clear()
    }


    private fun pushDestroyedActivityInfo(activity: Activity) {
        val activityName = activity.javaClass.name
        val uuid = UUID.randomUUID()
        val keyBuilder = java.lang.StringBuilder()
        keyBuilder.append(ACTIVITY_REFKEY_PREFIX)
            .append(activityName)
            .append("_")
            .append(java.lang.Long.toHexString(uuid.mostSignificantBits))
            .append(java.lang.Long.toHexString(uuid.leastSignificantBits))
        val key = keyBuilder.toString()
        val destroyedActivityInfo = DestroyedActivityInfo(key, activity, activityName)
        destroyedActivityInfos.add(destroyedActivityInfo)
        synchronized(lock) {
            lock.notifyAll()
        }
    }

    /**
     * 調(diào)用GC
     */
    private fun triggerGc() {
        val currentTime = System.currentTimeMillis()
        if (currentTime - lastTriggeredTime < GC_TIME / 2 - 100) {
            Log.d(TAG, "skip triggering gc for frequency")
            return
        }
        lastTriggeredTime = currentTime
        Log.d(TAG, "triggering gc...")
        Runtime.getRuntime().gc()
        try {
            Thread.sleep(100)
        } catch (e: InterruptedException) {
            e.printStackTrace()
        }
        Runtime.getRuntime().runFinalization()
        Log.d(TAG, "gc was triggered.")
    }

    private fun checkDestroyedActivities(): RetryableTaskExecutor.RetryableTask.Status {
        if (destroyedActivityInfos.isEmpty()) {
            synchronized(lock) {
                try {
                    while (destroyedActivityInfos.isEmpty())
                        lock.wait()
                } catch (ignored: Throwable) {
                    // Ignored.
                }
            }
            return RetryableTaskExecutor.RetryableTask.Status.RETRY
        }

        triggerGc()

        val infoIt = destroyedActivityInfos.iterator()
        while (infoIt.hasNext()) {
            val destroyedActivityInfo = infoIt.next()
            triggerGc()
            if (destroyedActivityInfo.activityRef.get() == null) {
                infoIt.remove()
                continue
            }
            ++destroyedActivityInfo.detectedCount
            if (destroyedActivityInfo.detectedCount < maxRedetectTimes) {
                triggerGc()
                continue
            }
            Log.i(
                TAG,
                "the leaked activity ${destroyedActivityInfo.activityName} with key ${destroyedActivityInfo.key} has been processed. stop polling",
            )
            // 內(nèi)存泄漏
            infoIt.remove()
        }
        return RetryableTaskExecutor.RetryableTask.Status.RETRY
    }

    companion object {

        private const val TAG = "ActivityRefWatcher"

        private val delayTime: Long = 2_000

        private const val ACTIVITY_REFKEY_PREFIX = "ACTIVITY_RESCANARY_REFKEY_"

        private val GC_TIME = TimeUnit.MINUTES.toMillis(1)

    }


}

3.2 dump與分析hprof

當(dāng)知道有Activity內(nèi)存泄漏之后祟敛,就要去分析內(nèi)存泄漏的引用鏈。這里可以通過內(nèi)存快照來分析泄漏鏈兆解,hprof文件就是虛擬機在某個時刻上所有對象的內(nèi)存快照馆铁,記錄了對象的類名,大小和引用關(guān)系等锅睛。所以埠巨,這里關(guān)于hprof有兩個操作历谍,一是dump hprof文件,二是分析hprof文件辣垒。這兩個操作都是耗時的望侈,一定要在子線程或通過fork一個子進程,進行dump和分析hprof勋桶。

dump hprof
dump內(nèi)存快照可以通過Debug.dumpHprofData方法脱衙,dump文件大小可能有幾百兆,一些優(yōu)化是邊dump邊裁剪文件哥遮,這需要涉及到native hook的技術(shù)岂丘。
下面分別列出在子線程中dump和在子進程dump的實例代碼:

  • 子線程dump

    public void dumpHprofData() {
        final String hprofPath = "/sdcard/myapp.hprof";

        // 在異步線程中執(zhí)行dumpHprofData()操作
        new Thread(new Runnable() {
            @Override
            public void run() {
                Debug.dumpHprofData(hprofPath);

                // 在主線程中提示用戶操作完成
                new Handler(Looper.getMainLooper()).post(new Runnable() {
                    @Override
                    public void run() {
                        Toast.makeText(MyActivity.this, "hprof file generated", Toast.LENGTH_SHORT).show();
                    }
                });
            }
        }).start();
    }

            val storageDirectory = File(application?.cacheDir, "leakactivity")
            if (!storageDirectory.exists()) {
                storageDirectory.mkdir()
            }
            val fileName =
                SimpleDateFormat("yyyy-MM-dd_HH-mm-ss_SSS'.hprof'", Locale.US).format(Date())
            val file = File(storageDirectory, fileName)
            // dump 出堆轉(zhuǎn)儲文件
            Debug.dumpHprofData(file.absolutePath)
            Log.i(TAG, "dumpHeap: ${file.absolutePath}")
  • 子進程dump
private fun dumpHprof() {
    Thread {
        // 創(chuàng)建一個子進程
        val process = Runtime.getRuntime().exec(arrayOf("sh"))

        // 獲取輸出流和輸入流
        val outputStream = process.outputStream
        val inputStream = process.inputStream

        // 向子進程寫入命令
        outputStream.write("am dumpheap com.package.name /sdcard/leak.hprof\n".toByteArray())
        outputStream.flush()

        // 讀取子進程輸出的結(jié)果
        val reader = BufferedReader(InputStreamReader(inputStream))
        var line: String?
        while (reader.readLine().also { line = it } != null) {
            Log.d("DumpHprof", line!!)
        }

        // 關(guān)閉輸出流和輸入流
        outputStream.close()
        inputStream.close()

        // 等待子進程結(jié)束
        process.waitFor()

        // 處理hprof文件
        // ...
    }.start()
}

這段代碼創(chuàng)建了一個子進程陵究,并向子進程發(fā)送命令來執(zhí)行Debug.dumpHprofData操作眠饮,這樣就可以讓主線程不卡頓了。

分析 hprof
分析hprof也是一種相對耗時的操作铜邮,分析hprof可以在本地也可以放到服務(wù)器上仪召,如果之前的hprof文件沒有裁剪,可以裁剪之后才分析或上傳松蒜。https://blog.yorek.xyz/android/3rd-library/hprof-shrink/ 一文中講了幾種方案扔茅,這里不展開分析。提供幾種開源方案秸苗,一種是shark_leakcanary庫召娜,一種是haha庫,當(dāng)然也可以自己實現(xiàn)分析hprof文件惊楼,其主要結(jié)構(gòu)是header和多個record組成玖瘸。
如下是使用shark_leakcanary庫實現(xiàn)的hprof文件的分析。

  private fun dumpHeap() {
        handler?.post {
            val heapAnalyzer = HeapAnalyzer(OnAnalysisProgressListener { step ->
                Log.i(TAG, "Analysis in progress, working on: ${step.name}")
            })
            val heapAnalysis = heapAnalyzer.analyze(
                heapDumpFile = file,
                leakingObjectFinder = FilteringLeakingObjectFinder(
                    AndroidObjectInspectors.appLeakingObjectFilters
                ),
                referenceMatchers = AndroidReferenceMatchers.appDefaults,
                computeRetainedHeapSize = true,
                objectInspectors = AndroidObjectInspectors.appDefaults.toMutableList(),
                proguardMapping = null,
                metadataExtractor = AndroidMetadataExtractor
            )
            Log.i(TAG, "dumpHeap: \n$heapAnalysis")
        }
    }

4. Bitmap優(yōu)化

4.1 常規(guī)Bitmap優(yōu)化

圖片占用的內(nèi)存大小 = 圖片寬度 × 圖片高度 × 每個像素占用的字節(jié)數(shù)
例如檀咙,如果有一張 1000 × 1000 像素的 ARGB_8888 格式的圖片雅倒,每個像素占用 4 個字節(jié),則該圖片占用的內(nèi)存大小為:1000 × 1000 × 4 = 4,000,000 字節(jié) = 3.81 MB

由于 Bitmap 對象可能占用大量的內(nèi)存弧可,因此在使用 Bitmap 時需要注意其優(yōu)化蔑匣,以避免內(nèi)存問題和性能問題。以下是一些 Android Bitmap 的優(yōu)化方案:

  1. 使用 inSampleSize 屬性來減少 Bitmap 對象的內(nèi)存使用棕诵,它指定了加載圖片時縮小的倍數(shù)裁良。例如,如果將 inSampleSize 設(shè)為 2校套,則圖片將被縮小為原始大小的 1/2价脾。
BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = 2;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.image, options);
  1. 使用 RGB_565 格式來減少 Bitmap 對象的內(nèi)存使用。默認(rèn)情況下搔确,Android 使用 ARGB_8888 格式來表示 Bitmap 對象彼棍。而使用 RGB_565 格式可以將每個像素的內(nèi)存使用減少到一半灭忠。
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.RGB_565;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.image, options);
  1. 使用 BitmapRegionDecoder 來只加載圖片的一部分,而不是整個圖片座硕。例如弛作,如果只需要加載圖片的頂部一部分,可以使用以下代碼:
InputStream inputStream = getResources().openRawResource(R.drawable.image);
BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(inputStream, false);
Bitmap bitmap = decoder.decodeRegion(new Rect(0, 0, decoder.getWidth(), decoder.getHeight() / 2), null);
  1. 緩存 Bitmap 對象
    使用 LruCache 來緩存 Bitmap 對象华匾,以避免頻繁地創(chuàng)建和銷毀 Bitmap 對象映琳。LruCache 是一個內(nèi)存緩存類,可以在內(nèi)存達(dá)到一定限制時自動刪除最近最少使用的對象蜘拉。例如萨西,以下代碼演示了如何使用 LruCache 來緩存 Bitmap 對象:
private LruCache<String, Bitmap> mBitmapCache;

public void initBitmapCache() {
    int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
    int cacheSize = maxMemory / 8;

    mBitmapCache = new LruCache<String, Bitmap>(cacheSize) {
        @Override
        protected int sizeOf(String key, Bitmap value) {
            return value.getByteCount() / 1024;
        }
    };
}

public void addBitmapToCache(String key, Bitmap bitmap) {
    if (getBitmapFromCache(key) == null) {
        mBitmapCache.put(key, bitmap);
    }
}

public Bitmap getBitmapFromCache(String key) {
    return mBitmapCache.get(key);
}
  1. 合理釋放 Bitmap 對象
    當(dāng) Bitmap 對象不再使用時,需要手動調(diào)用 recycle() 方法來釋放內(nèi)存旭旭。例如谎脯,以下代碼演示了如何在 ImageView 中加載 Bitmap 并在不再需要時釋放 Bitmap:
ImageView imageView = findViewById(R.id.image_view);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.image);
imageView.setImageBitmap(bitmap);

// 釋放 Bitmap 對象
imageView.setImageBitmap(null);
bitmap.recycle();

4.2 大圖監(jiān)控

可以Plugin、Transform持寄、ASM技術(shù)來在ImageView的方法進行插樁技術(shù)源梭。

  1. 創(chuàng)建一個Gradle插件,使用Transform技術(shù)修改字節(jié)碼稍味。
  2. 使用ASM技術(shù)在ImageView的setImageDrawable()方法中插入代碼废麻,用于監(jiān)控圖片的寬高。
    具體代碼不表模庐。

5. 內(nèi)存常見優(yōu)化

最后在來總結(jié)下烛愧,內(nèi)存優(yōu)化的有哪些常用方案:

  1. 使用 SparseArray 和 ArrayMap 替代 HashMap
    HashMap 存儲大量的對象時會消耗很大的內(nèi)存,尤其是在處理大量數(shù)據(jù)時掂碱。在 Android 開發(fā)中怜姿,我們可以使用 SparseArray 和 ArrayMap 來替代 HashMap。SparseArray 是 Android 提供的一個優(yōu)化版的 Map顶吮,專門用來處理鍵為 int 類型的情況社牲;而 ArrayMap 則是優(yōu)化版的 Map,專門用來處理小數(shù)據(jù)集合的情況悴了,它比 HashMap 更加高效搏恤。

  2. 使用 Bitmap 配置的 ARGB_8888
    Bitmap 是 Android 開發(fā)中經(jīng)常使用的對象,它占用大量的內(nèi)存湃交。在使用 Bitmap 時熟空,我們可以通過設(shè)置 Bitmap 的 Config 來減少內(nèi)存消耗。ARGB_8888 是一種高質(zhì)量的 Bitmap 配置搞莺,雖然會占用更多的內(nèi)存息罗,但是可以保證圖片的清晰度。

  3. 使用 BitmapFactory.Options 來壓縮圖片
    在 Android 應(yīng)用中才沧,我們經(jīng)常需要加載大量的圖片迈喉,而這些圖片的分辨率往往很高绍刮,導(dǎo)致內(nèi)存消耗過大。為了減少內(nèi)存消耗挨摸,我們可以使用 BitmapFactory.Options 來對圖片進行壓縮孩革。可以通過設(shè)置 BitmapFactory.Options 中的 inSampleSize 屬性來控制壓縮比例得运。

  4. 使用 LruCache 來緩存對象
    LruCache 是 Android 提供的一種緩存對象的方式膝蜈,它可以幫助我們減少內(nèi)存消耗。LruCache 可以按照最近最少使用的原則來緩存對象熔掺,并且可以根據(jù)緩存對象的大小來自動調(diào)整緩存容量饱搏。

  5. 及時釋放資源
    在 Android 開發(fā)中,我們需要及時釋放無用的資源置逻,以避免內(nèi)存泄漏和內(nèi)存溢出推沸。例如,關(guān)閉 Cursor 對象诽偷、釋放 Bitmap 對象坤学、及時取消異步任務(wù)等。

  6. 使用工具檢查內(nèi)存泄漏問題
    使用內(nèi)存分析工具报慕,可以幫助我們檢查內(nèi)存問題,包括內(nèi)存泄漏和內(nèi)存溢出压怠。我們可以使用這些工具來找出應(yīng)用中的內(nèi)存問題眠冈,并及時進行優(yōu)化。

  7. 優(yōu)化布局和控件
    在布局中菌瘫,我們可以使用 FrameLayout 代替 RelativeLayout蜗顽,因為 RelativeLayout 對內(nèi)存消耗較大。在使用控件時雨让,我們可以避免使用過多的控件和嵌套控件雇盖,盡量使用簡單的布局方式和控件。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末栖忠,一起剝皮案震驚了整個濱河市崔挖,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌庵寞,老刑警劉巖狸相,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異捐川,居然都是意外死亡脓鹃,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進店門古沥,熙熙樓的掌柜王于貴愁眉苦臉地迎上來瘸右,“玉大人娇跟,你說我怎么就攤上這事√” “怎么了逞频?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長栋齿。 經(jīng)常有香客問我苗胀,道長,這世上最難降的妖魔是什么瓦堵? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任基协,我火速辦了婚禮,結(jié)果婚禮上菇用,老公的妹妹穿的比我還像新娘澜驮。我一直安慰自己,他們只是感情好惋鸥,可當(dāng)我...
    茶點故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布杂穷。 她就那樣靜靜地躺著,像睡著了一般卦绣。 火紅的嫁衣襯著肌膚如雪耐量。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天滤港,我揣著相機與錄音廊蜒,去河邊找鬼。 笑死溅漾,一個胖子當(dāng)著我的面吹牛山叮,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播添履,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼屁倔,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了暮胧?” 一聲冷哼從身側(cè)響起锐借,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎叔壤,沒想到半個月后瞎饲,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡炼绘,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年嗅战,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡驮捍,死狀恐怖疟呐,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情东且,我是刑警寧澤启具,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站珊泳,受9級特大地震影響鲁冯,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜色查,卻給世界環(huán)境...
    茶點故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一薯演、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧秧了,春花似錦跨扮、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至晶通,卻和暖如春璃氢,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背录择。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工拔莱, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人隘竭。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓,卻偏偏與公主長得像讼渊,于是被迫代替她去往敵國和親动看。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,979評論 2 355

推薦閱讀更多精彩內(nèi)容