 Apple Lover Developer & Artist

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

 Apple/iOS Dev Challenges

[Challenge] SettingsKit 프레임워크 개발

singularis7 2023. 6. 19. 17:29
반응형

Overview

사용자가 iOS앱의 설정값을 사용자 정의할 수 있도록 설정 기능 및 사용자 인터페이스를 구현한다.

배경

Apple은 "Preferences and Settings Programming Guide"를 통해 앱을 사용자화 할 책임과 관련 도구에 관하여 설명한다. 여기서 사용자에게 노출할 기본 설정을 결정하고 앱 자체의 기본 설정 인터페이스를 어떻게 제공할지는 각 앱이 결정한다고 지침을 주고 있다.

개발자가 앱의 기본 설정값을 손쉽게 관리할 수 있는 도구도 제공하고 있다. 예를 들어 UserDefaults 프레임워크를 사용하면 앱을 구성하는 데 사용되는 정보들을 키-벨류 형태로 영구히 저장할 수 있고 iCloud를 통해 설정값을 공유할 수도 있다.

더 나아가, iOS 설정 번들를 활용하여 특정 설정 항목을 iOS의 설정 앱에서 보여줄 수도 있다. 이 경우 애플의 가이드에 따라 plist 파일을 구성하여 계층 구조의 UI 제어 인터페이스를 담아 설정 화면을 구성할 수 있다.

대부분의 상용 앱들은 이로 인해 앱 자체의 커스텀 설정 인터페이스를 제공하거나 iOS의 설정앱을 통해 앱을 커스터마이징 할 수 있도록 개발되었다.

Why?

여기서 다음의 의문이 발생한다.

  • 개별 앱마다 설정 화면과 기능을 매번 따로 구현하는 것은 비효율적이지 않을까?
  • 설정값의 커스터마이징 옵션에서 공통 부분을 추려낼 수 있지 않을까?

iOS 설정 번들 규칙을 통해 생성된 설정 화면이나 각종 상용앱을 관찰해 보았다. UI의 appearance 스타일은 조금씩 다르지만 구조적으로 보이는 다음의 공통점을 찾을 수 있었다.

  • 하나의 열에 다수의 행이 나열되어 설정값을 보여주는 테이블 레이아웃
  • 설정의 종류에 따라 분류되어 설정값을 탐색할 수 있는 네비게이션 구조
  • 심벌 이미지와 타이틀 그리고 액세서리 뷰를 통해 셀의 종류를 선언
  • 컨트롤 인터페이스를 통해 설정값의 사용자 조작 가능

의문을 해결하고자 공통된 특성을 사용해 다음의 도구 디자인 아이디어를 도출했다.

프레임워크의 책임

  • 설정 화면의 재사용 가능한 공통 UI 인터페이스를 개발한다.
  • 앱 개발자가 설정 화면과 옵션을 생성할 수 있는 API를 제공한다.
  • 설정 옵션과 화면 구성의 관심사 분리 디자인 채택

앱 개발자의 책임

  • 앱에서 사용될 설정 화면과 옵션을 결정한다.
  • 프레임워크의 API를 사용해 설정 화면 및 옵션을 선언한다.
  • 설정값이 앱에 반영될 수 있도록 코딩한다.

Hello World Code

설정 화면 단위의 정의

struct AboutSettingPage: SettingPage {

      // 해당 화면의 네비게이션바 타이틀로 사용할 문자열
    var title: String? { "About" }

      // SettingsCollectionViewController에 주입될 ViewModel 객체
      // SettigPresentable 프로토콜을 충족하는 타입만 담을 수 있음
    let viewModel = AboutSettingsViewModel()

}

특정 설정 화면에 포함될 설정 옵션 정의

final class AboutSettingsViewModel: SettingPresentable {

    // SettingPresentable 프로토콜 요구사항: 설정 화면에서 띄워줄 옵션 명단
      var items: [Item] {
        [
            Item(
                title: "🐶 Developer Name",
                description: "JeongTaek Han",
                section: .general,
                isGroup: false,
                isChecked: false
            ),
            Item(
                title: "💻 Github",
                description: "@smart8612",
                section: .general,
                isGroup: false,
                isChecked: false
            )
        ]
    }

    // SettingPresentable 프로토콜 요구사항: 설정 화면에서 띄워줄 설정 분류 헤더
    enum Section: SettingSectionPresentable {
        case general

        var title: String? {
            switch self {
            case .general:
                return "Copyright"
            }
        }

        var description: String? { nil }
    }

    // SettingPresentable 프로토콜 요구사항: 설정 아이템을 의미하는 구체 타입
    struct Item: SettingItemPresentable {

        var title: String
        var description: String?
        var section: Section
        var isGroup: Bool
        var isChecked: Bool

    }

}

View Controller

// 사용법

// 페이지 인스턴스의 생성
let AboutPage = AboutSettingPage()

// 계산된 설정 화면을 의미하는 ViewController
AboutPage.viewController

// Navigation Controller의 자식으로 설정된 ViewController
AboutPage.viewControllerEmbeddedInNavigationController

Technology

설정 화면의 재사용 가능한 공통 UI 인터페이스 및 설정값 데이터의 관리에 사용된 기술을 소개한다.

UIKit Modern API

iOS13 이후 UICollectionView API 디자인에는 혁명적인 변화가 이뤄졌다. UIKit의 현대적인 API는 설정 화면을 구성하는 공통의 UI 요소를 구현하기 위해 사용했다. DiffableDataSource를 통한 데이터와 화면 요소에 대한 관심사의 분리가 적용된 API 디자인을 사용하는 것은 설정 옵션 데이터와 설정을 보여주는 로직을 분리하여 관리하도록 도움 주었다.

// SettingsCollectionViewController 발췌
final class SettingsCollectionViewController<ViewModelType: SettingPresentable>: UICollectionViewController {

  // ...

  private var dataSource: DataSource?

  private func createDataSource() -> DataSource {
      DataSource(collectionView: collectionView) { [weak self] (collectionView, indexPath, item) -> UICollectionViewCell? in
          guard let self = self else { return nil }
          return self.collectionView.dequeueConfiguredReusableCell(using: self.cellRegistration, for: indexPath, item: item)
      }
  }

  private var snapshot: Snapshot {
        var snapshot = Snapshot()

        let settingItems = viewModel.items
        settingItems.forEach { item in
            guard let section = item.section as? Section else { return }
            if snapshot.indexOfSection(section) == .none {
                snapshot.appendSections([section])
            }
            snapshot.appendItems([item], toSection: section)
        }

        return snapshot
    }

    private func updateStatus() {
        dataSource?.apply(snapshot, animatingDifferences: true)
    }

  // ...

}

설정 화면 구성이 테이블 뷰와 구조가 유사함에도 UICollectionView를 사용하게 되었다. Compositional Layout의 힘이 여기서 작용된다. iOS14부터 제공된 List Layout과 List Cell은 UICollectionView를 마치 테이블 뷰처럼 사용할 수 있도록 레이아웃 해준다. 개인적으로, 잘 익혀둔 컬렉션 뷰 API가 열 테이블 뷰 부럽지 않은 상황이 만들어졌다고 생각하게 되었다. 테이블 뷰에서 가져가기 어려운 레이아웃 유연성은 덤이다.

// SettingsCollectionViewController 발췌
final class SettingsCollectionViewController<ViewModelType: SettingPresentable>: UICollectionViewController {

  // ...

      private static func listLayout() -> UICollectionViewLayout {
        var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
        config.headerMode = .supplementary
        config.footerMode = .supplementary
        return UICollectionViewCompositionalLayout.list(using: config)
    }

    private func configureUI() {
        collectionView.setCollectionViewLayout(Self.listLayout(), animated: false)
    }

  // ...

}

UIKit이 발전하면서 개발자가 신경써야할 셀의 상태도 다양해졌다. 개발자는 데이터에 집중하고 상태 관리는 프레임워크가 관리해 주는 cell configuration을 사용하였다. 이를 통해 표준화된 cell 레이아웃을 사용하여 사용자가 정의한 데이터를 활용해 손쉽게 cell을 표시할 수 있게 되었다. 필자는 UICollectionViewListCell의 하위클래스로 설정 옵션을 표시할 셀을 정의하였고 컬렉션 뷰 컨트롤러로 부터 셀을 구성하는 로직을 분리해 캡슐화시킬 수 있었다.

final class SettingCollectionViewCell<Item: SettingItemPresentable>: UICollectionViewListCell {

    var item: Item? {
        didSet {
            if item != oldValue {
                setNeedsUpdateConfiguration()
            }
        }
    }

    func updateUI(with item: Item) {
        self.item = item
    }

    override func updateConfiguration(using state: UICellConfigurationState) {
        var contentConfigure = defaultContentConfiguration()
        contentConfigure.text = item?.title
        contentConfiguration = contentConfigure

        var cellAccessories: [UICellAccessory] = []

        if item?.isGroup == true {
            cellAccessories.append(.disclosureIndicator())
        }

        if item?.isChecked == true {
            cellAccessories.append(.checkmark())
        }

        if let supplementaryTitle = item?.description {
            cellAccessories.append(.label(text: supplementaryTitle))
        }

        accessories = cellAccessories
    }

}

UserDefaults

iOS 앱의 기본 설정값을 담은 영구 저장소이다. 데이터는 키-벨류 형식으로 저장되는데, 설정값을 조작하는 관점에서 키값을 수동으로 입력해서 사용하기란 보통 쉬운 일이 아닐 것으로 생각하였다. 그래서 UserDefault 객체레 extension으로 키 이름에 해당되는 연산 프로퍼티를 구현하게 되었다.

연산 프로퍼티에는 getter와 setter가 구현되어있어 사용자가 연산 프로퍼티를 호출하거나 값을 할당하는 행위를 하는 것만으로 손쉽게 user default의 설정값을 조작할 수 있도록 만들었다. UserDefaults를 사용하는 입장에서 보면 기본 설정값을 조작할 수 있는 퍼사드 인터페이스가 보이는 셈이다. 이를 통해 사용자가 앱의 라이트 모드와 다크 모드 중 선호하는 apearance를 선택할 수 있는 설정 기능을 구현해 보았다.

extension UserDefaults {

    var colorSchema: UIUserInterfaceStyle {
        set {
            Self.standard.set(newValue.rawValue, forKey: Keys.colorSchema.rawValue)
        }
        get {
            let value = Self.standard.integer(forKey: Keys.colorSchema.rawValue)
            return UIUserInterfaceStyle(rawValue: value) ?? .unspecified
        }
    }

    enum Keys: String, CaseIterable {
        case colorSchema
    }

}

apearance 설정 데이터를 조작해 줄 컨트롤러 객체도 구현해 주었다.

struct AppearanceSettingController {

    private let preferences = UserDefaults.standard

    public init() {}

    var currentColorSchemaDescription: String {
        switch preferences.colorSchema {
        case .unspecified:
            return "System Mode"
        case .light:
            return "Light Mode"
        case .dark:
            return "Dark Mode"
        @unknown default:
            return "System Mode"
        }
    }

    var isUnspecifiedColorSchema: Bool {
        preferences.colorSchema == .unspecified
    }

    var isLightColorSchema: Bool {
        preferences.colorSchema == .light
    }

    var isDarkColorSchema: Bool {
        preferences.colorSchema == .dark
    }

    func changeColorSchema(to schema: UIUserInterfaceStyle) {
        preferences.colorSchema = schema
    }

}

설정 인터페이스에 컨트롤러를 연동하면 다음과 같다. 특정 설정 아이템을 선택했을 때 수행할 액션을 정의하려면 SettingsCollectionViewControllerDelegate 프로토콜을 채택하면 된다.

final class AppearanceSettingsViewModel: SettingPresentable {

    private var appearanceSettingController = AppearanceSettingController()

    var items: [Item] {
        [
            Item(
                title: "System Default",
                section: .theme,
                isGroup: false,
                isChecked: appearanceSettingController.isUnspecifiedColorSchema
            ),
            Item(
                title: "Light Mode",
                section: .theme,
                isGroup: false,
                isChecked: appearanceSettingController.isLightColorSchema
            ),
            Item(
                title: "Dark Mode",
                section: .theme,
                isGroup: false,
                isChecked: appearanceSettingController.isDarkColorSchema
            ),
        ]
    }

    enum Section: SettingSectionPresentable {
        case theme

        public var title: String? {
            switch self {
            case .theme:
                return "Theme"
            }
        }

        public var description: String? {
            switch self {
            case .theme:
                return "Configure app's color theme schema."
            }
        }
    }

    struct Item: SettingItemPresentable {

        public var title: String
        public var description: String?
        public var section: Section

        public var isGroup: Bool
        public var isChecked: Bool

    }

}

extension AppearanceSettingsViewModel: SettingCollectionViewControllerDelegate {

    func provideSettingPage(of item: any SettingItemPresentable, presentAction: ((any SettingPage)?) -> Void) {
        presentAction(nil)
    }

    func action(for item: any SettingItemPresentable) {
        guard let item = item as? Item else { return }
        let items = items

        if item == items[0] {
            appearanceSettingController.changeColorSchema(to: .unspecified)
        } else if item == items[1] {
            appearanceSettingController.changeColorSchema(to: .light)
        } else if item == items[2] {
            appearanceSettingController.changeColorSchema(to: .dark)
        }
    }

}

UserDefault는 자신 관리하는 설정값이 변경될 때마다 Notification을 보낸다. 이 이벤트를 받아서 설정의 변동 사항을 앱에 반영시키면 된다.

Modularization

Swift Package Manager를 활용해 SettingsKit 패키지로 코드를 묶어주었다. 다른 앱 프로젝트에서도 코드를 재사용할 수 있도록 묶어내기 위함이다. 필자가 생각하는 패키지 모듈화를 진행했을 때 가질 수 있는 강점은 협업 가능한 구조를 만들어준다는 점이다. 예를 들어 OrderApp이라는 iOS앱이 SettingsKit에 의존성을 가져서 개발되고 있다고 하자. SettingsKit은 OrderApp과 상관없이 병렬적으로 개발될 수 있다. Sementic Versioning 문법을 사용하면 메이저, 마이너, 패치 수준의 버전 업데이트로 관리하여 의존 관계를 띈 프로젝트 들이 변동 사항을 자신의 프로젝트에 어떻게 반영할지 전략을 세울 수 있다. 큰 규모의 한 프로젝트보다 작은 규모의 프로젝트를 개별 관리하는 것이 코드 관리가 수월할 것이다. 프로젝트 협업 관점에서 패키지 모듈화는 중요한 개념으로 생각하게 되었다.

Issue

설정 화면의 재사용 가능한 공통 UI 인터페이스 및 설정값 데이터의 관리 기능을 구현하며 겪은 대표적인 이슈를 소개한다.

[Generics] Setting Page 타입의 등장 이유?

SettingsKit에서는 설정 화면의 인터페이스를 정의하는 SettingsCollectionViewController를 설정의 종류와 관계없이 재사용할 수 있도록 화면이 사용되는 설정 맥락은 SettingRepresentable 프로토콜을 채택한 ViewModel 객체에 구현하고 이를 View Controller의 생성자로 주입하여 화면을 그려내는 전략을 취했다.

문제는 View Controller에 View Model의 Dependency Injection 과정에서 발생했다. 생성자를 통해 주입될 View Model 파라미터의 타입으로써 프로토콜을 사용하면 구체적인 타입은 런타임에 결정된다. UICollectionView를 사용하면서 ViewController에는 Modern UIKit API들이 사용되었는데 대부분 컴파일 타임에 타입이 결정되는 Swift Generic에 기반하고 있다.

생성자로 주입되는 SettingRepresentable과 여기에 연관 타입으로 선언된 타입들 모두 컴파일 타임에는 알 수 없기에 SettingRepresentable 프로토콜에 선언된 타입을 UICollectionView의 제네릭 API에서 사용하면 다음과 같은 오류를 만나게 된다.

Cannot access associated type 'Section' from 'SettingPresentable'; use a concrete type or generic parameter base instead

Cannot access associated type 'Item' from 'SettingPresentable'; use a concrete type or generic parameter base instead

필자의 해결책은 서로 다른 세상을 이어주는 개념을 만들어주는 것이었다. 그게 바로 Setting Page이다.

public protocol SettingPage {

    associatedtype ViewModelType: SettingPresentable

    var title: String? { get }
    var viewModel: ViewModelType { get }
    var viewController: UIViewController { get }

}

public extension SettingPage {

    var viewController: UIViewController {
        let viewController = SettingsCollectionViewController(viewModel: viewModel)
        viewController.settingDelegate = viewModel as? any SettingCollectionViewControllerDelegate
        viewController.title = title
        return viewController
    }

    var viewControllerEmbeddedInNavigationController: UINavigationController {
        let navigationController = UINavigationController(rootViewController: viewController)
        navigationController.title = title
        navigationController.navigationBar.prefersLargeTitles = true
        return navigationController
    }

}

ViewModel은 SettingPresentable 프로토콜 타입을 준수하는 어느 구체 타입이라고 associated type을 통해 선언해 두었다. Setting Page 프로토콜을 채택한 구체 타입에서 viewModel 프로퍼티에 SettingPresentable 프로토콜을 준수하는 타입의 인스턴스를 할당하면 ViewModelType의 구체 타입이 컴파일 타임에 결정되기 때문에 오류를 해결할 수 있게 된다.

Memory Leaks

컴퓨터의 메모리는 중요한 자원 중 하나이다. Swift에서 메모리의 관리는 ARC 규칙을 따라 메모리를 할당하고 해제하게 된다. 다만, 개발자가 꼼꼼하게 확인하지 않으면 강한 참조 사이클이 발생하여 사용하지 않는 데이터들이 메모리를 좀먹고 있는 모습을 보게 된다.

메모리는 프로그램의 디버깅 과정에서 점검할 수 있다. Xcode의 디버깅 도구를 사용하면 현재 메모리에 올라가 있는 데이터의 참조 구조도 볼 수 있으며 메모리 누수가 발생할 경우 경고도 띄워주기 때문에 아주 편리하다.

필자의 경우 메모리 누수를 확인하기 위해 같은 작업을 수십 번 반복해서 시행해 보는 테스트 과정을 진행해 보았다. 사용하지 않는 ViewController나 ViewModel 인스턴스가 메모리에서 정상적으로 제거되고 있는지, 인스턴스에 대해 불필요한 참조 관계가 있지는 않은지 살펴보게 된다.

이번에는 데이터 소스에서 강한 참조 사이클이 발생하여 사용하지 않는 View Controller와 View Model 인스턴스가 메모리에서 놀고 있는 상황을 마주 보게 되었다. 문제가 된 코드는 다음과 같다.

  private lazy var dataSource: DataSource = {
    let dataSource = DataSource(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
        collectionView.dequeueConfiguredReusableCell(using: self.cellRegistration, for: indexPath, item: item)
    }

    dataSource.supplementaryViewProvider = { (collectionView, kind, indexPath) in
        let currentSnapshot = dataSource.snapshot()
        let section = currentSnapshot.sectionIdentifiers[indexPath.section]

        let cell = collectionView.dequeueConfiguredReusableSupplementary(
            using: (kind == UICollectionView.elementKindSectionHeader) ? self.headerRegistration:self.footerRegistration,
            for: indexPath
        )
        var contentConfig = cell.defaultContentConfiguration()
        contentConfig.text = (kind == UICollectionView.elementKindSectionHeader) ? section.title:section.description
        cell.contentConfiguration = contentConfig

        return cell
    }

    return dataSource
}()

데이터 소스 프로퍼티를 옵셔널 하게 만들지 않으면서 ViewController의 생성 시 데이터 소스가 바로 초기화되도록 하기 위해 위와 같은 코드를 작성하게 되었다. 데이터 소스를 필자가 원하는 방식으로 초기화하기 위해 클로저 내부에 초기화 관련 코드가 담겼다.

클로저는 잘 사용하면 약이지만 잘못 사용하면 독약이 될 수도 있다는 점을 느꼈다. 데이터 소스에서 발생한 강한 참조 사이클의 원인이 클로저 캡처와 연관되었다는 사실을 파악하게 되었기 때문이다. 클로저 또한 참조 타입이기 때문에 내부에서 다른 참조 타입의 인스턴스를 사용할 경우 다른 참조 타입의 참조 카운트가 증가한다. 뷰 컨트롤러와 dataSource 클로저가 서로를 참조하는 맥락에서 UIKit의 ViewController 계층에서 해당 ViewController가 제거되면 여전히 서로를 참조하는 참조 카운트가 존재하여 메모리에서 유유자적하며 놀게 된다.

필자는 클로저를 사용하지 않고 옵셔널 타입의 데이터 소스 프로퍼티를 별도의 메서드를 통해 초기화해주는 방식을 사용해 클로저를 사용하지 않는 방식을 택했다.

위와 같은 예시 외에도 비동기 api의 클로저를 사용할 때에도 비슷한 문제를 종종 마주 볼 수 있는데, 참조 카운트가 증가되지 않도록 신경 써주면 메모리 누수를 예방할 수 있지 않을까 생각한다.

Reference

반응형