 Apple Lover Developer & Artist

영속적인 디자인에 현대의 공감을 채워넣는 공방입니다

 Apple/iOS Dev Challenges

[Challenge] 🛠️ iOS 앱 설계 퓨전 레시피 10부 - 이미지 로딩

singularis7 2023. 3. 5. 18:27
반응형

Overview

네트워크를 통해 받아온 이미지 데이터를 사용자 인터페이스에 보여주도록 구현해 본다.

화면 구성

이번 프로젝트에서 사용자 인터페이스를 통해 이미지를 띄워줘야 하는 화면은 총 3가지다. 음식 메뉴를 보여주는 MenuTableViewController, 주문서 명단을 보여주는 OrderTableViewController, 음식 메뉴의 세부 정보를 보여주는 MenuItemDetailViewController가 대표적인 예시이다.

performance

네트워크를 통해 이미지 데이터를 불러오는 것은 다소 시간이 소요되는 작업일 수 있다. 사용자가 원하는 음식 메뉴 아이템을 찾기 위해 화면을 전환하며 탐색한다. 기본적으로 개발자가 별도의 최적화 작업을 진행해주지 않으면 동일한 이미지를 중복하여 다운로드하게 될 것이다.

문제를 개선할 수 있도록 이미지 캐싱 메커니즘을 활용해 볼 것이다. 개선 아이디어는 URLSession의 shared 인스턴스가 URLCache의 shared를 활용하여 캐싱 메커니즘이 돌고 있음에 착안하였다. URLCache의 메모리 및 디스크 용량을 이미지를 담을 수 있을 만큼 키워주고 애플리케이션 전역적으로 적용해 주었다. 예시는 다음과 같다.

class AppDelegate: UIResponder, UIApplicationDelegate {

    private let userNotificationCenterController = UserNotificationCenterController.shared

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        configureSharedURLCache()
        return true
    }
}

// MARK: Configure Caching For URLSession Code
extension AppDelegate {
    private func configureSharedURLCache() {
        let temporaryDirectory = NSTemporaryDirectory()
        let urlCache = URLCache(
25_000_000,
            diskCapacity: 50_000_000,
            diskPath: temporaryDirectory
        )
        URLCache.shared = urlCache
    }   
}

애플 URLSession 문서에 따르면 URLSession의 shared 싱글톤 인스턴스는 configuration 객체를 갖고 있지 않다. general configuration이 적용된 URLSession과 유사하게 동작하여 요구사항이 제약적인 경우 유용히 활용할 수 있다.

일반적으로 공유 세션으로 작업 시 캐시 등의 작업을 사용자 지정하지 않은 것을 권장한다. 기본 세션의 기능에서 크게 벗어날 가능성이 있기 때문인 것으로 사료된다.

이번의 경우 기본 기능에서 변주를 주지 않기 위해 캐싱 공간을 증가시키는 방식을 사용했기에 위와 같이 구현했으나 더욱 적극적인 성능 최적화를 위해선 별도의 캐싱 정책이나 메커니즘을 구성해 보는 방향의 고민도 방법일 수 있다고 생각한다.

Plain ViewController

일반적인 ViewController에 올려진 ImageView에 네트워크를 통해 불러온 이미지를 띄우는 것은 어렵지 않다.

앞선 포스팅 중 네트워크 모델링 파트에서 제작한 이미지 로딩 메서드와 Swift Concurrency를 활용하면 다음과 같이 구현할 수 있다.

img

비동기적으로 불러온 이미지 데이터를 UIImage로 변환한 후 UIImage에 적용해 주는 방식이다.

Table View Controller

Table View에서의 이미지 로딩 과정은 생각보다 단순하지 않다. 데이터의 컬렉션을 보여주기에 처리할 이미지의 양도 많고 셀 재사용 메커니즘 등의 사유로 잘못된 이미지가 보일 수 있기 때문이다.

이미지가 정확한 타이밍에 정확한 위치에서 보임으로써 앱의 사용자 경험을 해치지 않도록 구현해 보자!

Placeholder Image

네트워크를 통해 이미지를 불러오면 통신 상황에 따라 많은 시간이 필요할 수 있다. 이미지가 로딩되기 전에 기본 Placeholder 이미지를 보여주어 사용자가 이미지가 보일 영역에 데이터가 로딩 중이라는 추정 가능하도록 도와줄 수 있다고 생각한다.

앞선 데이터 바인딩 포스팅에서 소개한 Cell 구성 방식은 contentConfiguration을 활용하는 것이다. contentConfiguration의 image 프로퍼티에 Placeholder 이미지를 등록해 주었다. 예를 들어 다음의 코드를 tableview cell을 구성하는 곳에 적용해 볼 수 있다.

var contentConfiguration = defaultContentConfiguration()
contentConfiguration.image = UIImage(systemName: "photo.on.rectangle")
cell.contentConfiguration = contentConfiguration

Request Image Data

Tableview cell을 구성하는 함수에서 셀에 담길 이미지를 요청한다. 비동기적으로 이미지 데이터를 완전히 받은 후에 현재 셀에 이미지를 설정해 준다.

이 과정에서 발생할 수 있는 문제점을 생각해 본다. 사용자가 빠르게 스크롤을 내리거나 비동기 요청이 완료되지 않은 셀이 화면 밖으로 벗어날 수 있다. 특히 재사용 셀에 잘못된 이미지가 불러와질 수 있다. 사용자가 앱의 다른 페이지로 전환했는데 이전에 요청한 비동기 작업이 중단되지 않아서 시스템의 자원을 비효율적으로 사용할 수 있다.

문제를 개선하며 이미지 요청 과정을 구현해 본다. 사용자가 빠르게 스크롤을 내리거나 새로운 페이지로 이동했을 때 불필요한 비동기 작업을 중단시켜 시스템 자원을 효율적으로 사용할 수 있도록 구현해본다.

Swift Concurrency를 활용해 비동기 코드를 실행할 때 Task의 Trailing Closure에 async-await 코드를 담아서 구동하였다. 사실 Task는 구조체 타입인데 인스턴스 생성시점에서 코드를 구동한다.

인스턴스를 활용하여 다양한 작업을 할 수 있다. 그중 Task를 취소할 수 있는 기능이 있다. 방법은 cancel 메서드를 호출하여 중단시킬 수 있는데 이를 위해 Task 인스턴스를 추적할 수 있어야 한다.

테이블 뷰는 indexpath를 통해 특정 셀을 지칭할 수 있다는 점을 착안해 각 ViewController에 진행 중인 Task 인스턴스를 관리해 줄 Dictionary를 선언했다.

var imageLoadTasks: [IndexPath:Task<Void, Never>] = [:]

셀 데이터를 구성할 때 이미지 요청 메서드를 호출할 때마다 위 컬렉션에 담아준다. 키값은 Indexpath이고 대응값은 task 인스턴스이다.

네트워크 요청을 구현하면서 이미지가 적절한 위치에 표시될 수 있도록 함께 구현해 본다. 아이디어는 셀이 재사용되었는지 확인해 보는 것이다. 비동기 작업이 요청될 시점의 셀과 완료된 시점의 셀을 비교해 본다. 재사용이 되었다면 셀의 indexpath가 변경되었을 것이다.

좀 더 구체적으로는 비동기 요청 시점의 indexPath는 클로저 캡처를 활용해 불러오고 완료 시점의 indexpath는 tableview를 통해 확인해 보는 방식을 생각해 볼 수 있다. 두 정보가 다르면 시점과 정보가 일치하지 않기 때문에 UI에 반영하지 않는다. 같으면 불러온 이미지를 보여준다. 마무리로 완료된 Task 인스턴스를 컬렉션에서 제거해 준다.

imageLoadTasks[indexPath] = Task {
    if let data = try? await restaurantController.fetchImage(from: menuItem.imageURL),
         let image = UIImage(data: data),
       let currentIndexPath = self.tableView.indexPath(for: cell) {
            cell.image = (currentIndexPath == indexPath) ? image:nil
    } else {
        cell.image = nil
    }

    imageLoadTasks[indexPath] = nil
}

사용자가 스크롤을 넘겨서 다수의 비동기 작업이 생겨났는데 그중 대부분은 사용되지 않을 정보라면 취소하여 시스템 자원을 효율적으로 운영해야 한다.

TableView에서 제공하는 위임 메서드와 ViewController의 라이프 사이클을 활용해 특정 인터페이스가 사용자에게 보이는지 확인할 수 있다. 이를 활용해 불필요한 비동기 처리를 취소시킬 수 있다.

TableView Delegate

특정 셀이 화면을 벗어났는지 확인하고 이벤트를 발생시켜 주는 위임 메서드인 didEndDisplaying을 활용해 볼 수 있다. 화면을 벗어난 셀에 대응하는 비동기 요청은 취소하는 방식으로 처리해 보았다.

override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    imageLoadTasks[indexPath]?.cancel()
}

ViewController LifeCycle

사용자가 비동기 요청이 잔뜩 이뤄진 페이지에서 벗어날 때 해당 페이지에서 진행 중인 비동기 작업을 전부 취소하는 아이디어다. 이를 위해 viewDidDisapear 이벤트가 활용하여 처리해 보았다.

override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
  imageLoadTasks.forEach { (key: IndexPath, value: Task<Void, Never>) in 
      value.cancel() 
    }
}

Refactoring

여기까지 잘 구현되었다면 화면에는 정상적으로 보일 것이다. 다만 셀에 데이터를 설정하는 코드가 외부에 노출되어 깨끗하지 못하다. 코드를 개선하기 위해 Modern Cell Configuration을 활용해 Cell을 관리하는 로직을 TableViewCell 커스텀 객체에 모아서 캡슐화시켜보도록 한다.

계획을 소개한다. 커스텀 셀 클래스에는 해당 셀에 보일 데이터를 의미하는 프로퍼티가 있다. 프로퍼티가 변경되면 UIKit에게 Cell의 상태가 변경되어야 한다는 신호를 준다. UIKit이 신호를 받으면 커스텀 객체에 정의된 Cell 갱신 설정값을 참조해 Cell을 최신 상태로 갱신해 준다.

Cell이 갱신되어야 한다는 메시지는 TableView Cell의 setNeedsUpdateConfiguration()를 통해 날릴 수 있으며 셀을 상태에 따라 갱신하는 논리는 updateConfiguration(using state: UICellConfigurationState)를 오버라이드 하여 담을 수 있다. 셀을 갱신해 줄 때에는 언제나 셀의 기본 상태를 불러오는 것으로부터 시작된다. 복잡성을 줄이기 위한 아이디어인 것 같다. 코드는 다음과 같이 구현해 보았다.

final class MenuItemTableViewCell: UITableViewCell {

    var itemName: String? = nil {
        didSet {
            if oldValue != itemName {
                setNeedsUpdateConfiguration()
            }
        }
    }

    var price: Double? = nil {
        didSet {
            if oldValue != price {
                setNeedsUpdateConfiguration()
            }
        }
    }

    var image: UIImage? = nil {
        didSet {
            if oldValue != image {
                setNeedsUpdateConfiguration()
            }
        }
    }

    override func updateConfiguration(using state: UICellConfigurationState) {
        var contentConfiguration = defaultContentConfiguration().updated(for: state)

        contentConfiguration.text = itemName
        contentConfiguration.secondaryText = price?.formatted(.currency(code: "usd"))
        contentConfiguration.image = (image == nil) ? UIImage(systemName: "photo.on.rectangle") : image
        contentConfiguration.prefersSideBySideTextAndSecondaryText = true
        contentConfiguration.imageProperties.maximumSize = CGSize(width: 30, height: 30)
        self.contentConfiguration = contentConfiguration
    }
}

최종 버전

리팩터링이 완료된 tableview cell 구성 함수는 다음과 같다.

private func configure(_ cell: UITableViewCell, forItemAt indexPath: IndexPath) {
        guard let cell = cell as? MenuItemTableViewCell else {
            return
        }

        let menuItem = menuItems[indexPath.row]

        cell.itemName = menuItem.name
        cell.price = menuItem.price
        cell.image = nil

        imageLoadTasks[indexPath] = Task {
            if let data = try? await restaurantController.fetchImage(from: menuItem.imageURL),
               let image = UIImage(data: data),
               let currentIndexPath = self.tableView.indexPath(for: cell) {
                cell.image = (currentIndexPath == indexPath) ? image:nil
            } else {
                cell.image = nil
            }

            imageLoadTasks[indexPath] = nil
        }
}

마무리

사용자 경험과 자원의 효율적 운영의 관점에서 네트워크를 통해 받아온 이미지 데이터를 사용자 인터페이스에 보여주도록 구현해 봤다.

다음 시간에는 Combine API를 활용하여 비동기 이벤트를 처리하며 주문서 관리 서비스를 구현해 보도록 하겠다. 😊😊

반응형