吊打面试官的Android性能优化秘籍

开篇:性能优化的重要性

在如今这个移动应用爆炸式增长的时代,Android 开发的竞争愈发激烈。用户对于 App 的要求早已不仅仅停留在功能满足上,性能表现更是成为了他们选择或抛弃一款应用的关键因素。性能优化,作为提升 App 用户体验的核心手段,在 Android 开发中占据着举足轻重的地位。

从用户体验的角度来看,性能直接关系到用户是否愿意持续使用你的应用。想象一下,当用户满怀期待地打开一款 App,却遭遇漫长的启动时间,或是在使用过程中频繁出现卡顿、掉帧的情况,那将是多么糟糕的体验。这种不良体验不仅会导致用户对应用的满意度直线下降,更有可能促使他们迅速卸载应用,转而投向竞争对手的怀抱。相反,一款性能卓越的 App,启动迅速、操作流畅、响应灵敏,能够为用户带来愉悦的使用感受,从而增加用户的粘性和忠诚度。

在实际开发中,性能优化也面临着诸多挑战。随着应用功能的不断丰富和业务逻辑的日益复杂,代码的规模和复杂度也在不断攀升,这往往会导致性能问题的出现。例如,不合理的内存管理可能引发内存泄漏和内存溢出,导致应用崩溃;复杂的布局嵌套和低效的绘制操作会造成界面卡顿,影响用户交互;频繁的网络请求和低效的数据库操作则会消耗大量资源,降低应用的响应速度。

而在面试中,性能优化相关的问题更是高频出现。面试官通过这些问题,希望了解应聘者对性能问题的理解深度、分析能力以及解决问题的经验和技巧。比如,如何优化应用的启动速度,如何避免内存泄漏,如何提升列表的滑动性能等等。这些问题不仅考察应聘者的技术功底,更能反映出他们在实际开发中是否具备关注性能、优化性能的意识和能力。

一、ANR 问题全解析

(一)ANR 是什么

ANR,即 Application Not Responding,意为应用程序无响应 。在 Android 系统中,当应用程序的主线程(也称为 UI 线程)被阻塞,无法及时响应用户输入事件(如触摸、按键操作)或完成系统要求的任务时,就会触发 ANR。一旦 ANR 发生,系统会向用户显示一个对话框,告知用户应用程序无响应,并提供 “等待” 和 “强制关闭” 两个选项。这不仅严重影响用户体验,还可能导致用户卸载应用。

从系统层面来看,ANR 是 Android 系统为了保障用户体验而设置的一种保护机制。当系统检测到应用程序的主线程在一定时间内没有处理新的事件或完成特定任务时,就会判定该应用出现了 ANR。例如,在用户点击应用中的按钮时,如果主线程因为某些原因被阻塞,无法及时响应用户的点击操作,超过一定时间后,ANR 就会被触发。

(二)ANR 的触发场景

  1. Activity 场景:当应用在 5 秒内未响应用户的输入事件(如按键或者触摸)时,就会触发 ANR。比如在 Activity 的 onCreate 或 onResume 方法中执行了耗时操作,导致主线程被阻塞,无法及时处理用户输入。例如,在 onCreate 方法中进行大量的文件读取或者复杂的数据库查询,这些操作如果没有放在子线程中执行,就很容易引发 ANR。假设我们有一个图片处理应用,在 Activity 启动时需要加载并处理一张高清图片,如果直接在主线程中进行图片解码和缩放等操作,而这些操作又比较耗时,就可能导致 5 秒内无法响应用户输入,从而触发 ANR。

  2. BroadcastReceiver 场景:BroadcastReceiver 未在 10 秒内完成相关的处理,就会触发 ANR。这通常是因为在 onReceive 方法中执行了耗时操作。例如,在接收到网络状态变化的广播时,在 onReceive 方法中进行网络请求或大量数据处理,而没有将这些操作放在子线程中,就可能导致广播处理超时,引发 ANR。比如一个新闻应用,在接收到新的新闻推送广播时,直接在 onReceive 方法中进行新闻内容的下载和解析,若这些操作耗时较长,就容易触发 ANR。

  3. Service 场景:Service 在 20 秒内(前台服务)无法处理完成,会触发 ANR。如果 Service 的任务在主线程中执行,并且任务耗时较长,就可能导致 ANR。例如,一个音乐播放服务,在播放音乐时需要从网络加载歌词,如果这个加载过程在主线程中进行,且网络状况不佳导致加载时间过长,就可能引发 ANR。又比如一个文件下载服务,在下载大文件时,如果没有使用异步操作,直接在主线程中进行文件写入,也容易导致 ANR。

(三)ANR 信息查看

当开发机器上出现 ANR 问题时,我们可以查看 /data/anr/traces.txt 文件来获取 ANR 信息。这个文件记录了发生 ANR 时各个线程的堆栈信息,通过分析这些信息,我们可以找出导致 ANR 的原因。最新的 ANR 信息通常在文件的最开始部分。我们可以使用 adb 命令将 traces.txt 文件导出到本地进行分析,具体命令如下:

adb pull /data/anr/traces.txt

在导出的 traces.txt 文件中,我们重点关注主线程的堆栈信息,查看是否有耗时操作或死锁等问题。例如,如果发现主线程在等待某个锁,而这个锁又被其他线程长时间持有,就可能是导致 ANR 的原因。同时,我们还可以查看文件中是否有关于网络操作、数据库操作等耗时操作的相关信息,进一步定位问题。

(四)避免 ANR 的策略

  1. 使用 AsyncTask 处理耗时 IO 操作:AsyncTask 是 Android 提供的一个轻量级异步任务类,它可以方便地在后台线程执行耗时操作,并在操作完成后将结果返回给主线程。例如,在进行网络请求或文件读写时,可以使用 AsyncTask 来避免主线程被阻塞。下面是一个使用 AsyncTask 进行网络请求的示例代码:
class DownloadTask extends AsyncTask<String, Void, String> {
    @Override
    protected String doInBackground(String... urls) {
        // 执行网络请求
        return downloadUrl(urls[0]);
    }
    @Override
    protected void onPostExecute(String result) {
        // 更新UI
        TextView textView = findViewById(R.id.textView);
        textView.setText(result);
    }
}

在上述代码中,doInBackground 方法在后台线程执行网络请求,onPostExecute 方法在主线程执行,用于更新 UI。

  1. 设置线程优先级:当使用 Thread 或者 HandlerThread 时,要设置线程优先级。未调用 Process.setThreadPriority (Process.THREAD_PRIORITY_BACKGROUND) 设置优先级,默认 Thread 的优先级和主线程相同,仍然会降低程序响应。通过设置线程优先级,可以确保主线程有足够的资源来响应用户输入。例如,在创建一个后台线程进行数据处理时,可以设置如下优先级:
Thread thread = new Thread(() -> {
    // 数据处理逻辑
});
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
thread.start();
  1. 避免主线程耗时操作:尽量避免在主线程中进行耗时的计算、文件读写、网络请求等操作。将这些操作放在子线程中执行,然后通过 Handler、runOnUiThread、AsyncTask、RxJava 等方式更新 UI。在 Activity 的 onCreate 和 onResume 回调中,要尽可能减少耗时的代码。例如,在 onCreate 方法中,可以将一些初始化操作放在子线程中进行,避免阻塞主线程。同时,在 BroadcastReceiver 的 onReceive 代码中也要尽量减少耗时操作,建议使用 IntentService 处理。IntentService 是一种特殊的 Service,它在单独的工作线程中处理任务,处理完成后会自动停止,非常适合处理耗时的异步任务。

二、内存优化策略

(一)内存泄漏的检测与定位

  1. 使用 Memory Profiler:Memory Profiler 是 Android Profiler 中的一个强大组件,它能够实时显示应用程序的内存使用情况,帮助我们快速定位内存泄漏问题。
    • 实时内存监控:打开 Memory Profiler 后,我们可以看到应用内存使用的实时图形,包括 Java、Native、Graphics 等不同类型内存的使用情况。通过观察这个图形,我们可以发现内存是否持续增长而没有释放,这可能是内存泄漏的迹象。例如,当我们反复打开和关闭某个 Activity 时,如果发现 Java 内存持续上升且没有下降的趋势,就需要警惕内存泄漏的发生。
    • 堆转储分析:我们可以通过点击 “Dump Java Heap” 按钮来获取应用当前的堆转储。堆转储文件包含了应用在某一时刻所有对象的信息,通过分析这个文件,我们可以查看哪些对象正在占用内存,以及它们的引用关系。在堆转储文件中,我们可以按照类名、包名或调用栈来排列对象,方便我们查找可能泄漏的对象。比如,我们发现某个 Activity 在关闭后仍然存在于堆中,且有其他对象持有它的引用,这就很可能是内存泄漏的原因。
    • 内存分配跟踪:Memory Profiler 还可以记录内存分配情况,帮助我们确定代码中哪些地方分配了大量对象或可能存在泄漏的对象。在 Android 8.0 及更高版本中,系统会自动跟踪应用的分配情况,我们只需在时间轴上选择要查看的区域即可查看对象分配信息。对于 Android 7.1 及更低版本,我们需要手动点击 “Record memory allocations” 按钮来开始录制。通过查看内存分配记录,我们可以找到那些频繁创建对象的代码段,进一步分析是否存在内存泄漏。
  1. 使用 LeakCanary:LeakCanary 是一个专门用于检测 Android 应用内存泄漏的开源库,它使用简单,能够在应用运行时自动检测内存泄漏,并提供详细的泄漏报告。
    • 集成 LeakCanary:在项目的 build.gradle 文件中添加 LeakCanary 的依赖:
dependencies {
    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
    releaseImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:2.12'
}

然后在 Application 类的 onCreate () 方法中初始化 LeakCanary:

public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        if (LeakCanary.isInAnalyzerProcess(this)) {
            return;
        }
        LeakCanary.install(this);
    }
}
  • 使用 LeakCanary 检测内存泄漏:集成完成后,LeakCanary 会自动监测应用中的内存泄漏情况。当它检测到内存泄漏时,会在通知栏显示一条通知。点击通知,我们可以查看详细的内存泄漏报告,报告中包含了泄漏对象的引用链,通过分析引用链,我们可以找到导致内存泄漏的原因。例如,如果发现某个 Activity 因为被一个静态变量持有引用而无法释放,我们就可以通过修改代码,避免这种不合理的引用,从而解决内存泄漏问题。

(二)内存抖动的避免

  1. 合理选择数据结构:选择合适的数据结构可以有效减少内存抖动。例如,在存储少量键值对时,使用 ArrayMap 或 SparseArray 比 HashMap 更节省内存,因为 HashMap 的内部实现需要维护一个数组和一个链表,会占用较多的内存空间,并且可能导致内存碎片。而 ArrayMap 和 SparseArray 的内部实现是使用两个数组来存储键和值,没有额外的开销,还可以避免 HashMap 的扩容和哈希冲突的问题。在频繁进行插入和删除操作时,LinkedList 可能比 ArrayList 更合适,因为 ArrayList 在进行插入和删除操作时,可能需要移动大量元素,导致内存的频繁分配和释放,而 LinkedList 则不会有这个问题。

  2. 使用对象池:对于那些创建和销毁成本较高的对象,我们可以使用对象池来管理它们的生命周期。例如,在游戏开发中,经常需要创建和销毁大量的子弹对象,如果每次都直接创建和销毁,会导致内存抖动。我们可以创建一个子弹对象池,当需要使用子弹时,从对象池中获取;当子弹使用完毕后,将其放回对象池,而不是直接销毁。在 Android 中,我们可以使用 Pools 类来实现对象池,例如:

// 创建一个容量为10的对象池
Pools.SynchronizedPool<MyObject> pool = new Pools.SynchronizedPool<>(10);
// 从对象池中获取一个对象
MyObject obj = pool.acquire();
if (obj == null) {
    // 如果对象池为空,则创建一个新的对象
    obj = new MyObject();
}
// 使用对象
obj.doSomething();
// 将对象放回对象池
pool.release(obj);
  1. 避免频繁创建临时对象:在循环或高频调用的方法中,要尽量避免创建临时对象。例如,在字符串拼接时,使用 StringBuilder 或 StringBuffer 代替 String 的直接拼接,因为使用 String 的 “+” 操作符进行字符串拼接会产生很多临时的字符串对象,占用内存空间,并触发 GC。在自定义 View 的 onDraw 方法中,也不要频繁创建对象,因为 onDraw 方法会被频繁调用,如果在其中创建对象,会导致内存抖动。我们可以将需要使用的对象提前创建好,并在 onDraw 方法中复用。

(三)大对象优化

  1. Bitmap 内存占用分析:Bitmap 是 Android 开发中常用的图像处理类,它能够加载和显示各种格式的图片。然而,Bitmap 对象会占用大量的内存,特别是在加载高分辨率图片时,很容易导致内存溢出(OOM)问题。Bitmap 对象的内存占用主要取决于图像尺寸和图像格式两个因素。可以通过公式 “内存占用 = 图像宽度 * 图像高度 * 每个像素所占字节数” 来计算 Bitmap 对象的内存占用。例如,一张分辨率为 1080 * 1920 的 RGBA 格式的 Bitmap 对象,其内存占用为 1080 * 1920 * 4 = 8294400 字节,约 8MB。

  2. Bitmap 优化策略

    • 采样率压缩:通过降低 Bitmap 的分辨率来减少其内存占用。BitmapFactory.Options 类提供了 inSampleSize 属性来控制采样率,该属性的值表示解码后的 Bitmap 宽高将为原始 Bitmap 宽高的 1/inSampleSize。例如,设置 inSampleSize 为 2,则解码后的 Bitmap 宽高将为原始 Bitmap 的一半,内存占用也将减少四分之一。以下是加载本地资源图片并进行采样率压缩的代码示例:
public static Bitmap loadBitmap(Context context, @DrawableRes int resId, int reqWidth, int reqHeight) {
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(context.getResources(), resId, options);
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeResource(context.getResources(), resId, options);
}
public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
    int width = options.outWidth;
    int height = options.outHeight;
    int inSampleSize = 1;
    if (width > reqWidth || height > reqHeight) {
        int halfWidth = width / 2;
        int halfHeight = height / 2;
        while ((halfWidth / inSampleSize) >= reqWidth && (halfHeight / inSampleSize) >= reqHeight) {
            inSampleSize *= 2;
        }
    }
    return inSampleSize;
}
  • 质量压缩:通过降低图像质量来减少其文件大小。Bitmap 类提供了 compress () 方法来进行质量压缩,该方法接受两个参数:outFormat 指定压缩后的图像格式,常见的格式包括 JPEG、PNG 等;quality 指定压缩质量,取值范围为 0 到 100,数值越小,压缩率越高,图像质量越低。例如:
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.image);
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outStream);
byte[] bytes = outStream.toByteArray();
  • 使用低色彩格式:Bitmap 支持多种色彩格式,每种格式占用不同的内存空间。例如,ARGB_8888 格式每个像素占用 4 个字节,而 RGB_565 格式每个像素仅占用 2 个字节。因此,在不需要透明度的情况下,可以使用低色彩格式来减少 Bitmap 内存占用。例如:
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.RGB_565;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.image, options);
  • 复用 Bitmap:可以通过 LruCache 等缓存机制来管理 Bitmap 对象的复用,避免频繁创建和销毁 Bitmap 对象导致的内存开销。例如,使用 LruCache 来缓存 Bitmap 对象:
private LruCache<String, Bitmap> lruCache;
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
    int cacheSize = maxMemory / 8;
    lruCache = new LruCache<String, Bitmap>(cacheSize) {
        @Override
        protected int sizeOf(String key, Bitmap bitmap) {
            return bitmap.getByteCount() / 1024;
        }
    };
}
public Bitmap getBitmap(String key) {
    return lruCache.get(key);
}
public void putBitmap(String key, Bitmap bitmap) {
    lruCache.put(key, bitmap);
}
  • 及时回收:调用 Bitmap.recycle () 方法可以释放 Bitmap 占用的内存。在不再需要使用 Bitmap 对象时,应及时调用 recycle () 方法,并将 Bitmap 对象置为 null,例如:
if (bitmap != null &&!bitmap.isRecycled()) {
    bitmap.recycle();
    bitmap = null;
}
System.gc();

需要注意的是,调用 System.gc () 并不能保证立即开始进行回收过程,而只是为了加快回收的到来。

(四)内存回收机制

  1. GC 工作原理:在 Android 中,垃圾回收(GC)的主要目的是回收不再使用的对象所占用的内存空间,以避免内存泄漏和内存溢出。GC 的工作原理基于可达性分析算法,它从一系列被称为 “GC Roots” 的对象开始,通过遍历对象图,标记所有可达的对象,然后回收那些不可达的对象所占用的内存。“GC Roots” 通常包括栈中的局部变量、静态变量、JNI 引用等。在 GC 过程中,会暂停应用的部分线程(称为 STW:Stop - the - World),这是为了确保在回收内存时,对象的引用关系不会发生变化。GC 会根据对象的生命周期和内存使用情况,采用不同的回收算法,如标记 - 清除算法、复制算法、标记 - 整理算法等。对于新生代(Young Generation),由于对象的生命周期较短,通常采用复制算法,将存活的对象复制到另一块内存区域,然后清除原来的内存区域;对于老年代(Old Generation),由于对象的生命周期较长,通常采用标记 - 整理算法,先标记存活的对象,然后将它们整理到内存的一端,再清除另一端的内存。

  2. 合理利用 GC:虽然 GC 是自动进行的,但我们在开发过程中,也可以通过一些方式来合理利用 GC,提高应用的性能。我们要避免频繁创建和销毁对象,因为这会导致 GC 频繁触发,增加 CPU 的负担。在进行大量数据处理时,可以适当调整堆内存的大小,通过在 AndroidManifest.xml 文件中设置 android:largeHeap 属性为 true 来申请更大的堆内存,但要注意这并不是一个通用的解决方案,因为过大的堆内存可能会导致其他问题,如 GC 时间变长。我们还要避免在主线程中进行耗时的 GC 操作,因为这会阻塞主线程,导致界面卡顿。如果需要进行一些内存清理操作,可以将其放在子线程中进行。例如,在一个图片加载库中,当内存不足时,我们可以在子线程中清理缓存的 Bitmap 对象,释放内存,而不是在主线程中直接调用 GC。

三、UI 渲染性能优化

(一)布局优化

  1. 使用 ConstraintLayout 减少布局嵌套:在 Android 开发中,布局嵌套过多会导致视图的测量和绘制过程变得复杂,从而影响 UI 渲染性能。ConstraintLayout 是一种强大的布局容器,它允许我们通过设置控件之间的约束关系来实现复杂的布局,而无需过多的嵌套。与传统的 LinearLayout 和 RelativeLayout 相比,ConstraintLayout 在布局嵌套方面具有明显的优势。LinearLayout 主要用于线性排列子视图,当需要实现复杂的布局时,往往需要进行多层嵌套;RelativeLayout 虽然可以通过相对位置来布局子视图,但在某些复杂场景下,也可能导致嵌套层数增加。而 ConstraintLayout 通过灵活的约束设置,可以在一层布局中完成复杂的布局需求。

在实际项目中,使用 ConstraintLayout 可以显著减少布局嵌套。以一个电商应用的商品详情页面为例,该页面包含商品图片、商品名称、价格、描述、购买按钮等多个元素,并且这些元素之间存在复杂的位置关系。如果使用 LinearLayout 和 RelativeLayout 来实现,可能需要多层嵌套才能达到理想的布局效果,这会增加布局的复杂度和渲染时间。而使用 ConstraintLayout,我们可以通过设置各个控件之间的约束关系,如商品图片位于页面顶部居中,商品名称位于图片下方左对齐,价格位于商品名称右侧等,轻松实现所需的布局,并且无需过多的嵌套。以下是使用 ConstraintLayout 实现该布局的示例代码:

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <ImageView
        android:id="@+id/product_image"
        android:layout_width="200dp"
        android:layout_height="200dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />
    <TextView
        android:id="@+id/product_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="商品名称"
        app:layout_constraintTop_toBottomOf="@id/product_image"
        app:layout_constraintStart_toStartOf="parent"
        android:layout_marginStart="16dp" />
    <TextView
        android:id="@+id/product_price"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="价格:99.99元"
        app:layout_constraintTop_toBottomOf="@id/product_image"
        app:layout_constraintStart_toEndOf="@id/product_name"
        android:layout_marginStart="16dp" />
    <TextView
        android:id="@+id/product_description"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="商品描述:这是一款非常棒的商品……"
        app:layout_constraintTop_toBottomOf="@id/product_name"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginStart="16dp"
        android:layout_marginEnd="16dp"
        android:layout_marginTop="16dp" />
    <Button
        android:id="@+id/buy_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="购买"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginBottom="16dp"
        android:layout_marginEnd="16dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

在上述代码中,通过 app:layout_constraintTop_toTopOf、app:layout_constraintStart_toStartOf 等属性,清晰地定义了各个控件之间的约束关系,使得布局简洁明了,同时也提高了渲染性能。

  1. 避免过度使用复杂自定义视图:虽然自定义视图可以实现一些独特的界面效果,但过度使用复杂的自定义视图会增加 UI 渲染的负担。复杂的自定义视图可能包含大量的绘制逻辑和计算,这会导致在绘制过程中消耗更多的 CPU 和 GPU 资源。在一个地图应用中,如果自定义地图视图的绘制逻辑过于复杂,可能会导致地图加载缓慢,缩放和平移时出现卡顿现象。

为了避免过度使用复杂自定义视图,我们可以优先考虑使用系统提供的标准视图组件,这些组件经过了优化,性能表现较好。如果确实需要使用自定义视图,我们可以对其进行优化,简化绘制逻辑,减少不必要的计算。我们可以将一些静态的绘制内容提前计算并缓存起来,在绘制时直接使用缓存结果,而不是每次都重新计算。同时,合理使用硬件加速、减少重绘区域等方法,也可以提高自定义视图的渲染性能。

(二)避免内存泄漏

  1. 确保所有视图和回调在不需要时能够被垃圾回收器回收:在 Android 开发中,内存泄漏是一个常见的问题,它会导致应用程序占用的内存不断增加,最终可能引发内存溢出(OOM)错误,导致应用崩溃。对于视图和回调,如果在它们不再需要时没有被正确释放,就会造成内存泄漏。假设在一个 Activity 中,我们注册了一个广播接收器(BroadcastReceiver),并在 Activity 销毁时没有取消注册,那么这个广播接收器就会一直持有 Activity 的引用,导致 Activity 无法被垃圾回收,从而引发内存泄漏。

为了避免这种情况,我们需要确保在视图和回调不再需要时,及时取消注册或释放相关资源。在 Activity 的 onDestroy 方法中,我们应该取消注册所有在 Activity 中注册的广播接收器、事件监听器等。对于一些持有上下文(Context)引用的对象,我们要注意避免长时间持有,尽量使用弱引用(WeakReference)来代替强引用。例如,在一个自定义的图片加载库中,如果使用静态变量来缓存图片,并且在图片加载完成后没有及时释放对 Activity 的引用,就可能导致 Activity 无法被回收。我们可以使用弱引用将 Activity 的引用存储在缓存中,这样当 Activity 被销毁时,垃圾回收器可以及时回收相关资源。

以下是一个使用弱引用避免内存泄漏的示例代码:

import java.lang.ref.WeakReference;
public class ImageLoader {
    private static ImageLoader instance;
    private WeakReference<Context> contextWeakReference;
    private ImageLoader(Context context) {
        contextWeakReference = new WeakReference<>(context);
    }
    public static ImageLoader getInstance(Context context) {
        if (instance == null) {
            instance = new ImageLoader(context);
        }
        return instance;
    }
    public void loadImage(String url) {
        Context context = contextWeakReference.get();
        if (context != null) {
            // 进行图片加载逻辑
        }
    }
}

在上述代码中,通过 WeakReference 来持有 Context 的引用,这样当 Context 对应的 Activity 被销毁时,垃圾回收器可以回收相关资源,避免了内存泄漏。

(三)硬件加速

  1. 介绍在 Activity 或 View 上启用硬件加速的方法和效果:在 Android 中,硬件加速是一项重要的性能优化技术,它利用 GPU(图形处理器)来分担 CPU 的图形绘制任务,从而提高 UI 的渲染速度和性能。在没有启用硬件加速时,图形绘制主要由 CPU 完成,这对于复杂的图形和大量的绘制操作来说,会消耗大量的 CPU 资源,导致 UI 渲染缓慢。而启用硬件加速后,GPU 可以高效地处理图形绘制任务,大大提高了绘制速度。

在 Activity 上启用硬件加速非常简单,我们可以在 AndroidManifest.xml 文件中的 application 标签或 activity 标签中添加 android:hardwareAccelerated=“true” 属性,如下所示:

<application
   ...
    android:hardwareAccelerated="true">
   ...
</application>

或者针对单个 Activity 启用硬件加速:

<activity
    android:name=".MainActivity"
   ...
    android:hardwareAccelerated="true">
</activity>

除了在 Activity 级别启用硬件加速,我们还可以在 View 级别启用硬件加速。在代码中,我们可以通过调用 View 的 setLayerType 方法来为特定的 View 启用硬件加速,例如:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
    View view = findViewById(R.id.my_view);
    view.setLayerType(View.LAYER_TYPE_HARDWARE, null);
}

启用硬件加速后,UI 的渲染性能会得到显著提升。在一个包含大量动画和复杂图形的游戏应用中,启用硬件加速后,动画的播放更加流畅,图形的绘制更加迅速,有效地提升了用户体验。然而,硬件加速也并非没有缺点,它可能会增加内存消耗,并且在某些设备上可能存在兼容性问题。因此,在启用硬件加速时,我们需要进行充分的测试,确保在不同设备和系统版本上都能正常工作。

(四)减少 Overdraw

  1. 使用 Hierarchy Viewer 等工具检测和减少过度绘制:过度绘制(Overdraw)是指在屏幕的同一个像素上绘制了多次,这会浪费 GPU 资源,降低 UI 的渲染性能。在一个布局中,如果有多个视图重叠,并且这些视图都有背景颜色或图像,就可能会导致过度绘制。例如,一个包含多个 TextView 的 LinearLayout,每个 TextView 都设置了背景颜色,当这些 TextView 重叠时,就会出现过度绘制的情况。

为了检测过度绘制,我们可以使用 Hierarchy Viewer 工具。Hierarchy Viewer 是 Android SDK 提供的一个可视化工具,它可以帮助我们分析布局的结构和绘制情况。通过 Hierarchy Viewer,我们可以查看每个视图的大小、位置以及是否存在过度绘制的区域。在 Android Studio 中,我们可以通过点击菜单栏中的 Tools -> Android -> Android Device Monitor,然后在 Android Device Monitor 中选择 Hierarchy Viewer 来打开该工具。

使用 Hierarchy Viewer 检测到过度绘制后,我们可以采取一些措施来减少过度绘制。我们可以移除布局中不必要的背景,避免在重叠的视图上设置相同的背景颜色或图像。在一个列表项布局中,如果列表项的背景已经在父容器中设置,那么子视图就不需要再设置相同的背景,这样可以减少一次绘制操作。我们还可以优化布局结构,减少视图的层级,使布局更加扁平化。通过使用 ConstraintLayout 等布局容器,合理设置视图之间的约束关系,可以避免不必要的嵌套,从而减少过度绘制。

以下是一个优化布局减少过度绘制的示例。假设我们有一个如下的布局:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#FFFFFF">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World"
        android:background="#FF0000" />
</LinearLayout>

在这个布局中,LinearLayout 和 TextView 都设置了背景,导致 TextView 的背景会覆盖 LinearLayout 的背景,造成了过度绘制。我们可以优化为:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World"
        android:background="#FF0000" />
</LinearLayout>

这样就移除了 LinearLayout 的背景,减少了一次绘制操作,从而降低了过度绘制。

四、网络请求性能优化

(一)缓存机制

在 Android 应用中,缓存机制是优化网络请求性能的重要手段之一。它可以有效减少不必要的网络请求,降低流量消耗,提高应用的响应速度和用户体验。缓存机制的核心思想是在本地存储已经获取的数据,当再次需要相同数据时,优先从本地缓存中读取,而不是重新发起网络请求。

在 Android 开发中,常用的缓存方式包括内存缓存和磁盘缓存。内存缓存具有快速读写的特点,适合存储频繁访问且数据量较小的数据,如图片的缩略图、常用的配置信息等。而磁盘缓存则适合存储数据量较大、不经常访问的数据,如完整的图片、视频文件等。以下是使用 LruCache 实现内存缓存的示例代码:

import android.util.LruCache;
public class MemoryCache {
    private LruCache<String, Object> mMemoryCache;
    public MemoryCache() {
        // 设置缓存大小为运行时可用内存的1/8
        final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        final int cacheSize = maxMemory / 8;
        mMemoryCache = new LruCache<String, Object>(cacheSize) {
            @Override
            protected int sizeOf(String key, Object value) {
                // 计算每个缓存项的大小,这里以字节为单位
                if (value instanceof byte[]) {
                    return ((byte[]) value).length;
                } else if (value instanceof String) {
                    return ((String) value).getBytes().length;
                }
                return 0;
            }
        };
    }
    public void put(String key, Object value) {
        if (get(key) == null) {
            mMemoryCache.put(key, value);
        }
    }
    public Object get(String key) {
        return mMemoryCache.get(key);
    }
    public void remove(String key) {
        mMemoryCache.remove(key);
    }
}

在上述代码中,我们通过 LruCache 创建了一个内存缓存,根据应用的运行时内存情况设置了缓存大小。在使用时,我们可以通过 put 方法将数据存入缓存,通过 get 方法从缓存中获取数据,通过 remove 方法移除缓存中的数据。

磁盘缓存可以使用 DiskLruCache 来实现,它提供了一种简单的方式来管理磁盘上的缓存文件。以下是使用 DiskLruCache 实现磁盘缓存的示例代码:

import android.content.Context;
import android.os.Environment;
import android.util.Log;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
public class DiskCache {
    private static final String TAG = "DiskCache";
    private static final int APP_VERSION = 1;
    private static final int VALUE_COUNT = 1;
    private static final long MAX_SIZE = 10 * 1024 * 1024; // 10MB
    private DiskLruCache mDiskLruCache;
    public DiskCache(Context context) {
        try {
            File cacheDir = getDiskCacheDir(context, "my_cache");
            mDiskLruCache = DiskLruCache.open(cacheDir, APP_VERSION, VALUE_COUNT, MAX_SIZE);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    public void put(String key, String value) {
        try {
            DiskLruCache.Editor editor = mDiskLruCache.edit(key);
            if (editor != null) {
                OutputStream outputStream = editor.newOutputStream(0);
                outputStream.write(value.getBytes());
                editor.commit();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    public String get(String key) {
        try {
            DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key);
            if (snapshot != null) {
                InputStream inputStream = snapshot.getInputStream(0);
                BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
                StringBuilder response = new StringBuilder();
                String line;
                while ((line = reader.readLine()) != null) {
                    response.append(line);
                }
                return response.toString();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
    public void remove(String key) {
        try {
            mDiskLruCache.remove(key);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    private File getDiskCacheDir(Context context, String uniqueName) {
        String cachePath;
        if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())
                ||!Environment.isExternalStorageRemovable()) {
            cachePath = context.getExternalCacheDir().getPath();
        } else {
            cachePath = context.getCacheDir().getPath();
        }
        return new File(cachePath + File.separator + uniqueName);
    }
}

在上述代码中,我们创建了一个 DiskCache 类,通过 DiskLruCache 实现了磁盘缓存。在构造函数中,我们打开了一个磁盘缓存,并设置了缓存的版本、每个键对应的值的数量以及最大缓存大小。在使用时,我们可以通过 put 方法将数据存入磁盘缓存,通过 get 方法从磁盘缓存中获取数据,通过 remove 方法移除磁盘缓存中的数据。

(二)数据压缩

在网络请求中,数据压缩是减少数据传输量、提高传输速度的有效方法。GZIP 是一种广泛使用的压缩算法,它可以对文本、图片和其他二进制数据进行高效压缩,从而减小数据的体积。在 Android 应用中,我们可以使用 GZIP 来压缩请求和响应数据。

在发送网络请求时,我们可以在请求头中设置 Accept-Encoding: gzip,告知服务器我们支持 GZIP 压缩。服务器在接收到请求后,如果支持 GZIP 压缩,会对响应数据进行压缩,并在响应头中设置 Content-Encoding: gzip。客户端在接收到响应后,根据响应头中的 Content-Encoding 字段判断数据是否被压缩,如果是,则使用 GZIPInputStream 对数据进行解压缩。

以下是使用 HttpURLConnection 进行 GZIP 压缩和解压缩的示例代码:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.zip.GZIPInputStream;
public class GzipExample {
    public static String sendRequest(String urlStr) {
        try {
            URL url = new URL(urlStr);
            HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
            urlConnection.setRequestProperty("Accept-Encoding", "gzip");
            InputStream inputStream = urlConnection.getInputStream();
            if ("gzip".equals(urlConnection.getContentEncoding())) {
                inputStream = new GZIPInputStream(inputStream);
            }
            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
            StringBuilder response = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                response.append(line);
            }
            return response.toString();
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }
}

在上述代码中,我们通过 HttpURLConnection 发送网络请求,并在请求头中设置了 Accept-Encoding: gzip。在接收到响应后,我们检查响应头中的 Content-Encoding 字段,如果是 gzip,则使用 GZIPInputStream 对输入流进行解压缩,然后读取解压缩后的数据。

(三)并行请求

在 Android 开发中,有时我们需要同时发送多个网络请求,以提高应用的响应速度。使用 HttpURLConnection 或 OkHttp 可以实现并行处理网络请求。

使用 HttpURLConnection 实现并行请求的示例代码如下:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ParallelHttpURLConnectionExample {
    private static final int NUM_THREADS = 4;
    private ExecutorService executorService;
    public ParallelHttpURLConnectionExample() {
        executorService = Executors.newFixedThreadPool(NUM_THREADS);
    }
    public void makeRequests(String[] urls) {
        for (String url : urls) {
            executorService.submit(() -> {
                try {
                    URL requestUrl = new URL(url);
                    HttpURLConnection urlConnection = (HttpURLConnection) requestUrl.openConnection();
                    InputStream inputStream = urlConnection.getInputStream();
                    BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
                    StringBuilder response = new StringBuilder();
                    String line;
                    while ((line = reader.readLine()) != null) {
                        response.append(line);
                    }
                    System.out.println("Response from " + url + ": " + response.toString());
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
        }
        executorService.shutdown();
    }
}

在上述代码中,我们创建了一个固定大小的线程池,通过 submit 方法将多个网络请求提交到线程池中并行执行。每个请求在单独的线程中执行,互不干扰。

使用 OkHttp 实现并行请求更加简洁,示例代码如下:

import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ParallelOkHttpExample {
    private static final int NUM_THREADS = 4;
    private ExecutorService executorService;
    private OkHttpClient client;
    public ParallelOkHttpExample() {
        executorService = Executors.newFixedThreadPool(NUM_THREADS);
        client = new OkHttpClient();
    }
    public void makeRequests(String[] urls) {
        for (String url : urls) {
            executorService.submit(() -> {
                Request request = new Request.Builder()
                       .url(url)
                       .build();
                try {
                    Response response = client.newCall(request).execute();
                    if (response.isSuccessful()) {
                        System.out.println("Response from " + url + ": " + response.body().string());
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
        }
        executorService.shutdown();
    }
}

在上述代码中,我们使用 OkHttp 创建了一个客户端,并通过 ExecutorService 将多个 OkHttp 请求提交到线程池中并行执行。OkHttp 的简洁 API 使得并行请求的实现更加方便。

(四)请求库选择

在 Android 开发中,选择合适的网络请求库可以大大简化开发过程,提高开发效率。Retrofit 和 Volley 是两个常用的网络请求库,它们各自具有不同的特点和优势。

Retrofit 是 Square 公司开发的一个网络请求框架,它基于 OkHttp 进行封装,具有以下特点:

  1. 简洁易用:Retrofit 使用注解来描述网络请求,代码简洁明了,易于理解和维护。例如,使用 @GET、@POST 等注解来指定请求方法,使用 @Path、@Query 等注解来传递参数。

  2. 类型安全:Retrofit 通过接口定义和类型转换,确保请求和响应的数据类型安全,减少类型转换错误。它可以自动将服务器返回的 JSON 数据解析成 Java 对象,无需手动解析。

  3. 可扩展性:Retrofit 支持多种数据格式的解析,如 JSON、XML 等,并且可以通过自定义 Converter 来支持更多的数据格式。它还支持 RxJava 等响应式编程框架,方便处理异步操作。

以下是使用 Retrofit 进行网络请求的示例代码:

import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
import retrofit2.http.GET;
import retrofit2.http.Path;
public class RetrofitExample {
    private static final String BASE_URL = "https://api.example.com/";
    public interface ApiService {
        @GET("users/{id}")
        Call<User> getUser(@Path("id") int id);
    }
    public static void main(String[] args) {
        Retrofit retrofit = new Retrofit.Builder()
               .baseUrl(BASE_URL)
               .addConverterFactory(GsonConverterFactory.create())
               .build();
        ApiService apiService = retrofit.create(ApiService.class);
        Call<User> call = apiService.getUser(1);
        call.enqueue(new Callback<User>() {
            @Override
            public void onResponse(Call<User> call, Response<User> response) {
                if (response.isSuccessful()) {
                    User user = response.body();
                    System.out.println("User: " + user);
                }
            }
            @Override
            public void onFailure(Call<User> call, Throwable t) {
                System.out.println("Error: " + t.getMessage());
            }
        });
    }
}
class User {
    private int id;
    private String name;
    // Getters and Setters
}

在上述代码中,我们使用 Retrofit 创建了一个网络请求接口 ApiService,通过 @GET 注解指定了请求的 URL 和方法,通过 @Path 注解传递了参数。然后通过 Retrofit.Builder 构建了一个 Retrofit 实例,并添加了 GsonConverterFactory 来解析 JSON 数据。最后通过创建 ApiService 的实例并调用其方法,发起了网络请求,并在回调中处理响应结果。

Volley 是 Google 开发的一个网络请求库,它具有以下特点:

  1. 轻量级:Volley 体积小,易于集成,适合小型项目和对性能要求不高的场景。

  2. 高效的请求队列:Volley 使用请求队列来管理网络请求,支持并发请求和请求优先级设置,能够自动处理请求的缓存和重试。

  3. 图片加载:Volley 提供了简单的图片加载功能,可以异步加载图片并自动缓存,减少网络带宽消耗。

以下是使用 Volley 进行网络请求的示例代码:

import android.content.Context;
import android.widget.TextView;
import com.android.volley.AuthFailureError;
import com.android.volley.Request;
import com.android.volley.RequestQueue;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonObjectRequest;
import com.android.volley.toolbox.Volley;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.HashMap;
import java.util.Map;
public class VolleyExample {
    public static void sendRequest(Context context, String url, TextView textView) {
        RequestQueue requestQueue = Volley.newRequestQueue(context);
        JsonObjectRequest jsonObjectRequest = new JsonObjectRequest(Request.Method.GET, url, null,
                new Response.Listener<JSONObject>() {
                    @Override
                    public void onResponse(JSONObject response) {
                        try {
                            textView.setText(response.getString("message"));
                        } catch (JSONException e) {
                            e.printStackTrace();
                        }
                    }
                },
                new Response.ErrorListener() {
                    @Override
                    public void onErrorResponse(VolleyError error) {
                        textView.setText("Error: " + error.getMessage());
                    }
                }) {
            @Override
            public Map<String, String> getHeaders() throws AuthFailureError {
                Map<String, String> headers = new HashMap<>();
                headers.put("Content-Type", "application/json");
                return headers;
            }
        };
        requestQueue.add(jsonObjectRequest);
    }
}

在上述代码中,我们使用 Volley 创建了一个请求队列,并通过 JsonObjectRequest 发起了一个 GET 请求。在请求的回调中,我们处理了响应结果和错误信息。同时,我们还通过 getHeaders 方法设置了请求头。

综上所述,Retrofit 适用于对代码简洁性、类型安全性和扩展性要求较高的大型项目,而 Volley 则适用于小型项目和对性能要求不高的场景。在实际开发中,我们可以根据项目的需求和特点选择合适的网络请求库。

五、应用启动时间优化

(一)懒加载与延迟初始化

懒加载与延迟初始化是优化应用启动时间的重要策略之一。在应用启动过程中,并非所有的组件和资源都需要立即加载和初始化。通过延迟非必需组件的初始化,直到真正需要时才进行加载,可以有效减少启动时的工作量,加快应用的启动速度。

在一个电商应用中,用户在启动应用时,可能首先看到的是商品列表页面。对于一些与商品详情页面相关的组件,如商品图片的高清大图加载逻辑、商品详情页面的复杂交互组件等,在应用启动时就不需要立即初始化。我们可以将这些组件的初始化操作延迟到用户点击进入商品详情页面时再进行。这样,在应用启动时,只需要加载和初始化与商品列表页面直接相关的组件,大大减少了启动时的资源消耗和时间开销。

在实际实现中,可以使用多种方式来实现懒加载与延迟初始化。在 Java 中,我们可以使用静态内部类实现懒加载。以下是一个简单的示例:

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

在上述代码中,LazyHolder 类是 LazyLoadExample 类的静态内部类。当 LazyLoadExample 类被加载时,LazyHolder 类并不会被立即加载。只有当调用 getInstance 方法时,LazyHolder 类才会被加载,并且会初始化 INSTANCE 实例。这样就实现了延迟初始化的效果。

另外,我们还可以使用代理模式来实现懒加载。通过代理对象,在实际需要时才创建真实对象并进行初始化。以下是一个使用代理模式实现懒加载的示例:

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public class LazyLoadProxy {
    private Object target;
    public LazyLoadProxy() {}
    public Object getProxy() {
        return Proxy.newProxyInstance(
                this.getClass().getClassLoader(),
                new Class[]{MyService.class},
                new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        if (target == null) {
                            target = new MyService();
                        }
                        return method.invoke(target, args);
                    }
                });
    }
}
interface MyService {
    void doSomething();
}
class MyServiceImpl implements MyService {
    @Override
    public void doSomething() {
        System.out.println("Doing something...");
    }
}

在上述代码中,LazyLoadProxy 类通过 Proxy.newProxyInstance 方法创建了一个代理对象。在代理对象的 invoke 方法中,当调用目标方法时,会首先检查 target 是否为空。如果为空,则创建真实的 MyServiceImpl 对象并进行初始化,然后再调用目标方法。这样就实现了懒加载的效果。

(二)代码优化

代码优化是减少应用启动时间的关键步骤。在应用启动过程中,onCreate 方法中的初始化代码执行时间会直接影响启动速度。因此,我们需要尽量减少 onCreate 中的初始化代码,将一些非必要的初始化操作延迟到后续合适的时机进行。

在一个社交应用中,onCreate 方法中可能包含了大量的初始化操作,如初始化数据库连接、加载用户配置信息、注册各种监听器等。其中,有些操作可能并不是在应用启动时就必须完成的。例如,加载用户的个性化主题配置信息,这个操作可以延迟到用户点击进入设置页面时再进行。我们可以将这些非必要的初始化代码从 onCreate 方法中移除,或者将它们放在一个单独的方法中,通过异步任务或事件驱动的方式在合适的时机调用。

除了减少 onCreate 中的初始化代码,我们还可以使用 ProGuard 工具来移除无用代码。ProGuard 是一个 Java 字节码处理工具,它可以对代码进行混淆、优化和压缩,

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Android 小码蜂

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值