在RxJs 5中,分享Angular Http网络调用的结果的正确方法是什么?

351

通过使用 Http,我们调用执行网络调用并返回 http observable 的方法:

getCustomer() {
    return this.http.get('/someUrl').map(res => res.json());
}

如果我们将此可观察对象添加多个订阅者:

let network$ = getCustomer();

let subscriber1 = network$.subscribe(...);
let subscriber2 = network$.subscribe(...);
我们想要做的是确保这不会导致多个网络请求。
这可能看起来像一个不寻常的场景,但实际上很常见:例如,如果调用者订阅可观察对象以显示错误消息,并使用异步管道将其传递到模板中,则我们已经有了两个订阅者。
在 RxJs 5 中正确的做法是什么?
也就是说,这似乎还不错:
getCustomer() {
    return this.http.get('/someUrl').map(res => res.json()).share();
}

但是在RxJs 5中,这是惯用方法吗?还是我们应该选择其他方法?

注意:根据Angular 5的新HttpClient,所有示例中的.map(res => res.json())部分现在已经无用了,因为默认情况下假定为JSON结果。


1
share()与publish().refCount()并不完全相同。请参考以下讨论:https://github.com/ReactiveX/rxjs/issues/1363 - Christian
1
修改问题,根据问题看起来代码文档需要更新 -> https://github.com/ReactiveX/rxjs/blob/master/src/operator/share.ts - Angular University
我认为“这取决于情况”。但是对于那些无法在本地缓存数据的调用,因为由于参数的更改/组合可能没有意义,使用.share()似乎绝对是正确的选择。但是,如果您可以在本地缓存某些内容,则关于ReplaySubject / BehaviorSubject的其他答案也是不错的解决方案。 - JimB
我认为我们不仅需要缓存数据,还需要更新/修改缓存的数据。这是一个常见的情况。例如,如果我想要向缓存的模型中添加一个新字段或更新字段的值。也许创建一个带有CRUD方法的单例__DataCacheService__是更好的方式?就像Redux的__store__一样。你觉得呢? - Lin Du
你可以简单地使用 ngx-cacheable!它更适合你的场景。请参考我下面的回答。 - Tushar Walzade
请考虑给@Arlo的答案点赞。当我使用多个订阅和combineLatest()时,我只需要在我的管道中添加shareReplay(1)作为最后一个操作符,请求就会在单个“调用堆栈”内共享。 - al-bex
22个回答

244

编辑:截至2021年,正确的做法是使用RxJs原生提出的shareReplay操作符。有关更多详细信息,请参见下面的答案。


缓存数据,如果已经缓存,则返回缓存数据,否则发起HTTP请求。

import {Injectable} from '@angular/core';
import {Http, Headers} from '@angular/http';
import {Observable} from 'rxjs/Observable';
import 'rxjs/add/observable/of'; //proper way to import the 'of' operator
import 'rxjs/add/operator/share';
import 'rxjs/add/operator/map';
import {Data} from './data';

@Injectable()
export class DataService {
  private url: string = 'https://cors-test.appspot.com/test';
  
  private data: Data;
  private observable: Observable<any>;

  constructor(private http: Http) {}

  getData() {
    if(this.data) {
      // if `data` is available just return it as `Observable`
      return Observable.of(this.data); 
    } else if(this.observable) {
      // if `this.observable` is set then the request is in progress
      // return the `Observable` for the ongoing request
      return this.observable;
    } else {
      // example header (not necessary)
      let headers = new Headers();
      headers.append('Content-Type', 'application/json');
      // create the request, store the `Observable` for subsequent subscribers
      this.observable = this.http.get(this.url, {
        headers: headers
      })
      .map(response =>  {
        // when the cached data is available we don't need the `Observable` reference anymore
        this.observable = null;

        if(response.status == 400) {
          return "FAILURE";
        } else if(response.status == 200) {
          this.data = new Data(response.json());
          return this.data;
        }
        // make it shared so more than one subscriber can get the result
      })
      .share();
      return this.observable;
    }
  }
}

Plunker示例

这篇文章 https://blog.thoughtram.io/angular/2018/03/05/advanced-caching-with-rxjs.html 是一个很好的解释如何使用 shareReplay 进行缓存。


4
map() 不同,do() 不会修改事件。你也可以使用 map(),但是你必须确保在回调结束时返回正确的值。 - Günter Zöchbauer
4
如果调用 .subscribe() 的位置不需要该值,那么可以这样做,因为它可能只会得到 null(取决于 this.extractData 返回的内容),但在我看来,这并不能很好地表达代码的意图。 - Günter Zöchbauer
3
this.extraDataextraData() { if(foo) { doSomething();}}这样的方式结尾时,将返回最后一个表达式的结果,这可能不是您想要的结果。 - Günter Zöchbauer
12
@Günter,谢谢你提供的代码,它有效。但是,我想了解为什么你要分别跟踪Data和Observable。如果像这样只缓存Observable<Data>,不会达到相同的效果吗?if (this.observable) { return this.observable; } else { this.observable = this.http.get(url) .map(res => res.json().data); return this.observable; } - July.Tech
5
@HarleenKaur,这是一个用于反序列化JSON数据的类,以获得强类型检查和自动补全功能。使用它并非必需,但很常见。 - Günter Zöchbauer
显示剩余18条评论

51

根据 @Cristian 的建议,以下是一种适用于 HTTP observables 的方法,它们只会发出一次信号然后完成:

getCustomer() {
    return this.http.get('/someUrl')
        .map(res => res.json()).publishLast().refCount();
}

使用这种方法存在一些问题 - 返回的可观察对象无法被取消或重试。这对你可能不是问题,但也有可能是。如果这是一个问题,那么“share”操作符可能是一个合理的选择(尽管存在一些讨厌的边缘情况)。关于选项的深入讨论,请参见此博客文章中的评论部分:http://blog.jhades.org/how-to-build-angular2-apps-using-rxjs-observable-data-services-pitfalls-to-avoid/ - Christian
2
小澄清... 尽管通过 publishLast().refCount() 共享的源可观察对象不能被取消,但一旦对由 refCount 返回的可观察对象的所有订阅都被取消,净效果是源可观察对象将被取消订阅,如果它正在进行中,则会取消它。 - Christian
@Christian 嘿,你能解释一下你说的“不能被取消或重试”的意思吗?谢谢。 - undefined

40

更新:Ben Lesh表示,在5.2.0之后的下一个小版本中,您将能够直接调用shareReplay()来真正缓存。

以前......

首先,请不要使用share()或publishReplay(1).refCount(),它们是相同的问题在于,它只在可观察对象处于活动状态时共享连接。如果您在它完成后连接,它会再次创建一个新的Observable,翻译为,不是真正的缓存。

Birowski提供了正确的解决方案,即使用ReplaySubject。 ReplaySubject会将您提供给它的值(bufferSize)缓存起来,在我们的情况下是1。它不会像share()一样在refCount达到零并且您建立新连接时创建一个新的observable,这是缓存的正确行为。

以下是一个可重用的函数

export function cacheable<T>(o: Observable<T>): Observable<T> {
  let replay = new ReplaySubject<T>(1);
  o.subscribe(
    x => replay.next(x),
    x => replay.error(x),
    () => replay.complete()
  );
  return replay.asObservable();
}

以下是如何使用它的方法

import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import { Observable } from 'rxjs/Observable';
import { cacheable } from '../utils/rxjs-functions';

@Injectable()
export class SettingsService {
  _cache: Observable<any>;
  constructor(private _http: Http, ) { }

  refresh = () => {
    if (this._cache) {
      return this._cache;
    }
    return this._cache = cacheable<any>(this._http.get('YOUR URL'));
  }
}

以下是一个更高级的可缓存函数版本。它具有自己的查找表和提供自定义查找表的能力。这样,您就不必像上面的示例中那样检查 this._cache。还要注意,与其将observable作为第一个参数传递,您需要传递能够返回observables的函数。这是因为Angular的Http会立即执行,所以通过返回延迟执行的函数,我们可以决定在缓存中已经存在时不调用它。

let cacheableCache: { [key: string]: Observable<any> } = {};
export function cacheable<T>(returnObservable: () => Observable<T>, key?: string, customCache?: { [key: string]: Observable<T> }): Observable<T> {
  if (!!key && (customCache || cacheableCache)[key]) {
    return (customCache || cacheableCache)[key] as Observable<T>;
  }
  let replay = new ReplaySubject<T>(1);
  returnObservable().subscribe(
    x => replay.next(x),
    x => replay.error(x),
    () => replay.complete()
  );
  let observable = replay.asObservable();
  if (!!key) {
    if (!!customCache) {
      customCache[key] = observable;
    } else {
      cacheableCache[key] = observable;
    }
  }
  return observable;
}

使用方法:

getData() => cacheable(this._http.get("YOUR URL"), "this is key for my cache")

有没有不使用这个解决方案作为RxJs操作符的原因:const data$ = this._http.get('url').pipe(cacheable()); /*1st subscribe*/ data$.subscribe(); /*2nd subscribe*/ data$.subscribe();?这样它的行为更像其他操作符。 - Felix
我在最后一个解决方案中得到了 Type 'Observable<any>' provides no match for the signature '(): Observable<unknown>'。 的错误信息。 - TCB13

37

rxjs 5.4.0新增了一个shareReplay方法。

作者明确表示"非常适合处理诸如缓存AJAX结果等事情"

rxjs PR #2443 feat(shareReplay): 添加publishReplay变体的shareReplay

shareReplay返回一个使用ReplaySubject作为广播源的可观测对象。该回放主题在来自源的错误时被回收,但不会在源完成时被回收。因此,shareReplay非常适合处理缓存AJAX结果等事情,它是可重试的。与share不同的是它不会重复源可观测对象,而只会重复源可观测对象的值。


这与此有关吗?不过这些文档是2014年的。https://github.com/Reactive-Extensions/RxJS/blob/master/doc/api/core/operators/sharereplay.md - Aaron Hoffman
4
我尝试给一个Observable添加.shareReplay(1, 10000),但我没有注意到任何缓存或行为变化。是否有可用的工作示例? - Matthew
查看变更日志 https://github.com/ReactiveX/rxjs/blob/275a9a39b3283daafa87f99a1cb4926021317168/CHANGELOG.md,它早期出现,被移除在v5中,又在5.4版本中添加回来了。那个rx-book链接确实是指v4,但它存在于当前的LTS v5.5.6版本和v6版本中。我想那里的rx-book链接已经过时了。 - Jason Awbrey

30
根据这篇文章,原来的observable可以很容易地添加缓存功能,只需添加publishReplay(1)和refCount。
因此,在if语句内部,只需追加。
.publishReplay(1)
.refCount();

使用 .map(...) 方法


30

rxjs版本5.4.0(2017-05-09)增加了对shareReplay的支持。

为什么使用shareReplay?

当您有副作用或繁重的计算,并且不希望在多个订阅者之间执行时,通常会使用shareReplay。在您知道将有晚订阅者需要访问先前发出的值的情况下,它也可能具有价值。这种在订阅时重播值的能力是share和shareReplay的区别所在。

您可以轻松修改一个Angular服务来使用这个功能,并返回一个带有缓存结果的observable,这个结果只会进行一次http调用(假设第一次调用成功)。

示例Angular服务

这是一个非常简单的客户服务,使用了shareReplay

customer.service.ts

import { shareReplay } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';

@Injectable({providedIn: 'root'})
export class CustomerService {

    private readonly _getCustomers: Observable<ICustomer[]>;

    constructor(private readonly http: HttpClient) {
        this._getCustomers = this.http.get<ICustomer[]>('/api/customers/').pipe(shareReplay());
    }
    
    getCustomers() : Observable<ICustomer[]> {
        return this._getCustomers;
    }
}

export interface ICustomer {
  /* ICustomer interface fields defined here */
}

请注意,构造函数中的赋值可以移动到方法getCustomers中,但由于从HttpClient返回的可观察对象是“冷”的,所以在构造函数中这样做是可以接受的,因为只有在第一次调用subscribe时才会进行http调用。
另外,这里的假设是初始返回的数据在应用程序实例的生命周期内不会变旧。

1
我非常喜欢这个模式,并计划将其实现到我跨多个应用程序使用的API服务的共享库中。一个示例是UserService,在除了一些地方之外,该服务在应用程序的生命周期内不需要使缓存失效,但对于那些情况,我要如何使其无效而不会导致以前的订阅变为孤立状态呢? - SirTophamHatt
如果我们将Observable的创建从构造函数移动到getCustomer方法中,那么调用getCustomer的不同组件将接收到不同的observable实例。这可能不是我们想要的。因此,我认为应该在构造函数中创建observable。如果我们可以接受对getCustomer()的不同调用返回不同的observables,那么在方法本身中拥有它是可以的。 - Shailesh Vaishampayan
使用shareReplay与窗口一起会让缓存变得陈旧,对吧? - undefined

10

我已经将这个问题加星标了,但我会尝试回答一下。

//this will be the shared observable that 
//anyone can subscribe to, get the value, 
//but not cause an api request
let customer$ = new Rx.ReplaySubject(1);

getCustomer().subscribe(customer$);

//here's the first subscriber
customer$.subscribe(val => console.log('subscriber 1: ' + val));

//here's the second subscriber
setTimeout(() => {
  customer$.subscribe(val => console.log('subscriber 2: ' + val));  
}, 1000);

function getCustomer() {
  return new Rx.Observable(observer => {
    console.log('api request');
    setTimeout(() => {
      console.log('api response');
      observer.next('customer object');
      observer.complete();
    }, 500);
  });
}

这里有证明 :)

只有一个要点需要记住:getCustomer().subscribe(customer$)

我们订阅的不是getCustomer() API响应,而是ReplaySubject,它是一个可观察对象,也可以订阅不同的Observable并(这很重要)保留它的最后一个发出的值并重新发布给任何它(ReplaySubject)的订阅者。


1
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - Thibs

8

我找到了一种将http get请求结果存储到sessionStorage中并在会话期间使用的方法,这样就不会再次调用服务器。

我使用它来调用github API以避免使用限制。

@Injectable()
export class HttpCache {
  constructor(private http: Http) {}

  get(url: string): Observable<any> {
    let cached: any;
    if (cached === sessionStorage.getItem(url)) {
      return Observable.of(JSON.parse(cached));
    } else {
      return this.http.get(url)
        .map(resp => {
          sessionStorage.setItem(url, resp.text());
          return resp.json();
        });
    }
  }
}

请注意,sessionStorage的限制是5M(或4.75M)。因此,不应将其用于大数据集。

------ 编辑 -------------
如果您想使用内存数据而不是sessionStorage来获取刷新的数据,请使用F5;

@Injectable()
export class HttpCache {
  cached: any = {};  // this will store data
  constructor(private http: Http) {}

  get(url: string): Observable<any> {
    if (this.cached[url]) {
      return Observable.of(this.cached[url]));
    } else {
      return this.http.get(url)
        .map(resp => {
          this.cached[url] = resp.text();
          return resp.json();
        });
    }
  }
}

如果您将数据存储在会话存储中,那么如何确保离开应用程序时会话存储被销毁? - Gags
2
但是这会给用户带来意外的效果。当用户按下浏览器的F5或刷新按钮时,他期望从服务器获得新鲜的数据。但实际上,他正在从localStorage获取过时的数据。错误报告、支持票据等正在到来......正如sessionStorage名称所示,我只会将其用于整个会话中预期保持一致的数据。 - Martin Schneider
@MA-Maddin,正如我所说,“我使用它来避免使用限制”。如果你想要用F5刷新数据,你需要使用内存而不是sessionStorage。答案已经采用了这种方法进行编辑。 - allenhwkim
是的,那可能是一个使用案例。我只是有些触动了,因为每个人都在谈论缓存,而 OP 在他的示例中有 getCustomer。;) 所以只是想警告一些可能没有看到风险的人 :) - Martin Schneider

7
你选择的实现方式将取决于你是否希望unsubscribe()取消你的HTTP请求。
无论如何,TypeScript装饰器是标准化行为的好方法。这是我编写的:
  @CacheObservableArgsKey
  getMyThing(id: string): Observable<any> {
    return this.http.get('things/'+id);
  }

装饰器定义:

/**
 * Decorator that replays and connects to the Observable returned from the function.
 * Caches the result using all arguments to form a key.
 * @param target
 * @param name
 * @param descriptor
 * @returns {PropertyDescriptor}
 */
export function CacheObservableArgsKey(target: Object, name: string, descriptor: PropertyDescriptor) {
  const originalFunc = descriptor.value;
  const cacheMap = new Map<string, any>();
  descriptor.value = function(this: any, ...args: any[]): any {
    const key = args.join('::');

    let returnValue = cacheMap.get(key);
    if (returnValue !== undefined) {
      console.log(`${name} cache-hit ${key}`, returnValue);
      return returnValue;
    }

    returnValue = originalFunc.apply(this, args);
    console.log(`${name} cache-miss ${key} new`, returnValue);
    if (returnValue instanceof Observable) {
      returnValue = returnValue.publishReplay(1);
      returnValue.connect();
    }
    else {
      console.warn('CacheHttpArgsKey: value not an Observable cannot publishReplay and connect', returnValue);
    }
    cacheMap.set(key, returnValue);
    return returnValue;
  };

  return descriptor;
}

嗨@Arlo - 上面的示例无法编译。从行returnValue.connect();中出现了属性'connect'在类型'{}'上不存在的错误。你能详细说明一下吗? - Hoof

5

使用Rxjs Observer/Observable + 缓存 + 订阅实现可缓存的HTTP响应数据

请查看以下代码

*免责声明:我是rxjs的新手,因此请注意我可能会误用observable/observer方法。我的解决方案纯粹是其他解决方案的综合体,并且是由于未能找到简单的文档解释而导致的结果。因此,我提供了完整的代码解决方案(就像我希望找到的那样),以帮助他人。

*请注意,这种方法基于GoogleFirebaseObservables。不幸的是,我缺乏适当的经验/时间来复制他们在内部所做的事情。但以下是一种提供对某些可缓存数据异步访问的简单方法。

情况:'product-list'组件负责显示产品列表。该网站是一个单页面Web应用程序,其中一些菜单按钮将“过滤”显示在页面上的产品。

解决方案:该组件“订阅”服务方法。服务方法返回一组产品对象,组件通过订阅回调访问这些对象。服务方法将其活动包装在一个新创建的Observer中并返回该观察者。在此观察者内部,它搜索缓存数据并将其传递回订阅者(组件),然后返回。否则,它会发出http调用以检索数据,订阅响应,在那里您可以处理该数据(例如将数据映射到自己的模型),然后将数据传递回订阅者。

代码

product-list.component.ts

import { Component, OnInit, Input } from '@angular/core';
import { ProductService } from '../../../services/product.service';
import { Product, ProductResponse } from '../../../models/Product';

@Component({
  selector: 'app-product-list',
  templateUrl: './product-list.component.html',
  styleUrls: ['./product-list.component.scss']
})
export class ProductListComponent implements OnInit {
  products: Product[];

  constructor(
    private productService: ProductService
  ) { }

  ngOnInit() {
    console.log('product-list init...');
    this.productService.getProducts().subscribe(products => {
      console.log('product-list received updated products');
      this.products = products;
    });
  }
}

product.service.ts

import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import { Observable, Observer } from 'rxjs';
import 'rxjs/add/operator/map';
import { Product, ProductResponse } from '../models/Product';

@Injectable()
export class ProductService {
  products: Product[];

  constructor(
    private http:Http
  ) {
    console.log('product service init.  calling http to get products...');

  }

  getProducts():Observable<Product[]>{
    //wrap getProducts around an Observable to make it async.
    let productsObservable$ = Observable.create((observer: Observer<Product[]>) => {
      //return products if it was previously fetched
      if(this.products){
        console.log('## returning existing products');
        observer.next(this.products);
        return observer.complete();

      }
      //Fetch products from REST API
      console.log('** products do not yet exist; fetching from rest api...');
      let headers = new Headers();
      this.http.get('http://localhost:3000/products/',  {headers: headers})
      .map(res => res.json()).subscribe((response:ProductResponse) => {
        console.log('productResponse: ', response);
        let productlist = Product.fromJsonList(response.products); //convert service observable to product[]
        this.products = productlist;
        observer.next(productlist);
      });
    }); 
    return productsObservable$;
  }
}

product.ts (the model)

export interface ProductResponse {
  success: boolean;
  msg: string;
  products: Product[];
}

export class Product {
  product_id: number;
  sku: string;
  product_title: string;
  ..etc...

  constructor(product_id: number,
    sku: string,
    product_title: string,
    ...etc...
  ){
    //typescript will not autoassign the formal parameters to related properties for exported classes.
    this.product_id = product_id;
    this.sku = sku;
    this.product_title = product_title;
    ...etc...
  }



  //Class method to convert products within http response to pure array of Product objects.
  //Caller: product.service:getProducts()
  static fromJsonList(products:any): Product[] {
    let mappedArray = products.map(Product.fromJson);
    return mappedArray;
  }

  //add more parameters depending on your database entries and constructor
  static fromJson({ 
      product_id,
      sku,
      product_title,
      ...etc...
  }): Product {
    return new Product(
      product_id,
      sku,
      product_title,
      ...etc...
    );
  }
}

以下是我在Chrome浏览器中加载页面时看到的输出示例。请注意,在初始加载时,产品是从http获取的(调用我的本地运行在端口3000上的节点rest服务)。当我点击导航到“过滤”视图时,产品将在缓存中找到。

我的Chrome日志(控制台):

core.es5.js:2925 Angular is running in the development mode. Call enableProdMode() to enable the production mode.
app.component.ts:19 app.component url: /products
product.service.ts:15 product service init.  calling http to get products...
product-list.component.ts:18 product-list init...
product.service.ts:29 ** products do not yet exist; fetching from rest api...
product.service.ts:33 productResponse:  {success: true, msg: "Products found", products: Array(23)}
product-list.component.ts:20 product-list received updated products

...[点击菜单按钮来筛选产品]...

app.component.ts:19 app.component url: /products/chocolatechip
product-list.component.ts:18 product-list init...
product.service.ts:24 ## returning existing products
product-list.component.ts:20 product-list received updated products

结论:这是我迄今为止发现的实现可缓存HTTP响应数据最简单的方法。在我的Angular应用程序中,每当我导航到产品的不同视图时,产品列表组件都会重新加载。ProductService似乎是一个共享实例,因此ProductService中的“products: Product []”的本地缓存在导航期间保留,并且随后对“GetProducts()”的调用返回缓存值。最后需要注意的一点是,我已经阅读了有关observable /订阅需要关闭以防止“内存泄漏”的评论。虽然我没有在此处包括它,但这是需要记住的事情。

2
注意 - 我已经找到了一种更强大的解决方案,涉及 RxJS BehaviorSubjects,这简化了代码并大大减少了“开销”。在 products.service.ts 中,1. 导入 { BehaviorSubject } from 'rxjs'; 2. 将 'products:Product[]' 更改为 'product$: BehaviorSubject<Product[]> = new BehaviorSubject<Product[]>([]);' 3. 现在您可以直接调用 http 而不返回任何内容。http_getProducts(){this.http.get(...).map(res => res.json()).subscribe(products => this.product$.next(products))}; - ObjectiveTC
2
本地变量 'product$' 是一个 behaviorSubject,它将同时 EMIT 和 STORE 最新的产品(来自第三部分中 product$.next(..) 的调用)。现在,在您的组件中,按照正常方式注入服务。您可以使用 productService.product$.value 获取最近分配的 product$ 值。或者,如果您想在 product$ 接收到新值时执行操作(即在第三部分中调用 product$.next(...) 函数),则可以订阅 product$。 - ObjectiveTC
2
例如,在products.component.ts文件中... this.productService.product$ .takeUntil(this.ngUnsubscribe) .subscribe((products) => {this.category); let filteredProducts = this.productService.getProductsByCategory(this.category); this.products = filteredProducts; }); - ObjectiveTC
2
关于取消订阅可观察对象的重要说明:".takeUntil(this.ngUnsubscribe)"。请参考此Stack Overflow问题/答案,该答案似乎展示了取消事件订阅的“事实上”推荐方式:https://dev59.com/ZFoT5IYBdhLWcg3w8S-2 - ObjectiveTC
3
如果Observable只打算接收一次数据,可以使用 .first() 或 .take(1) 方法。对于所有其他的“无限流”Observable,在 'ngOnDestroy()' 中应该取消订阅,否则可能会出现重复的 Observable 回调。参考链接:https://dev59.com/eF4c5IYBdhLWcg3wXJRX - ObjectiveTC

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接