☝点击上方蓝字,关注我们!
RxJava2的背压
背压的观察者模式:Flowable和Subscriber。默认队列大小为128,所有的操作符强制支持背压。
背压模式的流程同Observable一样,也是分成对象创建、逆向订阅和任务执行这三部分。
我们假设一种场景:上游发送无限多的数据,要求下游处理无限多的数据,并保证数据不丢失,同时避免OOM。
为了实现以上需求,我们先了解Flowable的五种处理策略。
1. 五种策略:
|
策略 |
描述 |
应用场景 |
|
MISSING |
没有指定背压策略,需要下游通过背压操作符(onBackpressureBuffer()/onBackpressureDrop()/onBackpressureLatest())指定背压策略。 |
无 |
|
ERROR |
如果放入Flowable的异步缓存池中的数据超限了,则会抛出MissingBackpressureException异常。 |
对数据丢失零容忍,宁愿抛异常也要尽可能处理所有已发送的数据。 |
|
BUFFER |
Flowable的异步缓存池没有固定大小,可以无限制添加数据,不会抛出MissingBackpressureException异常,但有可能导致OOM。 |
对数据丢失零容忍,确定上游发送数据速度大于下游处理速度。 |
|
DROP |
如果Flowable的异步缓存池满了,则会丢掉将要放入缓存池中的数据。 |
不在乎中间数据丢失和最后发送的数据值,多用于耗时任务进度更新和完成提示。 |
|
LATEST |
如果Flowable的异步缓存池满了,则会丢掉将要放入缓存池中的数据,但会将最后一条数据强行放入缓存池中。 |
不在乎中间数据丢失,多用于耗时任务进度更新并给出最后发射数据的处理结果。 |
我们做个实验,每隔100毫秒由上游发送一条数据,每隔300毫秒由下游处理一条数据,总共发送500条数据,由此看一下各个策略下的表现:
1public void flowableTest(final String tag, BackpressureStrategy strategy) { 2 Flowable 3 .create(new FlowableOnSubscribe<Integer>() { 4 @Override 5 public void subscribe(FlowableEmitter<Integer> e) throws Exception { 6 String threadName = Thread.currentThread().getName(); 7 Log.d(tag, threadName + "开始发射数据" + System.currentTimeMillis()); 8 for (int i = 1; i <= 500; i++) { 9 Log.d(tag, threadName + "发射---->" + i);10 e.onNext(i);11 try {12 Thread.sleep(100);//每隔100毫秒发射一次数据13 } catch (Exception ex) {14 e.onError(ex);15 }16 }17 Log.d(tag, threadName + "发射数据结束" + System.currentTimeMillis());18 e.onComplete();19 }20 }, strategy)21 .subscribeOn(Schedulers.newThread())22 .observeOn(Schedulers.newThread())23 .subscribe(new Subscriber<Integer>() {24 @Override25 public void onSubscribe(Subscription s) {26 s.request(Long.MAX_VALUE); //注意此处,暂时先这么设置27 }2829 @Override30 public void onNext(Integer integer) {31 Log.d(tag, Thread.currentThread().getName() + "接收---------->" + integer);32 try {33 Thread.sleep(300);//每隔300毫秒接收一次数据34 } catch (InterruptedException ignore) {35 }36 }3738 @Override39 public void onError(Throwable t) {40 t.printStackTrace();41 }4243 @Override44 public void onComplete() {45 Log.d(tag, Thread.currentThread().getName() + "接收----> 完成");46 }47 });48}
在起始阶段,所有的策略都是一致的,每隔100ms发送一条数据,每隔300ms接收一条数据。
1.1 MISSING
在MISSING策略下,且不指定下游的背压操作符,我们会发现,下游每接收一条数据,上游的缓存池就会清一条数据,当上游发射到第196条数据时,缓存池就溢出了(196-67=129),这时rx会抛出io.reactivex.exceptions.UndeliverableException的异常,程序终止。
1.2 ERROR
在ERROR策略下,我们发现上游在发送第129个数据时停止运行,而下游已经处理了45个。疑问来了,既然下游已经处理了45个数据,为什么第129个数据没有放进缓存池呢?这是因为在ERROR策略下,缓存池并不是接收一条,清理一条,而是累积一段时间后再清理,所以即使下游已经处理了45条数据,但缓存池依然是满的,抛出了MissingBackpressureException异常。
1.3 BUFFER
BUFFER策略下,我们发现,所有数据都顺序的发送/接收成功了,但是这并不代表可以随意使用BUFFER策略。BUFFER的缓存池没有固定大小,不会抛出MissingBackpressureException异常,但有可能导致OOM,慎用。
1.4 DROP
DROP策略下,我们会发现下游接收了128条数据后,再次接收数据便是第284条了,原因同ERROR策略:下游处理数据后,缓存池并不会及时清理,而是等到接收到第97条数据才进行的清理,之后上游发送的数据才得以进入到缓存池,而最终接收完成时,处理的最后一条数据是379。
1.5 LATEST
LATEST流程大致与DROP相同,不同的是,不管缓存池的状态如何,都会将最后一条数据强行插入到缓存池中(总数还是128),来保证下游能够收到最新发送的一条数据,所以最终下游可以处理第500条数据。
到此我们了解了Flowable针对背压的各种策略,由此可见,这五种策略并不能满足我们的需求,要想合理的使用Flowable,还需了解Subscription和FlowableEmitter。
2. Subscription:
Subscription与Disposable均是观察者与被观察对象建立订阅关系后回调回来的参数,如同通过Disposable的dispose()方法可以取消Observer与Oberverable的订阅关系一样,通过Subscription的cancel()方法也可以取消Subscriber与Flowable的订阅关系,
不同的是接口Subscription中多了一个方法request(long n)。
运行如下代码:
1public void flowableTest1() { 2 Flowable 3 .create(new FlowableOnSubscribe<Integer>() { 4 @Override 5 public void subscribe(FlowableEmitter<Integer> e) throws Exception { 6 System.out.println("发射----> 1"); 7 e.onNext(1); 8 System.out.println("发射----> 2"); 9 e.onNext(2);10 System.out.println("发射----> 3");11 e.onNext(3);12 System.out.println("发射----> 完成");13 e.onComplete();14 }15 }, BackpressureStrategy.BUFFER)16 .subscribeOn(Schedulers.newThread())17 .observeOn(Schedulers.newThread())18 .subscribe(new Subscriber<Integer>() {19 @Override20 public void onSubscribe(Subscription s) {21 //去掉代码s.request(Long.MAX_VALUE);22 }2324 @Override25 public void onNext(Integer integer) {26 System.out.println("接收----> " + integer);27 }2829 @Override30 public void onError(Throwable t) {31 }3233 @Override34 public void onComplete() {35 System.out.println("接收----> 完成");36 }37 });38}
运行结果如下:
我们发现Flowable照常发送数据,而Subsriber不再接收数据,因为Flowable在设计的时候,采用了一种新的思路——响应式拉取方式,来设置下游对数据的请求数量,上游可以根据下游的需求量,按需发送数据。如果不显示调用request则默认下游的需求量为零,所以运行上面的代码后,上游Flowable发射的数据不会交给下游Subscriber处理。
运行如下代码:
1public void flowableTest2() { 2 Flowable 3 .create(new FlowableOnSubscribe<Integer>() { 4 @Override 5 public void subscribe(FlowableEmitter<Integer> e) throws Exception { 6 System.out.println("发射----> 1"); 7 e.onNext(1); 8 System.out.println("发射----> 2"); 9 e.onNext(2);10 System.out.println("发射----> 3");11 e.onNext(3);12 System.out.println("发射----> 完成");13 e.onComplete();14 }15 }, BackpressureStrategy.BUFFER) 16 .subscribeOn(Schedulers.newThread())17 .observeOn(Schedulers.newThread())18 .subscribe(new Subscriber<Integer>() {19 @Override20 public void onSubscribe(Subscription s) {21 s.request(2);//设置Subscriber的消费能力为222 }2324 @Override25 public void onNext(Integer integer) {26 System.out.println("接收----> " + integer);27 }2829 @Override30 public void onError(Throwable t) {31 }3233 @Override34 public void onComplete() {35 System.out.println("接收----> 完成");36 }37 });38}
运行结果如下:
我们发现通过s.request(2)设置Subscriber的数据请求量为2条,超出其请求范围之外的数据则没有接收。
看到这儿疑问又来了,看上去s.request(2)只是设置了下游数据接收的限制,并不像上面提到的“响应式拉取方式”。的确,在此情况下,无论下游request()如何设置,上游依旧发射数据到缓存池,在超出阈值时,会根据策略进行处理,相关代码实例就不在此贴上了,所以虽然通过request()可以设置下游的请求数量,但是上游并没有获取到这个数量,如何获取呢?这便需要用到Flowable特有的发射器FlowableEmitter:
3. FlowableEmitter:
我们先看一下FlowableEmitter和ObservableEmitter的区别:
1public interface FlowableEmitter<T> extends Emitter<T> { 2 3 void setDisposable(@Nullable Disposable s); 4 5 void setCancellable(@Nullable Cancellable c); 6 7 long requested(); 8 9 boolean isCancelled();1011 @NonNull12 FlowableEmitter<T> serialize();1314 @Experimental15 boolean tryOnError(@NonNull Throwable t);16}
1public interface ObservableEmitter<T> extends Emitter<T> { 2 3 void setDisposable(@Nullable Disposable d); 4 5 void setCancellable(@Nullable Cancellable c); 6 7 boolean isDisposed(); 8 9 @NonNull10 ObservableEmitter<T> serialize();1112 @Experimental13 boolean tryOnError(@NonNull Throwable t);14}
其中FlowableEmitter多了一个long requested()方法,我们可以通过这个方法来获取当前未完成的请求数量。
我们来看下面的代码:
1public void flowableEmitTest(final int emitCount, final int requestCount) { 2 Flowable 3 .create(new FlowableOnSubscribe<Integer>() { 4 @Override 5 public void subscribe(FlowableEmitter<Integer> e) throws Exception { 6 String threadName = Thread.currentThread().getName(); 7 for (int i = 1; i <= emitCount; i++) { 8 Log.d(TAG, "当前未完成的请求数量-->" + e.requested()); 9 Log.d(TAG, threadName + "发射---->" + i);10 e.onNext(i);11 }12 Log.d(TAG, threadName + "发射数据结束" + System.currentTimeMillis());13 e.onComplete();14 }15 }, BackpressureStrategy.BUFFER)16 .subscribeOn(Schedulers.newThread())//添加两行代码,为上下游分配独立的线程17 .observeOn(Schedulers.newThread())18 .subscribe(new Subscriber<Integer>() {19 @Override20 public void onSubscribe(Subscription s) {21 s.request(requestCount);22 }2324 @Override25 public void onNext(Integer integer) {26 Log.d(TAG, Thread.currentThread().getName() + "接收---------->" + integer);27 }2829 @Override30 public void onError(Throwable t) {31 t.printStackTrace();32 }3334 @Override35 public void onComplete() {36 Log.d(TAG, Thread.currentThread().getName() + "接收----> 完成");37 }38 });39}
执行下面的代码:
1flowableEmitTest(5, 3);
如上代码运行时,由于下游request()的值为3,我们会认为,上游requested()的返回值应该依次是3、2、1、0,但实际上并非如此:
虽然我们指定了下游的数据请求量为3,但是我们在上游获取未完成请求数量的时候,并不是3,而是128,难道上游有个最小未完成请求数量?只要下游设置的数据请求量小于128,上游获取到的都是128?
带着这个疑问,我们执行下面的代码:
1flowableEmitTest(5, 500);
结果果真如此:
其实不论下游通过s.request()设置多少请求量,我们在上游获取到的初始未完成请求数量都是128。
这是因为Flowable有一个异步缓存池,上游发射的数据,先放到异步缓存池中,再由异步缓存池交给下游,所以上游在发射数据时,首先需要考虑的不是下游的数据请求量,而是缓存池中能不能放得下,否则在缓存池满的情况下依然会导致数据遗失或者背压异常。如果缓存池可以放得下,那就发送,至于是否超出了下游的数据需求量,可以在缓存池向下游传递数据时,再作判断,如果未超出,则将缓存池中的数据传递给下游,如果超出了,则不传递。
如果下游对数据的需求量超过缓存池的大小,而上游能获取到的最大需求量是128,上游对超出128的需求量是怎么获取到的呢?
带着这个疑问,我们执行如下代码:
1flowableEmitTest(150, 150);
结果如下:
在此我们发现,在发射了第96个数据到缓存池中后, requested()的返回值为32,然后上游发射97,下游接收96,97。再次requested(),返回值就变为了127。这是因为,缓存池中的数据并不是下游接收一条便清理一条,而是等待时机统一清理,但为什么缓存池清理后,requested()的返回值不是128呢?原因是在处理了96之后,会对缓存池已处理过的数据进行清理,此时97还没有被下游处理,所以缓存池的空闲大小为127。
4. Flowable的最终方案:
我们回到最初的需求:上游发送无限多的数据,要求下游处理无限多的数据,并保证数据不丢失,同时避免OOM。
最终,使用Flowable的正确操作如下:
1public void flowableSolution() { 2 Flowable 3 .create(new FlowableOnSubscribe<Integer>() { 4 @Override 5 public void subscribe(FlowableEmitter<Integer> e) throws Exception { 6 int i = 0; 7 while (true) { 8 Log.d(TAG, "当前未完成的请求数量-->" + e.requested()); 9 if (e.requested() == 0) continue;//此处添加代码,让flowable按需发送10 Log.d(TAG, "发射---->" + i);11 i++;12 e.onNext(i);13 }14 }15 }, BackpressureStrategy.MISSING)16 .subscribeOn(Schedulers.newThread())17 .observeOn(Schedulers.newThread())18 .subscribe(new Subscriber<Integer>() {19 private Subscription mSubscription;2021 @Override22 public void onSubscribe(Subscription s) {23 s.request(1); //设置初始请求数据量为124 mSubscription = s;25 }2627 @Override28 public void onNext(Integer integer) {29 try {30 Thread.sleep(50);31 Log.d(TAG, "接收------>" + integer);32 mSubscription.request(1);//每接收到一条数据增加一条请求量33 } catch (InterruptedException ignore) {34 }35 }3637 @Override38 public void onError(Throwable t) {39 }4041 @Override42 public void onComplete() {43 }44 });45}
我们在下游onNext(Integer integer) 方法中,每接收一条数据增加一条请求量:
1mSubscription.request(1);
在上游添加代码:
1if (e.requested() == 0) continue;//此处添加代码,让flowable按需发送数据
这样,上游严格按照下游的需求量发送数据,不会产生MissingBackpressureException异常,或者丢失数据。同时不会产生OOM。
5. 源码解析
看完Flowable的使用方式,我们有了一些疑问:首先,这个默认的128缓存池是怎么来的?另外,在Flowable的一些策略中都提到过下游接收数据后,缓存池不会立即清理,而是到处理了第96个数据时清理,这又是怎么回事儿呢?所谓的响应式拉取又是如何实现的呢?带着这几个问题,我们看一下Flowable这五种策略的相关源码:
5.1 对象创建
以上面代码为例,对象创建这个阶段同Observable类似,但有两处不同:
-
在create时多了一个记录背压策略的变量,这块我们直接跳过;
-
observeOn的Flowable初始化阶段会传入bufferSize,这部分我们看一下源码:
observeOn():
1@CheckReturnValue2@BackpressureSupport(BackpressureKind.FULL)3@SchedulerSupport(SchedulerSupport.CUSTOM)4public final Flowable<T> observeOn(Scheduler scheduler) {5 return observeOn(scheduler, false, bufferSize());6}
这里有个bufferSize(),就是我们想要的缓存池大小:
1public static int bufferSize() {2 return BUFFER_SIZE;3}45static final int BUFFER_SIZE;6static {7 BUFFER_SIZE = Math.max(1, Integer.getInteger("rx2.buffer-size", 128));8}
我们继续看observeOn方法:
1@CheckReturnValue2@BackpressureSupport(BackpressureKind.FULL)3@SchedulerSupport(SchedulerSupport.CUSTOM)4public final Flowable<T> observeOn(Scheduler scheduler, boolean delayError, int bufferSize) {5 ObjectHelper.requireNonNull(scheduler, "scheduler is null");6 ObjectHelper.verifyPositive(bufferSize, "bufferSize");7 return RxJavaPlugins.onAssembly(new FlowableObserveOn<T>(this, scheduler, delayError, bufferSize));8}
1public FlowableObserveOn( 2 Flowable<T> source, 3 Scheduler scheduler, 4 boolean delayError, 5 int prefetch) { 6 super(source); 7 this.scheduler = scheduler; 8 this.delayError = delayError; 9 this.prefetch = prefetch;10}
在FlowableObserveOn的构造方法里,这个prefetch变量就是传入的bufferSize。
5.2 逆向订阅
在逆向订阅的阶段,从下至上,我们首先还是先关注一下observeOn()的订阅过程,即FlowableObserveOn的subscribeActual()方法:
1@Override 2public void subscribeActual(Subscriber<? super T> s) { 3 Worker worker = scheduler.createWorker(); 4 5 if (s instanceof ConditionalSubscriber) { 6 source.subscribe(new ObserveOnConditionalSubscriber<T>( 7 (ConditionalSubscriber<? super T>) s, worker, delayError, prefetch)); 8 } else { 9 source.subscribe(new ObserveOnSubscriber<T>(s, worker, delayError, prefetch));10 }11}
跳过ConditionalSubscriber,我们直接看ObserveOnSubscriber:
1ObserveOnSubscriber(2 Subscriber<? super T> actual,3 Worker worker,4 boolean delayError,5 int prefetch) {6 super(worker, delayError, prefetch);7 this.actual = actual;8}
这里,prefetch即是bufferSize,我们继续看super方法:
1BaseObserveOnSubscriber( 2 Worker worker, 3 boolean delayError, 4 int prefetch) { 5 this.worker = worker; 6 this.delayError = delayError; 7 this.prefetch = prefetch; 8 this.requested = new AtomicLong(); 9 this.limit = prefetch - (prefetch >> 2);10}
我们看到这里有个limit变量,这个limit就是下游接收数据后,缓存池清理时的那个阈值,默认情况下,这个limit就是96。这也解释了上面示例代码中,在下游处理完第96个数据,接收到第97个数据时,上游的缓存池会被清理。
在create的订阅过程,我们可以看到不同策略的初始化过程,所有的Emitter都继承自BaseEmitter,BaseEmitte继承自AtomicLong,因为背压问题会涉及到不平衡,为了解决这种问题,通常会设置一个缓冲队列,缓冲队列不可能无限大吧?应该是有限制的。
1@Override 2public void subscribeActual(Subscriber<? super T> t) { 3 BaseEmitter<T> emitter; 4 5 switch (backpressure) { 6 case MISSING: { 7 emitter = new MissingEmitter<T>(t); 8 break; 9 }10 case ERROR: {11 emitter = new ErrorAsyncEmitter<T>(t);12 break;13 }14 case DROP: {15 emitter = new DropAsyncEmitter<T>(t);16 break;17 }18 case LATEST: {19 emitter = new LatestAsyncEmitter<T>(t);20 break;21 }22 default: {23 emitter = new BufferAsyncEmitter<T>(t, bufferSize());24 break;25 }26 }2728 t.onSubscribe(emitter);29 try {30 source.subscribe(emitter);31 } catch (Throwable ex) {32 Exceptions.throwIfFatal(ex);33 emitter.onError(ex);34 }35}
在ObserveOnSubscriber订阅之后,会执行以下代码:
1@Override 2public void onSubscribe(Subscription s) { 3 if (SubscriptionHelper.validate(this.s, s)) { 4 this.s = s; 5 6 if (s instanceof QueueSubscription) { 7 @SuppressWarnings("unchecked") 8 QueueSubscription<T> f = (QueueSubscription<T>) s; 910 int m = f.requestFusion(ANY | BOUNDARY);1112 if (m == SYNC) {13 sourceMode = SYNC;14 queue = f;15 done = true;1617 actual.onSubscribe(this);18 return;19 } else20 if (m == ASYNC) {21 sourceMode = ASYNC;22 queue = f;2324 actual.onSubscribe(this);2526 s.request(prefetch);2728 return;29 }30 }3132 queue = new SpscArrayQueue<T>(prefetch);3334 actual.onSubscribe(this);3536 s.request(prefetch);37 }38}
这里,我们看到了一个SpscArrayQueue变量,这是一个固定长度的缓存队列,用来缓存上游发射的数据。s.request()的这个s,实际是Emitter,我们看一下BaseEmitter的request()方法:
1@Override2public final void request(long n) {3 if (SubscriptionHelper.validate(n)) {4 BackpressureHelper.add(this, n);5 onRequested();6 }7}
这里调用了BackPressureHelper的add()方法:
1public static long add(AtomicLong requested, long n) { 2 for (;;) { 3 long r = requested.get(); 4 if (r == Long.MAX_VALUE) { 5 return Long.MAX_VALUE; 6 } 7 long u = addCap(r, n); 8 if (requested.compareAndSet(r, u)) { 9 return r;10 }11 }12}
其实就是更新BaseEmitter的父类AtomicLong的值,也就是缓存池的大小。
5.3 任务执行
我们直接看这段代码的subscribe():
1Flowable 2 .create(new FlowableOnSubscribe<Integer>() { 3 @Override 4 public void subscribe(FlowableEmitter<Integer> e) throws Exception { 5 int i = 0; 6 while (true) { 7 Log.d(TAG, "当前未完成的请求数量-->" + e.requested()); 8 if (e.requested() == 0) continue;//此处添加代码,让flowable按需发送数据 9 Log.d(TAG, "发射---->" + i);10 i++;11 e.onNext(i);12 }13 }14 }, BackpressureStrategy.DROP)
在DROP策略下,onNext会走到DropAsyncEmitter的父类,NoOverflowBaseAsyncEmitter的onNext():
1@Override 2public final void onNext(T t) { 3 if (isCancelled()) { 4 return; 5 } 6 7 if (t == null) { 8 onError(new NullPointerException("onNext called with null. Null values are generally not allowed in 2.x operators and sources.")); 9 return;10 }1112 if (get() != 0) {13 actual.onNext(t);14 BackpressureHelper.produced(this, 1);15 } else {16 onOverflow();17 }18}
跳过前面的检查,走到get()那句判断,如果AtomicLong的值不是0,即缓存池未满,走到Subscriber的onNext之后,回调用BackPressureHelper的produced()方法:
1public static long produced(AtomicLong requested, long n) { 2 for (;;) { 3 long current = requested.get(); 4 if (current == Long.MAX_VALUE) { 5 return Long.MAX_VALUE; 6 } 7 long update = current - n; 8 if (update < 0L) { 9 RxJavaPlugins.onError(new IllegalStateException("More produced than requested: " + update));10 update = 0L;11 }12 if (requested.compareAndSet(current, update)) {13 return update;14 }15 }16}
这个方法很简单,就是缓存池大小减1,重新给AtomicLong对象赋值。如果get()那句判断,AtomicLong的值是0,即缓存池已满,则调用onOverFlow()方法,这个方法是针对不同策略的处理方式,在此不做展开。
接下来我们走到observeOn的onNext()方法中去:
1@Override 2public final void onNext(T t) { 3 if (done) { 4 return; 5 } 6 if (sourceMode == ASYNC) { 7 trySchedule(); 8 return; 9 }10 if (!queue.offer(t)) {11 s.cancel();1213 error = new MissingBackpressureException("Queue is full?!");14 done = true;15 }16 trySchedule();17}
trySchedule()其实就是将线程调度交给Worker处理,最终Runnable对象执行run,过程同第3节的线程调度流程:
1final void trySchedule() { 2 if (getAndIncrement() != 0) { 3 return; 4 } 5 worker.schedule(this); 6} 7 8@Override 9public final void run() {10 if (outputFused) {11 runBackfused();12 } else if (sourceMode == SYNC) {13 runSync();14 } else {15 runAsync();16 }17}
这里我们来分析异步的执行代码:
1@Override 2void runAsync() { 3 … 4 for (;;) { 5 long r = requested.get(); 6 while (e != r) { 7 boolean d = done; 8 T v; 9 try { 10 v = q.poll(); 11 } catch (Throwable ex) { 12 … 13 return; 14 } 1516 … 17 a.onNext(v); 18 … 19 } 20 … 21 } 22}
很多逻辑判断代码我们都跳过,看到v=q.poll()的执行,这个poll()方法被重写了:
1@Nullable 2@Override 3public T poll() throws Exception { 4 T v = queue.poll(); 5 if (v != null && sourceMode != SYNC) { 6 long p = produced + 1; 7 if (p == limit) { 8 produced = 0; 9 s.request(p);10 } else {11 produced = p;12 }13 }14 return v;15}
这里,我们就看到了在从队列里取出值消费后,消费数+1,若此时下游的消费数已经到了缓存池需要清理的那个阈值,便重新向上游request。
到此为止,基本的Flowable的步骤就说完了。
狐友技术团队其他精彩文章
Swift之Codable实战技巧
不了解GIF的加载原理?看我就够了!
安卓系统权限,你真的了解吗?
加入搜狐技术作者天团
千元稿费等你来!
戳这里!☛