肇鑫的技术博客

业精于勤,荒于嬉

咕唧在iPad浮屏时,有时会意外地重新加载账户头像问题的处理

咕唧在iPad浮屏时,既屏幕右侧向里滑动弹出咕唧,有时会出现全部头像闪动一下的问题。这个问题没什么规律,如果立即你把它划回去,再重新滑出来,它就又没有这个问题了。另外,我在iOS 12的时候也没有遇到过这个问题。

我认为这可能是iOS 13 beta独有的一个问题。

我尝试解决这个问题。

方案一

因为视图是UITableView,我尝试减少reloadData()的次数。仔细阅读代码,修改了几处限定条件,减少了reloadData()。并且出于调试目的,我在预期不应该发生的位置,插入fatalError()强制结束应用。

测试结果

方案一减少了重新加载表格视图的次数,但是对于解决该问题无效。测试中发现,偶尔还是会出现重新加载头像的问题,并且,应用没有崩溃,也就是说,我预期不会执行的部分,也的确没有执行。

方案二

因为问题是和头像有关,于是着重查看头像部分的代码。

cell.imageView?.image = profileImage
            
DispatchQueue.main.async {
    ProfileImageHelper.decorate(cell.imageView!, for: accountCore.type)
}

这段代码使用了异步代码,在UI队列里执行了对于UIImageView的装饰。这里必须使用的原因是cell.imageView是只读属性,不能直接替代。并且,当cell.imageView?.image = profileImage之后,如果立即装饰图片视图,图片视图的尺寸还是0,必须在下一个事件循环才能获得实际尺寸。

imageView_decoration

上图是咕唧账户头像的部分截图。照片原本是正方形,在实际显示时,被处理为圆形。并且,根据账户类型的不同,加上了颜色不同的圆圈。

我们知道,异步的代码,与同步不同,是分成两次执行的。因此,存在一种可能,既第一部分的代码执行了之后,很久,第二部分才执行。这在显示上,就会出现先显示方形的头像,然后才变成圆形和圆圈,就像看幻灯片那样。

须要将此部分的代码由异步改为同步。

此前采用异步的原因是无法立即知道图片视图的大小,改成同步,就必须修改代码,手动指定视图的大小。我们知道,这在平时其实是一种不好的行为。代码写死了,就失去了灵活性。但是这里,我们不得不把它写死。

通过调试,我们知道这里目前的大小是48.0点。(点,point,是苹果使用的相对大小的尺寸单位)。

cell.imageView?.image = profileImage
ProfileImageHelper.decorate(cell.imageView!, for: accountCore.type)

测试结果

这次的测试很成功,之后没有发现再次出现这个问题。

方案三

虽然问题解决了,但是写死代码还是不够灵活。思考了一下,虽然图片视图的尺寸不能立即知道,但是图片的尺寸是可以知道的。因此,将写死的代码,改为使用图片的尺寸。这样问题就彻底解决了。

思考

虽然这个问题很有可能是iOS 13 beta引入的,但是解决掉这个问题,还是有收获的。我们在实际编程中,应该尽量减少异步代码的使用。因为,有时,异步代码会带来意想不到的问题。

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

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