肇鑫的技术博客

业精于勤,荒于嬉

CPU还是内存,编程中的取舍之道

最近解决了咕唧存在的一个占用内存过多的问题。

以2GB内存的iPad Pro 9.7为例,如果分享扩展使用的内存超过120MB,系统就会强制将分享扩展关闭。
从用户的角度来看,就是分享扩展闪退了。

调试模式下,苹果会在Xcode中提示内存超限时代码执行的位置,并且显示为紫色的断点。

咕唧出错的地方是图片转换的部分。当用户选择图片分享时,咕唧会在后台做多件事,以适应微博和推特服务器的要求。比如:

  1. 微博服务器要求图片大小不能超过5MB。推特要求静态图片大小不超过5MB,Gif动图不超过15MB。
  2. 此外,微博服务器对于短边超过1080像素的图片还会自动缩小到1080像素。

咕唧本身也有一些设置会影响到图片。比如:

  1. 出于隐私保护的目的,咕唧有设置默认在图片上传前,会删除图片的Exif信息和GPS信息。
  2. 出于节省流量的目的,咕唧有设置默认会缩小发布到推特的图片。

综合以上的几点,咕唧在发布前,会根据用户的账户类型不同,对于图片做额外的转换和修改。因为咕唧是跨平台的,同时支持iOS/watchOS/macOS,在图片处理时我使用的是Image I/O框架。并且当时的算法是CPU优先。因为程序中会多次使用CGImageSource,于是函数中将它做为了较长时间存在的临时变量。这样的好处是可以避免多次生成它。

这么做在iOS应用中没有问题,但是在分享扩展运行的时候,有时就会因为内存占用过多而闪退。

解决思路

Image I/O是苹果提供的底层调用,这些对象与我们平时用到的对象不一样,都是不透明的,也就是只能用,不能查看细节。

既然如此,我决定将所有使用到Image I/O框架的部分封装起来,单独构造一个类,将需要的功能暴露为函数,这样从使用者的角度,就完全看不出来是否使用了Image I/O框架。

而在这个单独构造的类中,不使用CPU优先,而是使用内存优先。虽然每次操作都会使用到CGImageSource,但是我每次使用时都会重新创建它,使用结束立即释放。这样的好处就是内存中不会长期存在一个中间变量,坏处就是会额外占用一些CPU资源。

结论

改造很成功。尝试了之前会导致分享扩展崩溃的几个图片,现在都能正常分享了。

深入研究RealmSwift的通知

RealmSwift官方文档在介绍通知时,简单易懂。在一般情况下,我们只需要要使用官方文档的代码就足够使用。但是,如果我们有更高的追求,就需要深入研究了。官方的代码:

class ViewController: UITableViewController {
    var notificationToken: NotificationToken? = nil

    override func viewDidLoad() {
        super.viewDidLoad()
        let realm = try! Realm()
        let results = realm.objects(Person.self).filter("age > 5")

        // Observe Results Notifications
        notificationToken = results.observe { [weak self] (changes: RealmCollectionChange) in
            guard let tableView = self?.tableView else { return }
            switch changes {
            case .initial:
                // Results are now populated and can be accessed without blocking the UI
                tableView.reloadData()
            case .update(_, let deletions, let insertions, let modifications):
                // Query results have changed, so apply them to the UITableView
                tableView.beginUpdates()
                tableView.insertRows(at: insertions.map({ IndexPath(row: $0, section: 0) }),
                                     with: .automatic)
                tableView.deleteRows(at: deletions.map({ IndexPath(row: $0, section: 0)}),
                                     with: .automatic)
                tableView.reloadRows(at: modifications.map({ IndexPath(row: $0, section: 0) }),
                                     with: .automatic)
                tableView.endUpdates()
            case .error(let error):
                // An error occurred while opening the Realm file on the background worker thread
                fatalError("\(error)")
            }
        }
    }

    deinit {
        notificationToken?.invalidate()
    }
}

其中.update(_, let deletions, let insertions, let modifications):中的三个变量,其实是相互关联的。根据苹果的文档performBatchUpdates(_:completion:)

Deletes are processed before inserts in batch operations. This means the indexes for the deletions are processed relative to the indexes of the table view’s state before the batch operation, and the indexes for the insertions are processed relative to the indexes of the state after all the deletions in the batch operation.

在批量操作中,删除优先于插入。这意味着删除的索引是基于批量操作之前的表格状态,而插入的索引则基于所有删除操作完成之后的状态。

问题来了。苹果告诉我们,在批量操作时,删除优先于插入,先删除,后插入。但是实际上还有更新操作,它的顺序又在哪里呢?

测试代码:

import UIKit
import RealmSwift

class TableViewController: UITableViewController {
    var items:Results<Item>!
    var token:NotificationToken? = nil

    override func viewDidLoad() {
        super.viewDidLoad()

        do {
            let realm = try Realm()
            items = realm.objects(Item.self).sorted(byKeyPath: "id")
            
            token = items.observe{ [weak self] (changes: RealmCollectionChange) in
                guard let tableView = self?.tableView else { return }
                switch changes {
                case .initial:
                    // Results are now populated and can be accessed without blocking the UI
                    tableView.reloadData()
                case .update(_, let deletions, let insertions, let modifications):
                    print("dels: \(deletions), ins: \(insertions), modifies: \(modifications)")
                    
                    // Query results have changed, so apply them to the UITableView
                    tableView.beginUpdates()
                    tableView.insertRows(at: insertions.map({ IndexPath(row: $0, section: 0) }),
                                         with: .automatic)
                    tableView.deleteRows(at: deletions.map({ IndexPath(row: $0, section: 0)}),
                                         with: .automatic)
                    tableView.reloadRows(at: modifications.map({ IndexPath(row: $0, section: 0) }),
                                         with: .automatic)
                    tableView.endUpdates()
                case .error(let error):
                    // An error occurred while opening the Realm file on the background worker thread
                    fatalError("\(error)")
                }
            }
        } catch let error {
            print(error)
        }
        
        DispatchQueue.main.async {
            self.addItems()
        }
        
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) {
            self.addDeleteUpdate()
        }
    }
    
    deinit {
        token?.invalidate()
        token = nil
    }

    // MARK: - Table view data source

    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        let item = items[indexPath.row]
        cell.textLabel?.text = String(item.id)
        cell.detailTextLabel?.text = item.title

        return cell
    }
}

extension TableViewController {
    func addItems() {
        do {
            let realm = try Realm()
            try realm.write {
                (0..<100).forEach {
                    let item = Item()
                    item.id = $0
                    item.title = String($0)
                    realm.add(item)
                }
            }
        } catch let error {
            print(error)
        }
    }
    
    func addDeleteUpdate() {
        do {
            let realm = try Realm()
            try realm.write {
                // add
                
                (100..<200).forEach {
                    let item = Item()
                    item.id = $0
                    item.title = String($0)
                    realm.add(item)
                }
                
                // delete
                stride(from: 20, to: 60, by: 2).forEach {
                    let item = realm.object(ofType: Item.self, forPrimaryKey: $0)
                    realm.delete(item!)
                }
                
                // update
                (80..<120).forEach {
                    let item = realm.object(ofType: Item.self, forPrimaryKey: $0)!
                    item.title = String(item.id + 1000)
                }
            }
        } catch let error {
            print(error)
        }
    }
}

应用启动之后,我们先插入100个数据,然后执行插入、删除和更新的操作。然后我们打印.update(_, let deletions, let insertions, let modifications):的数据:

dels: [20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58], ins: [80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179], modifies: [80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99]

我们可以看到,删除的索引是和原始顺序一致的。插入的索引则是基于删除,进行了移动。最后更新的部分,也是和原始顺序一致的,并且我们可以看到,它的后面的部分,和插入的索引进行了合并,所以只有一半。

结论

当进行批量操作时,执行的顺序是,先执行修改、之后是删除、最后是插入。

iOS下拉搜索的几种实现方式(二)

上文iOS下拉搜索的几种实现方式(一)提供了普通视图控制器下拉搜索的几种方法。但如果我们是在导航控制器中使用搜索,就变得简单了。因为我们可以直接使用系统提供的方案。

import UIKit

class TableViewController: UITableViewController {
    var array = (0...99).map { String($0) }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        customizeNavigationController()
    }
    
    private func customizeNavigationController() {
        self.navigationItem.leftBarButtonItem = editButtonItem
        self.navigationItem.title = "Pull to Search"
        self.navigationController?.navigationBar.prefersLargeTitles = true
        
        self.navigationItem.searchController = {
            let sc = UISearchController(searchResultsController: nil)
            sc.searchResultsUpdater = self
            sc.hidesNavigationBarDuringPresentation = true
            sc.obscuresBackgroundDuringPresentation = false
            
            sc.searchBar.delegate = self
            sc.searchBar.searchBarStyle = .minimal
            
            return sc
        }()
    }
    
    // MARK: - Table view data source
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return array.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = array[indexPath.row]
        
        return cell
    }
}

// MARK: -
extension TableViewController:UISearchBarDelegate {
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        tableView.tableHeaderView = nil
    }
}

// MARK: -
extension TableViewController:UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        array = (0...99).map({ String($0) })
            .filter({
                guard let text = self.navigationItem.searchController?.searchBar.text, !text.isEmpty else {
                    return true
                }
                
                return $0.contains(text)
            })
        
        tableView.reloadData()
    }
}

相关

iOS下拉搜索的几种实现方式(一)