肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

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

Image I/O

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

以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

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

上文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下拉搜索的几种实现方式(一)

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

iOS

iOS的设置,顶部有一个隐藏的下拉搜索。下拉到一定程度就会弹出来一个搜索框,如果力度不够,还会缩回去。我们要实现的就是类似这样的功能。

方法一:UIRefreshControl

UIRefreshControl是与UIScrollView绑定的一个类,当下拉时,会出现一个转圈圈的进度条,如果力度够大,就会执行指定的功能。优点:实现方式最简单。缺点:显示效果是动画效果,不是根据用户的下拉的进度对应的。此外,显示进度条会让用户感觉是网络应用,存在延迟,但是实际上其实是本地应用。

import UIKit

class TableViewController: UITableViewController {
    var array = (0...99).map { String($0) }
    var standardOffset = CGPoint.zero
    
    lazy var searchController:UISearchController = {
        let sc = UISearchController(searchResultsController: nil)
        sc.searchResultsUpdater = self
        sc.hidesNavigationBarDuringPresentation = false
        sc.obscuresBackgroundDuringPresentation = false
        
        sc.searchBar.delegate = self
        sc.searchBar.searchBarStyle = .minimal
        
        return sc
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        addRefreshControl()
    }
    
    private func addRefreshControl() {
        tableView.refreshControl = {
            let rc = UIRefreshControl()
            rc.addTarget(self, action: #selector(showSearchBar), for: .valueChanged)
            
            return rc
        }()
    }
    
    private func removeRefreshControl() {
        tableView.refreshControl = nil
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        standardOffset = tableView.contentOffset
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        standardOffset = .zero
    }
    
    @objc private func showSearchBar() {
        UIView.animate(withDuration: 0.4) {
            defer {
                DispatchQueue.main.async {
                    self.tableView.refreshControl?.endRefreshing()
                    self.removeRefreshControl()
                }
            }

            guard self.tableView.tableHeaderView == nil else { return }
            
            self.tableView.tableHeaderView = self.searchController.searchBar
        }
    }

    private func hideSearchBar() {
        UIView.animate(withDuration: 0.4, animations: {
            self.tableView.tableHeaderView = nil
            self.addRefreshControl()
        })
    }
    
    // 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.searchController.searchBar.text, !text.isEmpty else {
                    return true
                }
                
                return $0.contains(text)
            })
        
        tableView.reloadData()
    }
}

// MARK: - UIScrollViewDelegate
extension TableViewController {
    override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if scrollView.contentOffset.y > standardOffset.y {
            hideSearchBar()
        }
    }
}

方法二:键值观察

使用键值观察可以根据屏幕滚动的方向来判断是向上还是向下,从而计算是否应该添加搜索栏。这种方法的缺点是,键值观察发送的数据特别快,如果不进行的特别处理,界面会感觉卡。特别的,相比于方法三,没法在键值观察的同时更新UITableView

import UIKit

class TableViewController: UITableViewController {
    var array = (0...99).map { String($0) }
    var standardOffset = CGPoint.zero
    
    var observer:NSKeyValueObservation? = nil
    
    lazy var searchController:UISearchController = {
        let sc = UISearchController(searchResultsController: nil)
        sc.searchResultsUpdater = self
        sc.hidesNavigationBarDuringPresentation = false
        sc.obscuresBackgroundDuringPresentation = false
        
        sc.searchBar.delegate = self
        sc.searchBar.searchBarStyle = .minimal
        
        return sc
    }()
    
    // 必须实现取值,因为searcBar是引用,大小后面会变化。
    lazy var searchBarWidth = self.searchController.searchBar.bounds.width
    lazy var searchBarHeight = self.searchController.searchBar.bounds.height
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        // 获得初始值
        standardOffset = tableView.contentOffset
        registerObserver()
    }
    
    private func registerObserver() {
        observer = tableView.observe(\UITableView.contentOffset, options: .new) { (_, change) in
            guard let new = change.newValue else { return }
            
            DispatchQueue.main.async {
                self.updateUI(new: new)
            }
        }
    }
    
    private func updateUI(new:CGPoint) {
        let searchBar = self.searchController.searchBar
        
        // 如果存在搜索关键词,则搜索栏不发生变化
        guard let text = searchBar.text, text.isEmpty else { return }
        // 进度条到了尽头,会有反弹效果,此时无需计算
        guard !self.tableView.isDecelerating else { return }
        let deltaHeight = self.standardOffset.y - new.y
        
        if deltaHeight < 0 { // 上拉
            let height = (self.tableView.tableHeaderView?.bounds.height ?? 0) + deltaHeight
            let rect = CGRect(x: 0, y: 0, width: 0, height: max(height, 0))
            self.tableView.tableHeaderView?.bounds = rect
            
            if height < 0 {
                self.tableView.tableHeaderView = nil
            }
        } else if deltaHeight > 0 { // 下拉
            if self.tableView.tableHeaderView == nil {
                self.tableView.tableHeaderView = searchBar
            }
            
            let height = (self.tableView.tableHeaderView?.bounds.height ?? 0) + deltaHeight
            let rect = CGRect(x: 0, y: 0, width: self.searchBarWidth, height: min(height, self.searchBarHeight))
            self.tableView.tableHeaderView?.bounds = rect
        }
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        observer?.invalidate()
        standardOffset = .zero
    }
    
    // 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.searchController.searchBar.text, !text.isEmpty else {
                    return true
                }
                
                return $0.contains(text)
            })
        
        tableView.reloadData()
    }
}

// MARK: - UIScrollViewDelegate
extension TableViewController {
    /// 如果搜索栏显示内容达到一半,则全部显示;否则取消显示。
    /// - Parameter scrollView: tableView
    override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if let height = tableView.tableHeaderView?.bounds.height {
            if height >= self.searchBarHeight / 2 {
                let rect = CGRect(x: 0, y: 0, width: self.searchController.searchBar.bounds.width, height: self.searchBarHeight)
                tableView.tableHeaderView?.bounds = rect
            } else {
                tableView.tableHeaderView = nil
            }
            
            self.tableView.reloadData()
        }
    }
}

方法三:使用Combine

与方法二相比,因为使用了Combine,可以忽略过多的数据。缺点:使用过多的self.tableView.reloadData()可能会造成性能损失。如果有更好的办法,欢迎告知。

import UIKit
import Combine

class TableViewController: UITableViewController {
    var array = (0...99).map { String($0) }
    var standardOffset = CGPoint.zero
    
    // Combine Subject
    var offset = CurrentValueSubject<CGPoint, Never>(.zero)
    var offsetObserver:Cancellable? = nil
    
    lazy var searchController:UISearchController = {
        let sc = UISearchController(searchResultsController: nil)
        sc.searchResultsUpdater = self
        sc.hidesNavigationBarDuringPresentation = false
        sc.obscuresBackgroundDuringPresentation = false
        
        sc.searchBar.delegate = self
        sc.searchBar.searchBarStyle = .minimal
        
        return sc
    }()
    
    // 必须实现取值,因为searcBar是引用,大小后面会变化。
    lazy var searchBarWidth = self.searchController.searchBar.bounds.width
    lazy var searchBarHeight = self.searchController.searchBar.bounds.height
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        // 获得初始值
        standardOffset = tableView.contentOffset
        registerObserverWithCombine()
    }
    
    private func registerObserverWithCombine() {
        // 最高每0.03秒获取一次数据(33帧/秒),因为报送的数据太多,都处理显示会卡。
        offsetObserver = offset.debounce(for: .seconds(0.03), scheduler: RunLoop.main)
            .sink { self.updateUI(new: $0) }
    }
    
    /// 结尾必须使用`self.tableView.reloadData()`效果才会顺滑。但是如果表格复杂的话,可能会影响性能。
    private func updateUI(new:CGPoint) {
        let searchBar = self.searchController.searchBar
        
        // 如果存在搜索关键词,则搜索栏不发生变化
        guard let text = searchBar.text, text.isEmpty else { return }
        // 进度条到了尽头,会有反弹效果,此时无需计算
        guard !self.tableView.isDecelerating else { return }
        let deltaHeight = self.standardOffset.y - new.y
        
        if deltaHeight < 0 { // 上拉
            let height = (self.tableView.tableHeaderView?.bounds.height ?? 0) + deltaHeight
            let rect = CGRect(x: 0, y: 0, width: 0, height: max(height, 0))
            self.tableView.tableHeaderView?.bounds = rect
            
            if height < 0 {
                self.tableView.tableHeaderView = nil
            }
            
            self.tableView.reloadData()
        } else if deltaHeight > 0 { // 下拉
            if self.tableView.tableHeaderView == nil {
                self.tableView.tableHeaderView = searchBar
            }
            
            let height = (self.tableView.tableHeaderView?.bounds.height ?? 0) + deltaHeight
            let rect = CGRect(x: 0, y: 0, width: self.searchBarWidth, height: min(height, self.searchBarHeight))
            self.tableView.tableHeaderView?.bounds = rect
            
            self.tableView.reloadData()
        }
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        offsetObserver?.cancel()
        offsetObserver = nil
        standardOffset = .zero
    }
    
    // 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.searchController.searchBar.text, !text.isEmpty else {
                    return true
                }
                
                return $0.contains(text)
            })
        
        tableView.reloadData()
    }
}

// MARK: - UIScrollViewDelegate
extension TableViewController {
    /// 如果搜索栏显示内容达到一半,则全部显示;否则取消显示。
    /// - Parameter scrollView: tableView
    override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if let height = tableView.tableHeaderView?.bounds.height {
            guard height < self.searchBarHeight else { return }
            
            if height >= self.searchBarHeight / 2 {
                let rect = CGRect(x: 0, y: 0, width: self.searchController.searchBar.bounds.width, height: self.searchBarHeight)
                tableView.tableHeaderView?.bounds = rect
            } else {
                tableView.tableHeaderView = nil
            }
            
            self.tableView.reloadData()
        }
    }
    
    /// 更新`Combine`所需的数据
    override func scrollViewDidScroll(_ scrollView: UIScrollView) {
        offset.value = scrollView.contentOffset
    }
}

相关

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

Realm模型升级(一)

RealmSwift

所谓模型升级,是指数据库存储的数据,随着需求的变化,产生了格式的改变。这个时候,由于数据库里有数据,直接修改模型并运行程序,程序就会崩溃,原因是模型发生了变化。

Realm本身提供了模型升级的功能。官方文档的实例也简单易懂。

https://realm.io/docs/swift/latest#migrations

不过,今天我遇到一个特殊情况,发现需要在升级的时候使用List对象,是不能直接转换的,摸索之后找到解决方案。先看问题。

问题

已有代码

class Item:Object {
    @objc dynamic var title = ""
    let records = List<Record>()
    
    override class func primaryKey() -> String? {
        return "title"
    }
}

class Record:Object {
    @objc dynamic var id:Int = 1
    @objc dynamic var addedDate:Date = Date()
}

新需求需要在Item中添加一个新的属性@objc dynamic var addedDate:Date = Date()

class Item:Object {
    @objc dynamic var title = ""
    @objc dynamic var addedDate:Date = Date()
    let records = List<Record>()
    
    override class func primaryKey() -> String? {
        return "title"
    }
}

class Record:Object {
    @objc dynamic var id:Int = 1
    @objc dynamic var addedDate:Date = Date()
}

升级代码如下:

// update realm
let config = Realm.Configuration(
    // Set the new schema version. This must be greater than the previously used
    // version (if you've never set a schema version before, the version is 0).
    schemaVersion: 1,
    
    // Set the block which will be called automatically when opening a Realm with
    // a schema version lower than the one set above
    migrationBlock: { migration, oldSchemaVersion in
        // We haven’t migrated anything yet, so oldSchemaVersion == 0
        if (oldSchemaVersion < 1) {
            // Nothing to do!
            // Realm will automatically detect new properties and removed properties
            // And will update the schema on disk automatically
            
            migration.enumerateObjects(ofType: Item.className()) { oldObject, newObject in
                newObject!["addedDate"] = (oldObject!["records"] as! List<Record>).first?.addedDate ?? Date()
            }
        }
})

// Tell Realm to use this new configuration object for the default Realm
Realm.Configuration.defaultConfiguration = config

// Now that we've told Realm how to handle the schema change, opening the file
// will automatically perform the migration
let _ = try! Realm()

运行时程序出错,提示不能将List<DynamicObject>转换为List<Record>

解决

经过查找资料发现,需要将其转化为List<MigrationObject。代码如下。究其原因,应该是Realm实例还没有创建,任何Realm的对象都是不可用的。(RecordObject子类)

// update realm
let config = Realm.Configuration(
    // Set the new schema version. This must be greater than the previously used
    // version (if you've never set a schema version before, the version is 0).
    schemaVersion: 1,
    
    // Set the block which will be called automatically when opening a Realm with
    // a schema version lower than the one set above
    migrationBlock: { migration, oldSchemaVersion in
        // We haven’t migrated anything yet, so oldSchemaVersion == 0
        if (oldSchemaVersion < 1) {
            // Nothing to do!
            // Realm will automatically detect new properties and removed properties
            // And will update the schema on disk automatically
            
            migration.enumerateObjects(ofType: Item.className()) { oldObject, newObject in
                newObject!["addedDate"] = (oldObject!["records"] as? List<MigrationObject>)?.first?["addedDate"] as? Date ?? Date()
            }
        }
})

// Tell Realm to use this new configuration object for the default Realm
Realm.Configuration.defaultConfiguration = config

// Now that we've told Realm how to handle the schema change, opening the file
// will automatically perform the migration
let _ = try! Realm()

相关文章

Realm模型升级(二)

207. 课程表

LeetCode

There are a total of n courses you have to take, labeled from 0 to n-1.

Some courses may have prerequisites, for example to take course 0 you have to first take course 1, which is expressed as a pair: [0,1]

Given the total number of courses and a list of prerequisite pairs, is it possible for you to finish all courses?

Example 1:

Input: 2, [[1,0]]
Output: true
Explanation: 
There are a total of 2 courses to take.
To take course 1 you should have finished course 0. So it is possible.

Example 2:

Input: 2, [[1,0],[0,1]]
Output: false
Explanation: 
There are a total of 2 courses to take.
To take course 1 you should have finished course 0, and to take course 0 you should also have finished course 1. So it is impossible.

Note:

  1. The input prerequisites is a graph represented by a list of edges, not adjacency matrices. Read more about how a graph is represented.
  2. You may assume that there are no duplicate edges in the input prerequisites.

错误的解答

因为是n个课程,全部都要完成。因此课程的数量是无关的。需要考虑的是前置的限制之间是否冲突。前置的限制冲突,指的有向图的路径形成了环。

  1. 获得所有前置条件的序号
  2. 如果前置不为空
  3. 选取第一个条件
  4. 将第一个条件加入路径
  5. 在序号中删除该序号
  6. 不断地查找路径的前置顶点,如果该顶点以存在于路径,则为环,返回假
    1. 否则将该点插入到路径的头
    2. 继续查找前置顶点,直到没有前置顶点
  7. 不断地查找路径的后置顶点,如果该顶点以存在于路径,则为环,返回假
    1. 否则将该顶点添加到路径的尾
    2. 继续查找后置顶点,直到没有后置顶点
class Solution {
    func canFinish(_ numCourses: Int, _ prerequisites: [[Int]]) -> Bool {
        var availableIndexes = (0..<prerequisites.count).map {$0}
        while !availableIndexes.isEmpty {
            var path = [Int]()
            let pre = prerequisites[availableIndexes[0]]
            path.append(contentsOf: pre.reversed())
            availableIndexes.removeFirst()
            
            while let from = prePre(path, prerequisites: prerequisites, from: &availableIndexes) {
                guard !path.contains(from) else {
                    // 如果前置条件成环,则整条路径不可用,因为缺乏前置条件
                    return false
                }
                
                path.insert(from, at: 0)
            }
            
            while let to = postPre(path, prerequisites: prerequisites, from: &availableIndexes) {
                guard !path.contains(to) else {
                    // 如果后续为环,则之前的to对应值的顶点到路径末尾的内容不可用
                    return false
                }
                path.append(to)
            }
        }
        
        return true
    }
    
    func prePre(_ path:[Int], prerequisites: [[Int]], from availableIndexes:inout [Int]) -> Int? {
        let from = path[0]
        for i in 0..<availableIndexes.count {
            let a = availableIndexes[i]
            let p = prerequisites[a]
            if p[0] == from {
                availableIndexes.remove(at: i)
                return p[1]
            }
        }
        
        return nil
    }
    
    func postPre(_ path:[Int], prerequisites: [[Int]], from availableIndexes:inout [Int]) -> Int? {
        let to = path.last!
        for i in 0..<availableIndexes.count {
            let a = availableIndexes[i]
            let p = prerequisites[a]
            if to == p[1] {
                availableIndexes.remove(at: i)
                return p[0]
            }
        }
        
        return nil
    }
}

let s = Solution()
print(s.canFinish(3, [[1,0],[2,0],[0,2]]))

上面的代码虽然通过了提交,但是算法其实存在问题。

bad_solution

示例:5, [[1,0],[2,1],[3,2],[1,4],[4,2]]。图见上图,存在一个环。但是由于选取时,选择不是全部的向上顶点或向下顶点,而只是第一组,造成了环并没有被解析到的现象。算法会先获得路径[0,1,2,3],然后获得[1,4,2]。完美避开了环[1,2,4,1]。

要想解决这个问题,就不能只取一条一个向上顶点或向下顶点,而应该全部获取。

正确的解答

正确的解答使用的邻近链表的方法。(^表示无后继)

顶点Vertex 入度InDegree 出度链表OutDegree LinkedList
0 0 -> 1^
1 2 -> 2 -> 4^
2 1 -> 3 -> 4^
3 1 ^
4 1 -> 1^
  1. numCourses生成最初的邻近链表。初始化顶点数为0。
  2. 根据限制条件prerequisites设定邻近链表的属性
  3. 寻找临近链表的最初顶点,最初顶点即不依赖任何其它顶点的点。也就是入度为0的顶点。
    1. 找到之后。顶点数增加1。移除该顶点。以及该顶点与其它顶点的连线。
    2. 回到3
  4. 查看顶点数是否与numCourses相当,如果不等,就是遇到了环,无法完成课表。
class Solution {
    func canFinish(_ numCourses: Int, _ prerequisites: [[Int]]) -> Bool {
        var vertexNumber = 0
        var vertices:Array<Vertex> = (0..<numCourses).map {
            Vertex(value: $0, inDegree: 0, link: nil)
        }
        prerequisites.forEach { pre in
            let to = pre[0]
            let from = pre[1]
            
            vertices[to].inDegree += 1
            
            if let link = vertices[from].link {
                link.append(LinkList(value: to))
            } else {
                vertices[from].link = LinkList(value: to)
            }
        }
        
        while let vertex = getZeroInDegreeVertex(vertices) {
            //            print(vertex.value)
            vertexNumber += 1
            vertex.inDegree = -1
            var link = vertex.link
            while link != nil  {
                vertices[link!.value].inDegree -= 1
                link = link?.next
            }
        }
        
        return vertexNumber == numCourses
    }
    
    func getZeroInDegreeVertex(_ vertices:[Vertex]) -> Vertex? {
        for v in vertices {
            if v.inDegree == 0 {
                return v
            }
        }
        
        return nil
    }
}

class Vertex {
    let value:Int
    var inDegree:Int
    var link:LinkList<Int>? = nil
    
    init(value:Int, inDegree:Int, link:LinkList<Int>?) {
        self.value = value
        self.inDegree = inDegree
        self.link = link
    }
}

class LinkList<T> {
    let value:T
    var next:LinkList<T>? = nil
    
    init(value:T) {
        self.value = value
    }
    
    func append(_ link:LinkList<T>) {
        if self.next == nil {
            self.next = link
        } else {
            var l = self.next
            while l?.next != nil {
                l = l?.next
            }
            l?.next = link
        }
    }
}

let s = Solution()
//print(s.canFinish(5, [[1,0],[2,1],[3,2],[1,4],[4,2]])) // false
print(s.canFinish(5, [[1,0],[2,1],[3,2],[4,1],[2,4]])) // true
//print(s.canFinish(2, [[1,0]])) // true
//print(s.canFinish(2, [[1,0],[0,1]])) // false

相关

210. 课程表 II

asdfa

“309. 最佳买卖股票时机含冷冻期”的解题思路

LeetCode

309. 最佳买卖股票时机含冷冻期

原题

给定一个整数数组,其中第* i* 个元素代表了第 i 天的股票价格 。​

设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):

  • 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
  • 卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。

示例:

输入: [1,2,3,0,2]
输出: 3
解释: 对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]

解题思路1

尝试走出所有路径。利用递归和缓存结果的方式进行优化,并设置结束条件进行约束。可惜,不能通过所有测试,提示超时。

class Solution {
    var cachedResults = [[Int]:Int]()
    
    func maxProfit(_ prices: [Int]) -> Int {
        var dealProfit = 0
        var firstDealEndAt:Int? = nil
        
         // 买,卖,冻;买,卖,冻
        for buyIndex in 0..<prices.count {
            let sellIndexStartAt = buyIndex + 1
            guard sellIndexStartAt < prices.count else {
                return dealProfit
            }
            
            let buyPrice = prices[buyIndex]
            for sellIndex in sellIndexStartAt..<prices.count {
                if let dealEndAt = firstDealEndAt {
                    guard buyIndex < dealEndAt else {
                        break
                    }
                }
                
                let sellPrice = prices[sellIndex]
                if buyPrice < sellPrice {
                    if let endDealAt = firstDealEndAt {
                        if endDealAt > sellIndex {
                            firstDealEndAt = sellIndex
                        }
                    } else {
                        firstDealEndAt = sellIndex
                    }
                    
                    var profit = sellPrice - buyPrice
                    
                    let nextIndex = sellIndex + 2
                    
                    if nextIndex < prices.count {
                        let remainPrices = Array(prices[nextIndex...])
                        let remainProfit:Int = {
                            if let profit = cachedResults[remainPrices] {
                                return profit
                            }
                            
                            let profit = maxProfit(remainPrices)
                            cachedResults.updateValue(profit, forKey: remainPrices)
                            
                            return profit
                        }()
                        
                        profit += remainProfit
                    }
                    
                    dealProfit = max(dealProfit, profit)
                }
            }
        }

        return dealProfit
    }
}

解题思路2

思路2是同时计算每一步的状态。我们知道,针对每一个数据,它可能的状态有3个,即买、卖、等待。如图:

wvR4TN8.png-1

s0[i] = max(s0[i - 1], s2[i - 1]); // 冷却可能是原地等待,也可能是卖完之后强制冷却
s1[i] = max(s1[i - 1], s0[i - 1] - prices[i]); // Stay at s1, or buy from s0 原地等待,或者购买,如果购买,则肯定是从s0状态过来的。
s2[i] = s1[i - 1] + prices[i]; // 卖出获利

最终代码:

class Solution {
    func maxProfit(_ prices: [Int]) -> Int {
        guard !prices.isEmpty else {
            return 0
        }
        
        var s0 = Array<Int>(repeating: 0, count: prices.count)
        var s1 = Array<Int>(repeating: 0, count: prices.count)
        var s2 = Array<Int>(repeating: 0, count: prices.count)
        
        s0[0] = 0
        s1[0] = -prices[0]
        s2[0] = Int.min
        
        for index in 1..<prices.count {
            s0[index] = max(s0[index - 1], s2[index - 1])
            s1[index] = max(s1[index - 1], s0[index - 1] - prices[index])
            s2[index] = s1[index - 1] + prices[index]
        }

        return max(s0.last!, s2.last!) // 最后一步,要么是冷却状态,要么是卖出状态。不可能是买入状态
    }
}

参考资料:

"916. 单词子集"的解题思路

LeetCode

916. 单词子集

原题

我们给出两个单词数组 A 和 B。每个单词都是一串小写字母。

现在,如果 b 中的每个字母都出现在 a 中,包括重复出现的字母,那么称单词 b 是单词 a 的子集。 例如,“wrr” 是 “warrior” 的子集,但不是 “world” 的子集。

如果对 B 中的每一个单词 bb 都是 a 的子集,那么我们称 A 中的单词 a通用的

你可以按任意顺序以列表形式返回 A 中所有的通用单词。

示例 1:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["e","o"]
输出:["facebook","google","leetcode"]

示例 2:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["l","e"]
输出:["apple","google","leetcode"]

示例 3:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["e","oo"]
输出:["facebook","google"]

示例 4:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["lo","eo"]
输出:["google","leetcode"]

示例 5:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["ec","oc","ceo"]
输出:["facebook","leetcode"]

提示:

  1. 1 <= A.length, B.length <= 10000
  2. 1 <= A[i].length, B[i].length <= 10
  3. A[i] 和 B[i] 只由小写字母组成。
  4. A[i] 中所有的单词都是独一无二的,也就是说不存在 i != j 使得 A[i] == A[j]

解题思路1

这道题的一般思路很简单。循环A,然后循环B,B中的字符串中的字符如果在A的字符串中存在,则移除掉该字符,然后循环下一个字符。代码如下:

class Solution {
    func wordSubsets(_ A: [String], _ B: [String]) -> [String] {
        let array = A.filter { (s) -> Bool in
            for b in B {
                var s = s
                
                for bCharacter in b {
                    if let index = s.firstIndex(of: bCharacter) {
                        s.remove(at: index)
                    } else {
                        return false
                    }
                }
            }
            
            return true
        }
        
        return array
    }
}

这个思路验证时,提示会超时。也就是性能不达标。

解题思路2

我们注意到示例3中,存在两个相同的字母”oo“。

示例 3:

**输入:**A = ["amazon","apple","facebook","google","leetcode"], B = ["e","oo"]
输出:["facebook","google"]

我的优化思路是,把不重复的字符单独优化,重复的仍然按照“思路1”的方式进行处理。代码如下:

class Solution {
    func wordSubsets(_ A: [String], _ B: [String]) -> [String] {
        var bSet = Set<Character>()
        var bArray = [String]()
        
        B.forEach {
            let set:Set<Character> = Set($0)
            if set.count == $0.count {  // no duplicated
                bSet = bSet.union(set)
            } else {
                bArray.append($0)
            }
        }
        
        let array = A.filter { (s) -> Bool in
            // bSet filter
            let aSet:Set<Character> = Set(s)
            guard aSet.isSuperset(of: bSet) else {
                return false
            }
            
            
            for b in bArray {
                var s = s
                
                for bCharacter in b {
                    if let index = s.firstIndex(of: bCharacter) {
                        s.remove(at: index)
                    } else {
                        return false
                    }
                }
            }
            
            return true
        }
        
        return array
    }
}

进行验证。还是性能不达标。

最终的解题思路

没办法,只好去翻评论。结果有人说,必须全部优化重复的字母,不然就一定会超时。所谓全部优化,就是把B中所有重复的字母计算出来。

原题的B等价于所有的字母都存在,且出现的次数大于等于B中的字符串中出现的那个字符的最多次数。

单独计算B的字母频率。

func characterFrequencies(in array:[String]) -> Dictionary<Character,Int> {
    var dictionary = Dictionary<Character,Int>()
    
    for s in array {
        var sDic = [Character:Int]()
        
        for c in s {
            if let f = sDic[c] {
                sDic[c] = f + 1
            } else {
                sDic[c] = 1
            }
        }
        
        sDic.forEach { (c, f) in
            if let frequency = dictionary[c] {
                dictionary[c] = max(frequency, f)
            } else {
                dictionary[c] = f
            }
        }
    }
    
    return dictionary
}

完整代码:

class Solution {
    func wordSubsets(_ A: [String], _ B: [String]) -> [String] {
        let dictionary = characterFrequencies(in: B)
        
        let array = A.filter { (s) -> Bool in
            for (c,f) in dictionary {
                var index = s.startIndex
                
                for _ in 1...f {
                    if index < s.endIndex, let firstIndex = s[index...].firstIndex(of: c) {
                        let nextIndex = s.index(after: firstIndex)
                        index = nextIndex
                    } else {
                        return false
                    }
                }
            }
            
            return true
        }
        
        
        return array
    }
    
    func characterFrequencies(in array:[String]) -> Dictionary<Character,Int> {
        var dictionary = Dictionary<Character,Int>()
        
        for s in array {
            var sDic = [Character:Int]()
            
            for c in s {
                if let f = sDic[c] {
                    sDic[c] = f + 1
                } else {
                    sDic[c] = 1
                }
            }
            
            sDic.forEach { (c, f) in
                if let frequency = dictionary[c] {
                    dictionary[c] = max(frequency, f)
                } else {
                    dictionary[c] = f
                }
            }
        }
        
        return dictionary
    }
}

小结

我们经常需要用到字典结构来挺高应用的性能。