티스토리 뷰
[Swift] DI 라이브러리 소개 및 비교 - Factory, Swinject, Needle, swift-dependencies
_dodo 2023. 5. 26. 17:14의존성 주입하는게 귀찮아질 쯤 DI라이브러리를 알아보기 시작했는데요, 종류가 정말 많더라구요... 그래서 뭘 쓸지 고민하다가 각 라이브러리의 Overview를 읽어보며 특징을 정리해보게 되었습니다!
Factory (0.9K)
https://github.com/hmlongco/Factory
먼저 Factory라는 라이브러리를 소개해보겠습니다.
✅ 컨테이너 기반
✅ Mocking, Testing, Scope 기능 제공
✅ 프로퍼티 래퍼 사용 가능
우선 기본적인 기능은 위와 같고 한번 사용법을 살펴보도록 하겠습니다!
컨테이너 사용법
extension Container {
var myService: Factory<MyServiceType> {
Factory(self) { MyService() }
}
}
컨테이너 기반 DI 라이브러리이기 때문에 주입할 객체를 extension 컨테이너에 정의해주면 됩니다.
위와 같이 Factory 접근할 변수명을 지정하고 Factory에 프로토콜을 전달한 다음 실제 인스턴스화할 객체를 클로저에 넣어줍니다.
class ContentViewModel: ObservableObject {
@Injected(\.myService) private var myService
...
}
그리고 의존성을 가지는 객체에서 @Injected라는 프로퍼티래퍼를 이용해 선언해주면 ContentViewModel이 생성될때 위에서 생성해둔 MyServcie 객체가 할당되게 됩니다.
하지만 만약 프로퍼티 래퍼를 사용하지 않는다면 다른 방법으로도 접근할 수 있다고 합니다. SwiftUI환경이 아니라면 프로퍼티 래퍼가 조금 어색할 수도 있을 것 같네요
class ContentViewModel: ObservableObject {
private let myService = Container.shared.myService()
private let eventLogger = Container.shared.eventLogger()
...
}
이렇게 직접 싱글턴 컨테이너에 접근해서 resolve하거나
class ContentViewModel: ObservableObject {
let service: MyServiceType
init(container: Container) {
service = container.service()
}
}
컨테이너만 주입받아 resolve할 수도 있습니다.
Mocking
프리뷰 화면을 그리거나 테스팅 할때 직접적인 데이터 사용하는 객체 대신 가짜 객체를 사용하게 되는데요, 이때는 아래와 같이 작업하면 된다고 합니다.
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let _ = Container.shared..myService.register { MockService2() }
ContentView()
}
}
프리뷰 내에서 뷰를 생성하기 전에 .register를 이용하여 myService의 객체를 가짜 객체 (MockService2)로 바꿔주었습니다. 이렇게 하면 내부에서 myService가 반환되기 전에 객체가 변경되어 Mock객체가 반환되게 되는 것이죠.
Testing
final class FactoryCoreTests: XCTestCase {
override func setUp() {
super.setUp()
Container.shared = Container()
}
func testLoaded() throws {
Container.shared.accountProvider.register { MockProvider(accounts: .sampleAccounts) }
let model = Container.shared.someViewModel()
model.load()
XCTAssertTrue(model.isLoaded)
}
}
테스트시에도 Mocking과 마찬가지로 객체를 생성하기 전에 Mock으로 변경해주면 됩니다!
Scope
Factory에서 Scope란 객체의 생명주기를 의미합니다. 생성된 객체가 매번 새로 만들어져야 하는지, 싱글턴 객체여야하는지, 혹은 특정 시점에 교체되어야 하는지 등에 대한 설정 기능도 제공한다고 합니다.
extension Container {
var networkService: Factory<NetworkProviding> {
self { NetworkProvider() }
.singleton
}
var myService: Factory<MyServiceType> {
self { MyService() }
.scope(.session)
}
}
보시면 netowrkService는 싱글턴으로 사용되었고 myService는 특정 세션을 가지네요. 자세한 사항은 Factory-Scope 공식문서에서 확인하실 수 있습니다. 싱글턴 외에도 Cache로 설정할 수도 있고, Session은 특정한 트리거를 기점으로 새로 생성되는 기능입니다. 예를 들어서 사용자가 로그아웃했을때 외부에서 세션을 종료시키면 객체가 바뀌게 됩니다. 그 외에도 시간을 설정해서 1시간, 2시간 등으로 객체의 생명주기를 설정할 수 있습니다.
Swinject (5.8K)
https://github.com/Swinject/Swinject
Swinject는 5.8K 스타로 제가 조사한 라이브러리 중에서 가장 많은 스타를 받은 라이브러리입니다. 주요 기능은 다음과 같습니다!
✅ 컨테이너 기반 + 계층 구성 가능
✅ Storyboard를 이용한 DI 기능도 제공
Swinject도 마찬가지로 컨테이너 기반의 DI 매커니즘을 가지고 있는데요, 한번 사용법을 살펴보겠습니다.
컨테이너 사용법
// 컨테이너 세팅
let container = Container()
container.register(Animal.self) { _ in Cat(name: "Mimi") }
container.register(Person.self) { r in
PetOwner(pet: r.resolve(Animal.self)!)
}
// 사용
let person = container.resolve(Person.self)!
person.play() // prints "I'm playing with Mimi."
Factory와 마찬가지로 먼저 컨테이너를 생성하고 .register 메서드를 이용해 사용할 객체를 등록해줍니다.
그 다음 .resolve함수를 통해 객체를 꺼내올 수 있습니다.
계층구조 구현
let parentContainer = Container()
parentContainer.register(Animal.self) { _ in Cat() }
let childContainer = Container(parent: parentContainer)
let cat = childContainer.resolve(Animal.self)
print(cat != nil) // prints "true"
parent를 사용하면 Container를 겹쳐 계층구조를 구성할 수도 있습니다.
컨테이너 관리
extension Container {
static let shared: Container = {
let container = Container()
container.register(Networking.self) { _ in
NetworkManager()
}.inObjectScope(.container)
container.register(Database.self) { _ in
DatabaseManager()
}.inObjectScope(.container)
return container
}()
}
// 사용
Container.shared.resolve(Networking.self)
extension을 구현에 어디서든 접근이 용이하도록 설정하면 더 좋겠죠?
테스팅
class MyAppTests: XCTestCase {
var container: Container!
override func setUp() {
super.setUp()
Container.shared.register(Networking.self) { _ in
MockNetworkManager() // Mock 객체를 주입
}
// testing...
}
}
테스트시엔 원래 객체 대신 Mock객체를 재 register하는 것으로 mocking을 할 수 있습니다.
스토리보드 DI
조사한 라이브러리 중 유일하게 스토리보드를 이용한 DI 제공했습니다. Swinject/SwinjectStoryboard 레포에 가시면 더 자세히 보실 수 있는데요
// AppDelegate.swift
let sb = SwinjectStoryboard.create(
name: "Animals", bundle: nil, container: container)
let dogController = sb.instantiateViewControllerWithIdentifier("Dog")
as! AnimalViewController
스토리보드 네임을 입력해서 VC와 함께 설정해주고
extension SwinjectStoryboard {
@objc class func setup() {
defaultContainer.storyboardInitCompleted(AnimalViewController.self) { r, c in
c.animal = r.resolve(Animal.self)
}
defaultContainer.register(Dog.self) { _ in Cat(name: "Hachi") }
}
}
SwinjectStoryboard라는 클래스를 extension해서 컨테이너를 설정하면 된다고합니다.
그럼 이런식으로 Storyboard ID를 이용해서 resolve된다고 하네요!
Needle (1.6K)
https://github.com/uber/needle
Needle은 앞서 본 라이브러리들과 달리 VC의존성을 주로 관리하는 라이브러리입니다. 코디네이터 패턴이나 Ribs를 써보신 분들은 비슷한 개념이라고 생각하면 되겠습니다.
✅ 계층적 DI 구조
✅ 컴파일 타임에 안전하게 구성
✅ 코드 제너레이터
계층적 DI
Needle에서는 계층적 DI를 제공한다고 하는데요, 이 의미는 부모의 의존성을 그대로 받은 자식 의존성을 만들 수 있다는 것으로 의존성을 재활용할 수 있다는 의미입니다!
한 곳에서 모든 컴포넌트의 스코프를 관리하는 Container DI와 다르게 선언에 따라 각각 다른 스코프를 갖게 됩니다.
인스턴스를 한번에 관리하려는 용도보단 VC -> ViewModel -> Usecase -> Repository ... 와 같은 깊은 의존성을 촘촘하게 관리하는데 유용합니다.
protocol MyDependency: Dependency {
var chocolate: Food { get }
var milk: Food { get }
}
class MyComponent: Component<MyDependency> {
var hotChocolate: Drink {
return HotChocolate(dependency.chocolate, dependency.milk)
}
var myChildComponent: MyChildComponent {
return MyChildComponent(parent: self)
}
}
우선 Needle은 컴포넌트라는 객체를 사용하고 이 객체가 하나의 Scope입니다.
위 코드에서 MyDependency는 부모 의존성에 대한 프로토콜인데요, 프로토콜내에 있는 프로퍼티는 부모 컴포넌트를 탐색해서 가져오겠다는 의미입니다. 그리고 실제 컴포넌트 (MyConponent)에서는 추가적인 프로퍼터를 선언할 수 있습니다. 따라서 MyComponent는 chocolate + milk + hotChocolate이라는 프로퍼티를 가지는데, 이중 chocolate + milk는 부모에게서 찾고, hotChocolate는 직접 생성합니다.
그럼 MyComponent의 부모가 어디있는지 어떻게 찾을 수 있을까요? 코드를 다시 보시면 MyChildComponent가 선언되어 있는데요, 이 선언을 이용해서 부모를 추론하게 됩니다. 그리고 이러한 작업을 코드제너레이터가 해줍니다.
코드 제너레이터 + 컴파일 타임 안전성
Needle을 사용하려면 코드 제너레이터를 함께 설치해서 사용해야합니다.
brew install needle
이 코드 제너레이터의 역할은 의존성 그래프를 그리는 것입니다.
class LoggedInComponent: Component<LoggedInDependency> {
var gameComponent: GameComponent {
return GameComponent(parent: self)
}
}
이런 컴포넌트가 있을 때, LoggedInComponent가 GameComponent의 부모 컴포넌트임을 추론해 resolve한다고합니다. 즉, gameComponent가 생성될 때 gameComponent를 자식으로 선언한 컴포넌트가 있는지 살펴보고 컴포넌트가 있다면 그 컴포넌트에서 프로퍼티를 가져옵니다. 만약 여러개의 부모 컴포넌트가 있다면 자식 컴포넌트가 찾는 프로퍼티가 있는 (깊이가) 가장 가까운 컴포넌트에서 프로퍼티를 가져온다고합니다.
그리고 이때 찾는 프로퍼티를 가진 부모가 없는 등의 의존성 문제가 있을 경우 코드가 제너레이트 되지 않고 오류를 발생시키므로 컴파일 타임에 의존성에 대한 오류를 확인할 수 있다고 합니다!
사용법
import NeedleFoundation
protocol LoggedInDependency: Dependency {
var imageCache: ImageCache { get }
var networkService: NetworkService { get }
}
class LoggedInComponent: Component<LoggedInDependency> {
var scoreStream: ScoreStream {
return mutableScoreStream
}
var mutableScoreStream: MutableScoreStream {
return shared { ScoreStreamImpl() }
}
var loginViewController: UIViewController {
return LoggedInViewController(
gameBuilder: gameComponent,
scoreStream: scoreStream,
scoreSheetBuilder: scoreSheetComponent,
imageCache: dependency.imageCache
)
}
// MARK: - Children
var gameComponent: GameComponent {
return GameComponent(parent: self)
}
}
먼저 컴포넌트를 생성해주어야겠죠? 부모에서 받을 프로퍼티와, 해당 컴포넌트를 부모로하는 자식 컴포넌트를 선언해줄 수 있습니다. 또한 위 코드에서는 의존성을 주입을 완료한 VC 프로퍼티도 가지고 있네요.
트리를 올라가면 결국 최상위 컴포넌트가 있을 텐데요, 부모 의존성이 없는 최상위 컴포넌트는 다음과 BootstrapComponent로 선언할 수 있다고 합니다.
let rootComponent = RootComponent()
class RootComponent: NeedleFoundation.BootstrapComponent {
/// Root component code...
}
루트 컴포넌트는 부모가 없기 때문에 바로 인스턴스화 할 수 있으므로 이를 이용해 App/SceneDelegate 설정을 해줄 수 있습니다.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(frame: windowScene.coordinateSpace.bounds)
window?.windowScene = windowScene
registerProviderFactories() // generated Needle Function
let rootComponent = RootComponent() // our starting point
window?.rootViewController = rootComponent.rootViewController
window?.makeKeyAndVisible()
}
}
만약 Mocking을 하고 싶다면 Mocking 객체들을 대신 할당한 MockVC를 생성하거나, 프로퍼티 클로저에 Impl객체 대신 Mock객체를 넣어 테스트해볼 수 있을 것 같습니다!
⚠️ 디팬던시를 다 맞춰주었는데도 'Could not find a provider for'오류가 발생하는 경우가 있었는데요, 부모가 자식에게 넘겨주는 프로퍼티를 public으로 변경하니 해결되었습니다. Issue
protocol SoundHelperDependency: Dependency { var timerCompleteSoundHelper: SoundHelper { get } } final class SoundHelperComponent: Component<SoundHelperDependency> { var soundHelper: SoundHelper { dependency.timerCompleteSoundHelper } } final class RootComponent: BootstrapComponent, SoundHelperDependency { public var timerCompleteSoundHelper: SoundHelper { // public TimerCompleteSoundHelper() } var soundHelperComponent: SoundHelperComponent { SoundHelperComponent(parent: self) } }
swift-dependencies (0.9K)
https://github.com/pointfreeco/swift-dependencies
이 라이브러리는 TCA를 만든 곳에서 만든 라이브러리이며 TCA에는 아예 내장되어 있습니다! 따라서 SwiftUI+TCA 조합에 아주 유용합니다. 하지만 꼭 TCA를 써야만 적용할 수 있는 것은 아니고, 좋은 기능들이 많아서 소개해보려고합니다.
✅ TCA 내장! (TCA + SwiftUI 👍)
✅ 프로퍼티 래퍼 사용
✅ 간단히 preview mock과 test mock 생성 가능
사용법
final class FeatureModel: ObservableObject {
@Dependency(\.continuousClock) var clock // Controllable way to sleep a task
@Dependency(\.date.now) var now // Controllable way to ask for current date
@Dependency(\.mainQueue) var mainQueue // Controllable scheduling on main queue
@Dependency(\.uuid) var uuid // Controllable UUID creation
// ...
}
swift-dependencies는 @Dependency라는 프로퍼티래퍼를 이용해 사용할 수 있습니다. 위와 같이 선언해주면 정해진 객체가 자동으로 할당되는 것이죠. 그리고 이를 위해서 몇가지 설정을 해주어야합니다.
1. DependencyKey 정의
private enum APIClientKey: DependencyKey {
// TCA스타일에 맞춰 live 프로퍼티를 만들어도 되고 APIClient()처럼 일반적으로 인스턴스화해도 가능합니다. 🙆♀️
static let liveValue = APIClient.live
}
인스턴스화하고자 하는 객체가 APIClient라는 객체라면 이에 대한 DependencyKey를 정의해주어야합니다. 그리고 필수적으로 static let liveVlaue를 선언해주어야합니다.
2. 익스텐션에 프로퍼티 추가
extension DependencyValues {
var apiClient: APIClient {
get { self[APIClientKey.self] }
set { self[APIClientKey.self] = newValue }
}
}
@Dependency가 알 수 있도록 DependencyValue에 해당 객체(apiClient)에 대한 설정을 해주어야합니다.
3. @Dependency를 통해 사용
final class TodosModel: ObservableObject {
@Dependency(\.apiClient) var apiClient
// ...
}
세팅을 완료하면 이러한 형태로 사용할 수 있습니다.
Mocking
조사한 라이브러리중에 mocking 기능이 가장 강력한 것 같습니다.
우선 기본적으로 중간에 객체 내부 동작을 변경하고 싶다면 withDependencies라는 함수를 사용할 수 있습니다.
@MainActor
func testFetchUser() async {
let model = withDependencies {
$0.apiClient.fetchTodos = { _ in Todo(id: 1, title: "Get milk") }
} operation: {
TodosModel()
}
await store.loadButtonTapped()
XCTAssertEqual(
model.todos,
[Todo(id: 1, title: "Get milk")]
)
}
withDependencies 함수를 이용하면 apiClient에 대한 내부 동작을 변경해서 Mock 데이터를 사용하는 객체를 생성할 수 있습니다. 직접 mock 객체를 위한 프로토콜이나 클래스를 생성하지 않아도 된다는 점이 특이하죠?
어떻게 이런게 가능한지..
프로토콜도, Mock클래스도 없이 mock 생성이 어떻게 가능할까요? TCA 제작사(?)에서 만든 라이브러리 답게 Client, 보통 Service라고도 많이 부르는 비즈니스 로직 객체가 구조체와 일급 객체(함수)를 이용한 형태로 작성되어 있기 때문입니다!
private actor Speech {
private var audioEngine: AVAudioEngine? = nil
private var recognitionTask: SFSpeechRecognitionTask? = nil
private var recognitionContinuation:
AsyncThrowingStream<SpeechRecognitionResult, Error>.Continuation?
func startTask(
request: SFSpeechAudioBufferRecognitionRequest
) -> AsyncThrowingStream<SpeechRecognitionResult, Error> {
AsyncThrowingStream { continuation in
self.recognitionContinuation = continuation
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
} catch {
continuation.finish(throwing: error)
return
}
self.audioEngine = AVAudioEngine()
let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))!
self.recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in
switch (result, error) {
case let (.some(result), _):
continuation.yield(SpeechRecognitionResult(result))
case (_, .some):
continuation.finish(throwing: error)
case (.none, .none):
fatalError("It should not be possible to have both a nil result and nil error.")
}
}
continuation.onTermination = { [audioEngine, recognitionTask] _ in
_ = speechRecognizer
audioEngine?.stop()
audioEngine?.inputNode.removeTap(onBus: 0)
recognitionTask?.finish()
}
self.audioEngine?.inputNode.installTap(
onBus: 0,
bufferSize: 1024,
format: self.audioEngine?.inputNode.outputFormat(forBus: 0)
) { buffer, when in
request.append(buffer)
}
self.audioEngine?.prepare()
do {
try self.audioEngine?.start()
} catch {
continuation.finish(throwing: error)
return
}
}
}
}
조금 복잡하지만 예제 있는 코드를 가져와보았습니다. 보시면 actor를 이용해 선언이 되어있고 내부적으로 필요한 비즈니스 로직들이 있습니다.
struct SpeechClient {
var authorizationStatus: @Sendable () -> SFSpeechRecognizerAuthorizationStatus
var requestAuthorization: @Sendable () async -> SFSpeechRecognizerAuthorizationStatus
var startTask:
@Sendable (SFSpeechAudioBufferRecognitionRequest) async -> AsyncThrowingStream<
SpeechRecognitionResult, Error
>
}
Client의 기능들은 함수로 마치 프로토콜처럼 정의가 되어 있는데요
extension SpeechClient: DependencyKey {
static var liveValue: SpeechClient {
let speech = Speech()
return SpeechClient(
authorizationStatus: { SFSpeechRecognizer.authorizationStatus() },
requestAuthorization: {
await withUnsafeContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
},
startTask: { request in
await speech.startTask(request: request)
}
)
}
DenpendencyKey로 liveValue 선언할 때 아까 작성했던 비즈니스 로직 객체(Speech)를 이용한 로직들을 이용한 클로저를 할당해줍니다.
따라서 SpeechClient를 생성할 때 올바른 Input Output의 클로저만 전달해주면 되므로 아까 withDependencies를 이용해 간단히 행동을 변경할 수 있었던 것입니다!
live value, preview value, test value
더해서 Mocking에 관련하여 보다 더 편리한 기능을 소개해드리겠습니다.
private enum APIClientKey: DependencyKey {
static let liveValue = APIClient.live
}
이 코드가 기억나시나요? liveValue가 무엇인지 의구심이 생기셨을 수도 있는데요, 사실 여기에 2개의 프로퍼티를 더 추가할 수 있습니다.
private enum APIClientKey: DependencyKey {
static let liveValue = APIClient. // ...
static let testValue = APIClient. // ...
static let previewValue = APIClient. // ...
}
말 그대로, 테스트를 위한 값, 프리뷰를 위한 값입니다. 라이브 Value는 실제 서비스에 사용되는 값을 의미하게 되구요!
struct ApiClient {
var call: (String, [String: String]) async throws -> Void
}
import Dependencies
extension AnalyticsClient: DependencyKey {
static let testValue = Self(
call: unimplemented("ApiClient.call")
)
}
call이라는 메서드를 가진 Client가 있을 때 test value에 실제 비즈니스 로직이 아니라 임의의 값을 할당할 수 있습니다. (여기서는 구현체가 없다고 써놨지만 Mock을 대신 넣을 수 있습니다.)
func testFeature() async throws {
let model = withDependencies {
$0.apiClient = .testValue
} operation: {
FeatureModel()
}
// ...
}
그리고 사용할때 간단히 객체를 바꿔주면 훨씬 간편하게 사용할 수 있습니다.
previewValue도 마찬가지로
extension APIClient: DependencyKey {
static let previewValue = Self(
fetchUsers: {
[
User(id: 1, name: "Blob"),
User(id: 2, name: "Blob Jr."),
User(id: 3, name: "Blob Sr."),
]
},
fetchUser: { id in
User(id: id, name: "Blob, id: \(id)")
}
)
struct Feature_Previews: PreviewProvider {
static var previews: some View {
FeatureView(
model: withDependencies {
$0.apiClient = .previewValue
} operation: {
FeatureModel()
}
)
}
}
이렇게 손쉽게 사용할 수 있습니다!
단점이 있다면, dependency를 등록할 때 기존에 등록된 dependency를 참조할 수 없는 것 같습니다. (혹시 가능하다면 댓글 남겨주세요..!)
따라서 의존성 계층구조를 구성하는데는 어려움이 있을 것 같습니다.
마치며
라이브러리들을 살펴 본 후 다음과 같은 기준을 세워볼 수 있었습니다.
Factory | Swinject | Needle | swift-dependencies | |
구분 | 컨테이너형 DI | 컨테이너형, 계층형 DI | 계층형 DI | 컨테이너형 DI |
추천 프레임워크 | SwiftUI | UIKit , Storyboard | UIKit | SwiftUI |
Mocking | O | O | X | O |
Self-registration | O | O | O | X |
Scope | O | O | O(일부) | X |
간단하게 컨테이너 기반 DI를 하고싶다면 -> Factory
스토리보드 기반 프로젝트거나 자유도 높은 DI가 필요하다면 -> Swinject
VC계층 및 깊은 의존성 관리가 필요한데 Ribs는 좀 부담스럽다면 -> Needle
SwiftUI 프로젝트이고 Mocking을 쉽게 하고싶다면 -> swift-dependencies
아무래도 모든 라이브러리의 기능을 세세하게 살펴보기는 어려워서 대략적으로 한번 살펴보았습니다. 이 외에도 Pure, Cleanse 등 더 많은 라이브러리들이 있는데 이것들은 나중에 추가적으로 포스팅해보려고합니다. ㅎㅎ 그리고 소개해드린 것들도 추가적으로 제공하는 기능들도 정말 많고 다양하므로 공식문서를 참고해주시면 좋을 것 같습니다!
ref
'iOS' 카테고리의 다른 글
[iOS] iOS에서 Hot Reloading 사용하기 - InjectIII 소개 (0) | 2023.05.31 |
---|---|
[iOS] iOS에 GraphQL 적용하기 - Apollo 라이브러리 이용 (0) | 2023.05.31 |
[iOS] 긴 문자열, URL 등의 정적 리소스 관리하기! - .plist, .rtf 이용 (0) | 2023.05.23 |
[iOS] 로컬 Push Notification(푸시 알림) 시간 · 주기 설정하기 + 관리 (0) | 2023.05.17 |
[iOS] 로컬 Push Notification(푸시 알림) 구현하기 (1) | 2023.05.17 |
- Total
- Today
- Yesterday
- SWM
- notion
- swift
- GetX
- 비동기/동기
- Swift Concurrency
- design pattern
- Flux
- MVVM
- programmers
- SwiftUI
- 리액티브 프로그래밍
- TestCode
- 아키텍쳐 패턴
- Architecture Pattern
- RX
- DocC
- coordinator pattern
- MVC
- 노션
- 소프트웨어마에스트로
- MVI
- ios
- combine
- reactive programming
- healthkit
- 코디네이터 패턴
- 프로그래머스
- Bloking/Non-bloking
- Flutter
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |