性能优化技巧知识梳理(2) - 内存优化

一、前言

对于应用中的内存优化,和布局优化类似,也有很多的技巧,这里我们分为以下几方面来总结:

  • Java优化技巧
  • 避免不必要对象的创建
  • 保证不使用对象的释放
  • 使用性能优化工具,定位内存问题

二、Java 优化技巧

首先,我们介绍一些Java语法中的优化技巧,强烈推荐大家在编程时参考阿里巴巴编写的<<阿里巴巴Java开发手册>>,下载地址,这里简要介绍一些常用的知识点:

  • 尽量采用原始数据类型,而不是对象,例如int要比Integer占用更少的内存。
  • 如果一个方法不需要访问对象的成员变量,或者调用非静态方法,那么应当将它声明为static
  • 将常量声明为static final
  • 避免内部的getXXX()/setXXX()方法,而是直接访问变量。
  • 使用增强的for循环,而不是for(int i = 0; i < 100; i++)这样的循环。
  • 避免使用float类型,当对精度要求不高,采用int类型。

三、避免不必要对象的创建

(1) 单例对象在需要的时候初始化

在使用单例时,我们应当仅在使用到该单例时才去初始化它,这里我们可以通过“静态初始化会在类被加载时触发”这一原理,来实现懒加载。

public class OptSingleton {
    
    private OptSingleton() {}
    
    public static OptSingleton getInstance() {
        return Holder.INSTANCE;
    }
    
    private static class Holder {
        public static final OptSingleton INSTANCE = new OptSingleton();
    }
}

(2) 避免进行自动装箱

自动装箱指的是将原始的数据类型转换成为引用类型,例如int转换成为Integer,这种自动装箱操作,虽然方便了我们的使用,但是在某些场景下的不当使用有可能会导致性能问题,主要有两点:

  • 第一点:使用操作符时的自动装箱
    public static void badAssemble() {
        Integer sum = 0;
        for (int i = 0; i < (1 << 30); i++) {
            sum = sum + i;
        }
    }

就有自动装箱的过程,其中sum+i可以分解为下面这两句,也就是说,在循环的过程中,我们创建了大量的临时对象Integer,而创建完之后,它们很快又会被GC回收掉,因此,会出现内存抖动的现象。

int result = sum + i;
Integer sum = new Integer(result);

我们使用Android Studio提供的检测工具可以验证上面的结论:


而如果我们使用正常的写法,那么是不会出现上面的情况的:

    public static void badAssemble() {
        int sum = 0;
        for (int i = 0; i < (1 << 30); i++) {
            sum = sum + i;
        }
    }

此时的监测结果为:


  • 第二点:使用容器时的自动装箱

当我们使用例如HashMap这种容器的时候,除了要存储保存的数据,还要存储Key值,这些Key值就是由自动装箱的过程所产生的。

此时,我们就可以考虑选用Android平台上提供的优化容器来尽可能地避免装箱操作,这些容器包括SparseArraySparseBooleanArraySparseIntArraySparseLongArray,这些容器有以下特点:

  • key值都为原始数据类型int,避免了隐式装箱的操作,这同时也是它的局限性。
  • 其内部是通过两个数组存储数据的,一个用于key,另一个用于value,为了优化性能,它内部对数据还采取了压缩的方式来表示稀疏数组的数据,从而节约内存空间。
  • 在查找数据时,采用的是二分查找法,相比于HashMap需要遍历Entry数组找到相等的hash值,一般来说,我们的数据量都不会太大,而在数据量较小时,二分查找要比遍历数组,查找速度更快。

(3) 预先指定容器的大小

当我们使用例如HashMapArrayList这些容器时,往往不习惯给它们指定一个初始值,然而当这些容器存储空间不足时,就会去自动扩容,其扩容的大小往往是原始大小的两倍。

因此,当我们需要存储额外的一个元素的时候刚好容器不够了,那么就需要扩容,但是这时候就会出现额外的浪费空间。

(4) 对于占用资源的 Activity,合理的使用 LaunchMode

对于Activity来说,其默认的启动模式是standard,也就是说,每次启动这个Activity,都会创建一个新的实例,像类似于浏览器这种内存大户,每次外部打开一个网页,都需要创建一个Activity,而Activity又会去实例化WebView,那么是相当耗费资源的,这时,我们就可以考虑使用singleTask或者singleInstance来实现。

(5) 处理屏幕旋转导致的重建

当屏幕发生旋转时,如果我们没有在AndroidManifest.xml中,对其configChanges属性进行声明,那么就会导致Activity进行重建,此时,就需要重新加载Activity所需要展示的数据。

此时,我们就可以对其进行如下的声明:

android:configChanges="keyboardHidden|orientation|screenSize"

接着在ActivityonConfigurationChanged进行监听,对布局进行相应的改变,而不需要重新加载数据。

(6) 处理字符串拼接

在代码中,我们经常使用到字符串拼接的操作,这里有两点注意:

采用高效的拼接方式

例如下面的操作,就会创建大量的临时对象:

    public static void badString() {
        String result = "result";
        String append = "append";
        for (int i = 0; i < (1 << 30); i++) {
            result += append;
        }
    }

内存检测的结果如下,可以发现,我们出现了大量内存抖动的情况:


而如果我们采用StringBuilder的方式进行拼接:

    public static void goodString() {
        StringBuilder result = new StringBuilder("result");
        String append = "append";
        for (int i = 0; i < (1 << 20); i++) {
            result.append(append);
        }
    }

那么最终的结果为:



因此,在处理字符串拼接的时候,应当尽量避免直接使用"+"号,而是使用以下两种方式的一种:

  • 使用静态方法,String.format方法进行拼接。
  • 非线程安全的StringBuilder,或者是线程安全的StringBuffer

避免不必要的字符串拼接

当我们需要打印Log时,一般会将它们写在一个公共类中,然后使用一个DEBUG开关,让他们在外发版本上关闭:

    private static final boolean DEBUG = true;
    
    public static void LogD(String tag, String msg) {
        if (DEBUG) {
            Log.d(tag, msg);
        }
    }

但是这种方式有一点弊端,就是,我们在调用该方法时msg一般都是通过拼接多个字符串进行传入的,也就是说,即使没有打印该Log,也会进行字符串拼接的操作,因此,我们应当尽量将DEBUG开关放在字符串拼接的外部,避免不必要拼接操作。

(7) 减少不必要的异常

在某些时候,如果我们能预见到某些有可能会发生异常的场景,那么提前进行判断,将可以避免由于异常所带来的代价,以启动第三方应用为例,我们可以先判断该intent所对应的应用是否存在,再去启动它,而不是等到异常发生时再去捕获:

    public static void startApp(Context context) {
        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("www.qq.com"));
        intent.setComponent(new ComponentName("com.android.browser", "com.android.browser.BrowserActivity"));
        if (intent.resolveActivity(context.getPackageManager()) == null) {
            return;
        }
        try {
            context.startActivity(intent);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

(8) 线程复用

当执行异步操作时,不要通过new Thread的方式启动一个新的线程来执行操作,而是尽可能地对已经创建的线程进行复用,一般来说,主要有两种方式:

(9) 合理的适应对象池

例如,我们最常用的Handler发送消息,当需要创建一个消息时,可以使用Handler提供的obtainMessage方法,获取到Message对象,其内部,就会去Message中所维护的一个静态链表中,查找当前可用的Message对象,并将其标志位置为0,表明其正在使用。

使用对象池时,应当注意两点:

  • 将对象放回对象池时,注意初始化,防止出现脏数据。
  • 合理的控制对象池的增长,防止出现大量无用对象。

(10) 使用 inBitmap 对内存块复用

inBitmap指的是复用内存块,不需要重新给这个Bitmap申请一块新的内存,避免了一次内存的分配和回收,关于inBitmap的详细解释,可以参见这篇文章,Managing Bitmap Memory,其Demo对应的下载地址,对于inBItmap属性的使用,有以下两点限制:

  • 该属性只能在3.0之后使用,在2.3上,bitmap的数据是存储在native的内存区域中。
  • 4.4之前,inBitmap只能重用相同大小的bitmap内存区域,而在4.4之后,可以重用任何bitmap内存区域,只要这块内存比将要分配的内存大就可以。

(11) 使用注解替代枚举

public class Constant {

    public static final int FLAG_START = 0;
    public static final int FLAG_STOP = 1;
    public static final int FLAG_PAUSE = 2;

    @IntDef({FLAG_START, FLAG_STOP, FLAG_PAUSE})
    public @interface VideoState {}
}

当我们定义的形参时,在参数之前,加上之前定义的注解:

    public static void accept(@Constant.VideoState int videoState) {
        Log.d("OptUtils", "state=" + videoState);
    }

如果我们传入了不属于上面的三个值,那么IDE就会警告我们:

(12) 谨慎初始化 Application

当我们在项目当中,引入一些第三方库,或者将一些组件放到其它进程,加入我们自定义了Application的子类,并且在AndroidManifest.xml中进行了声明,那么在启动这些运行在其它进程中的组件时,就会调用该ApplicationonCreate()方法,此时,我们就应当根据进程所要求的资源进行初始化。

例如下面,我们将RemoteActivity声明在remote进程当中,并且给application指定了自定义的OptApplication

    <application
        android:name=".OptApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".OptActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity android:name=".RemoteActivity" android:process=":remote"/>
    </application>

OptApplication中,判断一下调用该方法进程名,进行不同逻辑的初始化操作:

public class OptApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        if (isMainProcess()) {
            //对主进程的资源进行初始化。
            Log.d("OptApplication", "isMainProcess=" + true);
        } else {
            //对其它进程资源进行初始化。
            Log.d("OptApplication", "isMainProcess=" + false);
        }
    }

    private boolean isMainProcess() {
        ActivityManager am = ((ActivityManager) getSystemService(Context.ACTIVITY_SERVICE));
        List<ActivityManager.RunningAppProcessInfo> process = am.getRunningAppProcesses();
        String mainProcessName = getPackageName();
        int myPid = android.os.Process.myPid();
        for (ActivityManager.RunningAppProcessInfo info : process) {
            if (info.pid == myPid && mainProcessName.equals(info.processName)) {
                return true;
            }
        }
        return false;
    }
}

(13) 避免在 onDraw 方法中创建对象

onDraw方法中创建临时对象,不仅会影响绘制的性能,而且这些临时对象在onDraw方法执行完之后又很快被回收,那么将会造成内存抖动。

(14) 合理地使用 ArrayMap 替代 HashMap

前面我们介绍了SparseArray,它的局限性是其key值只能为原始数据类型int,而如果我们要求它的key值为引用类型时,那么可以考虑使用ArrayMap

SparseArray一样,它会对key使用二分法进行添加、查找、删除等操作,在添加、删除、查找数据的时候都是先使用二分查找法得到相应的index,然后通过index进行添加、查找、删除操作。

如果在数据量较大的情况,那么它的性能将退化至少50%

(15) 谨慎使用抽象编程

抽象能够提升代码的灵活性与可维护性,然而,抽象会导致一个显著的额外内存开销:它们需要同等量的代码用于可执行,这些代码会被mapping到内存中。

(16) 使用 Protocol Buffers

在平时的网络数据传输时,一般用的最多的是JSON或者xml,而Protocal BuffersGoogle为序列化结构数据而设计的,相比于普通的数据传输方式,它具有以下优点:

  • 编码/解码方式简单
  • 序列化 & 反序列化 & 速度快
  • 数据压缩效果更好

关于Protocol Buffers的详细介绍,大家可以阅读 Carson_Ho 所写的一系列文章,推荐阅读:Protocol Buffer 序列化原理大揭秘 - 为什么Protocol Buffer性能这么好?

(17) 谨慎使用依赖注入框架

诸如Guice或者RoboGuice这些依赖注入框架,它们可以减少大量findViewById的繁琐操作,但是这些注解的框架为了要搜寻代码中的注解,通常都需要经历较长的初始化过程,并且还可能将一些你用不到的对象也一并加载到内存当中,这些用不到的对象会一直占用内存空间,等到很久之后才释放。

(18) 谨慎使用多进程

在我们有大量需要运行在后台的任务,例如音乐、视频、下载等业务,那么可以将它们放在独立的进程当中。但是,我们不应当滥用它们,因为每创建一个新的进程,那么必然要分配一些内存来保存该进程的一些信息,这都将增加内存的占用。

(19) 使用 ProGurad 优化代码

通过ProGuard对代码进行优化、压缩、混淆,可以移除不需要的代码、重命名类、域与方法等,做法就是在buildTypes的指定类型下增加下面的代码:

    buildTypes {
        release {
            //对于release版本采用进行混淆。
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-project.txt'
        }
        debug {
            //对于debug版本不混淆。
            minifyEnabled false
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-project.txt'
        }

这里的混淆规文件有两份,如果有多份,那么可以使用逗号分隔,第一个是Android自带的混淆文件,而第二个则是应用自定义的混淆规则文件,关于混淆文件的语法,可以参考这篇文章: ProGuard 代码混淆技术详解

(20) 谨慎使用第三方 Library

在项目中引入第三方Library时,应当注意以下几点:

  • 不要导入无用的功能:如果需要使用到定位功能,那么就只需要导入定位的Library即可,不要引入导航等Library
  • 不要导入功能重复的Library:目前存在很多开源的第三方网络框架,例如Volley/OkHttp/Retrofit等,那么在我们引入一个新的网络框架时应当先检查代码中原有的网络框架,将之前的代码都替换成为新的框架,而不是导入多份。
  • 使用为移动平台定制的Library:很多开源项目都会针对移动平台进行项目的优化与裁剪,我们应当首先考虑使用拥有这些版本的开源库。

(21) 使用 AnimatedVectorDrawable 替换帧动画

图片压缩知识梳理(6) - VectorDrawable 及 AnimatedVectorDrawable 使用详解 中,我们介绍了AnimatedVectorDrawable的使用,在需要实现一些简单图形的动画时,它比帧动画效率更高、占用内存更小。

(22) 读取和屏幕分辨率匹配的图片

当我们读取图片时,应当尽量结合当前手机的分辨率进行处理,这里有两点建议:

  • 在图片加载到内存之前,对其进行缩放,避免加载进入过大的图片,以从资源文件中读取图片为例,我们传入预期的宽高,先将Bitmap.ConfiginJustDecodeBounds置为true,获取到目标图片的宽高而不是将整张图片都加载到内存中,在根据预期的宽高计算出一个比例,去加载一个适合屏幕分辨率的图片,具体的操作如下面的代码块所示:
    public static int calculateInSampleSize(BitmapFactory.Options options, int dstWidth, int dstHeight) {
        int srcWidth = options.outWidth;
        int srcHeight = options.outHeight;
        int inSampleSize = 1;
        if(srcHeight > dstHeight && srcWidth > dstHeight) {
            int halfWidth = srcWidth / 2;
            int halfHeight = srcHeight / 2;
            while ((halfHeight / inSampleSize) > dstHeight && (halfWidth / inSampleSize) > dstWidth) {
                inSampleSize *= 2;
            }
        }
        return inSampleSize;
    }

    public static Bitmap decodeResource(Resources res, @DrawableRes int resId, Bitmap.Config config, int dstWidth, int dstHeight) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inPreferredConfig = config;
        if(dstWidth <= 0 && dstHeight <= 0) {
            return BitmapFactory.decodeResource(res, resId, options);
        }
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, resId, options);
        options.inSampleSize = calculateInSampleSize(options, dstWidth, dstHeight);
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeResource(res, resId, options);
    }
  • 将图片放在与屏幕分辨率匹配的文件夹当中

图片基础知识梳理(2) - Bitmap 占用内存分析 一文当中,我们分析过,在res目录下可以建立多个不同的图片文件夹,即drawable-xhpi/drawable-xxhdpi/drawable-xxxhdpi,只有当图片放在机型对应分辨率下的文件夹时,才不会进行缩放操作,如果某张图片放在比它分辨率低的文件夹当中,那么将会进行放大操作,不仅会使图片变得模糊,还要占用额外的内存。

因此,我们应当将图片放在对应机型分辨率的文件夹当中。

三、保证不使用对象的释放

(1) 避免 Activity 泄露

Activity泄露是我们在开发中最长遇见的内存泄露类型,下面总结几点大家比较容易犯的错误:

在 Activity 中定义非静态的 Handler 内部类

例如下面这样,我们在Activity中定义了一个非静态的内部类LeakHandler,那么作为内部类,leakHandler默认持有外部类的实例,也就是LeakActivity

public class LeakActivity extends Activity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        LeakHandler leakHandler = new LeakHandler();
        leakHandler.sendEmptyMessageDelayed(0, 50000);
    }
    
    private class LeakHandler extends Handler {

        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
        }
    }
}

在调用了sendEmptyMessageDelayed之后,那么会创建一个Message对象放到Looper的队列MessageQueue当中等待被执行,而该Message中的target会执行发送它的Handler,也就是LeakHandler,那么在该消息被处理之前,会一直存在一条从LeakActivityMessageQueue的引用链,因此,在这段时间内如果Activity被销毁,它的内存也无法释放,就是造成内存泄露。

对于这种问题,有以下几个处理的技巧:

  • Handler定义为静态内部类,这样它就不会持有外部的类的引用,如果需要在handleMessage中调用Activity中的方法,那么可以传入它作为参数,并持有它的弱引用以保证它能够回收。
    private static class SafeHandler extends Handler {
        
        private WeakReference<Activity> mActHolder;
        
        public SafeHandler(Activity activity) {
            mActHolder = new WeakReference<>(activity);    
        }

        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            if (mActHolder != null) {
                Activity activity = mActHolder.get();
                if (activity != null && !activity.isDestroyed()) {
                    //仅在 Activity 没有被销毁时,才执行操作。
                }
            }
        }
    }
  • ActivityonDestroy()方法中,通过removeCallbacksAndMessages(null)方法移除所有未执行的消息。

单例中的成员变量或者 static 成员变量持有了 Activity 的引用

根据持有的方式,可以简单地分为直接持有、间接持有两种类型:

  • 直接持有:在Android的很多Api中,都会使用到上下文信息Context,而Activity继承于Context类,因此我们经常会将它传给其它类,并将它作为这些类的成员变量以便后续的操作,那么如果这个成员变量所属的类是一个单例,或者说它是该类中的一个静态成员变量,那么就会导致该Activity所占用的内存无法被释放。
  • 间接持有:某个中间对象持有了Activity,而该中间对象又作为了单例中的成员变量或者某类中的static成员变量,这些对象最常见的有以下两类:
    (a) Activity的非静态内部类,例如监听器,那么它就会默认持有Activity的引用。
    (b) Activity中的控件,其mContext变量指向了它所属的Activity

当出现这种情况时,我们应当注意这几点:

  • 如果可以使用ApplicationContext,那么就用Activity.getApplicationContext()来替代,不要用Activity
  • 如果必须使用Activity,那么确保在ActivityonDestroy()方法执行时,将它们到Activity的引用链想方设法切断,将引用设为空,或者注销监听器。

当然不仅是Activity,对于应用当中的某些大对象,例如Bitmap等,我们也应当注意,是否出了类似于上面这种直接和间接引用的情况。

(2) 对于只执行一次的后台任务,使用 IntentService 替代 Service

当我们需要将某些任务的生命周期和Activity分离开来,那么一般会使用Service,但是Service就需要我们进行手动管理,如果忘记,那么将会导致额外的内存占用,并且拥有Service进程的oom_adj值一般会高于没有Service的进程,系统会更倾向于将它保留。

对于一些短时的后台任务,我们可以考虑采用IntentService,它的onHandleIntent回调是在异步线程中执行的,并且任务执行完毕后,该Service会自动销毁,不需要手动管理。

(3) 在 onLowMemory() / onTrimMemory() 回调当中,释放不必要的资源

为了能让各个应用知晓当前系统内存的使用情况,提供了两种类型的回调onLowMemoryonTrimMemory,在ApplicationActivityFragementServiceContentProvider这些组件中,都可以收到这两个回调,进行相应的处理。

onLowMemory

当最后一个后台应用(优先级为background的进程)被杀死之后,前台应用就会收到onLowMemory回调。

onTrimMemory(int level)

onLowMemory相比,onTrimMemory的回调更加频繁,每次计算进程优先级时,只要满足对应的条件,就会触发。level参数则表明了当前内存的占用情况,各等级的解释如下表所示,等级从上到下,进程被杀的可能性逐渐增大:


我们应当根据当前的等级,释放掉一些不必要的内存,以免应用进程被杀死。

(4) 及时关闭 Cursor

无论是使用数据库,还是ContentProvider来查询数据,在查询完毕之后,一定要记得关闭Cursor

(5) 属性动画导致的内存泄露

(6) HashMap 中,由于对象的 hashCode 改变,导致内存泄露

四、使用性能优化工具,定位内存问题

关于内存的优化工具,之前一系列的文章已经介绍过了,大家可以查看下面这三篇文章:

五、特别鸣谢

以上的总结,借鉴了网上几位大神的总结,特此鸣谢:

参考的文章包括以下几篇:


更多文章,欢迎访问我的 Android 知识梳理系列:

推荐阅读更多精彩内容