普通视图

发现新文章,点击刷新页面。
昨天以前掘金专栏-iOS学习社区

软件架构之SOLID原则

2023年4月17日 13:52

关注公众号,提前Get更多技术好文

当开发大型软件时,编写易于维护和扩展的代码变得至关重要。SOLID原则是一组指导原则,可帮助我们实现高质量、易于维护的代码。这些原则旨在使软件架构更加健壮、灵活和可扩展,从而减少后期维护成本。

SOLID原则由Robert C. Martin在他的书籍《Agile Software Development, Principles, Patterns, and Practices》中提出。它们代表了一组面向对象编程(OOP)的最佳实践。下面将介绍SOLID原则的五个组成部分。

  • 单一职责原则(Single Responsibility Principle, SRP): 一个类应该只有一个引起它变化的原因。这意味着一个类应该只有一项职责,因此它应该只有一个原因需要被修改。通过将代码分解成更小、更简单的部分,我们可以轻松地对其进行修改和维护,从而提高代码的可读性和可维护性。

  • 开放封闭原则(Open-Closed Principle, OCP): 软件实体(类、模块、函数等)应该是对扩展开放的,但对修改关闭的。这意味着当我们需要增加新功能时,我们应该尽可能地利用现有的代码,而不是修改它们。这可以通过使用接口和抽象类等OOP技术来实现。

  • 里氏替换原则(Liskov Substitution Principle, LSP): 所有引用基类的地方必须能够透明地使用其子类的对象。这意味着子类应该能够替换其基类,并且程序的行为不会受到影响。遵循LSP原则可以确保代码的正确性和稳定性。

  • 接口隔离原则(Interface Segregation Principle, ISP): 客户端不应该依赖于它不需要的接口。这意味着我们应该将接口拆分成更小、更特定的部分,以避免客户端代码依赖于不必要的接口。这将提高代码的可维护性和可扩展性。

  • 依赖反转原则(Dependency Inversion Principle, DIP): 高层模块不应该依赖于低层模块,它们应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。这可以通过依赖注入(DI)等技术实现。遵循DIP原则可以降低模块之间的耦合度,从而提高代码的灵活性和可扩展性。

单一职责原则

SRP 是 SOLID 五大设计原则中最容易被误解的一个,也许是名字的原因,很多程序员认为这个原则就是指:每个模块都应该只做一件事。然而,这并不是 SRP 的全部。在现实环境中,软件系统为了满足用户需求,必然要做出这样那样的修改,所以 SRP 的最终描述就变成了:

任何一个软件模块都应该只对某一类行为者负责。

“软件模块”指的是一组紧密相关的函数和数据结构。相关这个词实际上就隐含了 SRP 这一原则。代码和数据就是靠着与某一类行为者的相关性被组合在一起的。

下面看一个违反 SRP 原则的例子:

工资管理程序中的 Employee 类有三个函数,calculatePay()、reportHours()和save()。这三个函数分别对应三个不同的行为者。

截屏2023-04-09 20.59.28.png

这三个函数被放在同一个类中,这样做实际上是将三类行为者的行为耦合在了一起,这有可能导致CFO团队的命令影响到了COO团队所依赖的功能。

有很多不同的方法可以解决上面的问题,每一种方法都需要将相关函数划分成不同的类,即使每个类都只对应一类行为者。

小结: 单一职责原则主要讨论的是函数与类之间的关系。

开闭原则

开闭原则是由 Bertrand Meyer 在1988年提出的,该设计原则认为:

设计良好的计算机软件应该易于扩展,同时抗拒修改。

一个良好的软件架构师会努力将旧代码的修改需求量降至最小,甚至为0。

下面通过一个例子来了解开闭原则:

假设我们需要设计一个在Web页面上展示财务数据的系统,页面上的数据需要滚动展示,其中负值显示为红色。接下来,该系统的所有者又要求用同样的数据生成一份报表,该报表可以用黑白打印机打印,同时报表格式要得到合理分页等。

  • 首先,我们可以将不同需求的代码分组(SRP)

截屏2023-04-09 20.58.31.png

  • 然后再调整这些分组之间的依赖关系(DIP)

截屏2023-04-09 20.57.42.png

这里很重要的一点是这些单线框的边界都是单向跨越的。也是说,上面的所有组件之间的关系都是单向依赖的。

如果组件A不像因为组件B的修改而受到影响,那么就该让组件B依赖于组件A。

小结: OCP 是我们进行架构设计的主导原则,其主要目标是让系统易于扩展,同时限制其每次被修改所影响的范围。实现方式是通过将系统划分为一系列组件,并将这些组件间的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。

里氏替换原则

里氏替换原则是由美国计算机科学家Barbara Liskov 在1987年提出的。她在一篇名为《数据抽象和层次》的论文中首次提出了该原则,该原则强调了在面向对象编程中继承的使用,即子类对象应该能够替换其父类对象并且仍然能够保持原有的行为表现。

下面通过一个简单的例子来了解 LSP:

16810452239304.png

上述设计是符合 LSP 原则的,因为 Bi11ing 应用程序的行为并不依赖于其使用的任何一个衍生类。也就是说,这两个衍生类的对象都是可以用来替换 License 类对象的。

接口隔离原则

“接口隔离原则”这个名字来自下图这种软件结构。

16810453903567.png

有多个用户需要操作 OPS 类。但是 User1 只需要使用 op1, User2 只需要使用 op2,User3 只需要使用op3。

在这种情况下,User1 虽然不需要调用 op2、op3,但在源代码层次上也与它们形成依赖关系。这种依赖意 味着我们对 OPS 代码中 op2 所做的任何修改,即使不会影响到 User1 的功能,也会导致它需要被重新编译和部署。 这个问题可以通过将不同的操作隔离成接又来解決,具体如图所示:

16810457636993.png

User1 的源代码会依赖于 U1Ops 和 op1,但不会依赖于OPS。这样一来,我们之后对OPS做的修改只要不影响到 User1 的功能,就不需要重新编译和部署 User1 了。

**小结:**任何层次的软件设计如果依赖了它并不需要的东西,就会带来意料之外的麻烦。

依赖反转原则

依赖反转原则(DIP)主要想告诉我们的是,如果想要设计一个灵活的系统,在源代码层次的依赖关系中就应该 多引用抽象类型,而非具体实现。

我们每次修改抽象接口的时候,一定也会去修改对应的具体实现。相反,当我们修改具体实现时,却很少需要修改相应的抽象接口。所以我们认为接口比实现更稳定。

下面通过一个例子来了解DIP

以一个图书馆管理系统为例,假设系统需要支持三种不同类型的书籍:小说、历史书籍和科学书籍。同时,系统需要能够提供书籍的分类、名称、作者等信息,并支持借出和归还书籍的功能。

按照依赖反转原则,我们可以通过抽象类或接口来定义一个书籍的通用接口,所有的具体书籍类型都实现该接口,如下所示:

protocol Book {
    var name: String { get }
    var author: String { get }
    var type: String { get }
    var isBorrowed: Bool { get set }
}

class Novel: Book {
    var name: String
    var author: String
    var type: String = "小说"
    var isBorrowed: Bool = false
    
    init(name: String, author: String) {
        self.name = name
        self.author = author
    }
}

class HistoryBook: Book {
    var name: String
    var author: String
    var type: String = "历史书籍"
    var isBorrowed: Bool = false
    
    init(name: String, author: String) {
        self.name = name
        self.author = author
    }
}

class ScienceBook: Book {
    var name: String
    var author: String
    var type: String = "科学书籍"
    var isBorrowed: Bool = false
    
    init(name: String, author: String) {
        self.name = name
        self.author = author
    }
}

此时,所有书籍类型都实现了Book接口,并且都能提供相同的属性和方法。这样一来,我们在使用这些不同类型的书籍时,就可以依赖于它们实现的相同接口,而不是依赖于具体的书籍类型。

下面是一个简单的借阅功能实现的例子:

class Library {
    var books: [Book] = []
    
    func borrow(book: Book) {
        guard let index = books.firstIndex(where: { $0.name == book.name }) else {
            print("该书不存在")
            return
        }
        
        if books[index].isBorrowed {
            print("该书已被借出")
        } else {
            books[index].isBorrowed = true
            print("借书成功")
        }
    }
    
    func returnBook(book: Book) {
        guard let index = books.firstIndex(where: { $0.name == book.name }) else {
            print("该书不存在")
            return
        }
        
        if !books[index].isBorrowed {
            print("该书未被借出")
        } else {
            books[index].isBorrowed = false
            print("还书成功")
        }
    }

在这个例子中,Library类并不依赖于具体的书籍类型,而是依赖于Book接口。这使得我们可以轻松地将来增加更多的书籍类型,而无需更改Library类的代码。

优秀的软件架构师会花费大精力来设计接口,以减少未来对其进行改动。 毕竟争取在不修改接口的情况下为软 件增加新的功能是软件设计的基础常识。

也就是说,如果想要在软件架构设计上追求稳定,就必须多使用稳定的抽象接口,少依赖多变的具体实现。下面有几条具体的编码守则:

  • 应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。
  • 不要在具体实现类上创建衍生类。在静态类型的编程语言中,继承关系是所有一切源代码依赖关系中最强的、最难被修改的,所以我们对继承的使用应该格外小心。即使是在稍微便于修改的动态类型语言中,这条守则也应该被认真考虑。
  • 不要覆盖(override)包含具体实现的函数。调用包含具体实现的函数通常就意味着引入了源代码级别的依赖。即使覆盖了这些函数,我们也无法消除这其中的依赖。这些函数继承了那些依赖关系。在这里,控制依赖关系的唯一办 法,就是创建一个抽象函数,然后再为该函数提供多种具体实现。
  • 应避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物的名宇。这基本上是DIP原则的另外一个表达方式。

如果想遵守上述的编码守则,我们就必须对那些易变对象的创建过程做一些特殊处理,这样的谨慎是很有必要的,因为,基本在所有的编程语言中,创建对象操作都免不了在源代码层次上依赖对象的具体实现。

在大部分编程语言中,人们都会选择抽象工厂模式来解决源代码依赖问题。

16810529229120.png

这条曲线将整个系统划分为两部分组件:抽象接口层与具体实现层边界。抽象接口组件中包含了应用的所有高阶业务规则,而具体实现组件则包括了所有这些规则所需要做的具体操作及其相关的细节信息。

小结: 依赖反转原则是一条非常重要的软件设计原则,它可以帮助我们设计出高度解耦的系统,提高系统的灵活性和可扩展性。


注:本文大部分内容来自于《架构整洁之道》

iOS UITableView estimatedRowHeight 小记

2021年2月19日 23:38

原文地址

estimatedRowHeight 是 iOS7.0 以后引入的属性,用来预估列表视图的高度。下面看一下官网的解释:

大概的意思是:

为行高提供一个非负的预估值,可以提高列表视图的加载性能。如果列表包含高度可变的行,则在加载表时计算这些行的所有高度可能会非常昂贵。估算允许您将几何体计算的一些成本从加载时间推迟到滚动时间。

其默认值是 automaticDimension,这意味着表视图会默认选择一个预估高度供你使用。将该值设置为0将禁用估计高度,这将导致表视图请求每个单元格的实际高度。如果表使用自调整大小的单元格,则此属性的值不能为0。

使用预估高度时,表视图会自动管理从滚动视图继承的 contentOffset 和 contentSize 属性。不要试图直接读取或修改这些属性。

注意:estimatedRowHeight 在 iOS11 之前默认值为0,在 iOS11 之后,默认值为automaticDimension。

例子

下面我们通过一个例子来从下面两个方面来了解这个属性:

  • tableView:heightForRowAtIndexPath: 和 tableView:cellForRowAtIndexPath 执行次数
  • contentSize 的变化情况。
- (void)viewDidLoad {
    [super viewDidLoad];
    for (NSInteger i = 0; i < 100; ++i) {
        NSString *text = [NSString stringWithFormat:@"%ld", i];
        [self.dataSource addObject:text];
    }
    
    [self.listView reloadData];
    
        [self.listView addObserver:self forKeyPath:@"contentSize" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
}

- (NSMutableArray *)dataSource {
    if (!_dataSource) {
        _dataSource = [NSMutableArray arrayWithCapacity:10];
    }
    
    return _dataSource;
}

- (UITableView *)listView {
    if (!_listView) {
        _listView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain];
        _listView.delegate = self;
        _listView.dataSource = self;
        [self.view addSubview:_listView];
    }
    
    return _listView;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.dataSource.count;
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    NSLog(@"height at row:%ld", indexPath.row);
    return 200;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"testCell"];
    if (!cell) {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"testCell"];
    }
    
    cell.textLabel.text = self.dataSource[indexPath.row];
    NSLog(@"cell at row:%ld", indexPath.row);
    return cell;
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
    if ([keyPath isEqualToString:@"contentSize"]) {
         NSLog(@"contentSize:%@",  NSStringFromCGSize(self.listView.contentSize));
    }
}

禁用 estimatedRowHeight 属性:

打印结果如下:

2021-02-17 15:44:21.220452+0800 CategoryDemo[39013:1055808] height at row:0
...
2021-02-17 15:44:21.247421+0800 CategoryDemo[39013:1055808] height at row:49
2021-02-17 16:10:23.095358+0800 CategoryDemo[40146:1088361] contentSize:{414, 10000}
2021-02-17 15:44:21.261236+0800 CategoryDemo[39013:1055808] cell at row:0
2021-02-17 15:44:21.262052+0800 CategoryDemo[39013:1055808] height at row:0
2021-02-17 15:44:21.263151+0800 CategoryDemo[39013:1055808] cell at row:1
2021-02-17 15:44:21.263665+0800 CategoryDemo[39013:1055808] height at row:1
2021-02-17 15:44:21.264298+0800 CategoryDemo[39013:1055808] cell at row:2
2021-02-17 15:44:21.264718+0800 CategoryDemo[39013:1055808] height at row:2
2021-02-17 15:44:21.265399+0800 CategoryDemo[39013:1055808] cell at row:3
2021-02-17 15:44:21.265783+0800 CategoryDemo[39013:1055808] height at row:3
2021-02-17 15:44:21.266447+0800 CategoryDemo[39013:1055808] cell at row:4
2021-02-17 15:44:21.266824+0800 CategoryDemo[39013:1055808] height at row:4

通过打印结果可以看出:

  • tableView:heightForRowAtIndexPath:方法会先全部执行一遍。
  • 只加载可见区域内的 cell。
  • 加载 cell 时又调用了一遍 tableView:heightForRowAtIndexPath: 方法。
  • 一次生成 contentSize,值不会变化。

不禁用 estimatedRowHeight 属性:

打印结果如下:

2021-02-17 16:17:57.565084+0800 CategoryDemo[43013:1104211] contentSize:{414, 2200}
2021-02-17 16:17:57.581135+0800 CategoryDemo[43013:1104211] cell at row:0
2021-02-17 16:17:57.582222+0800 CategoryDemo[43013:1104211] height at row:0
...
2021-02-17 16:17:57.684118+0800 CategoryDemo[43013:1104211] cell at row:19
2021-02-17 16:17:57.684557+0800 CategoryDemo[43013:1104211] height at row:19
2021-02-17 16:17:57.685262+0800 CategoryDemo[43013:1104211] contentSize:{414, 5320}
  • 加载 20 个 cell(如果20个cell的高度小于tableview的可见区,则加载可见区内的cell),可能会影响展现埋点。
  • 只在加载 cell 时调用一遍 tableView:heightForRowAtIndexPath: 方法。
  • contentSize 的值会变化。

总结

  • 在禁用预估高度时,系统会先把所有 cell 的实际高度先计算出来,也就是先执行tableView:heightForRowAtIndexPath:代理方法,接着用获取的 cell 实际高度的总和计算tableView 的 contentSize,然后才显示tableViewCell的内容。在这个过程中,如果实际高度计算比较复杂的话,就会消耗更多的性能。

  • 在使用预估高度时,系统会先使用预估高度来计算 tableView 的 contentSize, 因此 contentSize 的高度会动态变化,如果差值为0,tableView 的 contentSize 高度不再变化。由于使用预估高度代替了实际高度的计算,减少了实际高度计算时的性能消耗,但是这种实际高度和预估高度差值的动态变化在滑动过快时可能会产生跳跃现象,所以预估高度和真实高度越接近越好。

一次 category 的误用引发的 crash

2021年1月28日 23:17

原文地址

在最近的一次开发中,不小心在自定义的 UIViewController 的 category 中重写了 dealloc 方法,导致项目中莫名出现了许多野指针的 crash,虽然重写 dealloc 方法会引发一些不确定的行为,但是为什么会引发 crash 呢?带着疑问又重新温习了下 category 的源码。

Category 的底层实现

可以在 objc4 源码的 objc-runtime-new.m 文件中看到它的实现,如下:

typedef struct category_t *Category;

struct category_t {
    const char *name; // Category 的名字
    classref_t cls; // 要扩展的类
    struct method_list_t *instanceMethods; // category的实例方法列表
    struct method_list_t *classMethods; // category类方法列表
    struct protocol_list_t *protocols; // category的协议列表
    struct property_list_t *instanceProperties; // category的属性列表

    method_list_t *methodsForMeta(bool isMeta) {
        if (isMeta) return classMethods;
        else return instanceMethods;
    }

    property_list_t *propertiesForMeta(bool isMeta) {
        if (isMeta) return nil; // classProperties;
        else return instanceProperties;
    }
};

通过源码可以看出,category 其实就是一个 category_t 类型的结构体,它维护要扩展类和分类的相关信息。

扩展:从源码中可以看出,分类的结构体中并没有成员变量的存储方式,这就解释了为什么在分类中无法添加
成员变量了。
此外还需要注意的是,虽然分类中会提供了属性列表的的存储方式,但它并不会帮我们自动生成成员变量,它
只会生成setter getter方法的声明,具体还需要我们自己去实现。

category 是如何加载的

OC 的 runtime 是通过 dyld 动态加载的,而 _objc_init() 方法是 runtime 被加载后第一个执行的方法。我们从_objc_init()开始来追溯 category 的加载过程。

首先看下 _objc_init() 的实现,如下:

void _objc_init(void)
{
    static bool initialized = false;
    if (initialized) return;
    initialized = true;
    
    // fixme defer initialization until an objc-using image is found?
    environ_init();
    tls_init();
    static_init();
    lock_init();
    exception_init();
        
    // Register for unmap first, in case some +load unmaps something
    _dyld_register_func_for_remove_image(&unmap_image);
    dyld_register_image_state_change_handler(dyld_image_state_bound,
                                             1/*batch*/, &map_2_images);
    dyld_register_image_state_change_handler(dyld_image_state_dependents_initialized, 0/*not batch*/, &load_images);
}

我们看下 dyld_register_image_state_change_handler(dyld_image_state_bound, 1/*batch*/, &map_2_images),这里注册了一个回调,当 dyld_image 状态为 dyld_image_state_bound 时,触发map_2_images用来将 image Map到内存 ,其实现如下:

const char *
map_2_images(enum dyld_image_states state, uint32_t infoCount,
             const struct dyld_image_info infoList[])
{
    rwlock_writer_t lock(runtimeLock);
    return map_images_nolock(state, infoCount, infoList);
}

这里会调用 map_images_nolock 方法,map_images_nolock 的源码很多,我们就不在这里列出来了,感兴趣的同学可以自己去查阅源码。在 map_images_nolock 的实现中,我们会发现一个重要函数_read_images,它用来初始化 Map 后的 image。

继续查看 _read_images 的源码,在 Discover categories 的代码段中,会调用一个关键函数 remethodizeClass,其实现如下:

static void remethodizeClass(Class cls)
{
    category_list *cats;
    bool isMeta;

    runtimeLock.assertWriting();

    isMeta = cls->isMetaClass();

    // Re-methodizing: check for more categories
    if ((cats = unattachedCategoriesForClass(cls, false/*not realizing*/))) {
        if (PrintConnecting) {
            _objc_inform("CLASS: attaching categories to class '%s' %s", 
                         cls->nameForLogging(), isMeta ? "(meta)" : "");
        }
        
        attachCategories(cls, cats, true /*flush caches*/);        
        free(cats);
    }
}

这里会调用 attachCategories 函数将类别中的方法、属性、协议附加到类上,源码如下:

static void 
attachCategories(Class cls, category_list *cats, bool flush_caches)
{
    if (!cats) return;
    if (PrintReplacedMethods) printReplacements(cls, cats);

    bool isMeta = cls->isMetaClass();

    // fixme rearrange to remove these intermediate allocations
    method_list_t **mlists = (method_list_t **)
        malloc(cats->count * sizeof(*mlists));
    property_list_t **proplists = (property_list_t **)
        malloc(cats->count * sizeof(*proplists));
    protocol_list_t **protolists = (protocol_list_t **)
        malloc(cats->count * sizeof(*protolists));

    // Count backwards through cats to get newest categories first
    int mcount = 0;
    int propcount = 0;
    int protocount = 0;
    int i = cats->count;
    bool fromBundle = NO;
    while (i--) {
        auto& entry = cats->list[i];

        method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
        if (mlist) {
            mlists[mcount++] = mlist;
            fromBundle |= entry.hi->isBundle();
        }

        property_list_t *proplist = entry.cat->propertiesForMeta(isMeta);
        if (proplist) {
            proplists[propcount++] = proplist;
        }

        protocol_list_t *protolist = entry.cat->protocols;
        if (protolist) {
            protolists[protocount++] = protolist;
        }
    }

    auto rw = cls->data();

    prepareMethodLists(cls, mlists, mcount, NO, fromBundle);
    rw->methods.attachLists(mlists, mcount);
    free(mlists);
    if (flush_caches  &&  mcount > 0) flushCaches(cls);

    rw->properties.attachLists(proplists, propcount);
    free(proplists);

    rw->protocols.attachLists(protolists, protocount);
    free(protolists);
}

attachCategories 函数中,首先会进行一些内存分配的工作,然后获取分类的方法、属性和协议,并放到指定的数组中,最后调用 attachLists 方法将分类和原类中的方法、属性、和协议进行了合并。看下 attachLists 函数的实现:

void attachLists(List* const * addedLists, uint32_t addedCount) {
        if (addedCount == 0) return;

        if (hasArray()) {
            // many lists -> many lists
            uint32_t oldCount = array()->count;
            uint32_t newCount = oldCount + addedCount;
            setArray((array_t *)realloc(array(), array_t::byteSize(newCount)));
            array()->count = newCount;
            memmove(array()->lists + addedCount, array()->lists, 
                    oldCount * sizeof(array()->lists[0]));
            memcpy(array()->lists, addedLists, 
                   addedCount * sizeof(array()->lists[0]));
        }
        else if (!list  &&  addedCount == 1) {
            // 0 lists -> 1 list
            list = addedLists[0];
        } 
        else {
            // 1 list -> many lists
            List* oldList = list;
            uint32_t oldCount = oldList ? 1 : 0;
            uint32_t newCount = oldCount + addedCount;
            setArray((array_t *)malloc(array_t::byteSize(newCount)));
            array()->count = newCount;
            if (oldList) array()->lists[addedCount] = oldList;
            memcpy(array()->lists, addedLists, 
                   addedCount * sizeof(array()->lists[0]));
        }
    }

这个函数中,通过 memmove 和 memcpy 的操作,将分类的方法、属性、协议列表放入了类对象中原本存储的方法、属性、协议列表的前面

为什么会出现 crash

我们通过一个例子来复现下文章开头提到的 crash , 代码如下:

// 在 UIViewController+Test.m 的类别中重写 dealloc 方法
- (void)dealloc {

}

// TestAViewController.m
@interface TestAViewController ()

@property (nonatomic, strong) TestBViewController *bViewController;

@end

@implementation TestAViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    self.view.backgroundColor = UIColor.whiteColor;
    _bViewController = [TestBViewController new];
    __weak typeof (self) weakSelf = self;
    _bViewController.willDismiss = ^{
        __strong typeof(weakSelf) strongSelf = weakSelf;
        [strongSelf.navigationController popViewControllerAnimated:YES];
    };
    
    [self setDefinesPresentationContext:YES];
    [self.bViewController setModalPresentationStyle:UIModalPresentationCurrentContext];
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    if (!self.isFirst) {
        [self presentViewController:self.bViewController animated:YES completion:nil];
    }

    self.isFirst = YES;
};

// TestBViewController.m 中有个关闭按钮 点击执行下面的方法
- (void)onDismiss {
    if (self.willDismiss) {
        self.willDismiss();
    }
    
    
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
        if (self.presentingViewController) {
            [self dismissViewControllerAnimated:YES completion:^{
                
            }];
        }
    });
}

运行程序,点击 TestBViewController 中的关闭按钮,出现如下 crash:

*** -[TestAViewController retain]: message sent to deallocated instance 0x7fba53d161d0

从这条日志可以看出,是因为访问了已经释放的对象地址,导致的 crash.

分析:因为在类别中重写了 dealloc 方法,会导致 UIViewController 本身的 dealloc 方法不会执行,这样就不会释放 dealloc 中的成员变量,指向这些成员变量的指针会变成野指针,如果通过野指针不小心访问了已经释放的 UIViewController 对象的地址,就会出现上面的 crash.

总结

虽然 category 为我们提供了许多便利,但是我们在使用时也有多加小心,以免掉入陷阱,下面用苹果官网的一段话作为结束:

Avoid Category Method Name Clashes
Because the methods declared in a category are added to an existing class, 
you need to be very careful about method names.

If the name of a method declared in a category is the same as a method in the 
original class, or a method in another category on the same class (or even a 
superclass), the behavior is undefined as to which method implementation is used 
at runtime. This is less likely to be an issue if you’re using categories with 
your own classes, but can cause problems when using categories to add methods to 
standard Cocoa or Cocoa Touch classes.

----扫一扫关注公众号,get更多技术好文-----

iOS性能优化之图片最佳实践

2020年7月24日 11:56

原文地址

UIImage 是用来处理图像数据的高级类,UIImageViewUIKit 提供的用于显示 UIImage 的类。若采用 MVC 模型进行类比,UIImage 可以看作模型对象(Model),UIImageView 是一个视图(View)。它们都肩负着各自的职责:

UIImage 负责加载图片内容, UIImageView 负责显示和渲染它。

这看似是一个简单的单向过程,但实际情况却复杂的多,因为渲染是一个连续的过程,而不是一次性事件。这里还有一个非常关键的隐藏阶段,对衡量 app 性能至关重要,这个阶段被称为解码。

图片解码

在讨论解码之前,先了解下缓冲区的概念。

缓冲区:是一块连续的内存区域,用来表示一系列元素组成的内存,这些元素具有相同的尺寸,并通常具有相同的内部结构。

图像缓冲区:它是一种特定缓冲区,它保存了某些图像在内存中的表示。此缓冲区的每个元素,描述了图像中每个像素的颜色和透明度。因此这个缓冲区在内存中的大小与它包含的图像大小成正比。

帧缓冲区:它保存了 app 中实际渲染后的输出。因此,当 app 更新其视图层次结构时,UIKit 将重新渲染 app 的窗口及其所有视图到帧缓冲区中。帧缓冲区中提供了每个像素的颜色信息,显示硬件降读取这些信息用来点亮显示器上对应的像素。

如果 app 中没有任何改变,则显示硬件会从帧缓冲区中取出上次看到的相同数据。但是如果改变了视图内容,UIKit会重新渲染内容,并将其放入帧缓冲区,下一次显示硬件从帧缓冲区读取内容时,就会获取到新的内容。

数据缓冲区:包含图像文件的数据缓冲区,通常以某些元数据开头,这些元数据描述了存储在数据缓冲区中的图像大小和图像数据本身。

下面看下图像渲染到帧缓冲区的详细过程:

这块区域将由图像视图进行渲染填充。我们已经为图像视图分配一个 UIImage,它有一个表示图像文件内容的数据缓冲区。我们需要用每个像素的数据来填充帧缓冲区,为了做到这一点,UIImage 将分配一个图像缓冲区,其大小等于包含在数据缓冲区中的图像大小,并执行称为解码的操作,这就是将 JPEGPNG 或其它编码的图像数据转换为每个像素的图像信息。然后取决于我们图像视图的内容模式,当 UIKit 要求图像视图进行渲染时,它会将数据复制到帧缓冲区的过程中对来自图像缓冲区的数据进行复制和缩放。

解码阶段是 CPU 密集型的,特别是对于大型图像。因此,不是每次 UIKit 要求图像视图渲染时都执行一次这个过程。UIImage 绑定在图像缓冲区上,所以它只执行一次这个过程。因此,在你的 app 中,对于每个被解码的图像,都可能会持续存在大量的内存分配,这种内存分配与输入的图像大小成正比,而与帧缓冲区中实际渲染的图像视图大小没有必然联系,这会对内存产生相当不利的后果。

减少 CPU 的使用率

我们可以使用一种称为向下采样的技术来实现这一目标。

我们可以通过这种下采样技术来节省一些内存。本质上,我们要做的就是捕捉该缩小操作,并将其放入缩略图的对象中,最终达到降低内存的目的,因为我们将有一个较小的解码图像缓冲区。

这样,我们设置了一个图像源,创建了一个缩略图,然后将解码缓冲区捕获到 UIImage 中,并将该 UIImage 分配给我们的图像视图。接下来我们就可以丢弃包含图片数据的数据缓冲区,最终结果就是我们的 app 中将具有一个更小的长期内存占用足迹。

下面看下如何使用代码来实现这一过程:

  • 首先,创建一个 CGImageSource 对象
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!

KCGImageSourceShouldCache 参数为 false,用来告诉 Core Graphic 框架我们只是在创建一个对象,来表示存储在该 URL 的文件中的信息,不要立即解码这个图像,只需要创建一个表示它的对象,我们需要来自此 URL 的文件信息。

  • 然后在水平和垂直轴上进行计算,该计算基于期望的图片大小以及我们要渲染的像素和点大小:
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
                         kCGImageSourceShouldCacheImmediately: true,
                         kCGImageSourceCreateThumbnailWithTransform: true,
                         kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary

这里也创建了一个缩略图选项的字典,最重要的是 CacheImmediately 这个选项,通过这个选项,告诉 Core Graphics,当我们要求你创建缩略图时,这就是你应该为我创建解码缓冲区的确切时刻。因此,我们可以确切的控制何时调用 CPU 来进行解码。

  • 最后,我们创建缩略图,即拿到返回的 CGImage 。
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)

其完整代码如下:

func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
        let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
        let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
        let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
        let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
                                 kCGImageSourceShouldCacheImmediately: true,
                                 kCGImageSourceCreateThumbnailWithTransform: true,
                                 kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
        let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)
        return UIImage(cgImage: downsampledImage)
    }

在 UICollectionView 中的使用

我们可能会在创建单元格时,直接使用下采样技术来生成的图片,代码如下:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! MyCollectionViewCell
    cell.layoutIfNeeded()
    let imageViewSize = cell.imageView.bounds.size
    let scale = collectionView.traitCollection.displayScale
    cell.imageView.image = downsample(imageAt: "", to: imageViewSize, scale: scale)
}

这样确实会减少内存的使用量,但这并不能解决我们的另一个问题。这些问题在可滚动的视图中是非常常见的。

当我们滚动页面时,CPU 相对比较空闲或它所做的工作可以在显示硬件需要帧缓冲的下一个副本之前完成,所以,当帧缓冲被更新时,我们能看到流畅的效果,并且显示硬件能及时获得新帧。

但是,如果我们将显示另一行图像,将单元格交回 UICollectionView 之前,我们要求 core Graphics 解码这些图像,这将会花费很长的 CPU 时间,以至于我们不得不重新渲染帧缓冲区,但显示器硬件按固定的时间间隔运行,因此,从用户的角度来看,app 好像卡住了一样。

这不仅会造成信息粘连,还会有明显的响应性后果,也对电池寿命有不利的影响。

我们可以使用两种技术来平滑我们的 CPU 使用率:

第一个是预取,它的基本思想是:预取允许 UICollectionView 告知我们的数据源,它当前不需要一个单元格,但它在不久的将来需要,因此,如果你有任何工作要做,也许现在可以提前开始。这允许我们随时间的推移,分摊 CPU 的使用率,因此,我们减少了CPU 使用的峰值。

另一种技术是在后台执行工作,既然我们已经随时间分散了工作量,我们也可以将这些技术分散到可用的 CPU 上。

这样做的效果是使你的 app 具有更强的响应性,并且该设备具有更长的电池寿命。

具体代码如下:

func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
// Asynchronously decode and downsample every image we are about to show
for indexPath in indexPaths {
DispatchQueue.global(qos: .userInitiated).async {
let downsampledImage = downsample(images[indexPath.row])
DispatchQueue.main.async { 
 self.update(at: indexPath, with: downsampledImage)
 }
}
}
 }

我们在全局兵法队列中来使用下采样技术,但这里有个潜在的缺陷,就是有可能会引起线程爆炸。当我们要求系统去做比 CPU 能够做的工作更多的工作时,就会发生这种情况。

为类避免线程爆炸,我们现在不是简单的将工作分配到全局异步队列中,而是创建一个串行队列,并且在预取方法的实现中,异步的将任务分配到该队列中,实现如下:

let serialQueue = DispatchQueue(label: "Decode queue") 
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
// Asynchronously decode and downsample every image we are about to show
for indexPath in indexPaths {
serialQueue.async {
let downsampledImage = downsample(images[indexPath.row])
DispatchQueue.main.async { self.update(at: indexPath, with: downsampledImage)
}
}
 }

扫一扫关注公众号,get更多技术好文

❌
❌