Android性能优化系列:如何合理使用内存

内存性能优化简介:

  • Android系统通过在Android Lollipop替换Dalvik为ART,且在Android N添加JIT的方式提升编译安装性能

  • 对象内存管理,避免内存抖动、内存泄漏

  • 按需要提供变量的基本数据类型

  • 避免自动装箱拆箱的转换

  • 在某些场景使用Android提供的SparseArray集合组和ArrayMap代替HashMap能达到内存高效

  • 尽量使用for each循环

  • 使用Annotation @IntDef实现枚举

  • 常量使用static final声明能节约内存

  • 使用StringBuilder或StringBuffer拼接字符串

  • 生命周期内不变的变量声明为本地变量对象,不在方法内创建节省内存

  • 对象数量固定不变的列表,使用数组比集合更内存高效

  • 尽量少创建临时对象,因为会频繁触发垃圾回收;避免实例化非必要对象,因为会对内存和计算性能带来影响

  • 对于要大量创建耗费资源的对象时,使用对象池模式或享元模式

1 Android编译器

从Android Lollipop(API Level 21)开始,ART(Android runtime)变成了唯一的运行时,取代了Dalvik。

我们要优化内存分配,以便能有一个快速的垃圾回收过程,哪怕停止其他所有的操作。当内存的使用到达上限时,垃圾回收启动它的任务,暂停其他每个方法、任务、线程和进程的执行,直到垃圾回收结束后,这些对象才会恢复执行。

自动垃圾回收不是万能的,糟糕的内存管理会导致糟糕的UI性能,进而带来糟糕的用户体验。如果想要让应用程序拥有最佳的性能,还是需要对代码和内存的使用情况进行分析,这点是无法避免的。

ART运行时使用的是提前编译(ahead-of-time complication,AOT),它在应用程序首次安装时执行编译。AOT可以做到以下事情:

  • 归功于预编译(pre-complication),设备的电量消耗下降

  • 执行应用程序的速度比Dalvik快

  • 改善内存管理和垃圾回收

然而,这些好处也需要付出一些代价。安装软件所花费的时间更多了,因为系统需要在安装时编译应用程序。这就导致程序的安装速度相对其他类型的编译器较慢。

Google在Android N中,为ART的AOT编译器添加了一个即时编译器(just-in-time complier,JIT)。JIT作用于应用程序执行期间,并且只在需要时才起作用,它采用的编译方式不同于AOT编译器。JIT编译器使用了代码分析技术,它不是用来替代AOT编译器,而是作为AOT的一个补充,它的引入能为系统的性能带来改善。它能根据设备的使用情况,缓存并重用应用程序的方法。该特性能为每种系统节省编译时间,提升性能。

总结:在内存性能提升上,Android系统通过在Android Lollipop替换Dalvik为ART,且在Android N添加JIT的方式提升编译安装性能

2 内存泄漏

内存泄漏指的是,一个不再被使用的对象被另外一个还存活着的对象引用着。在这种情况下,垃圾回收器会跳过它,因为这种引用关系足以让该对象继续驻留在内存中。

  • Activity内存泄漏

避免Activity泄漏的一般准则是,当我们要调用某个方法时,如果不是Activity中的特定方法,就不要使用Activity作为Context。我们可以通过方法得到Application的Context。

  • 静态字段

静态字段非常危险,它们会关联Activity以及其他一些对象,或者被它们所关联,大部分的内存问题都是由此引起的。静态对象的生命周期和应用程序的生命周期一样长,这意味着直到应用程序结束,它们才会被回收。

// 典型的静态字段内存泄漏
public class MainActivity extends Activity {
	// private static View view;

	// private static Drawable drawable;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
		// view = findViewById(R.id.textView);	
		
		view = findViewById(R.id.textView);
		view.setBackground(drawable);		
	}
}
  • 非静态内部类

由于内部类需要在它的整个生命周期都能访问外部类,因此,当Activity被销毁而AsyncTask仍然在工作时,内存泄漏就发生了。这不仅会发生在Activity.finish()方法被调用时,甚至由于配置发生变化或内存紧张,导致Activity被重新创建时,泄漏问题也可能发生,比如屏幕旋转等。

// 非静态内部类内存泄漏解决方案
public class MainActivity extends Activity {
	
	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		new MyAsyncTask(this).execute();
	}

	private static class MyAsyncTask extends AsyncTask {
		private WeakReference<MainActivity> activity;

		public MyAsyncTask(MainActivity activity) {
			this.activity = new WeakReference<>(activity);
		}

		.....
	}
}

通常,作为一个好习惯,在处理线程时,对Activity采用弱引用的方式进行持有,哪怕该线程并非一个内部类。

  • 单例
public class Singleton {
	private static Singleton singleton;
	// private Callback callback;
	private WeakReference<Callback> callback;

	public static Singleton getInstance() {
		if (singleton == null)
			singleton = new Singleton();
		return singleton;
	}

	public Callback getCallback() {
		return callback;
	}

	// 使用这种方式,在Activity销毁时要setCallback(null)
	// public void setCallback(Callback callback) {
	// 	 this.callback = callback;
	// }

	public void setCallback(Callback callback) {
		this.callback = new WeakReference<>(callback);
	}

	public interface Callback {
		void callback();
	}
}
  • 匿名内部类

匿名内部类默认持有外部类的引用,所以会导致内存泄漏。同样可以使用弱引用的方式解决。

  • Handler

Handler在Activity使用时,配合弱引用使用,且在Activity销毁时要清空消息队列handler.removeMessageAndCallbacks(null);

  • Service

系统通过最近最少使用算法(Least Recently Used,LRU)来缓存活跃着的进程,这意味着它可以强制关闭早期使用过的进程,并保留最新进程。每次我们让一个不再被使用的Service继续保持活跃状态,相当于是在利用Service创建一个内存泄漏,并且阻碍了系统清理堆栈空间以分配给新进程。所以,当Service在后台执行完工作后,一定要记得将它关闭释放。在使用Service时,应尽量使用IntentService,因为IntentService能在后台工作执行完成后自动结束。

3 内存抖动

在短时间内,大量的新对象被实例化,运行时无法承载这样的内存分配。在这种情况下,垃圾回收事件被大量调用,影响到应用程序内存及UI的整体性能。

4 引用

Java提供了4种级别的引用强度:

  • Normal:主要的引用类型。它对应的是简单的对象创建,当这个对象不再被引用时会被回收

  • Soft:当一个垃圾回收事件被触发时,这种强度的引用不足以让它继续驻留在内存中。因此,在执行期间,它可能随时会变为null

SoftReference<SampleObject> sampleObjectSoftRef = new SoftReference<SampleObject>(new SampleObject());
SampleObject sampleObject = sampleObjectSoftRef.get();
  • Weak:和Soft引用类似,但是强度更弱
WeakReference<SampleObject> sampleObjectWeakRef = new WeakReference<SampleObject>(new SampleObject());
  • Phantom:这是最弱的引用,所引用的对象是专门被用来回收的。这种引用很少使用,通过PhantomReference.get()方法返回的总是null。这是专门给引用队列使用的

具体的引用区别参考:Java四种引用类型

5 取消部分后台服务

为了改善内存管理,减少后台进程带来的不良影响,下列广播将不再发送:

  • ConnectivityManager.CONNECTIVITY_ACTION:从Android N开始,只有通过代码注册BroadcastReceiver,并且应用程序处于前台时,才能接收到网络变化的广播

  • Camera.ACTION_NEW_PICTURE:当设备拍下一张新照片并添加到存储媒介时会触发该action。现在该action将不再可用,无论是接收或发送都不可用

  • Camera.ACTION_NEW_VIDEO:当设备录下一段视频并添加到存储媒介时会触发该action。该action也不再可用

开发应用程序时,应该避免使用隐式广播,以减少他们对后台操作带来的影响,因为这会导致非必要的内存浪费和电量消耗。

6 数据类型

Java所提供的基本类型,系统根据不同的数据类型为其分配相应数量的内存:

数据类型字节大小
byte8bit
short16bit
int32bit
long64bit
float32bit
double64bit
boolean通常是8bit,具体的bit位取决于虚拟机
char16bit

如果不是确实需要,不要使用一个比需求更大的基本类型,每次CPU在处理时,会浪费不必要的内存和计算量。在计算一个表达式时,系统需要做一个隐式转换,将表达式中的基本类型转为其中最大的那个基本类型。

7 自动装箱

自动装箱类型:

  • java.lang.Byte

  • java.lang.Short

  • java.lang.Integer

  • java.lang.Long

  • java.lang.Float

  • java.lang.Double

  • java.lang.Boolean

  • java.lang.Character

使用自动装箱并非改善应用程序性能的正确方式,它会带来许多额外消耗。

Integer sum = 0;
for (int i = 0; i < 500; i++) {
	sum += i;
}

一个Integer对象需要16byte的内存空间,而基本类型只需16bit,自动装箱会耗费更多的内存。

8 Sparse数组集

在集合中,我们不能使用基本类型作为泛型参数来实现下列这些接口,这样就不得不使用包装类:

List<Integer> list;
Map<Integer, Object> map;
Set<Integer> set;

每次使用集合中的一个Integer对象时自动装箱至少会发生一次,也会造成内存浪费。

Android提供了一系列非常有用的对象,用于替代Map对象,避免发生自动装箱,防止无意义的大量内存分配,就是Sparse数组。

下列是各种Sparse数组以及他们能替换的相应Map类型:

  • SparseBooleanArray:HashMap<Integer, Boolean>

  • SparseLongArray:HashMap<Integer, Long>

  • SparseIntArray:HashMap<Integer, Integer>

  • SparseArray:HashMap<Integer, E>

  • LongSparseArray:HashMap<Long, E>

SparseArray对象使用两个不同的数组,分别用于存储哈希和对象。第一个数组存储key哈希过后的列表,而第二个数组存储的是按照key哈希过后的列表进行排列的键值对

在这里插入图片描述

当需要添加一个值时,与HashMap类似,我们需要向SparseArray.put()方法指定一个整型的key及其对应的值。如果有多个key的哈希被放进同一个位置,则会造成冲突。

当需要获取一个值时,只要调用SparseArray.get()指定相关的key。在内部会使用该key对哈希数组进行二分搜索(先计算出该key的哈希值,再进行二分搜索),找到哈希的索引,再通过该索引,进一步找到其在另外一个数组中所对应的值。

在这里插入图片描述

当我们在二分搜索中得到索引,通过该索引在另外一个数组中找到key,如果这个key和原始的key不匹配,我们就认为发生了冲突。搜索会以该key为中心,在上下两边方向上继续进行,直到找出相同的key及其所对应的值。如果数组中包含大量对象,那么查找的时间也会显示增加。

这些数组所使用的内存区域是连续的,因此,每次从SparseArray中移除一个键值对时,数据会被压实(Compaction)或调整(Resize):

  • Compaction:指的是将要被删除的对象移动到数组末端,其他所有的对象向左移动。处在末端的那些要被删除对象的内存块,可以被未来增加的对象重用,以减少内存分配

  • Resize:指的是将数组中所有的元素(那些不需要被删除的元素)复制到另外一个数组中,然后将旧的数组删除。另一方面,当要添加一个新元素时,同样也需要将所有的元素(数组中已存在的元素及新添加的元素)复制到新的数组中去。这是最慢的方法,但这也是绝对内存安全的,因为不存在无用的内存分配

SparseArray相比HashMap的优劣

HashMap:

优点:

  • 只有一个单独的数组来存储哈希、键以及值,它使用大数组技术以避免冲突发生,因此HashMap速度快

  • 可以导出到其他Java平台上使用HashMap

缺点:

  • 使用大数组技术对内存不利,因为它分配的内存超过了实际所需

  • 循环使用速度慢内存低效的迭代模式

for (Iterator iter = map.keySet().iterator(); iter.hasNext();) {
	Object value = iter.next();
}

SparseArray:

优点:

  • 内存高效的,因为它在执行期间进行的内存分配,处于一个可接受的增长范围内

  • 循环可以通过索引进行迭代内存高效

for (int i = 0; i < map.size(); i++) {
	Object value = map.get(map.keyAt(i));
}

缺点:

  • SparseArray不能在其他Java平台上使用

注:作为开发者,我们应该努力在每个平台上都实现性能最优,而非在不同平台上重用相同的代码。因为,从内存的角度看,相同的代码在不同平台上会带来不同的影响。这也是为什么着重建议,在我们所工作的平台上对代码进行分析,然后根据结果来最终决定采用何种方式的原因所在。

适合使用SparseArray的场景:

是否使用SparseArray,取决于内存管理策略以及CPU性能,节省内存空间需要以牺牲CPU计算量作为代价。

  • 所处理的对象数量在一千以内,并且没有打算对数据进行大量的添加或删除

  • 数据的组织形式为Map,数据量不大,但存在大量的迭代

9 ArrayMap

ArrayMap对象是Android对Map接口的一个实现,它比HashMap更加内存高效。它的主要目的是让你能够像HashMap一样使用对象作为Map的key。

它的实现和用法与SparseArray类似,包括对内存的使用和消耗计算量的方式都是类似的。

10 循环

循环有三种方式:

  • Iterator循环

  • while循环

  • for循环

循环执行速度:Iterator < while < for < for each

for循环的三种方式对比:

// 速度最慢,dummies数组在每次循环中都需要重新计算一次数组的长度dummies.length
// dummies.length的额外消耗是因为每次循环时即时编译器都需要解释该语句
private void classicCycle(Dummy[] dummies) {
	int sum = 0;
	for (int i = 0; i < dummies.length;i++) {
		sum += dummies[i].dummy;
	}
}

// dummies.length放在循环外,只计算一次数组长度
private void fasterCycle(Dummy[] dummies) {
	int sum = 0;
	int length = dummies.length;
	for (int i = 0; i < length; i++) {
		sum += dummies[i].dummy;
	}
}

// for each循环最快
private void enhancedCycle(Dummy[] dummies) {
	int sum = 0;
	for (Dummy dummy : dummies) {
		sum += a.dummy;
	}
}

11 枚举

枚举对开发者非常友好,数量有限的元素、描述性的名字,因此增强了代码的可读性且还支持多态性;但枚举(new Enum(String name, int ordinal)) 在应用程序生成时会被转换为对象,需要分别为枚举的name参数分配String,为ordinal参数分配一个integer,以及一个array和一个包装类,更糟糕的是,枚举需要被复制到每个使用到它的进程中,因此在多进程应用程序中,枚举会增加额外的消耗。

Android提供了简化从枚举到整型值的转换:@IntDef,该注解还能利用flag属性让常量可以组合使用。

@IntDef 注解将所有的整型值集合起来。例如,以注解的方式将这些整型值转换为类似枚举的东西,却不会带来任何内存性能上的问题。

public static final int RECTANGLE = 0;
public static final int TRIANGLE = 1;
public static final int SQUARE = 2;
	public static final int CIRCLE = 3;

@IntDef({RECTANGLE, TRIANGLE, SQUARE, CIRCLE})
publci @interface Shape {}

public abstract void setShape(@Shape int mode);

@Shape
public abstract int getShape();

注:枚举影响着内存的整体性能,因为它们会带来非必要的内存呢分配。要尽量避免使用枚举,将它们替换为static final整型值。如果你需要限制整型值的数量,那么你可以创建一个自己的注解,来使用这些整型值。

12 常量(静态变量)

在Java编译器中有一个特殊的方法叫做,该方法负责处理类的初始化,但仅作用于静态变量和静态代码块,按照它们在类中的顺序,对它们进行初始化。

如果静态变量还有final关键字,情况就不太一样了。这种情况下,就不是通过方法进行初始化,它们会被存储在DEX文件中。这能带来两方面的好处,既不需要更多的内存分配,也不需要额外的操作对它进行分配。但这仅适用于基本类型和字符串常量,所以不应该为对象这样做。

注:代码中的常量应该用static和final进行修饰,这样才能充分节约内存,避免在Java编译器的方法中初始化。

13 字符串

  • 创建字符串:
// 错误:
// ①字符串"example"本身就是一个对象,他的内存必须被分配
// ②new String的操作也带来了一个新对象
String string = new String("example");

// 符合内存对性能的要求
String string = "example";
  • 字符串拼接:

通常在字符串操作时,经常会这样使用:

String string = "This is ";
string += "a string";

但实际并不是这样,对于此类操作,StringBuffer和StringBuilder相对于String更加高效,因为它们是以字符数组的形式工作:

StringBuffer stringBuffer = new StringBuffer("This is ");
stringBuffer.append("a string");

如果需要进行大量的字符串连接操作,这会是一个更好的方案。甚至可以说,这在任何时候都是一个最佳实践方案。记住StringBuffer和StringBuilder之间的不同点:StringBuffer是线程安全的,所以它更慢,但是可以在多线程环境下使用;而StringBuilder不是线程安全的,所以它更快,但是它只能在单线程中使用。

另一个要记住的是,StringBuilder和StringBuffer默认都有16个字符的初始化容量。当它们的容量不够用,需要增加时,会初始化并分配一个双倍大小的新对象,旧对象会在下一次垃圾回收事件中被回收。为避免这个不必要的内存浪费,如果已经知道所处理字符的大小,那么我们可以在实例化StringBuffer或StringBuilder时,为其指定一个初始容量:

StringBuffer stringBuffer = new StringBuffer(64);
stringBuffer.append("This is ");
stringBuffer.append("a string");
stringBuffer.append....

14 本地变量

方法内的某个对象在方法的整个执行过程中都没有被修改过。这意味着,该对象可以被放置在方法外部。这样,它只需被分配一次,并且不会被回收,改善了内存管理:

// dateFormat对象是不变的,可以放在方法外创建分配内存
public static format(Date date) {
	DateFormat dateFormat = new SimpleDateFormat("yyyy-MM--dd'T'HH:mm:ss.SSSZ");
	return dateFormat.format(date);
}

// 正确创建对象
private DateFormat dateFormat = new SimpleDateFormat("yyyy-MM--dd'T'HH:mm:ss.SSSZ");

public String format(Date date) {
	return dateFormat(date);
}

15 数组VS集合

集合能够根据需求自动扩大或者减小,并且提供了大量非常有用的方法,可用于添加、删除、获取、改变、移动对象,但这也使得集合的使用代价高昂。如果所处理对象的数量是固定不变的,那么原始的数组比集合更加内存高效。

16 StrictMode

StrictMode可用于找出内存及网络相关的问题。StrictMode启用后,在后台运行,当有问题发生时,它会按照我们制定的策略来通知我们。

if (BuildConfig.DEBUG) {
	StrictMode.VmPolicy policy = new StrictMode.VmPolicy.Builder()
			.detectAll()
			.penaltyLog()
			.build();
	StrictMode.setVmPolicy(policy);
}
  • detectActivityLeaks:检查Activity泄漏

  • detectLeakedClosableObjects:检查是否存在未关闭的可关闭对象

  • detectLeakdedRegistractionObjects:检查当Context被销毁时,ServiceConnection或BroadcastReceiver是否存在泄漏

  • detectSqlLiteObjects:检查是否有SQLite对象在使用后,未被关闭

  • detectAll:检查每个可疑的行为

  • penaltyDeath:当检测到问题时,进程会被杀气且应用程序崩溃

  • penaltyDropBox:当检测到问题时,相关日志会被发送到DropBoxManager,DropBoxManager收集这些日志,用于调试

  • penaltyLog:当问题发生时,日志会被记录到Logcat

17 对象池模式和享元模式

对象池模式:

当我们要大量创建耗费资源的对象时,该模式特别有用。

该模式的背后思想是,避免对一个将来可能会被重用的对象进行垃圾回收,节省了创建对象所花费的时间。在该模式中,需要处理以下三种类型的对象:

  • ReusableObject:可重用对象,被客户使用,被对象池管理

  • Client:客户,需要一个可重用对象来做一些事情,所以它需要向对象池请求一个对象,并在事情完成后将对象归还

  • ObjectPool:对象池,持有所有可重用对象,负责供给和回收这些对象

ObjectPool应该是一个单例对象,以便集中管理所有的可重用对象;ObjectPool包含的对象数量有一个上限,当一位客户在请求一个可重用对象时,如果对象池已满,并且所有可重用对象都在使用中,那么该请求会被延迟,直到有其他客户归还了一个可重用对象。

在这里插入图片描述

对象池模式使用注意点:

  • 对象在使用完毕后,客户应当尽快归还它

  • 刚被使用完的对象,在被传递给另一个请求它的客户之前,需要被恢复成某一个一致的状态,以便能够清晰地管理这些对象

  • 如果某个可重用对象在被客户释放后,仍与其他对象保持着引用关系,就会导致一个内存泄漏。所以在大部分情况下,可重用对象需要被恢复成创建时的状态

  • 如果条件允许,可以在创建对象池时,预先分配一定数量的可重用对象,这样可以为未来的对象访问节省一些时间

对象池模式代码:

public abstract class ObjectPool<T> {
	// 使用两个SparseArray数组保存对象集合,防止这些对象在借出后被系统回收
	private SparseArray<T> freePool;
	private SparseArray<T> lentPool;
	private int maxCapacity;

	public ObjectPool(int initialCapacity, int maxCapacity) {
		initialize(initialCapacity);
		this.maxCapacity = maxCapacity;
	}

	public ObjectPool(int maxCapacity) {
		this(maxCapacity / 2, maxCapacity);
	}

	public T acquire() {
		T t = null;
		synchronized(freePool) {
			int freeSize = freePool.size();
			for (int i = 0; i < freeSize; i++) {
				int key = freePool.keyAt(i);
				t = freePool.get(key);
				if (t != null) {
					this.lentPool.put(key, t);
					this.freePool.remove(key);
					return t;
				}
			}
			if (t == null && lentPool.size() + freeSize < maxCapacity) {
				t = create();
				lentPool.put(lentPool.size() + freeSize, t);
			}
		}
		return t;
	}

	public void release(T t) {
		if (t == null) {
			return null;
		}
		int key = lentPool.indexOfValue(t);
		restore(t);
		this.freePool.put(key, t);
		this.lentPool.remove(key);
	}

	protected abstract T create();

	protected void restore(T t) {}

	private void initialize(final int initialCapacity) {
		lentPool = new SparseArray<>();
		freePool = new SparseArray<>();
		for (int i = 0; i < initialCapacity; i++) {
			freePool.put(i, create());
		}
	}
}

享元模式:

对象池模式和享元模式的区别:

  • 对象池模式:面对需要大量分配高成本对象时,通过对象重用尽量减少内存分配以及垃圾回收对系统产生的影响

  • 享元模式:通过节省所有对象共享的状态,以减少载入内存的量;即为所有对象只创建一个实例,实现内部状态的重用,减少复制它所带来的消耗

享元模式的客户请求对象可分为两种类型的状态:

  • Internal state:内部状态。由所有能够唯一标识一个对象的字段组成,并且这些字段不与其他对象共享

  • External state:外部状态。在所有可交换对象之间能够共享的字段集合

注:享元模式适用于大量细粒度对象的复用。该模式只需使用少量对象即可实现大量对象的复用,前提是这些对象的粒度小,变化小,对象之间较为相似。内部状态存储与对象内部,一般在构造时或者通过setter函数设置,这些状态不随外界环境变化而变化。外部状态由客户保存,一般是在请求的时候,将外部状态通过参数传入对象中,这些状态随着外界环境变化而变化。

在这里插入图片描述

享元模式代码:

public interface Courier<T> {
	void equip(T param);
}

// 享元对象
public class PackCourier implements Courier<Pack> {
	private Van van;

	// id为内部状态且用于唯一标识一个对象,用在Factory中实现对象复用
	public PackCourier(int id) {
		super(id);
		van = new Van(id);
	}

	// pack为外部状态
	public void equip(Pack pack) {
		van.load(pack);
	}
}

// 客户,通过courier.equip(pack)将外部状态pack传入享元对象courier
public class Delivery extends Id {
	private Courier<Pack> courier;

	public Delivery(int id) {
		super(id);
		courier = new Factory().getCourier(0);
	}

	public void deliver(Pack pack, Destination destination) {
		courier.equip(pack);
	}
}

public class Factory {
	private static SparseArray<Courier> pool;

	public Factory() {
		if (pool == null)
			pool = new SparseArray<>();
	}

	public Courier getCourier(int type) {
		Courier courier = pool.get(type);
		if (courier == null) {
			courier = create(type);
			pool.put(type, courier);
		}
		return courier;
	}

	private Courier create(int type) {
		Courier courier = null;
		switch(type) {
			case 0:
				courier = new PackCourier(0);
		}
		return courier;
	}
}

// 每个Delivery都是由同一个Courier完成操作
for (int i = 0; i < DEFAULT_COURIER_NUMBER; i++) {
	new Delivery(i).deliver(new Pack(i), new Destination(i));
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值