NSCountedSet - 由取图片主色引发的思考

0.背景

在 iOS 中某场景中需要查询主色,取主色时发现当图片比较大时,会比较耗时,经排查发现了 NSCountedSet 用法上的一些问题。以下是取主色的部分代码:

   NSCountedSet *colorSet = [NSCountedSet setWithCapacity:row*column];
   for (int x=0; x

1.分析

首先分析下取主色的方法:遍历矩形的图片像素点,将所有的像素取出来,然后计算其中出现最多的像素。

最少需要一次全遍历,时间复杂度 O(m*n) (m=row, n=column) 。计数的算法,首先想到的是进行哈希,哈希时间复杂度可以近似认为是 0(1)。比如题中使用的 NSCountedSet ,底层实现就是哈希。

进一步分析发现,耗时主要是 [colorSet addObject:color] 这行代码引入的。

2.NSCountedSet 使用测试

接下来分析到了 [colorSet addObject:color] 这个插入方法的实现、复杂度、使用等。将 NSCountedSet 看做是是黑盒的进行测试:

① 将普通对象作为Key

测试的类为Foo,实现如下:

// Foo Interface
@interface Foo : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSUInteger age;
@end
// Foo Implementation
@implementation Foo

- (NSUInteger)hash {
    NSUInteger result = [super hash];
    printf("%s - %s - %lu\n", NSStringFromSelector(_cmd).UTF8String, _name.UTF8String, result);
//    NSLog(@"%@ - %lu", NSStringFromSelector(_cmd), _index);
    return result;
}

- (BOOL)isEqual:(id)object {
    printf("*******%s - %s\n", NSStringFromSelector(_cmd).UTF8String, _name.UTF8String);
//    NSLog(@"*******%@ - [1] - %lu - %lu", NSStringFromSelector(_cmd), ((Foo *)object).index, _index);
    if (![object isKindOfClass:[Foo class]]) {
        return NO;
    }
//    NSLog(@"*******%@ - [2]", NSStringFromSelector(_cmd));
    BOOL result = [((Foo *)object).name isEqualToString:_name];
    if (result) {
//        NSLog(@"*******%@ - [3] - %lu √", NSStringFromSelector(_cmd), _index);
    }
    return result;
}

- (NSString *)description {
    return [NSString stringWithFormat:@"%@", _name];
}

@end

测试代码:

        NSUInteger count = 5;
        NSCountedSet *countedSet = [NSCountedSet setWithCapacity:count];
        for (NSUInteger i = 0; i

测试结果,每次 addObject 都会调用 hash 方法,前几次调用不会调用 isEqual 方法,之后每次都会调用 isEqual 方法。计数是 hash 结果 + isEqual 综合的。

② 将NSArray实例作为Key

测试发现,NSCountedSet 的元素为数组时,会对数组的每个元素分别比较,所有元素都相同,才会计数。

数组的元素为基本数据类型时会直接比较,如果是对象仍会使用 isEqual 方法比较。

③ 将字典实例作为Key

测试发现,NSCountedSet 的元素为字典时,会对字典的每个键值对分别比较,所有元素都相同,才会计数。

3.NSCountedSet 的实现

虽然没有完整的实现,但是在 GNUStep 里还是能看到部分实现的,主要是以下两个文件:

NSCountedSet : https://github.com/gnustep/libs-base/blob/master/Source/NSCountedSet.m
GSCountedSet : https://github.com/gnustep/libs-base/blob/master/Source/GSCountedSet.m

其中计数部分的注释可以佐证,计数会使用 isEqual 进行比较:

/**
 * Returns the number of times that an object that is equal to the
 * specified object (as determined by the [-isEqual:] method) has
 * been added to the set and not removed from it.
 */

关键实现:对新添加的元素进行哈希,取值如果存在则计数加1,否则添加哈希并赋值为1,代码如下:

/**
 * Adds an object to the set.  If the set already contains an object
 * equal to the specified object (as determined by the [-isEqual:]
 * method) then the count for that object is incremented rather
 * than the new object being added.
 */
- (void) addObject: (id)anObject
{
  GSIMapNode node;

  if (anObject == nil)
    {
      [NSException raise: NSInvalidArgumentException
          format: @"Tried to nil value to counted set"];
    }

  _version++;
  node = GSIMapNodeForKey(&map, (GSIMapKey)anObject);
  if (node == 0)
    {
      GSIMapAddPair(&map,(GSIMapKey)anObject,(GSIMapVal)(NSUInteger)1);
    }
  else
    {
      node->value.nsu++;
    }
  _version++;
}

根据以上代码及测试,对于对象作为key的实现推测:

  • 相同类的实例对象会随机生成1~N中不同的hash结果;
  • 获取 hash 值后调用 isEqual 方法判断是否和已有的 key 相同:
    - 如果相同,则计数+1;
    - 如果都不相同,则为该 hash 值增加一个新的 key;

4.结论

本文中的例子:

原因:

  • 会出现卡顿,如上文分析, NSCountedSet 会对数组的每个元素进行比较,耗时主要就在这里,这里会有一次对数组遍历的时间。

解决办法:

  • 一种有效做法是使用字符串,或者位移,将rgba进行合并,然后再作为元素插入 NSCountedSet 。

NSCountedSet 使用的注意事项:

  • 1.除非有特定用途,一般不要使用对象作为元素;
  • 2.尽量避免使用 数组、字典 作为元素,NSCountedSet 内部的遍历会使得其耗时增加;

最终的测试代码:

    NSCountedSet *countedSet = [NSCountedSet new];
    
    NSLog(@"start");
    for (NSInteger i = 0; i<400000; i++) {
        int red   = arc4random()%10+225;
        int green = arc4random()%10+100;
        int blue  = arc4random()%10;
        int alpha = 1.0;
//        NSArray *color = @[@(red),@(green),@(blue),@(alpha)];
        NSString *color = [NSString stringWithFormat:@"%d,%d,%d,%d",red, green, blue, alpha];
//        NSNumber *color = @((red<<16)+(green<<8)+blue);
        [countedSet addObject:color];
    }
    NSLog(@"ending");
    __block NSUInteger count = 0;
    [countedSet enumerateObjectsUsingBlock:^(id  _Nonnull obj, BOOL * _Nonnull stop) {
        count += [countedSet countForObject:obj];
    }];
    NSLog(@"end");

以 NSString 作为元素耗时 1s 为基准,其他情况耗时如下:

元素 耗时
NSArray 19.0s
NSString 1.0s
NSNumber 400ms

如果将 NSCountedSet 替换为 NSMutableDictionary ,耗时为 1.1s 。可以看到优化后效率提升了约50倍。

你可能感兴趣的:(NSCountedSet - 由取图片主色引发的思考)