[Conference] Property wrappers to the rescue!

Junyoung Park·2022년 11월 20일
0

Conference

목록 보기
1/5
post-thumbnail
post-custom-banner

Property wrappers to the rescue! - Davide Repetto - Swift Heroes 2022

Property wrappers to the rescue!

프로퍼티 래퍼

  • 로직을 추가하기 위해 특정 값을 감싸는 타입
  • 읽기 가운데 프로퍼티 저장 또는 연산 방법을 정의하는 별도의 레이어
  • @State, @Binding, @StateObject 등 SwiftUI 프레임워크 내에서 낯설지 않은 타입

구현 방법

  • 구조체 / 클래스 모두 지원 가능
  • @propertyWrapper 어트리뷰트로 정의되어야 함
  • wrappedValue 프로퍼티를 가져야 함

구현 예시 1

@propertyWrapper
struct Capitalized {
    private var value: String = ""
    var wrappedValue: String {
        set {
            value = newValue.capitalized
        }
        get {
            return value
        }
    }
    
    init(wrappedValue: String) {
        self.wrappedValue = wrappedValue
    }
}
  • wrappedValueget, set을 통해 주어진 value를 조정
  • 주어진 값의 capitalized를 리턴
import Foundation

struct UserModel {
    @Capitalized var firstName: String
    @Capitalized var lastName: String
}
  • 해당 프로퍼티 사용법은 @을 통해 해당 프로퍼티를 따른다고 선언하기만 하면 됨 (선언형)
import Foundation

struct CapitalizedWrapper {
    private var _value: String = ""
    var value: String {
        set {
            _value = newValue.capitalized
        }
        
        get {
            return _value
        }
    }
    
    init(value: String) {
        self._value = value.capitalized
    }
}
  • @propertyWrapper 없이 해당 방법을 구현하는 로직은 상동
import Foundation

struct WrappedUserModel {
    var firstName: CapitalizedWrapper
    var lastName: CapitalizedWrapper
}
  • 실제 사용에서 한 차례 더 value를 찾아야 하는 단계가 필요
let wrappedUser = WrappedUserModel(firstName: CapitalizedWrapper(value: "junyeong"), lastName: CapitalizedWrapper(value: "park"))
wrappedUser.firstName.value // Junyeong

let user = UserModel(firstName: "junyeong", lastName: "park")
user.firstName // Junyeong
@propertyWrapper
struct Capitalized {
    private var value: String = ""
    var wrappedValue: String {
        set {
            value = newValue.capitalized
        }
        get {
            return value
        }
    }
    var projectedValue: Capitalized {
        return self
    }
    
    init(wrappedValue: String) {
        self.wrappedValue = wrappedValue
    }
}
  • projectedValue라는 자기 자신을 리턴했다면, $으로 접근 가능
user.$firstName
  • $을 통해 해당 주소 접근 가능
@propertyWrapper
struct PlainUserDefaultsBacked<T> {
    let key: String
    let defaultValue: T
    var storage: UserDefaults = .standard
    var wrappedValue: T {
        get {
            let value = storage.value(forKey: key) as? T
            return value ?? defaultValue
        }
        set {
            storage.setValue(newValue, forKey: key)
        }
    }
}
  • 프로퍼티 래퍼를 유저 디폴트와 같은 데이터 저장에 활용하는 것 또한 가능
  • 제네릭으로 선언한 해당 구조체는 프로퍼티 래퍼를 따름
  • 키, 디폴트 값, 스토리지 등이 구조체 내 포함
import Foundation

@propertyWrapper
struct CodableUserDefaultsBacked<T: Codable> {
    let key: String
    let defaultValue: T
    var storage: UserDefaults = .standard
    
    var wrappedValue: T {
        get {
            guard
                let data = storage.value(forKey: key) as? Data,
                let value = try? JSONDecoder().decode(T.self, from: data) else {
                return defaultValue
            }
            return value
        }
        set {
            let data = try? JSONEncoder().encode(newValue)
            storage.setValue(data,forKey: key)
        }
    }
}
  • Codable 프로토콜을 따르는 제네릭을 받은 뒤 구조체 내에서 자동으로 인코딩과 디코딩을 해줄 수 있음
import Foundation

struct NoteModel: Codable {
    let title: String
}
  • 간단한 Codable 프로토콜을 따르는 데이터 모델
import Foundation

class UserDefaultsDataSource {
    @PlainUserDefaultsBacked(key: "is_first_launch", defaultValue: true)
    static var isFirstLaunch: Bool
    @PlainUserDefaultsBacked(key: "user_name", defaultValue: "unknown")
    static var userName: String
    @PlainUserDefaultsBacked(key: "counter", defaultValue: 0, storage: .standard)
    static var counter: Int
    
    @CodableUserDefaultsBacked(key: "notes", defaultValue: nil)
    static var notes: [NoteModel]?
}
  • 위 유저 디폴트를 활용한 구조체를 사용하고 있는 데이터 소스 클래스
  • static으로 선언한 해당 변수를 통해 현 시점의 유저 디폴트 값에 접근하거나 수정 가능
import UIKit

class PropertyWrapperController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        print(UserDefaultsDataSource.isFirstLaunch) // true
        print(UserDefaultsDataSource.userName) // unknown
        print(UserDefaultsDataSource.counter) // 0
        
        UserDefaultsDataSource.isFirstLaunch = false
        UserDefaultsDataSource.userName = "Junyeong"
        UserDefaultsDataSource.counter += 1
        
        print(UserDefaultsDataSource.isFirstLaunch) // false
        print(UserDefaultsDataSource.userName) // Junyeong
        print(UserDefaultsDataSource.counter) // 1
        
        print(UserDefaultsDataSource.notes) // nil
        UserDefaultsDataSource.notes = [NoteModel(title: "First Note")]
        print(UserDefaultsDataSource.notes)
        // [NoteModel(title: "First Note")]
        UserDefaultsDataSource.notes?.append(NoteModel(title: "Second Note"))
        print(UserDefaultsDataSource.notes)
        // [NoteModel(title: "First Note"), NoteModel(title: "Second Note")]
    }
}
  • 실제로 뷰를 이니셜라이즈할 때마다 유저 디폴트 값이 계속해서 더해짐

구현 예시 2

  • 이미지 어셋 사용에 프로퍼티 래퍼를 적용 가능
  • 여러 코드의 중앙 관리 및 자동화에 용이
@propertyWrapper
struct ImageAsset {
    let key: String
    init(_ key: String) {
        self.key = key
    }
    
    func image(for name: String) -> UIImage {
        UIImage(named: name) ?? .init()
    }
    
    var projectedValue: String {
        return key
    }
    
    var wrappedValue: UIImage {
        self.image(for: key)
    }
}
  • 프로퍼티 래퍼를 따르는 해당 이미지 어셋 구조체는 키값을 이니셜라이즈 파라미터로 건네받아 키를 초기화
  • projectedValue는 키를 리턴
  • wrappedValue는 이니셜라이즈에 사용된 키를 통해 내장 함수 image를 사용, 주어진 이미지를 리턴
enum Asset {
    @ImageAsset("menu_icon") static var menuIcon: UIImage
    @ImageAsset("search_icon") static var searchIcon: UIImage
    @ImageAsset("settings_icon") static var settingsIcon: UIImage
    @ImageAsset("plus_icon") static var plusIcon: UIImage
}
  • 해당 구조체를 static으로 가지고 있는 이넘을 통해 손쉽게 어셋 이미지 접근 가능
import UIKit

class AssetViewController: UIViewController {
    private let imageView: UIImageView = {
        let imageView = UIImageView()
        return imageView
    }()
    override func viewDidLoad() {
        super.viewDidLoad()
        imageView.image = Asset.menuIcon
        imageView.image = Asset.plusIcon
        imageView.image = Asset.searchIcon
        imageView.image = Asset.settingsIcon
        
    }

}

구현 예시 3

  • 컬러 또한 이미지와 마찬가지로 이넘을 통해 쉽게 중앙 관리 가능
enum Colors {
    static var mainRed: UIColor {
        UITraitCollection.current.userInterfaceStyle == .dark ? UIColor.black : UIColor.red
    }
}
  • (어셋의 라이트/다크 모드를 사용하지 않고) 이넘을 통해 컬러 스킴에 맞춰 지원을 하기 위해서는 연산 프로퍼티를 통해 해당 값을 리턴
@propertyWrapper
struct Color {
    var light: UIColor
    var dark: UIColor
    
    var isDark: Bool {
        UITraitCollection.current.userInterfaceStyle == .dark
    }
    
    var projectedValue: Color { return self }
    
    var wrappedValue: UIColor {
        if isDark {
            return dark
        } else {
            return light
        }
    }
}
  • 프로퍼티 래퍼로 감싼 컬러를 사용한다면 보다 깔끔
enum Colors {
    @Color(light: .red, dark: .black) static var mainRed2
}
import UIKit

class ColorViewController: UIViewController {
    private let imageView: UIImageView = {
        let imageView = UIImageView()
        return imageView
    }()
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = Colors.mainRed2
        let lightColor: UIColor = Colors.$mainRed2.light
        let darkColor: UIColor = Colors.$mainRed2.dark
        let usingDarkColor: Bool = Colors.$mainRed2.isDark
    }
}
  • projectedValue로 리턴해주고 있기 때문에 $를 통해 접근 가능
  • Color를 선언할 때 파라미터로 넣은 값에 접근 가능

정리

  • 래퍼: 프로퍼티에 기능을 추가
  • 프로퍼티: 감싸지 않은(unwrapped) 프로퍼티처럼 사용 가능

한계

  • 하나의 프로퍼티는 하나의 래퍼 어트리뷰트만 가질 수 있음
  • 프로퍼티 래퍼는 서브클래스에서 오버라이드할 수 없음
  • 프로토콜에서 선언할 수도 없음

특징

  • 코드 재사용성과 일반화에 용이
  • 코드 클린
  • 사용하기 간단
  • 저장된 값이 매우 투명: projectedValue가 있다면 $ 마크를 통해 접근 가능 → 값 자체에 접근할 수 있기 때문에 위험할 수 있음
  • DSL(Domain Specific Language)와 같은 맥락으로 사용하지 않아야 할 것: 너무 많은 로직을 프로퍼티 래퍼 안에 숨기고 다른 개발자와 소통하기 어려워지기 때문. 프로퍼티 래퍼가 만들어진 본래 목적에 어긋나기도 함

항상 주어진 @State, @Binding 등에서만 접하면 프로퍼티 래퍼를 직접 커스텀하고 선언해보면서 바깥에서 물론 작업해줄 수 있는 일이지만 보다 불필요한 코드를 줄이고 코드를 클린하게 만들어줄 수 있는 해당 기능에 감탄했다.

profile
JUST DO IT
post-custom-banner

0개의 댓글