Skip to content

yijiye/ios-wanted-VideoRecorder

ย 
ย 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

38 Commits
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

README

VideoRecoder

๋น„๋””์˜ค ๋…นํ™”, ์ €์žฅ ๋ฐ ์žฌ์ƒํ•˜๋Š” ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜

ํ”„๋กœ์ ํŠธ ๊ธฐ๊ฐ„: 2023.06.05 - 2023.06.11

๊ฐœ๋ฐœ์ž

๋ฆฌ์ง€
Github Profile

๋ชฉ์ฐจ

  1. ์‹คํ–‰ ํ™”๋ฉด
  2. ์•ฑ ๊ธฐ๋Šฅ
  3. ์ ์šฉ ๊ธฐ์ˆ 
  4. ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ
  5. ํ•ต์‹ฌ๊ฒฝํ—˜
  6. ์ฐธ๊ณ  ๋งํฌ

์‹คํ–‰ ํ™”๋ฉด

Untitled-4.mp4

์•ฑ ๊ธฐ๋Šฅ

  • ์นด๋ฉ”๋ผ๊ตฌํ˜„์œผ๋กœ ์˜์ƒ ๋…นํ™”, ํ™”๋ฉด ์ „ํ™˜ ๊ฐ€๋Šฅ
  • ์˜์ƒ ๋…นํ™”์‹œ๊ฐ„ ๊ธฐ๋ก
  • ์˜์ƒ ์ œ๋ชฉ ์ €์žฅ
  • ์ธ๋„ค์ผ ์ถ”์ถœ, ์˜์ƒ๊ธธ์ด ํ‘œ์‹œ
  • ๋…นํ™”ํ•œ ์˜์ƒ ํด๋ฆญ์‹œ ์žฌ์ƒํ™”๋ฉด์œผ๋กœ ์ด๋™ ๋ฐ ์žฌ์ƒ
  • ๋…นํ™”ํ•œ ์˜์ƒ LocalDB ๋ฐ RemoteDB์— ์ €์žฅ/๋ฐฑ์—…

๋ฉ”์ธ ํ™”๋ฉด ์‚ญ์ œ ๋…นํ™” ํ™”๋ฉด ์˜์ƒ์ œ๋ชฉ ์ €์žฅ ํ”Œ๋ ˆ์ด
LocalDB RemoteDB

์ ์šฉ ๊ธฐ์ˆ 

UI Local DB Remote DB Reactive Architecture Dependency
UIKit CoreData FirebaseStorage Combine MVVM SPM

์„ธ๋ถ€ ๋‚ด์šฉ

ํ™”๋ฉด๊ตฌํ˜„

  • UIKit์„ ์‚ฌ์šฉํ•˜์—ฌ ์ฝ”๋“œ๋ฒ ์ด์Šค๋กœ UI๋ฅผ ๊ตฌ์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค.
  • ์ด 3๊ฐœ์˜ ํ™”๋ฉด์œผ๋กœ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.
    • VideoList ํ™”๋ฉด
    • ์˜์ƒ ๋…นํ™” ํ™”๋ฉด
    • ๋…นํ™”๋œ ์˜์ƒ ํ”Œ๋ ˆ์ด๋˜๋Š” ํ™”๋ฉด
  • VideoList ํ™”๋ฉด์€ UICollectionView๋ฅผ ํ™œ์šฉํ•˜์˜€๊ณ , UICollectionCompositionalLayout์˜ List ๋ชจ๋“œ๋ฅผ ์ ์šฉํ•˜์˜€์Šต๋‹ˆ๋‹ค.
  • ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ์€ DiffableDataSource, NSDiffableDataSourceSnapshot๋ฅผ ์‚ฌ์šฉํ•˜์˜€์Šต๋‹ˆ๋‹ค.
  • ์˜์ƒ ๋…นํ™” ํ™”๋ฉด์€ AVFoundation ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ง์ ‘ ์ปค์Šคํ…€ํ•˜์˜€์œผ๋ฉฐ ๋‹ค๋ฅธ UI์š”์†Œ๋„ ์ปค์Šคํ…€ํ•˜์—ฌ ํ™”๋ฉด์— ๋„์› ์Šต๋‹ˆ๋‹ค.
  • ๋…นํ™”๋œ ์˜์ƒ์€AVPlayerViewController๋ฅผ ์‚ฌ์šฉํ•˜์˜€์Šต๋‹ˆ๋‹ค.

DataBase

  • ๋…นํ™”๊ฐ€ ์ข…๋ฃŒ๋œ ์ง€์ ์—์„œ LocalDB์™€ RemoteDB์— ์ €์žฅ๋˜๋„๋ก ๊ตฌํ˜„ํ•˜์˜€์Šต๋‹ˆ๋‹ค.
  • LocalDB๋Š” Apple์—์„œ ์ œ๊ณตํ•˜๋Š” CoreData ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜์˜€์Šต๋‹ˆ๋‹ค.
  • RemoteDB๋Š” FirebaseStorage๋ฅผ ์‚ฌ์šฉํ•˜์˜€์Šต๋‹ˆ๋‹ค.
  • ์ €์žฅ๋œ ๋ฐ์ดํ„ฐ๋Š” ์•ฑ์—์„œ ์ €์žฅ, ์‚ญ์ œ์‹œ ๋™๊ธฐํ™”๋˜๋„๋ก ๊ตฌํ˜„ํ•˜์˜€์Šต๋‹ˆ๋‹ค.
  • ์˜์กด์„ฑ ๊ด€๋ฆฌ๋„๊ตฌ๋กœ Swift Package Manager๋ฅผ ์‚ฌ์šฉํ•˜์˜€์Šต๋‹ˆ๋‹ค.

Reactive, Architecture

  • ViewController์˜ ์—ญํ• ์„ ๋ถ„๋ฆฌํ•˜๊ณ ์ž MVVM ํŒจํ„ด์„ ์‚ฌ์šฉํ•˜์˜€์Šต๋‹ˆ๋‹ค.
  • View - ViewModel๊ฐ„ ๋ฐ”์ธ๋”ฉ์‹œ Apple์—์„œ ์ œ๊ณตํ•˜๋Š” Combine ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜์˜€์Šต๋‹ˆ๋‹ค.

ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ


ํ•ต์‹ฌ๊ฒฝํ—˜

โœ… AVFoundation

AVFoundation

Framework Video Record ํ™”๋ฉด ๊ตฌํ˜„ํ•˜๊ธฐ

Overview

AVFoundation์€ Apple ํ”Œ๋žซํผ์—์„œ ์‹œ์ฒญ๊ฐ ๋ฏธ๋””์–ด๋ฅผ ๊ฒ€์‚ฌ, ์žฌ์ƒ, ์บก์ฒ˜ ๋ฐ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ ๊ด‘๋ฒ”์œ„ํ•œ ์ž‘์—…์„ ํฌํ•จํ•˜๋Š” ๋ช‡ ๊ฐ€์ง€ ์ฃผ์š” ๊ธฐ์ˆ  ์˜์—ญ์„ ๊ฒฐํ•ฉํ•œ๋‹ค.

์ฆ‰, Apple ํ”Œ๋žซํผ์— ์‹œ์ฒญ๊ฐ ๊ด€๋ จํ•œ ํ•˜๋“œ์›จ์–ด๋ฅผ ์ปจํŠธ๋กคํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” ํ”„๋ ˆ์ž„์›Œํฌ์ด๋‹ค.

  • STEP
    • CaptureSession ์ƒ์„ฑ
    • CaptureDevice ์ƒ์„ฑ
    • CaptureDeivceInput ์ƒ์„ฑ
    • Video UI์— ์ถœ๋ ฅ
    • Recording

Capture setup

API Collection media capture๋ฅผ ์œ„ํ•ด ๋‚ด์žฅ ์นด๋ฉ”๋ผ๋‚˜ ๋งˆ์ดํฌ ๊ทธ๋ฆฌ๊ณ  ์™ธ๋ถ€ ๋””๋ฐ”์ด์Šค๋ฅผ ๊ตฌ์„ฑํ•ด์•ผํ•œ๋‹ค.

  • ์‚ฌ์šฉ์ž ์ง€์ • ์นด๋ฉ”๋ผ UI ๊ตฌํ˜„
  • ์‚ฌ์šฉ์ž๊ฐ€ ์ดˆ์ , ๋…ธ์ถœ ๋ฐ ์•ˆ์ •ํ™” ์˜ต์…˜๊ณผ ๊ฐ™์€ ์‚ฌ์ง„ ๋ฐ ๋น„๋””์˜ค ์บก์ฒ˜๋ฅผ ์ง์ ‘ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ตฌํ˜„
  • RAW ํ˜•์‹ ์‚ฌ์ง„, ๊นŠ์ด ์ง€๋„ ๋˜๋Š” ์‚ฌ์šฉ์ž ์ง€์ • ์‹œ๊ฐ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ๋Š” ๋น„๋””์˜ค์™€ ๊ฐ™์€ ์‹œ์Šคํ…œ ์นด๋ฉ”๋ผ UI์™€ ๋‹ค๋ฅธ ๊ฒฐ๊ณผ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
  • ์บก์ฒ˜ ์žฅ์น˜์—์„œ ์ง์ ‘ ํ”ฝ์…€ ๋˜๋Š” ์˜ค๋””์˜ค ๋ฐ์ดํ„ฐ ์ŠคํŠธ๋ฆฌ๋ฐ์— ์‹ค์‹œ๊ฐ„์œผ๋กœ ์•ก์„ธ์Šคํ•  ์ˆ˜ ์žˆ๋‹ค.

์บก์ณ ์•„ํ‚คํ…์ณ์˜ ๋ฉ”์ธํŒŒํŠธ๋Š” sessions, inputs, output 3๊ฐ€์ง€ ์ด๋‹ค.

  • CaptureSession : ํ•˜๋‚˜ ์ด์ƒ์˜ input๊ณผ output์„ ์—ฐ๊ฒฐํ•œ๋‹ค.
  • Inputs : iOS๋‚˜ Mac์— ๋นŒํŠธ์ธ๋œ ์นด๋ฉ”๋ผ๋‚˜ ๋งˆ์ดํฌ์™€ ๊ฐ™์€ ๋””๋ฐ”์ด์Šค๋ฅผ ํฌํ•จํ•œ media์˜ ์†Œ์Šค๋ฅผ ๋œปํ•œ๋‹ค. ๋””๋ฐ”์ด์Šค๋กœ ์ฐ์€ ์‚ฌ์ง„์ด๋‚˜ ๋™์˜์ƒ์„ ๋งํ•œ๋‹ค.
  • Outputs : ์‚ฌ์šฉ๊ฐ€๋Šฅํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋งŒ๋“ค์–ด ๋‚ธ ๊ฒฐ๊ณผ๋ฌผ
  • CatureDevice : ๋””๋ฐ”์ด์Šค, ๋‚ด ์•„์ดํฐ ์นด๋ฉ”๋ผ

CaptureSession

Input๊ณผ Output์„ ์—ฐ๊ฒฐํ•ด์ฃผ์–ด ๋ฐ์ดํ„ฐ ํ๋ฆ„์„ ์ œ์–ดํ•œ๋‹ค.

let captureSession = AVCaptureSession()
captureSession.sessionPreset = .high

...
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    DispatchQueue.global(qos: .background).async { [weak self] in
        self?.captureSession.startRunning()
    }
}
    
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    DispatchQueue.global(qos: .background).async { [weak self] in
        self?.captureSession.stopRunning()
    }
}
  • sessionPreset : ๋…นํ™” ํ’ˆ์งˆ์„ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. ๋†’๊ฒŒํ• ์ˆ˜๋ก ๋ฐฐํ„ฐ๋ฆฌ ์†Œ๋น„๋Ÿ‰์ด ๋Š˜์–ด๋‚œ๋‹ค.
  • startRunning() : ์‹ค์งˆ์ ์€ ํ”Œ๋กœ์šฐ๊ฐ€ ์‹œ์ž‘๋œ๋‹ค. ์ด๋Š” UI๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฉ”์ธ์Šค๋ ˆ๋“œ์™€ ๋‹ค๋ฅธ ์Šค๋ ˆ๋“œ์—์„œ ์ฒ˜๋ฆฌํ•ด์ค˜์•ผํ•œ๋‹ค.
  • stopRunning() : ์„ธ์…˜์˜ ์ผ์ด ๋๋‚ฌ์„ ๋•Œ ํ˜ธ์ถœํ•œ๋‹ค.

๋‚˜๋Š” view๊ฐ€ ๋‚˜ํƒ€๋‚ ๋•Œ ์‹œ์ž‘์„ํ•ด์ฃผ๊ณ  view๊ฐ€ ์‚ฌ๋ผ์งˆ๋•Œ ์ข…๋ฃŒ๋ฅผ ์„ค์ •ํ•ด์ฃผ์—ˆ๋‹ค. ๋‘˜๋‹ค ๋ฉ”์ธ์Šค๋ ˆ๋“œ๊ฐ€ ์•„๋‹Œ global()์•ˆ์— ๋„ฃ์–ด์ฃผ์—ˆ๋‹ค.

CaptureDevice

์‚ฌ์šฉํ•˜๋ ค๋Š” ์žฅ์น˜๋ฅผ ์ •์˜ํ•ด์ค€๋‹ค.

// audioDevice
let audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)

// cameraDevice
private func selectedCamera(in position: AVCaptureDevice.Position) -> AVCaptureDevice? {
    let deviceTypes: [AVCaptureDevice.DeviceType] = [.builtInTrueDepthCamera, .builtInDualCamera, .builtInWideAngleCamera]
        
    let discoverySession = AVCaptureDevice.DiscoverySession(
        deviceTypes: deviceTypes,
        mediaType: .video,
        position: .unspecified
    )
        
    let devices = discoverySession.devices
    guard !devices.isEmpty,
          let device = devices.first(where: { device in device.position == position }) else { return nil }
        
    return device
}

CaptureDeviceInput

captureDevice๋ฅผ ์ด์šฉํ•ด์„œ session์— captureDeviceInput์„ ์ถ”๊ฐ€ํ•ด์ค€๋‹ค.

private func setUpSession() {
    guard let audioDevice = AVCaptureDevice.default(for: AVMediaType.audio) else { return }

    captureSession.sessionPreset = .high
        
    do {
        // 1
        captureSession.beginConfiguration()
        
        // 2
        videoDevice = selectedCamera(in: .back)
        guard let videoDevice else { return }
        videoInput = try AVCaptureDeviceInput(device: videoDevice)
        guard let videoInput else { return }
            
        if captureSession.canAddInput(videoInput) {
            captureSession.addInput(videoInput)
        }
            
        // 3
        let audioInput = try AVCaptureDeviceInput(device: audioDevice)
        if captureSession.canAddInput(audioInput)  {
            captureSession.addInput(audioInput)
        }
        
        // 4
        if captureSession.canAddOutput(videoOutput) {
            captureSession.addOutput(videoOutput)
        }

        // 5
        captureSession.commitConfiguration()
        self.view.layer.addSublayer(videoPreViewLayer)
        
        // 6
        setUpCloseButton()
        setUpRecordStackView()
        
        videoPreViewLayer.session = captureSession
            
    } catch let error as NSError {
        print(error.localizedDescription)
    }
}
  1. ์„ธ์…˜ ๊ตฌ์„ฑ์˜ ์‹œ์ž‘์„ ๋‚˜ํƒ€๋‚ธ๋‹ค.
  2. ๋น„๋””์˜ค ๋””๋ฐ”์ด์Šค์— ๋Œ€ํ•œ ์ž…๋ ฅ์„ ๋งŒ๋“ค์–ด ์„ธ์…˜์— ์ถ”๊ฐ€ํ•œ๋‹ค.
  3. ์˜ค๋””์˜ค ๋””๋ฐ”์ด์Šค์— ๋Œ€ํ•œ ์ž…๋ ฅ์„ ๋งŒ๋“ค์–ด ์„ธ์…˜์— ์ถ”๊ฐ€ํ•œ๋‹ค.
  4. ๋น„๋””์˜ค, ์˜ค๋””์˜ค๋ฅผ ํŒŒ์ผ๋กœ ์ถœ๋ ฅํ•˜๊ธฐ ์œ„ํ•œ output์„ ๋งŒ๋“ค์–ด ์„ธ์…˜์— ์ถ”๊ฐ€ํ•œ๋‹ค.
  5. ์„ธ์…˜ ๊ตฌ์„ฑ์˜ ์™„๋ฃŒ๋ฅผ ๋‚˜ํƒ€๋‚ธ๋‹ค.
  6. videoPreViewLayer ์œ„์— ์ถ”๊ฐ€ํ•˜๋Š” UI์š”์†Œ๋ฅผ ๋„ฃ์–ด์ค€๋‹ค.

Video UI

ํ™”๋ฉด์— ๋น„๋””์˜ค๋‚˜ ์‚ฌ์ง„ ์ดฌ์˜์‹œ ๋ณด์—ฌ์ง€๋Š” UI๋ฅผ ๊ตฌํ˜„ํ•œ๋‹ค.

 private lazy var videoPreViewLayer: AVCaptureVideoPreviewLayer = {
    let previewLayer = AVCaptureVideoPreviewLayer()
    previewLayer.frame = self.view.frame
    previewLayer.videoGravity = .resizeAspectFill
        
    return previewLayer
}()
  • frame ์€ view์˜ frame์— ๋งž์ถ”๊ณ  videoGravity์— ์›ํ•˜๋Š” ๊ฐ’์„ ๋„ฃ์–ด์ฃผ์—ˆ๋‹ค.
  • session์„ ๊ตฌ์„ฑํ• ๋•Œ view์˜ layer์— ์ถ”๊ฐ€ํ•ด์ฃผ๊ณ  ๊ทธ ์•„๋ž˜ ๋‹ค๋ฅธ ์š”์†Œ๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ์ •์ƒ์ ์œผ๋กœ ํ™”๋ฉด์— ๋‚˜ํƒ€๋‚ฌ๋‹ค.
  • ์šฐ์„  Layer๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ  ๋‹ค๋ฅธ view๋‚˜ button๋“ฑ UI๋Š” ๊ทธ ๋‹ค์Œ์— ์ถ”๊ฐ€ํ•ด์•ผ ํ™”๋ฉด์— ๋œธ!
  • ๋˜ํ•œ ์นด๋ฉ”๋ผ ์‚ฌ์šฉ์ „ ๊ธฐ๋ณธ info.plist ์„ค์ •๋„ ํ•ด์•ผํ•œ๋‹ค.

Recording

์ด์ œ view์— ๋‚˜ํƒ€๋Š” ํ™”๋ฉด์„ ๋…นํ™”ํ•ด์•ผํ•˜๋Š”๋ฐ ์ด๋Š” AVCaptureMovieFileOutput์„ ์ด์šฉํ•˜๋ฉด ๋œ๋‹ค.

let videoOutput = AVCaptureMovieFileOutput()

// ๋…นํ™”์‹œ์ž‘
private func startRecording() {
    startTimer()
    outputURL = viewModel.createVideoURL()
    guard let outputURL else { return }
    videoOutput.startRecording(to: outputURL, recordingDelegate: self)
}

// ๋…นํ™”์ข…๋ฃŒ
private func stopRecording() {
    if videoOutput.isRecording == true {
        stopTimer()
        videoOutput.stopRecording()
    }
}

๋…นํ™”๊ฐ€ ์ข…๋ฃŒ๋œ ํ›„์˜ ์ž‘์—…์€ AVCaptureFileOutputRecordingDelegate๋ฅผ ์ค€์ˆ˜ํ•˜์—ฌ ์›ํ•˜๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

extension RecordVideoViewController: AVCaptureFileOutputRecordingDelegate {
    func fileOutput(_ output: AVCaptureFileOutput, didStartRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) {
        recordStackView.changeCameraModeButton.isEnabled = false
     }
    
    func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
        recordStackView.changeCameraModeButton.isEnabled = true
    if (error != nil) {
         print(error?.localizedDescription as Any)
    } else {
        guard let videoRecordedURL = outputURL,
              let videoData = try? Data(contentsOf: videoRecordedURL) else { return }
            
        let title = "์˜์ƒ์˜ ์ œ๋ชฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”."
        let save = "์ €์žฅ"
        let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
        let saveAction = UIAlertAction(title: save, style: .default) { [weak self] _ in
            guard let videoTitle = alert.textFields?[0].text else { return }
            self?.fetchThumbnail(from: videoRecordedURL, videoData: videoData, title: videoTitle)
        }
            
        alert.addTextField()
        alert.addAction(saveAction)
        self.present(alert, animated: true)
    }
}
  • ์ฒซ ๋ฒˆ์งธ ๋ฉ”์„œ๋“œ๋Š” ๋…นํ™”๊ฐ€ ์‹œ์ž‘๋˜๋ฉด ํ˜ธ์ถœ๋˜๋Š” ๋ฉ”์„œ๋“œ๋กœ ๋‚˜๋Š” ๋…นํ™”๊ฐ€ ์‹œ์ž‘๋˜์—ˆ์„ ๋•Œ, ์นด๋ฉ”๋ผ ๋ชจ๋“œ๋ฅผ ๋ฐ”๊พธ๋Š” ๋ฒ„ํŠผ์ด ์ž‘๋™ํ•˜์ง€ ์•Š๋„๋ก ๊ตฌํ˜„ํ–ˆ๋‹ค.
  • ๋‘ ๋ฒˆ์งธ ๋ฉ”์„œ๋“œ๋Š” ๋…นํ™”๊ฐ€ ์ข…๋ฃŒ๋˜๋ฉด ํ˜ธ์ถœ๋˜๋Š” ๋ฉ”์„œ๋“œ๋กœ ์ข…๋ฃŒ๊ฐ€ ๋˜๋ฉด ์ œ๋ชฉ์„ ์ž…๋ ฅํ•˜๋Š” Alert์ฐฝ์ด ๋œจ๊ณ  ์ž…๋ ฅ๋œ ์ œ๋ชฉ๊ณผ ๋‚˜๋จธ์ง€ ๋ฐ์ดํ„ฐ๋ฅผ ์ด์šฉํ•ด ๋กœ์ปฌ DB์— ์ €์žฅํ•˜๋„๋ก ๊ตฌํ˜„ํ–ˆ๋‹ค.
โœ… AVAssetImageGenerator

AVAssetImageGenerator๋กœ Thumbnail ๋งŒ๋“ค๊ธฐ

class video asset์—์„œ image๋ฅผ ๋งŒ๋“ค์–ด๋‚ด๋Š” ๊ฐ์ฒด

.mp4 ํฌ๋งท ์˜์ƒ์˜ ์ธ๋„ค์ผ์„ ๊ตฌํ•˜๊ธฐ ์œ„ํ•ด AVAssetImageGenerator๋ฅผ ์‚ฌ์šฉํ•˜์˜€๋‹ค. ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•ด Asset์ด ํ•„์š”ํ•˜๊ณ  Asset์„ ๊ตฌํ•˜๊ธฐ ์œ„ํ•ด URL์ด ํ•„์š”ํ•˜๋‹ค. ๋‚˜๋Š” ๋…นํ™”๊ฐ€ ์™„๋ฃŒ๋œ ๋ฐ์ดํ„ฐ(์˜์ƒ)์„ CoreData์— ์ €์žฅํ•˜์˜€๋Š”๋ฐ ์ด๋•Œ outPutURL(์ž„์‹œ URL)์— ๋‹ด์€ ํ›„ Data๋กœ ๋ฝ‘์•„์„œ Dataํƒ€์ž…์œผ๋กœ ์ €์žฅ์„ ํ•˜์˜€๋‹ค. ๊ทธ๋ž˜์„œ CoreData์— ์ €์žฅํ•œ model ํƒ€์ž…์„ ๋งŒ๋“ค ๋•Œ URL์ด ์ด์šฉ๋˜๊ณ  ์ด๋•Œ ์ธ๋„ค์ผ์„ ๋ฝ‘์•„์„œ ๊ฐ™์ด CoreData์— ๋„ฃ๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ ๊ตฌํ˜„ํ–ˆ๋‹ค.

URL์„ ๋ฐ›์•„์™€์„œ Asset์„ ๋งŒ๋“ค๊ณ  AVAssetImageGenerator๋ฅผ ์ด์šฉํ•˜์—ฌ ์ด๋ฏธ์ง€๋ฅผ ๋ฝ‘์•„๋‚ด๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ๊ตฌํ˜„

 private func generateThumbnail(from url: URL) -> Future<UIImage?, RecordingError> {
    return Future<UIImage?, RecordingError> { promise in
        DispatchQueue.global().async {
            let asset = AVAsset(url: url)
            let imageGenerator = AVAssetImageGenerator(asset: asset)
            imageGenerator.appliesPreferredTrackTransform = true

            let time = CMTime(seconds: 1, preferredTimescale: 1)

            guard let cgImage = try? imageGenerator.copyCGImage(at: time, actualTime: nil) else {
                promise(.failure(.thumbnail))
                return
            }
            let thumbnailImage = UIImage(cgImage: cgImage)
            promise(.success(thumbnailImage))
        }
    }
}
  • ๋‚˜๋Š” Combine์„ ์‚ฌ์šฉํ•˜์—ฌ Future๋ฅผ ์ด์šฉํ–ˆ๋‹ค. (์ด๋ ‡๊ฒŒ ์“ฐ๋Š”๊ฒŒ ๋งž๋‚˜..?)
  • UI๋ฅผ ๋ฐฉํ•ดํ•˜๋ฉด ์•ˆ๋˜๊ธฐ ๋•Œ๋ฌธ์— DispatchQueue.global().async ๋ธ”๋ก ์•ˆ์— ๋„ฃ์–ด์ฃผ์—ˆ๋‹ค.
  • CMTime : 1์ดˆ๋กœ ์ •์˜ ํ–ˆ๋‹ค.
  • copyCGImage(at, actualTime:) : cgImage๋ฅผ ๊ตฌํ•œ๋‹ค. ์ด ํ”„๋กœํผํ‹ฐ๋Š” iOS16๋ถ€ํ„ฐ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์ง€๋งŒ ์ด ์•ฑ์˜ ํƒ€๊ฒŸ์€ ์ตœ์†Œ iOS14๋ฒ„์ „์ด๊ธฐ ๋•Œ๋ฌธ์— ์‚ฌ์šฉํ•˜์˜€๋‹ค.
  • ๋งˆ์ง€๋ง‰์— UIImage๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ success์— ๋„ฃ์–ด์ฃผ์—ˆ๋‹ค.

Future๋ฅผ ๊ตฌ๋…ํ•˜์—ฌ ์„ฑ๊ณต์‹œ image๋ฅผ ๋ฐ›์•„ CoreData์— ์ €์žฅ

private func fetchThumbnail(from videoRecordedURL: URL, videoData: Data, title: String) {
    generateThumbnail(from: videoRecordedURL)
        .receive(on: DispatchQueue.main)
        .sink { completion in
            switch completion {
            case .finished:
                break
            case .failure(let error):
                print(error.localizedDescription)
            }
        } receiveValue: { [weak self] image in
            if let image {
                guard let imageData = image.pngData(),
                      let playTime = self?.fetchPlayTime(videoRecordedURL.absoluteString) else { return }
                let video = Video(title: "\(title).mp4", date: Date(), savedVideo: videoData, thumbnailImage: imageData, playTime: playTime)
                self?.viewModel.create(video)
            }
        }
        .store(in: &cancellables)
}

์ œ๋ชฉ ๊ทธ๋Œ€๋กœ Futureํƒ€์ž…์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ๊ตฌ๋…ํ•˜์—ฌ ์‹คํŒจ์™€ ์„ฑ๊ณต ์ผ€์ด์Šค๋กœ ๋‚˜๋ˆ  ์„ฑ๊ณตํ–ˆ์„ ๋•Œ ์ด๋ฏธ์ง€๋ฅผ ๋ฐ›์•„์™€์„œ CoreData์— ๋„ฃ์„ model ํƒ€์ž…์— ์ด๋ฏธ์ง€๋ฅผ ๊ฐ™์ด ์ €์žฅํ•ด์ฃผ์—ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  viewModel์˜ create๋ฅผ ์‹คํ–‰ํ•ด ์‹ค์ œ ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ์ปฌ DB์— ์ €์žฅํ–ˆ๋‹ค.

๋…นํ™”๊ฐ€ ๋๋‚˜๊ณ  ์‹คํ–‰๋˜๋„๋ก ๊ตฌํ˜„

AVFoundation์—๋Š” AVCaptureFileOutputRecordingDelegate ๊ฐ€ ์žˆ๊ณ  ์—ฌ๊ธฐ์— ๋…นํ™”๊ฐ€ ์ข…๋ฃŒ๋˜๋ฉด ์‹คํ–‰๋˜๋Š” ๋ฉ”์„œ๋“œ๊ฐ€ ์žˆ๋‹ค. ๋‚˜๋Š” ๋…นํ™”๊ฐ€ ์ข…๋ฃŒ๋˜๋ฉด ์ธ๋„ค์ผ์„ ๊ฐ€์ ธ์˜ค๊ณ  ๊ทธ๊ฑธ ๋กœ์ปฌDB์— ์ €์žฅํ•˜๋„๋ก ํ–ˆ๋‹ค.

func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
        recordStackView.changeCameraModeButton.isEnabled = true
    if (error != nil) {
        print(error?.localizedDescription as Any)
    } else {
        guard let videoRecordedURL = outputURL,
              let videoData = try? Data(contentsOf: videoRecordedURL) else { return }
            
        let title = "์˜์ƒ์˜ ์ œ๋ชฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”."
        let save = "์ €์žฅ"
        let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
        let saveAction = UIAlertAction(title: save, style: .default) { [weak self] _ in
            guard let videoTitle = alert.textFields?[0].text else { return }
            self?.fetchThumbnail(from: videoRecordedURL, videoData: videoData, title: videoTitle)
            }
            
        alert.addTextField()
        alert.addAction(saveAction)
        self.present(alert, animated: true)
    }
}
  • ์ถ”๊ฐ€์ ์œผ๋กœ ๋น„๋””์˜ค์˜ ์ด๋ฆ„์€ ์ €์žฅ์ด ๋๋‚˜๋ฉด Alert์ฐฝ์ด ๋œจ๊ณ  ๊ฑฐ๊ธฐ TextField์— ์ž…๋ ฅํ•˜์—ฌ ์–ป์€ text๋ฅผ ์‚ฌ์šฉํ–ˆ๋‹ค. Alert์— textField๊ฐ€ ํฌํ•จ๋˜์–ด์žˆ์–ด ์‰ฝ๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.
โœ… AVPlayerViewController

AVPlayerViewController

Class playback ์ปจํŠธ๋กค์„ ์œ„ํ•œ ์œ ์ € ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ  ํ”Œ๋ ˆ์ด์–ด๋กœ ๋ถ€ํ„ฐ ์ปจํ…์ธ ๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” viewController

Overview

AVKitํ”„๋ ˆ์ž„์›Œํฌ๋Š” AVPlayerViewController subclassing์„ ์ง€์›ํ•˜์ง€ ์•Š๋Š”๋‹ค.

  • Airplay ์ง€์›
  • Picture in Picture(PiP) ์ง€์› PiP ์žฌ์ƒ์„ ํ†ตํ•ด ์‚ฌ์šฉ์ž๋Š” ๋น„๋””์˜ค ํ”Œ๋ ˆ์ด์–ด๋ฅผ ์ž‘์€ ํ”Œ๋กœํŒ… ์ฐฝ์œผ๋กœ ์ตœ์†Œํ™”ํ•˜์—ฌ ๊ธฐ๋ณธ ์•ฑ์ด๋‚˜ ๋‹ค๋ฅธ ์•ฑ์—์„œ ๋‹ค๋ฅธ ํ™œ๋™์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ๋‹ค.
  • tvOS Playback ๊ฒฝํ—˜ ์ปค์Šคํ…€ ์ง€์›

์ง์ ‘ ๊ตฌํ˜„ํ•˜๊ธฐ

video๋ฅผ ํ”Œ๋ ˆ์ดํ•˜๋ ค๋ฉด videoURL์ด ํ•„์š”ํ•˜๋‹ค. ์ด URL(filepath)์„ AVPlayer(url:)์— ๋„ฃ์–ด์ฃผ์–ด ํ”Œ๋ ˆ์ดํ•˜๋„๋ก ํ•ด์•ผํ•˜๋Š”๋ฐ ์—ฌ๊ธฐ์„œ ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ…์ด ์žˆ์—ˆ๋‹ค.

  • ๐Ÿ” ์ €์žฅํ•˜๋Š” Model ํƒ€์ž…์— URL์„ ํ†ตํ•ด ์ธ๋„ค์ผ์„ ์ €์žฅํ•˜๋‹ˆ URL์„ ๊ฐ™์ด ์ €์žฅํ•ด์„œ ์ด URL์„ ์‚ฌ์šฉํ•ด๋ณด์ž

์ฒ˜์Œ ์ด๋ ‡๊ฒŒ ์ ‘๊ทผํ–ˆ๋Š”๋ฐ, ์•ฑ์„ ์ข…๋ฃŒํ•˜๊ณ  ๋‹ค์‹œ ์‹คํ–‰ํ•˜๋‹ˆ ๋™์˜์ƒ ์žฌ์ƒ์ด ์ œ๋Œ€๋กœ ๋˜์ง€ ์•Š์•˜๋‹ค. ์ด์œ ๋ฅผ ์ƒ๊ฐํ•ด๋ณด๋‹ˆ ์ž„์‹œ๋กœ URL์„ ๋งŒ๋“ค์–ด์„œ ์ด URL์ด ๋ณ€๊ฒฝ๋˜์—ˆ๋‚˜? ์ •ํ™•ํ•˜์ง€ ์•Š์€๊ฐ€ ์˜์‹ฌํ–ˆ๋‹ค.

func createVideoURL() -> URL? {
    let directory = NSTemporaryDirectory() as NSString
        
    if directory != "" {
        let path = directory.appendingPathComponent(NSUUID().uuidString + ".mp4")
        return URL(fileURLWithPath: path)
    }
        
    return nil
}

์ด๋ ‡๊ฒŒ ์ž„์‹œDirectory๋ฅผ ๋งŒ๋“ค๊ณ  Filepath๋ฅผ ์ €์žฅํ•œ ๊ฒƒ์ด ๋ฌธ์ œ์ธ๊ฐ€ ์‹ถ์–ด์„œ FileManager๋ฅผ ์ด์šฉํ•ด์„œ ์ €์žฅํ•˜๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ ๋ณ€๊ฒฝํ–ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ์ด๋งˆ์ €๋„ ํ•ด๊ฒฐ๋˜์ง€ ์•Š์•˜๋‹ค. URL์€ ์•ฑ์„ ๋‹ค์‹œ ์ผฐ์„ ๋•Œ์™€ ์ฒ˜์Œ๊ณผ ๋™์ผํ–ˆ๋‹ค. ๊ฒฐ๊ตญ ๋ฌธ์ œ๋Š” URL์€ ๋งž์ง€๋งŒ ๊ทธ ์•ˆ์— ํŒŒ์ผ์ด ์—†๋‹ค๋Š”๊ฒŒ ๋ฌธ์ œ์˜€๋‹ค. ์ƒ๊ฐํ•ด๋ณด๋‹ˆ CoreData์— ์˜์ƒ์„ ์ €์žฅํ–ˆ๋Š”๋ฐ filePath๋Š” CoreData ์œ„์น˜์™€ ๋งž์ง€ ์•Š์•˜๋‹ค. ๊ทธ๋ƒฅ filePath๋งŒ ์ผ์น˜ํ–ˆ์„ ๋ฟ...๐Ÿฅฒ

ํ•ด๊ฒฐ๋ฐฉ๋ฒ•

์ €์žฅ๋œ ๋ฐ์ดํ„ฐ์˜ url์„ ์ฐพ์•„์•ผํ–ˆ๋Š”๋ฐ CoreData์ €์žฅ ์œ„์น˜๋ฅผ ์ผ์ผํžˆ ์•Œ์•„๋‚ด๋Š” ๊ฒƒ์€ ํž˜๋“ค์—ˆ๋‹ค. ์ €์žฅ๋˜๊ณ ๋‚˜์„œ ์•„๋Š”๊ฑด ๋˜์ง€๋งŒ ๊ณ„์†ํ•ด์„œ ์ถ”์ ํ•˜๊ธฐ๋Š” ๋ถˆ๊ฐ€๋Šฅํ–ˆ๋‹ค. ๋”ฐ๋ผ์„œ Dataํƒ€์ž…์„ url๋กœ ๋ณ€๊ฒฝํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๊ณ„์†ํ•ด์„œ ์ฐพ์•„๋ณด์•˜๊ณ  write(to:options) ๋ฉ”์„œ๋“œ๋ฅผ ๋ฐœ๊ฒฌํ–ˆ๋‹ค. ์ด๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ด์•„์ค„ url์„ ๋ณ€์ˆ˜๊ณ  ๊ฐ€์ง€๊ณ  ์žˆ๋‹ค.

  • ์ƒˆ๋กœ์šด ์ž„์‹œ url์„ ๋งŒ๋“ ๋‹ค.
  • ํ™”๋ฉด์— ๋„์šธ data๋ฅผ url์— ์ €์žฅํ•œ๋‹ค.
  • ๊ทธ url์„ player์— ๋„ฃ์–ด์ฃผ์–ด ํ™”๋ฉด์— ๋„์šด๋‹ค.

์ด๋ ‡๊ฒŒ ํ•˜๋‹ˆ๊นŒ ์•ฑ์„ ์ข…๋ฃŒํ•˜๊ณ  ๋‹ค์‹œ์ผœ๋„ ์ •์ƒ ์ž‘๋™ํ•˜๋Š” ๊ฒƒ์„ ํ™•์ธํ–ˆ๋‹ค.

import AVKit
...

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    guard let videoEntity = viewModel.read(at: indexPath),
          let video = videoEntity.savedVideo,
          let videoURL = viewModel.createVideoURL() else { return }
        
    do {
        try video.write(to: videoURL)
        let playerController = AVPlayerViewController()
        let player = AVPlayer(url: videoURL)
        playerController.player = player
        playerController.entersFullScreenWhenPlaybackBegins = true
        self.present(playerController, animated: true) {
            player.play()
        }
    } catch let error {
        print(error.localizedDescription)
    }
}
โœ… Future - Combine

Future, Combine

Class single value๋ฅผ ์ƒ์‚ฐํ•˜๊ณ  finishํ•˜๊ฑฐ๋‚˜ failํ•˜๋Š” publisher

Overview

Future๋Š” ๋น„๋™๊ธฐ์ ์œผ๋กœ single element๋ฅผ publishํ•  ๋•Œ ์‚ฌ์šฉํ•œ๋‹ค. ํด๋กœ์ €๋Š” promise๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ์„ฑ๊ณต์ธ์ง€ ์‹คํŒจ์ธ์ง€ ๊ฒฐ๊ณผ๋ฅผ ์ „๋‹ฌํ•œ๋‹ค. ์„ฑ๊ณต์ธ ๊ฒฝ์šฐ future์˜ ๋‹ค์šด์ŠคํŠธ๋ฆผ ๊ตฌ๋…์ž๊ฐ€ ๊ทธ ์š”์†Œ๋ฅผ ๋ฐ›๊ณ  error์ธ ๊ฒฝ์šฐ์— publishing์„ ์ข…๋ฃŒํ•œ๋‹ค.

func generateAsyncRandomNumberFromFuture() -> Future <Int, Never> {
    return Future() { promise in
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            let number = Int.random(in: 1...10)
            promise(Result.success(number))
        }
    }
}

Published value๋ฅผ ์ „๋‹ฌ๋ฐ›์„ ๋•Œ subscriber๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ฐ›๋Š”๋‹ค.

cancellable = generateAsyncRandomNumberFromFuture()
    .sink { number in print("Got random number \(number).") }
โœ… Firebase

Firebase

CloudDB

์„ค์น˜ํ•˜๊ธฐ

๋ฐฑ์—… ์šฉ์œผ๋กœ CloudBD๊ฐ€ ํ•„์š”ํ–ˆ๊ณ , Firebase๋ฅผ ์‚ฌ์šฉํ•˜์˜€๋‹ค. Firebase ์‚ฌ์ดํŠธ์— ๋“ค์–ด๊ฐ€๋ฉด Apple ํ”„๋กœ์ ํŠธ์— ์ถ”๊ฐ€ํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ์ž์„ธํžˆ ๋‚˜์™€์žˆ๋‹ค.

  1. Firebase์— ํ”„๋กœ์ ํŠธ ์ƒ์„ฑํ•˜๊ธฐ
  2. Firebase์— ์•ฑ ๋“ฑ๋กํ•˜๊ธฐ (์ด๋•Œ, ์•ฑ ๋ฒˆ๋“ค ID๊ฐ€ ํ•„์š”ํ•˜๋‹ค)
  3. Firebase ๊ตฌ์„ฑ ํŒŒ์ผ ์ถ”๊ฐ€
    • GoogleService-Info.plist ๋‹ค์šด๋กœ๋“œํ•œ๋‹ค.
    • XCode์— ์ถ”๊ฐ€ํ•œ๋‹ค.
  4. ์•ฑ์— Firebase SDK๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค.
    • SPM์„ ์ด์šฉํ•˜์—ฌ Firebase ์ข…์†ํ•ญ๋ชฉ์„ ์„ค์น˜ํ•˜๊ณ  ๊ด€๋ฆฌํ•œ๋‹ค.
    • Xcode -> File -> Add Packages
  5. ์•ฑ์—์„œ Firebase๋ฅผ ์ดˆ๊ธฐํ™”ํ•œ๋‹ค.
    • UIApplicationDelegate์—์„œ ์ดˆ๊ธฐํ™”ํ•ด์คŒ
@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        FirebaseApp.configure()
        return true
    }

๋ฐ์ดํ„ฐ ํŒŒ์ผ ์ €์žฅ ๋ฐ ์‚ญ์ œํ•˜๊ธฐ

๋‚˜๋Š” ๋™์˜์ƒ์„ ๋ฐฑ์—…ํ•ด์•ผํ•˜๋ฏ€๋กœ Firebase Storage๋ฅผ ํ™œ์šฉํ–ˆ๋‹ค.

  • SPM์—์„œ FirebaseFireStore, FirebaseStorage๋ฅผ ์„ ํƒํ–ˆ๋‹ค.

FirebaseStorage๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ๊ฐ์ฒด๋ฅผ ํ•˜๋‚˜ ์ƒ์„ฑํ•˜๊ณ  ๊ทธ ๊ฐ์ฒด๋ฅผ ํ†ตํ•ด ์ €์žฅ๊ณผ ์‚ญ์ œ๋ฅผ ๊ด€๋ฆฌํ•˜์˜€๋‹ค.

import FirebaseStorage
import Firebase

final class FirebaseStorageManager {
    static func uploadVideo(_ video: Data, id: UUID) {
        let metaData = StorageMetadata()
        metaData.contentType = ".mp4"
        
        let firebaseReference = Storage.storage().reference().child("\(id).mp4")
        firebaseReference.putData(video, metadata: metaData)
    }
    
    static func deleteVideo(id: UUID) {
        let firebaseReference = Storage.storage().reference().child("\(id).mp4")
        firebaseReference.delete { error in
            if let error = error {
                print("๋™์˜์ƒ ์‚ญ์ œ ์‹คํŒจ: \(error)")
            } else {
                print("๋™์˜์ƒ ์‚ญ์ œ ์„ฑ๊ณต")
            }
        }
    }
}
  • ์ €์žฅํ•  ๋•Œ child์•ˆ์— ๋“ค์–ด๊ฐ€๋Š” ๊ฒƒ์ด ์ด๋ฆ„์ด ๋˜๊ณ  .mp4๋ฅผ ์ด์šฉํ•ด ํ™•์žฅ์ž๋ฅผ ์„ค์ •ํ•˜์˜€๋‹ค.
  • ์‚ญ์ œํ•  ๋•Œ๋Š” title๋กœ ๋น„๊ตํ•˜๋ฉด ๊ฐ™์€ ์ด๋ฆ„์˜ ๋™์˜์ƒ์„ ๊ตฌ๋ถ„ํ•˜๊ธฐ ์–ด๋ ค์šธ ๊ฒƒ ๊ฐ™์•„ ๊ณ ์œ ํ•œ ์‹๋ณ„์ž์ธ UUID๋ฅผ ์ด์šฉํ•˜์—ฌ ์‚ญ์ œํ•˜๋„๋ก ํ•˜์˜€๋‹ค.

ํ˜„์žฌ localDB์— 3๊ฐœ์˜ ํŒŒ์ผ์ด ์ €์žฅ๋˜์–ด ์žˆ๊ณ  ๋ฐฑ์—…ํŒŒ์ผ๋„ 3๊ฐœ๊ฐ€ ์กด์žฌํ•œ๋‹ค.


์ฐธ๊ณ  ๋งํฌ

๊ณต์‹๋ฌธ์„œ

๋ธ”๋กœ๊ทธ

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Swift 100.0%