肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

Swift 5 String补遗

Foundation

Swift 5中的String采用了UTF-8编码。而NSString是UTF-16编码的。NSStringString的转换是lazy的,这句话充满了刀光剑影。

所谓lazySwift中最常见的用法,简单的描述就是,当在需要复制的时候,不进行复制,而仅标记,然后如果后面的操作是读操作,就一直读,直到出现了写操作,才会真正将内容分离写入。这么做的好处,是性能比较好,如果有幸最终也没有写入操作,那么就完全省去了写入操作和额外的内存占用。

不过由于StringNSString的编码不同,这种lazy导致了一个严重的问题。就是如果你从某个框架获得了一个String,你其实是不知道它是原生的String,还是过来的NSString。比如你读取了一个String.Index,等你要用的时候,它可能已经失效了。

举一个简单的例子:

import Foundation

let ns:NSString = "ab两只老虎,两只老虎,跑得快,跑得快。"
var s = ns as String

let aIndex = s.firstIndex(of: "只")!
print(s[aIndex]) // 只
s += ""
print(s[aIndex]) // \270

为了解决上面的问题,Swift有两项硬性规定。

  1. 对于String.Index,索引只对于它自身的String。使用非自身字符串的索引,可能导致未知的问题。
  2. String只要有任何改变,String.Index都应该重新获取。

解决办法

由于String.Index非常容易失效,且不能直接使用。因此,在一个字符串使用另一个字符串的索引是需要转换才能使用。但是,这种转换,Swift本身是没有直接提供的。需要自己算一下。

import Foundation

extension String {
    func sameIndex(_ index:String.Index, of str:String) -> String.Index? {
        let offSet = self.distance(from: self.startIndex, to: index)
        return str.index(str.startIndex, offsetBy: offSet, limitedBy: str.endIndex)
    }
}

let ns:NSString = "ab两只老虎,两只老虎,跑得快,跑得快。"
var s = ns as String

let aIndex = s.firstIndex(of: "只")!
print(s[aIndex]) // 只
let s1 = s + ""
let i1 = s.sameIndex(aIndex, of: s1)!
print(s1[i1]) // 只

于此类似,Range<String.Index>也有同样的问题。更扩大一步说,只要是支持Collection类型的,都有这个问题。

CGImageDestination写入属性时的架构

Image I/O

func CGImageDestinationAddImage(CGImageDestination, CGImage, CFDictionary?)func CGImageDestinationSetProperties(CGImageDestination, CFDictionary?)都可以设定一个字典参数做为写入到图片的属性,但是这两个是存在区别的。

前者是写入属性到图片,后者是写入属性到图片的容器。举例来说,如果你保存一个JPEG的图片,那么写入Exif信息,就是写入到图片。而如果你是写入一个动态Gif图片,那么动图里面的每一帧,都是单独一张图片,每个图片有自己的属性。此外,动图本身是一个容器,有自己的属性,比如一共包含了多少张图片,播放时,间隔多长时间放一张,播放结束之后是否循环播放等。

如果本该写入到图片的属性,写入到了容器,就会出现属性丢失的现象。

切记切记。

相关问题

CGImageSource对照片自动旋转问题的解决

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