IOS handles network data gracefully. Can you really? Why don’t you read this one

Time:2022-5-23

I believe that when you usually use the app, you often have the experience that the waiting time for loading network data is too long. When scrolling, it is accompanied by Caton. Even when there is no network, the whole application is unavailable. So how can we improve the user experience, ensure that users do not have a long sense of waiting, can easily enjoy waiting, and have clear expectations for the loaded content?

IOS handles network data gracefully. Can you really? Why don't you read this one

Case sharing

In modern work and life, mobile phone is no longer a simple communication tool. It is more like a terminal integrating office, entertainment and consumption. It has imperceptibly become a part of our life. Therefore, as IOS developers, in our daily development, we are no longer as simple as processing and displaying sporadic data. In order to flow, we often need to display a large amount of valuable information in the app to attract users. How to display these massive data gracefully is your personal experience.

As most IOS developers know, displaying scrolling data is a common task in building mobile applications. Apple’s SDK provides two components, uitableview and uicollectionview, to help perform such tasks. However, ensuring smooth, silky scrolling can be tricky when you need to display large amounts of data. So today, I would like to take this opportunity to share with you my personal experience in dealing with a large amount of scrollable data.

In this article, you will learn the following:

1. Let your app scroll infinitely and load the scrolling data seamlessly

2. Avoid jamming when rolling your app data and realize smooth rolling like silk

3. Asynchronously store (CACHE) and obtain images to make your app have higher response speed

As a developer, it is particularly important to have a learning atmosphere and an exchange circle. This is my IOS development exchange group:130 595 548, whether you are Xiaobai or Daniel, welcome to settle in. Let’s make progress and develop together! (the group will provide some free learning books and materials collected by the group owners and hundreds of interview questions and answer documents for free!)

Infinite scrolling, seamless loading

When it comes to list paging, I believe the first thing you think of is mjrefresh, which is used to refresh the data by pulling up and down. When the rolling data reaches the bottom, send a request to the server, and then display a loading animation at the bottom of the control. After the requested data returns, the loading animation disappears, and the uitableview or uicollectionview control continues to load these data and display them to the user. The effect is shown in the following figure:

In this case, a phenomenon is caused, that is, a blank is left during the time from the app requests data from the server to the data return. If the network is poor, the blank time will last, which will give a bad experience. Then how to avoid this phenomenon! Or can we get the rest of the data in advance and request the data without the user’s perception, which looks like seamless loading!

Of course, the answer is yes!

In order to improve the application experience, on IOS 10, Apple introduced the prefetching API for uicollectionview and uitableview, which provides a mechanism to prepare data in advance before it needs to display data, in order to improve the scrolling performance of data.

First of all, let me introduce a concept to you: infinite scrolling, which allows users to load content continuously without paging. When the UI is initialized, the app will load some initial data, and then load more data when the user scrolls to the bottom of the display.

Over the years, social media companies like instagram, twitter and Facebook have used this technology. If you check their app, you can see the actual effect of infinite scrolling. Here I’ll show you the effect of instagram!

How to achieve

Because instagram’s UI is too complex, I won’t imitate the implementation here, but I imitated its loading mechanism and also realized a simple effect of infinite data scrolling and seamless loading.

To put it simply, my idea:

First, customize a cell view, which is composed of a uilabel and a uiimageview to display text and network pictures; Then simulate the network request to obtain data. Note that this step must be executed asynchronously; Finally, use uitableview to display the returned data. In viewdidload, first request network data to obtain some initialization data, and then use the prefetching API of uitableview to preload the data, so as to realize the seamless loading of data.

How to realize infinite scrolling! In fact, this infinite scrolling is not really endless. Strictly speaking, it has an end, but the data behind this function is immeasurable. Only with the support of a large amount of data can the application continuously obtain data from the server.

Normally, when we build the uitableview control, we need to initialize its number of rows (numsofrow). This number of rows is a key factor for us to realize unlimited loading and seamless loading. Suppose we update the number of rows of uitableview and reload according to the data returned by the server every time, then the prefetching API I mentioned earlier will lose its function in this case, Because the premise of its function is to ensure that the current number of rows of uitableview is less than its total rows when preloading data. Of course, the former can also realize data loading, but its effect is not seamless loading. It will have a loading waiting time every time it loads data.

Returning to the infinite scrolling I mentioned above, it is not difficult to implement. Under normal circumstances, when we request a large amount of data of the same type from the server, we will provide an interface, which I call the paging request interface. Each time the data is returned, the interface will tell the client how many pages of data there are in total, how much data each page is, and what page it is currently, In this way, we can calculate the total data, and this is the total number of rows of uitableview.

An example of the response data is as follows (for clarity, it only shows the fields related to paging):

{

  "has_more": true,
  "page": 1,
  "total": 84,
  "items": [

    ...
    ...
  ]
}

Next, I will use the code to realize it step by step!

Simulate paging request

Since I didn’t find a suitable paging test interface, I simulated a paging request interface myself. Every time I call this interface, I delay 2s to simulate the status of network request. The code is as follows:

func fetchImages() {
        guard !isFetchInProcess else {
            return
        }

        isFetchInProcess = true
        //Delay 2s to simulate network environment
        Print ("+ + + + + + + + + + + + + + + + + + +")
        DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 2) {
            Print ("+ + + + + + + + + + + + + + + + + + + + + + +")
            DispatchQueue.main.async {
                self.total = 1000
                self.currentPage += 1
                self.isFetchInProcess = false
                //Initialize 30 pictures
                let imagesData = (1...30).map {
                    ImageModel(url: baseURL+"\($0).png", order: $0)
                }
                self.images.append(contentsOf: imagesData)

                if self.currentPage > 1 {
                    let newIndexPaths = self.calculateIndexPathsToReload(from: imagesData)
                    self.delegate?.onFetchCompleted(with: newIndexPaths)
                } else {
                    self.delegate?.onFetchCompleted(with: .none)
                }
            }
        }
    }

Data callback processing:

extension ViewController: PreloadCellViewModelDelegate {

    func onFetchCompleted(with newIndexPathsToReload: [IndexPath]?) {
        guard let newIndexPathsToReload = newIndexPathsToReload else {
            tableView.tableFooterView = nil
            tableView.reloadData()
            return
        }

        let indexPathsToReload = visibleIndexPathsToReload(intersecting: newIndexPathsToReload)
        indicatorView.stopAnimating()
        tableView.reloadRows(at: indexPathsToReload, with: .automatic)
    }

    func onFetchFailed(with reason: String) {
        indicatorView.stopAnimating()
        tableView.reloadData()
    }
}

Preload data

First, if you want the uitableview to preload data, you need to insert the following code into the viewdidload() function and request the data of the first page:

override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        ...
        tableView.prefetchDataSource = self
        ...
         //Analog request picture
        viewModel.fetchImages()
    }

Then, you need to implement the protocol of uitableviewdatasourceprefetching, which contains two functions:

// this protocol can provide information about cells before they are displayed on screen.

@protocol UITableViewDataSourcePrefetching <NSObject>

@required

// indexPaths are ordered ascending by geometric distance from the table view
- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;

@optional

// indexPaths that previously were considered as candidates for pre-fetching, but were not actually used; may be a subset of the previous call to -tableView:prefetchRowsAtIndexPaths:
- (void)tableView:(UITableView *)tableView cancelPrefetchingForRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;

@end

The first function will prefetch the next indexpaths based on the current rolling direction and speed. Usually, we will implement the logic of preloading data here.

The second function is an optional method. When some cells are invisible due to the user’s fast scrolling, you can cancel any pending data loading operation through this method, which is conducive to improving the scrolling performance. I will talk about it below.

The logic code for implementing these two functions is:

extension ViewController: UITableViewDataSourcePrefetching {
    //Page turning request
    func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
        let needFetch = indexPaths.contains { $0.row >= viewModel.currentCount}
        if needFetch {
            // 1. Page turning request if conditions are met
            indicatorView.startAnimating()
            viewModel.fetchImages()
        }

        for indexPath in indexPaths {
            if let _ = viewModel.loadingOperations[indexPath] {
                return
            }

            if let dataloader = viewModel.loadImage(at: indexPath.row) {
                Print ("prefetch the picture on the \ (indexpath. Row) line")
                //2. Preheat the pictures to be downloaded
                viewModel.loadingQueue.addOperation(dataloader)
                //3 add the download thread to the record array to search according to the index
                viewModel.loadingOperations[indexPath] = dataloader
            }
        }
    }

    func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]){
        //When this line does not need to be displayed, cancel prefetch to avoid waste of resources
        indexPaths.forEach {
            if let dataLoader = viewModel.loadingOperations[$0] {
                Print ("cancel prefetchingforrowsat on line \ ($0. Row))
                dataLoader.cancel()
                viewModel.loadingOperations.removeValue(forKey: $0)
            }
        }
    }
}

Finally, with two useful methods, the function is completed:

//Used to calculate the cell that needs reload when tableview loads new data
    func visibleIndexPathsToReload(intersecting indexPaths: [IndexPath]) -> [IndexPath] {
        let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows ?? []
        let indexPathsIntersection = Set(indexPathsForVisibleRows).intersection(indexPaths)
        return Array(indexPathsIntersection)
    }

    //Used to determine whether the rows of the index exceed the maximum number of data currently received
    func isLoadingCell(for indexPath: IndexPath) -> Bool {
        return indexPath.row >= (viewModel.currentCount)
    }

The miracle of witnessing the moment has arrived. Please see the effect:

Through the log, we can also clearly see that there are prefetch and cancelprefetch operations in the rolling process:

Well, here I’ll simply realize the effect of endless scrolling and seamless loading of data in uitableview. Have you learned it?

How to avoid jamming when rolling

When you encounter an application with a rolling jam, it is usually because the task runs for a long time, which hinders the UI update on the main thread. If you want the main thread to be free to respond to such update events, the first step is to hand over the time-consuming task to the sub thread for execution to avoid blocking the main thread when obtaining data.

Apple provides many ways to implement concurrency for applications, such as GCD. I use it here to load pictures on cell asynchronously.

The code is as follows:

class DataLoadOperation: Operation {
    var image: UIImage?
    var loadingCompleteHandle: ((UIImage?) -> ())?
    private var _image: ImageModel
    private let cachedImages = NSCache<NSURL, UIImage>()

    init(_ image: ImageModel) {
        _image = image
    }

    public final func image(url: NSURL) -> UIImage? {
        return cachedImages.object(forKey: url)
    }

    override func main() {
        if isCancelled {
            return
        }

        guard let url = _image.url else {
            return
        }
        downloadImageFrom(url) { (image) in
            DispatchQueue.main.async { [weak self] in
                guard let ss = self else { return }
                if ss.isCancelled { return }
                ss.image = image
                ss.loadingCompleteHandle?(ss.image)
            }
        }

    }

    // Returns the cached image if available, otherwise asynchronously loads and caches it.
    func downloadImageFrom(_ url: NSURL, completeHandler: @escaping (UIImage?) -> ()) {
        // Check for a cached image.
        if let cachedImage = image(url: url) {
            DispatchQueue.main.async {
                Print ("hit cache")
                completeHandler(cachedImage)
            }
            return
        }

        URLSession.shared.dataTask(with: url as URL) { data, response, error in
            guard
                let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,
                let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
                let data = data, error == nil,
                let _image = UIImage(data: data)
                else { return }
            // Cache the image.
            self.cachedImages.setObject(_image, forKey: url, cost: data.count)
            completeHandler(_image)
            }.resume()
    }
}

How to use it! Don’t worry. I’ll give you a little suggestion here. We all know that the method of instantiating cell in uitableview is: tableview: cellforrowatindexpath:, I believe many people will bind data in this method and update the UI. In fact, this is a relatively inefficient behavior, because this method needs to be called once for each cell, It should quickly execute and return the instance of reused cell. Do not perform data binding here, because there is no cell on the screen at present. We can bind data in tableview: willdisplaycell: forrowatindexpath: this method will be called before displaying the cell.

The implementation code of performing the download task for each cell is as follows:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "PreloadCellID") as? ProloadTableViewCell else {
            fatalError("Sorry, could not load cell")
        }

        if isLoadingCell(for: indexPath) {
            cell.updateUI(.none, orderNo: "\(indexPath.row)")
        }

        return cell
    }

    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        //Preheat image to process the image to be displayed
        guard let cell = cell as? ProloadTableViewCell else {
            return
        }

        //Update the cell after downloading the picture
        let updateCellClosure: (UIImage?) -> () = { [unowned self] (image) in
            cell.updateUI(image, orderNo: "\(indexPath.row)")
            viewModel.loadingOperations.removeValue(forKey: indexPath)
        }

        // 1\.  First, judge whether the created download thread already exists
        if let dataLoader = viewModel.loadingOperations[indexPath] {
            if let image = dataLoader.image {
                //1.1 if the picture has been downloaded, update it directly
                cell.updateUI(image, orderNo: "\(indexPath.row)")
            } else {
                //1.2 if the picture has not been downloaded, update the cell after the picture is downloaded
                dataLoader.loadingCompleteHandle = updateCellClosure
            }
        } else {
            // 2\.  If not found, create a new download thread for the specified URL
            Print ("create a new image download thread in the \ (indexpath. Row) line")
            if let dataloader = viewModel.loadImage(at: indexPath.row) {
                //2.1 add callback after downloading pictures
                dataloader.loadingCompleteHandle = updateCellClosure
                //2.2 start download
                viewModel.loadingQueue.addOperation(dataloader)
                //2.3 add the download thread to the record array to search according to the index
                viewModel.loadingOperations[indexPath] = dataloader
            }
        }
    }

Asynchronous downloading (preheating) of preloaded pictures:

func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
        let needFetch = indexPaths.contains { $0.row >= viewModel.currentCount}
        if needFetch {
            // 1. Page turning request if conditions are met
            indicatorView.startAnimating()
            viewModel.fetchImages()
        }

        for indexPath in indexPaths {
            if let _ = viewModel.loadingOperations[indexPath] {
                return
            }

            if let dataloader = viewModel.loadImage(at: indexPath.row) {
                Print ("prefetch the picture on the \ (indexpath. Row) line")
                //2. Preheat the pictures to be downloaded
                viewModel.loadingQueue.addOperation(dataloader)
                //3 add the download thread to the record array to search according to the index
                viewModel.loadingOperations[indexPath] = dataloader
            }
        }
    }

When canceling prefetch, cancel the task to avoid wasting resources

func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]){
        //When this line does not need to be displayed, cancel prefetch to avoid waste of resources
        indexPaths.forEach {
            if let dataLoader = viewModel.loadingOperations[$0] {
                Print ("cancel prefetchingforrowsat on line \ ($0. Row))
                dataLoader.cancel()
                viewModel.loadingOperations.removeValue(forKey: $0)
            }
        }
    }

After this treatment, our uitableview must roll like silk. What are you waiting for? Don’t give it a try.

Picture cache

Although I added concurrent operations to my application above, I couldn’t help thinking when I looked at the performance analysis of Xcode. My application eats too much memory. If I keep brushing, my mobile phone should terminate my application sooner or later. The following figure is the performance analysis diagram when I brush to 200 lines:

Memory

disk

IOS handles network data gracefully. Can you really? Why don't you read this one

It can be seen that the performance analysis of my application is not ideal. The reason is that a large number of image resources are displayed in my application. Each time I scroll back and forth, I will download new images again without caching the images.

Therefore, to solve this problem, I added a cache nscache object to my application to cache images. The specific code is as follows:

class ImageCache: NSObject {

    private var cache = NSCache<AnyObject, UIImage>()
    public static let shared = ImageCache()
    private override init() {}

    func getCache() -> NSCache<AnyObject, UIImage> {
       return cache
    }
}

At the beginning of downloading, check whether the cache is hit. If it is hit, the picture will be returned directly. Otherwise, download the picture again and add it to the cache:

func downloadImageFrom(_ url: URL, completeHandler: @escaping (UIImage?) -> ()) {
        // Check for a cached image.
        if let cachedImage = getCacheImage(url: url as NSURL) {
            Print ("hit cache")
            DispatchQueue.main.async {
                completeHandler(cachedImage)
            }
            return
        }

        URLSession.shared.dataTask(with: url) { data, response, error in
            guard
                let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,
                let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
                let data = data, error == nil,
                let _image = UIImage(data: data)
                else { return }

            // Cache the image.
            ImageCache.shared.getCache().setObject(_image, forKey: url as NSURL)

            completeHandler(_image)
            }.resume()
    }

After the cache is blessed, use Xcode to check the performance of my application, and you will find that the occupation of memory and disk has decreased a lot:

Memory

disk

As for the technology of image caching, only the simplest one is used here. Many open source image libraries outside have different caching strategies. Those who are interested can go to GitHub to learn their source code. I won’t repeat it here.

last

Finally, I finished writing and breathed a sigh of relief. The length of this article is a little long. I spent a little time doing research, and then I wanted to tell you about this knowledge point and that knowledge point. Finally, I quoted scriptures (patchwork) to complete this article. I hope you can like:).