【问题标题】:NSOperation class run multiple operationsNSOperation 类运行多个操作
【发布时间】:2014-04-24 17:49:07
【问题描述】:

所以我问了几个关于 UICollectionView 的问题。了解它是如何工作的,我正在尝试实现延迟加载以将 15 个图像加载到视图控制器上。我发现很多例子123...第一个和第三个例子只处理一个操作,第二个例子我认为根本不使用操作,只使用线程。我的问题是可以使用 NSOperation 类和使用/重用操作吗?我读到您无法重新运行操作,但我认为您可以再次初始化它们。这是我的代码:

视图控制器:

UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc]init];
layout.sectionInset = UIEdgeInsetsMake(10, 20, 10, 20);
[layout setItemSize:CGSizeMake(75, 75)];
self.images = [[UICollectionView alloc]initWithFrame:CGRectMake(0, 230, self.view.frame.size.width, 200) collectionViewLayout:layout];
self.images.delegate = self;
self.images.dataSource = self;
[self.images registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"cellIdentifier"];
[self.view addSubview:self.images];
self.operation = [[NSOperationQueue alloc]init];
[self.operation addOperationWithBlock:^{
    NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"getgallery.php?user=%@", userId] relativeToURL:[NSURL URLWithString:@"http://www.mywebsite.com/"]];
    NSData *data = [NSData dataWithContentsOfURL:url];
//datasource for all images
    self.imagesGalleryPaths = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:nil];
    [[NSOperationQueue mainQueue]addOperationWithBlock:^{
        [self.images reloadData];
//reload collection view to place placeholders
    }];
}];

- (void)viewDidAppear:(BOOL)animated{
//get the visible cells right away
visibleCellPaths = [NSArray new];
visibleCellPaths = self.images.indexPathsForVisibleItems;
self.processedImages = [[NSMutableDictionary alloc]initWithCapacity:visibleCellPaths.count];
}
#pragma mark - collection view
- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView{
return 1;
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section{
int i;
if (self.imagesGalleryPaths.count == 0)
    i = 25;
else
    i = self.imagesGalleryPaths.count;
return i;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath{
UICollectionViewCell *cell = [UICollectionViewCell new];
cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"cellIdentifier" forIndexPath:indexPath];
cell.layer.borderWidth = 1;
cell.layer.borderColor = [[UIColor whiteColor]CGColor];
UIImageView *image = [[UIImageView alloc]init];
if (self.imagesGalleryPaths.count != 0) {
    if ([visibleCellPaths containsObject:indexPath]) {
        [self setUpDownloads:visibleCellPaths];
    }
    image.image = [UIImage imageNamed:@"default.png"];
    image.frame = CGRectMake(0, 0, cell.frame.size.width, cell.frame.size.height);
    [cell.contentView addSubview:image];
}
return cell;
}

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
visibleCellPaths = [NSArray new];
visibleCellPaths = self.images.indexPathsForVisibleItems;
[self setUpDownloads:visibleCellPaths];
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
visibleCellPaths = [NSArray new];
visibleCellPaths = self.images.indexPathsForVisibleItems;
[self setUpDownloads:visibleCellPaths];
}

- (void)setUpDownloads:(NSArray *)visiblePaths{
//I want to pass the visiblePaths to the NSOperation class if visible cells changed
GalleryOps *gallery = [[GalleryOps alloc]init];
//I will use a dictionary to keep track of which indexPaths are being downloaded...
//...so there are no duplicate downloads
}

GalleryOps.m

@implementation GalleryOps

- (void)main{
    //would I initialize all the operations here and perform them?
}

显示空的 GalleryOps 类几乎没有意义,因为我不知道如何使用多个操作对其进行初始化。我知道我必须重写 main 方法,一旦我从 URL 获取图像数据,我需要更新 UI,为此我需要一个委托方法......另一件事我还不知道该怎么做但是有很多例子可以说明这一点。我最大的问题是如何将可见单元格传递给这个类并运行多个操作?当新的可见单元格出现时,我会检查一下要取消哪些,保留哪些。这里有什么建议吗?提前致谢!

【问题讨论】:

  • 为什么每次下载都没有操作实例?然后你可以控制有多少与队列同时运行。
  • 你能举个例子吗?在 NSOperation 类中还是在视图控制器中?
  • 你已经在做addOperationWithBlock:,将一个操作实例添加到队列中——你能再做一次同样的事情吗...
  • @Rob,我知道有很多图书馆,但作为学习过程的一部分,我想先了解基础知识的工作原理,然后再让我的生活更轻松。
  • @Wain,我认为你是对的。我可以通过队列块操作循环遍历可见单元格,同时检查作为参数 indexPath 传递的内容,比较已加载的图像,并一一插入图像。取消对 viewWillDisappear 的所有操作。如果不可能进行多个 NSOperations,现在可能是最好的方法。

标签: ios objective-c nsoperation


【解决方案1】:

查看您的proposed solution,您似乎想推迟使操作可取消的问题。此外,您似乎想推迟使用缓存(尽管它并不比您的 NSMutableDictionary 属性复杂)。

因此,撇开这些不谈,您修改后的代码示例有两个“全局”问题:

  1. 您可以大大简化图像检索过程。 startOperationForVisibleCells 和两个滚动代表的使用是不必要的复杂。有一种更简单的模式,您可以在其中停用这三种方法(并获得更好的用户体验)。

  2. 您的cellForItemForIndexPath 有问题,您正在添加子视图。问题是单元格被重复使用,因此每次重复使用单元格时,您都会添加更多冗余子视图。

    你真的应该继承UICollectionViewCell(在我下面的例子中是CustomCell),把单元格的配置,包括添加子视图,放在那里。它简化了您的cellForItemAtIndexPath 并消除了添加额外子视图的问题。

除了这两个大问题,还有一堆小问题:

  1. 您忽略了为您的操作队列设置maxConcurrentOperationCount。您确实希望将其设置为 45 以避免操作超时错误。

  2. 您正在使用NSIndexPath 键入您的imageGalleryData。问题是,如果您曾经删除过一行,那么所有后续索引都会出错。我怀疑这现在不是问题(您可能不希望删除项目),但如果您使用其他内容(例如 URL)键入它,它同样容易,但它更面向未来。

  3. 我建议将您的操作队列从 operation 重命名为 queue。同样,我会将UICollectionViewimages(可能被错误地推断为图像数组)重命名为collectionView。这是风格,如果你不想这样做,你不必这样做,但这是我在下面使用的约定。

  4. 与其将NSData 保存在名为imageGalleryDataNSMutableDictionary 中,不如保存UIImage。当您滚动回以前下载的单元格时,这使您不必从 NSData 重新转换为 UIImage(这将使滚动过程更顺畅)。

所以,把所有这些放在一起,你会得到类似的东西:

static NSString * const kCellIdentifier = @"CustomCellIdentifier";

- (void)viewDidLoad
{
    [super viewDidLoad];

    UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc]init];
    layout.sectionInset = UIEdgeInsetsMake(10, 20, 10, 20);
    [layout setItemSize:CGSizeMake(75, 75)];

    // renamed `images` collection view to `collectionView` to follow common conventions

    self.collectionView = [[UICollectionView alloc]initWithFrame:CGRectMake(0, 230, self.view.frame.size.width, 200) collectionViewLayout:layout];
    self.collectionView.delegate = self;
    self.collectionView.dataSource = self;

    // you didn't show where you instantiated this in your examples, but I'll do it here

    self.imageGalleryData = [NSMutableDictionary dictionary];

    // register a custom class, not `UICollectionViewCell`

    [self.collectionView registerClass:[CustomCell class] forCellWithReuseIdentifier:kCellIdentifier];
    [self.view addSubview:self.collectionView];

    // (a) change queue variable name;
    // (b) set maxConcurrentOperationCount to prevent timeouts

    self.queue = [[NSOperationQueue alloc]init];
    self.queue.maxConcurrentOperationCount = 5;

    [self.queue addOperationWithBlock:^{
        NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"getgallery.php?user=%@", userId] relativeToURL:[NSURL URLWithString:@"http://www.mywebsite.com/"]];
        NSData *data = [NSData dataWithContentsOfURL:url];
        //datasource for all images
        self.imagesGalleryPaths = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:nil];
        [[NSOperationQueue mainQueue]addOperationWithBlock:^{
            [self.collectionView reloadData];
        }];
    }];
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
    return [self.imagesGalleryPaths count];          // just use whatever is the right value here, don't make this unnecessarily smaller
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    CustomCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:kCellIdentifier forIndexPath:indexPath];

    NSString *key = self.imagesGalleryPaths[indexPath.row];                 // I don't know whether this was simply array, or some nested structure, so tweak this accordingly
    UIImage *image = self.imageGalleryData[key]; 

    if (image) {
        cell.imageView.image = image;                                       // if we have image already, just use it
    } else {
        cell.imageView.image = [UIImage imageNamed:@"profile_default.png"]; // otherwise set the placeholder ...

        [self.queue addOperationWithBlock:^{                                // ... and initiate the asynchronous retrieval
            NSURL *url = [NSURL URLWithString:...];                         // build your URL from the `key` as appropriate
            NSData *responseData = [NSData dataWithContentsOfURL:url];
            if (responseData != nil) {
                UIImage *downloadedImage = [UIImage imageWithData:responseData];
                if (downloadedImage) {
                    [[NSOperationQueue mainQueue]addOperationWithBlock:^{
                        self.imageGalleryData[key] = downloadedImage;
                        CustomCell *updateCell = (id)[collectionView cellForItemAtIndexPath:indexPath];
                        if (updateCell) {
                            updateCell.imageView.image = downloadedImage;
                        }
                    }];
                }
            }
        }];
    }

    return cell;
}

// don't forget to purge your gallery data if you run low in memory

- (void)didReceiveMemoryWarning
{
    [super didReceiveMemoryWarning];
    [self.imageGalleryData removeAllObjects];
}

现在,显然我无权访问您的服务器,所以我无法检查这一点(特别是,我不知道您的 JSON 是返回完整的 URL 还是只是一个文件名,或者是否有一些嵌套字典数组)。但是我不想让你太迷失在我的代码细节中,而是看一下基本模式:消除你在可见单元格中的循环和响应滚动事件,让cellForItemAtIndexPath为你做所有的工作.

现在,我介绍的一件事是 CustomCell 的概念,它是一个 UICollectionViewCell 子类,可能看起来像:

//  CustomCell.h

#import <UIKit/UIKit.h>

@interface CustomCell : UICollectionViewCell

@property (nonatomic, weak) UIImageView *imageView;

@end

然后将单元格配置和这里的子视图添加到@implementation:

//  CustomCell.m

#import "CustomCell.h"

@implementation CustomCell

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        self.layer.borderWidth = 1;
        self.layer.borderColor = [[UIColor whiteColor]CGColor];

        UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, frame.size.width, frame.size.height)];
        [self addSubview:imageView];
        _imageView = imageView;
    }
    return self;
}
    
@end

顺便说一句,我仍然认为如果你想正确地做到这一点,你应该参考my other answer。如果您不想迷失在正确实现的那些杂草中,那么只需使用第三方UIImageView 类别,它支持异步图像检索、缓存、对可见单元的网络请求进行优先排序等,例如@987654323 @。

【讨论】:

  • 谢谢。我想我主要了解您的过程,但我不明白您为什么删除滚动功能...您的代码不会下载所有图像,即使它们不在屏幕上?您的示例没有摆脱延迟加载吗?
  • @denikov 不,cellForItemAtIndexPath 仅对可见单元格调用,并为滚动时出现的那些单元格再次调用。因此,您所有的自定义滚动内容都是不必要的。唯一一次你会做自定义滚动的东西是如果你不仅要获取可见的单元格,还要尝试预取当前不可见的单元格。但还有许多优化远比这重要。
  • 哇,不知道!再次感谢。我将重做这个并继续学习代表和操作。顺便说一句,我有一个关于代表的问题,但这对 SO 来说可能不是最好的……你有什么办法可以帮忙吗?它来自这个例子:raywenderlich.com/19788/…。关于在 init 方法中传递代表的原因......很难把我的头包裹在代表身上
  • 我了解自定义 init 的设置,但我对将“self”的委托从“root”视图控制器传递到 imagedownloader 感到困惑。然后在 imagedownloader 中,它将 imagedownloader 委托设置为来自根控制器的“自我”。所以imagedownloader的代表是root???我对一般意义上的说法感到困惑。你能用简单的英语给我解释一下那部分吗? :)
【解决方案2】:

您可以使用队列和操作本身来管理多个操作。如果我正确阅读了您的问题,那么您有一个操作(从 JSON 获取图像 URL 列表)要生成子操作。您可以通过让父操作将子操作添加到队列中,或使用依赖操作(子操作将父操作作为依赖项)来做到这一点。

对于您想要做的事情,您不需要继承 NSOperation,NSBlockOperation 应该可以满足您的需求。由于 KVO 依赖关系,子类化 NSOperation 比看起来更棘手(很容易出错)。

但是对于您的问题的具体情况:

我的问题是可以使用 NSOperation 类和使用/重用操作吗?我读到您无法重新运行操作,但我认为您可以在再次初始化它们后重新运行

如果您再次初始化它们,它们就是新对象(或者至少,它们应该是)。 NSOperations 不能重新运行,因为它们有内部状态——我上面提到的棘手的 KVO 位。一旦他们进入“完成”状态,该实例就无法返回到干净状态。

操作应该是相当轻量级的对象,重用它们不应该有任何重大价值,并且有很多潜在的麻烦。创建新的操作应该是要走的路。

Apple 示例代码“LazyTableImages”可能会给您一些提示,告诉您如何完成您正在尝试做的事情。

【讨论】:

  • 感谢您回答我的问题。使用块操作,它们可以被取消吗?我的意思是,在我的代码中,当用户滚动时我会更新可见单元格......我能否以某种方式检查当前操作的 indexPath 是否是新可见单元格的一部分?如果没有,取消操作?
  • @denikov 不,如果你想让你的操作可以取消,你会想要继承NSOperation。正如 quellish 所说,这更复杂,但这是正确的方法。
  • 我想我明白了……因为你仍然在队列中添加了一个阻塞操作,你只能取消队列,而不是操作本身。
  • @denikov 块操作的问题是您无法取消正在进行的操作。 (您可以取消尚未开始的那些,但不能取消当前正在运行的。)为此,必须对操作进行编程以检查它是否已取消,以及默认的具体 NSOperation 类(例如 @ 987654324@) 不提供这种能力。这就是为什么你必须继承NSOperation,这样你就可以编写自己的取消逻辑。
  • NSBlockOperation 可以取消。请参阅 WWDC 2012 会议“构建并发用户界面”。
【解决方案3】:

基于NSOperation 的图像延迟加载的组成元素可能包括:

  1. 创建一个专用的NSOperationQueue,用于下载操作。通常这是配置为 4 或 5 的 maxConcurrentOperationCount,以便您享受并发,但不会超过最大并发网络操作数。

    如果您不使用此maxConcurrentOperationCount,并且网络连接速度较慢(例如蜂窝网络),您可能会面临网络请求超时的风险。

  2. 有一个模型对象(例如一个数组)来支持您的集合视图或表格视图。这通常只有图像的一些标识符(例如 URL),而不是图像本身。

  3. 实现一个缓存机制来存储下载的图像,以防止需要重新下载已经下载的图像。一些实现只做基于内存的缓存(通过NSCache)。其他人(例如SDWebImage)将做两层缓存,内存(NSCache,以获得最佳性能)和二级持久存储缓存(这样当内存压力迫使你清除NSCache时,你仍然有一个保存在设备上的演绎版,因此您不必从网络重新检索它)。其他人(例如AFNetworking)依赖NSURLCache 将来自服务器的响应缓存到持久存储中。

  4. 编写一个NSOperation 子类来下载单个图像。您要进行此可取消操作。这意味着两种不同的设计考虑

    • 首先,关于操作本身,您可能想要进行并发操作(请参阅并发编程指南中的Configuring Operations for Concurrent Execution 部分)。

    • 其次,关于网络请求,您需要一个可取消的网络请求。如果使用NSURLConnection,这意味着使用基于委托的再现(不是任何便利方法)。如果使用NSURLConnection,诀窍是你必须将它安排在一个持续存在的运行循环中。有很多技巧可以实现这一点,但最简单的方法是在主运行循环中安排它(使用scheduleInRunLoop:forMode:)(尽管有更优雅的方法),即使您将从@ 中的操作运行它987654338@。我个人为此目的启动了一个新的专用线程(如 AFNetworking 所做的那样),但主运行循环更简单,适合此类进程。

      如果使用NSURLSession,这个过程可能会更容易一点,因为您可以使用dataTaskWithRequest 的完成块再现而侥幸,如果您不想这样做,则无需进入基于委托的实现。但这仅适用于 iOS 7+(如果您需要做任何花哨的事情,例如处理身份验证质询请求,您最终还是会采用基于委托的方法)。

    • 结合前面两点,自定义NSOperation子类会检测到操作何时被取消,然后取消网络请求并完成操作。

    顺便说一句,操作实例永远不会被重用。您为正在下载的每个图像创建一个新的操作实例。

  5. 顺便说一下,如果您下载的图像很大(例如,它们的尺寸大于图像视图所需的像素数),您可能需要在使用它们之前调整图像大小。下载 JPG 或 PNG 图像时,它们会被压缩,但是当您在图像视图中使用它们时,它们是未压缩的,通常每个像素需要 4 个字节(例如,1000x1000 的图像需要 4mb,即使 JPG 比这小得多) .

    有很多图像大小调整算法,但这里有一个:https://stackoverflow.com/a/10859625/1271826

  6. 您需要一个cellForItemAtIndexPath,然后将上述部分组合在一起,即:

    • 检查图片是否已经在缓存中,如果是,使用它。

    • 如果没有,您将启动网络请求以检索图像。您可能想查看此单元格(可能是表格视图中重复使用的单元格)是否已经有一个图像操作正在进行中,如果是,请取消它。

      无论如何,您可以实例化一个新的NSOperation 子类来下载图像,并让完成块更新缓存,然后更新单元格的图像视图。

    • 顺便说一下,当您异步更新单元格的图像视图时,确保该单元格仍然可见并且该单元格在此期间没有被重复使用。您可以通过调用 [collectionView cellForItemAtIndexPath:indexPath] 来执行此操作(不应与您在此处编写的名称相似的 UICollectionViewDataSource 方法混淆)。

这些是流程的组成部分,我建议您一次解决一个。编写一个优雅的延迟加载实现涉及很多内容。

最简单的解决方案是考虑使用现有的UIImageView 类别(例如SDWebImage 提供的),它可以为您完成所有这些工作。即使您不使用该库,您也可以通过查看源代码来学到很多东西。

【讨论】:

  • 感谢 Rob 的冗长解释。即使大部分内容都超出了我的想象,我明天会尝试分解所有内容并尝试理解它。再次感谢!
  • 从 iOS 5 开始,NSURLCache 不仅在内存缓存中,也在磁盘上。添加忽略服务器发送的 HTTP 缓存指令的额外缓存层可能只是为了复杂性而增加复杂性。服务器发送响应并(应该)告诉客户端该响应的有效时间以及何时应针对服务器重新验证。 URL 加载系统会为您完成所有这些工作。
  • @quellish 理论上,NSURLCache 可以提供持久缓存,但在实践中,有时会出现问题。如果您正在执行任何图像操作,则可能是错误的抽象级别(因为您可能想要缓存被操作的图像,而不是原始响应)。但是有些人提倡这种方法,例如AFNetworking(在他们的 GitHub“问题”页面上一直在积极讨论),所以在我的缓存选项列表中添加了这个替代方案。
【解决方案4】:

所以我想通了,我想我会称之为“肮脏的方式”,做我想做的事。我认为我的收藏视图不会让用户一次又一次地回来,只是偶尔一次。所以没有理由缓存所有的图像。我知道@Rob 会因为我这样做而对我大喊大叫,但在我学习代表并对其感到满意之前,这就是我感到满意的。

viewDidAppear 还是一样,我马上就得到了可见的单元格。最初我将 25 个单元格放入 numberOfItems... 的原因只是为了立即获得可见单元格。所以现在我的 cellForItemAtIndexPath 是这样的:

UICollectionViewCell *cell = [UICollectionViewCell new];
cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"cellIdentifier" forIndexPath:indexPath];
cell.layer.borderWidth = 1;
cell.layer.borderColor = [[UIColor whiteColor]CGColor];
UIImageView *image = [[UIImageView alloc]init];
if (self.imagesGalleryPaths.count != 0) {
    image.image = [UIImage imageNamed:@"profile_default.png"];
    image.frame = CGRectMake(0, 0, cell.frame.size.width, cell.frame.size.height);
    [cell.contentView addSubview:image];
}
return cell;

在 viewDidLoad 我添加了这个:

if (self.imagesGalleryPaths.count != 0) {
    [[NSOperationQueue mainQueue]addOperationWithBlock:^{
        [self.images reloadData];
        [self startOperationForVisibleCells];
    }];
}

这是我的 startOperationForVisibleCells:

[self.operation addOperationWithBlock:^{
    int i=0;
    while (i < visibleCellPaths.count) {
        NSIndexPath *indexPath = [visibleCellPaths objectAtIndex:i];
        if (![self.imageGalleryData.allKeys containsObject:indexPath]) {
            NSURL *url = [@"myurl"];
            NSData *responseData = [NSData dataWithContentsOfURL:url];
            if (responseData != nil) {
                [self.imageGalleryData setObject:responseData forKey:indexPath];
                [[NSOperationQueue mainQueue]addOperationWithBlock:^{
                    UICollectionViewCell *cell = [self.images cellForItemAtIndexPath:indexPath];
                    UIImageView *image = [UIImageView new];
                    image.image = [UIImage imageWithData:responseData];
                    image.frame = CGRectMake(0, 0, cell.frame.size.width, cell.frame.size.height);
                    [cell.contentView addSubview:image];
                }];
            }
        }
        i++;
    }
}];

这就是我一一更新单元格的方式。当用户滚动离开时:

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
visibleCellPaths = [NSArray new];
visibleCellPaths = self.images.indexPathsForVisibleItems;
for (int i=0; i<visibleCellPaths.count; i++) {
    NSIndexPath *indexPath = [visibleCellPaths objectAtIndex:i];
    if ([self.imageGalleryData.allKeys containsObject:indexPath]) {
        UICollectionViewCell *cell = [self.images cellForItemAtIndexPath:indexPath];
        UIImageView *image = [UIImageView new];
        image.image = [UIImage imageWithData:[self.imageGalleryData objectForKey:indexPath]];
        image.frame = CGRectMake(0, 0, cell.frame.size.width, cell.frame.size.height);
        [cell.contentView addSubview:image];
    }else{
        [self startOperationForVisibleCells];
    }
}
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
//same functions as previous
}

我确信这是一种非常糟糕的做法,但目前它确实有效。图像被一张一张地加载,当用户滚动时它们停止加载。如果它是如此糟糕并且人们投票反对,我会删除这个答案,以防有些人会从我这里学习错误的方式。任何 cmets 都对这种做事方式表示赞赏。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-11-25
    • 1970-01-01
    • 2012-09-21
    • 1970-01-01
    相关资源
    最近更新 更多