본문 바로가기

iOS

백업/복구 (+ 백업 리스트 UI 및 Custom Progress View)

728x90

이 글은 가볍게 .. 어떤 흐름으로 구현이 되는가 .. 를 중점으로 작성한 것임 .. 자세한 설명은 .. 다음 글이나 .. 다다음글이나 .. 언젠가 쓸 것임 .. 🫠

 

UI를 만들어보자.

제일 귀찮은 .. 근데 제일 잘 짜야하는 .. UI 

이런 내가 클라이언트 개발자가 되어도 되는걸까? ㅇㅉ. 우하하.

 

일단 메인 화면의 네비게이션 오른쪽 버튼으로 백업 버튼을 하나 추가하고 > 버튼을 누르면 백업/복구 화면으로 이동하도록 UI를 구성했다.

화면 전환은 push로 구현 (오른쪽 > 왼쪽으로 슬라이드 인)

 

좌 > 우의 화면 전환

사진 왤케 크심요 .. 

아무튼 이렇게 UI를 만들고 ...

 

코드가 궁금하다면 [더보기] ㄱ ㄱ

더보기

정말 궁금하심? 왜? 참고로 코드베이스(SnapKit O / Then X)로 구성했음요 .. + Extension 코드도 있어서 복붙해서 실행하면 오류가 나타날 것임 우하하

 

    // MARK: - UI Method
    
    private func configureNavigationBar() {
        let addButton = UIBarButtonItem(title: "추가", style: .plain, target: self, action: #selector(touchUpPlusButton))
        let backupButton = UIBarButtonItem(title: "백업", style: .plain, target: self, action: #selector(touchUpBackUpButton))
        
        navigationItem.rightBarButtonItems = [addButton, backupButton]
        navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), menu: menu)
        
        title = "쇼핑 리스트"
    }

이게 이전 화면 (사진에서 왼쪽)

 

import UIKit

import SnapKit

class BackUpViewController: UIViewController {
    
    // MARK: - UI Property
    
    private lazy var buttonStackView : UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.distribution = .fillProportionally
        stackView.spacing = 5
        stackView.addArrangedSubview(backupButton)
        stackView.addArrangedSubview(restoreButton)
        return stackView
    }()
    
    private lazy var backupButton : UIButton = {
        let button = UIButton()
        button.setTitle("백업", for: .normal)
        button.setTitleColor(.systemRed, for: .normal)
        button.addTarget(self, action: #selector(touchUpBackUpButton), for: .touchUpInside)
        return button
    }()
    
    private lazy var restoreButton : UIButton = {
        let button = UIButton()
        button.setTitle("복구", for: .normal)
        button.setTitleColor(.systemBlue, for: .normal)
        button.addTarget(self, action: #selector(touchUpRestoreButton), for: .touchUpInside)
        return button
    }()
    
    private var listTableView : UITableView = {
        let tableView = UITableView()
        tableView.backgroundColor = .clear
        return tableView
    }()

    // MARK: - Life Cycle
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configureUI()
        setConstraints()
    }
    
    // MARK: - UI Method
    
    private func configureUI() {
        view.backgroundColor = .white
        configureTableView()
    }
    
    private func setConstraints() {
        [buttonStackView, listTableView].forEach {
            view.addSubview($0)
        }
        
        buttonStackView.snp.makeConstraints { make in
            make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(20)
            make.height.equalTo(50)
        }
        
        listTableView.snp.makeConstraints { make in
            make.top.equalTo(buttonStackView.snp.bottom).offset(10)
            make.leading.trailing.equalToSuperview().inset(20)
            make.bottom.equalTo(view.safeAreaLayoutGuide)
        }
    }
    
    private func configureTableView() {
        listTableView.delegate = self
        listTableView.dataSource = self
        
        listTableView.register(BackUpListTableViewCell.self, forCellReuseIdentifier: BackUpListTableViewCell.reuseIdentifier)
    }
    
    // MARK: - @objc
    
    @objc func touchUpBackUpButton() {
        
    }
    
    @objc func touchUpRestoreButton() {
        
    }
}

// MARK: - UITableView Protocol

extension BackUpViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 5
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: BackUpListTableViewCell.reuseIdentifier, for: indexPath) as? BackUpListTableViewCell else { return UITableViewCell() }
        return cell
    }
}

그리고 이것이 백업/복구 UI 코드 >< 

 

백업/복구 기능을 구현하자.

UI를 만들었으니 이제 기능을 구현해보자 (아우 귀찮아) (아님) 

백업을 할 수 있는 방법은 애플에서 제공하는 파일매니저를 사용할 수 있고, 다른 앱에 백업 파일을 넣을 수도 있다. (예를 들면 구글 드라이브 등 ..)

 

이 글에서는 파일 매니저를 통해서 백업 파일을 저장하고 이것을 갖고 와서 복구를 하는 기능을 구현할 것이다.

 

백업 기능

복구를 하려고 해도 백업이 되어 있어야 복구를 하니까 백업을 먼저 구현해보자 ..

 

백업 플로우는 아래와 같다.

  • 앱의 백업 파일을 저장할 도큐먼트에 접근 > 백업을 저장할 위치를 찾아서
  • realm파일을 찾아 URL 생성 > 백업 파일을 찾은 다음
  • Zip 라이브러리를 통해 백업 파일을 압축 > 백업 파일을 압축 
  • 위 과정이 모두 성공적이라면 압축 파일을 Activity Controller를 통해 해당 도큐먼트 위치에 저장 

하는 과정을 거친다. 

 

    // Extension
    func documentDirectoryPath() -> URL? {
        guard let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil }
        return documentDirectory
    }
    
    ...
    
    @objc func touchUpBackUpButton() {
        var urlPaths = [URL]()
        
        guard let path = documentDirectoryPath() else {
            showAlertMessage(title: "도큐먼트 위치에 오류가 있습니다.")
            return
        }
        
        let realmFile = path.appendingPathComponent("default.realm")
        guard FileManager.default.fileExists(atPath: realmFile.path) else {
            showAlertMessage(title: "백업할 파일이 없습니다.")
            return
        }
        
        if let url = URL(string: realmFile.path) {
            urlPaths.append(url)
        }
        
        do {
            let zipFilePath = try Zip.quickZipFiles(urlPaths, fileName: "ShoppingList_\(dateFormatter.string(from: Date()))")
            print("Archive Location: \(zipFilePath)")
            
            showActivityViewController()
        } catch {
            showAlertMessage(title: "압축을 실패했습니다")
        }
    }
    
    ... 

    private func showActivityViewController() {
        guard let path = documentDirectoryPath() else {
            showAlertMessage(title: "도큐먼트 위치에 오류가 있습니다.")
            return
        }
        
        let backupFileURL = path.appendingPathComponent("ShoppingList_\(dateFormatter.string(from: Date())).zip")
        
        let viewController = UIActivityViewController(activityItems: [backupFileURL], applicationActivities: [])
        self.present(viewController, animated: true)
    }

 

여기까지 완료한 뒤에 빌드를 해서 도큐먼트에 저장을 하면 그 위치에 .zip 파일이 생기는 것을 확인할 수 있다.

 

백업 파일의 이름을 다르게 하기 위해서 (= 중복 방지) 앱 이름_시간으로 표시했다.

 

복구 기능

위의 과정을 통해서 저장된 백업 파일을 똑같은 위치에 접근해서 갖고 온 뒤 원하는 데이터를 뷰에 보여주면 복구 기능이 완성된다.

 

파일 앱에 저장을 했기 때문에 여기에 접근을 해서 .zip 파일을 불러와야 한다.

그래서 UIDocumentPickerViewController를 사용해야 하고, 프로토콜 채택을 통해 원하는 기능을 구현할 수 있다.

 

    @objc func touchUpRestoreButton() {
        let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.archive], asCopy: true)
        documentPicker.delegate = self
        documentPicker.allowsMultipleSelection = false
        self.present(documentPicker, animated: true)
    }

위와 같이 복구 버튼을 눌렀을 때 도큐먼트 속 파일들을 볼 수 있도록 한다. 

 

// MARK: - UIDocumentPickerView Protocol

extension BackUpViewController: UIDocumentPickerDelegate {
    func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
        print(#function)
    }
    
    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
        guard let selectedFileURL = urls.first else {
            showAlertMessage(title: "선택하신 파일을 찾을 수 없습니다.")
            return
        }
        
        guard let path = documentDirectoryPath() else {
            showAlertMessage(title: "도큐먼트 위치에 오류가 있습니다.")
            return
        }
        
        let sandboxFileURL = path.appendingPathComponent(selectedFileURL.lastPathComponent)
        
        if FileManager.default.fileExists(atPath: sandboxFileURL.path) {
            let fileURL = sandboxFileURL
            
            do {
                try Zip.unzipFile(fileURL, destination: path, overwrite: true, password: nil, progress: { progress in
                    print("progress: \(progress)")
                }, fileOutputHandler: { unzippedFile in
                    print("unzippedFile: \(unzippedFile)")
                    self.showAlertMessage(title: "복구가 완료되었습니다.")
                })
            } catch {
                showAlertMessage(title: "압축 해제에 실패했습니다.")
            }
        } else {
            do {
                try FileManager.default.copyItem(at: selectedFileURL, to: sandboxFileURL)
                
                let fileURL = sandboxFileURL
                
                try Zip.unzipFile(fileURL, destination: path, overwrite: true, password: nil, progress: { progress in
                    print("progress: \(progress)")
                }, fileOutputHandler: { unzippedFile in
                    print("unzippedFile: \(unzippedFile)")
                    self.showAlertMessage(title: "복구가 완료되었습니다.") { _ in
                        self.changeRootViewController()
                    }
                })
            } catch {
                showAlertMessage(title: "압축 해제에 실패했습니다.")
            }
        }
    }
}

 

    func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
        print(#function)
    }

이 함수의 경우 아무것도 누르지 않고 내렸을 때 실행되는 함수이다.

 

    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
    	...
    }

중요한 것은 이 부분, 파일을 선택했을 때 실행되는 함수이다.

 

실행되는 플로우는 아래와 같다.

  • 파일 앱에서 선택한 파일을 앱 도큐먼트 내부에 저장한다.
  • 선택한 파일을 압축 해제하고
  • 만약 잘못된 파일을 갖고 왔을 때 분기처리를 하여 사용자에게 안내를 한다.
  • 제대로 된 파일을 갖고 와서 압축을 해제 했다면, 다시 처음 화면으로 이동하여 앱의 싱크를 맞춘다. 

 

이렇게 작성하면 백업 및 복구 기능을 구현할 수 있다. 

 


추가하면 더 기깔난 기능을 몇가지 .. 정리해보겠음 ..

 

백업한 파일들을 테이블 뷰/컬렉션 뷰에 보여주는 UI

내가 언제, 언제 파일을 백업했는지 리스트를 보고 싶을 수도 있으니까? (진짜?) 그 리스트를 볼 수 있도록 UI를 구성/구현해보면 ..

 

먼저 UI는 간단하게 테이블 뷰로 만들고 라벨을 하나 만들어주자.

이렇게 만들 수 있다.

솔직히 이 정도는 .. 쉬우니까 .. 코드 공유 .. 안해도 .. 그래도 궁금하면 [더보기] .. 더보기 열면 유료결제임 ㅈㅅ 구라임.

더보기

우하하 신한 110-304-... 로 500원 보내주삼.

 

    private func configureTableView() {
        listTableView.delegate = self
        listTableView.dataSource = self
        
        listTableView.register(BackUpListTableViewCell.self, forCellReuseIdentifier: BackUpListTableViewCell.reuseIdentifier)
    }
    
    ...
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return zipList.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: BackUpListTableViewCell.reuseIdentifier, for: indexPath) as? BackUpListTableViewCell else { return UITableViewCell() }
        cell.setData(zipList[indexPath.row])
        return cell
    }
}
    
// MARK: - UITableViewCell

class BackUpListTableViewCell: UITableViewCell {
    
    // MARK: - UI Property
    
    private var label : UILabel = {
        let label = UILabel()
        label.textColor = .darkGray
        label.font = .systemFont(ofSize: 15)
        return label
    }()
    
    // MARK: - Initializer
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        configureUI()
        setConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - UI Method
    
    private func configureUI() {
        
    }
    
    private func setConstraints() {
        contentView.addSubview(label)
        
        label.snp.makeConstraints { make in
            make.leading.equalToSuperview().inset(10)
            make.centerY.equalToSuperview()
        }
    }
    
    // MARK: - Data Binding
    
    func setData(_ data: String) {
        label.text = data
    }
}

UI를 만들었다면 이제 데이터랑 연결을 해야 하는데,

 

간단하게 String으로 파일의 이름을 보여줄 것이니까, [String] 형태로 프로퍼티를 하나 선언하자.

    private var zipList: [String] = [] {
        didSet {
            listTableView.reloadData()
        }
    }

그리고 프로퍼티 옵저버를 통해서 해당 프로퍼티가 바뀔 때마다 테이블뷰를 리로드하여 UI가 갱신되도록 했다.

 

 func fetchDocumentZipFile() -> [String] {
     var zipList: [String] = [""]
     
     do {
         guard let path = documentDirectoryPath() else { return zipList }
         let docs = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil)
         print("docs: \(docs)")

         let zip = docs.filter { $0.pathExtension == "zip" } 
         print("zip: \(zip)")
         
         zipList = zip.map { $0.lastPathComponent }
         return zipList
     } catch {
         print("ERROR")
     }
     
     return zipList
 }

위의 함수를 통해서 .zip 파일이 있는지 찾고 있다면 그 이름을 [String] 형태로 반환한다.

 

그리고 이 함수를 리스트가 있는 뷰컨이 불릴 때마다 호출한다.

// MARK: - Life Cycle

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    zipList = fetchDocumentZipFile()
}

그러면 화면에 .zip 파일 리스트가 보인다.

 


압축 해제 진행 정도를 보여주는 UI

지금은 압축한 파일을 해제하는 과정이 빠르겠지만 헤비 유저의 경우 백업한 파일의 용량이 클 것이다. 그러면 그만큼 해제하는 시간도 길텐데 그 시간을 그냥 두는 것보다 UI로 진행 정도를 알려주는 것이 보다 좋은 사용성을 나타낸다.

 

어떻게 할 수 있는가?

우리가 사용한 외부 라이브러리 중에서 Zip 라이브러리를 통해 압축 - 압축 해제 과정을 거쳤는데, 여기서 아래와 같은 코드를 작성했다.

 

do {
    try Zip.unzipFile(fileURL, destination: path, overwrite: true, password: nil, progress: { progress in
        print("progress: \(progress)")
    }, fileOutputHandler: { unzippedFile in
        print("unzippedFile: \(unzippedFile)")
        self.showAlertMessage(title: "복구가 완료되었습니다.")
    })
} catch {
    showAlertMessage(title: "압축 해제에 실패했습니다.")
}

그리고 Zip에서 제공하는 upzipFile 메서드에서 progress 매개변수가 있다는 것을 확인할 수 있고 이것을 이용하면 진행정도를 UI로 보여줄 수 있다.

 

먼저 UI를 만들어보자.

UIBeizerPath를 활용해서 둥근형태의 progress bar를 만들 수 있다.

 

import UIKit

class BackUpProgressView: UIView {
    
    var color: UIColor? = .gray {
        didSet { setNeedsDisplay() }
    }
    
    var ringWidth: CGFloat = 5

    // progress 값에 따라서 UI가 변하도록 프로퍼티 옵저버를 작성한다.
    var progress: CGFloat = 0 {
        didSet { setNeedsDisplay() }
    }

    private var progressLayer = CAShapeLayer()
    private var backgroundMask = CAShapeLayer()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupLayers()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupLayers()
    }

    private func setupLayers() {
        backgroundMask.lineWidth = ringWidth
        backgroundMask.fillColor = nil
        backgroundMask.strokeColor = UIColor.black.cgColor
        layer.mask = backgroundMask

        progressLayer.lineWidth = ringWidth
        progressLayer.fillColor = nil
        layer.addSublayer(progressLayer)
        layer.transform = CATransform3DMakeRotation(CGFloat(90 * Double.pi / 180), 0, 0, -1)
    }

    override func draw(_ rect: CGRect) {
        let circlePath = UIBezierPath(ovalIn: rect.insetBy(dx: ringWidth / 2, dy: ringWidth / 2))
        backgroundMask.path = circlePath.cgPath

        progressLayer.path = circlePath.cgPath
        progressLayer.lineCap = .round
        progressLayer.strokeStart = 0
        progressLayer.strokeEnd = progress
        progressLayer.strokeColor = color?.cgColor
    }
}

이렇게 Progress 정도를 둥근 형태로 보여주는 UI를 만들고,

 

필요한 ViewController에서 인스턴스를 생성한다.

private var progressView: BackUpProgressView = {
    let progreeView = BackUpProgressView()
    progreeView.color = .systemPink
    progreeView.isHidden = true
    return progreeView
}()

처음에는 보이지 않도록 설정하고 

 

 do {
     try Zip.unzipFile(fileURL, destination: path, overwrite: true, password: nil, progress: { progress in
         print("progress: \(progress)")
         self.progressView.isHidden = false
         self.progressView.progress = progress
     }, fileOutputHandler: { unzippedFile in
         print("unzippedFile: \(unzippedFile)")
         self.showAlertMessage(title: "복구가 완료되었습니다.") { _ in
             self.progressView.isHidden = true
             self.changeRootViewController()
         }
     })
 } catch {
     showAlertMessage(title: "압축 해제에 실패했습니다.")
 }

압축 해제가 진행되는 동안 보이도록 한 다음, 그 정도를 progress 값으로 설정한다. (progress의 경우 프로퍼티 옵저버를 통해 그 값에 따라 UI가 변하게 된다.)

 

이렇게 하면 아래와 같이 압축 해제 정도를 UI로 볼 수 있다.

실제로 보면 (아직 압축 용량이 작아서) 빨리 진행되기 때문에 gif는 조금 느리게 설정했음요 ..  아직 조금 어색한 부분들이 있긴 한데 .. 아무튼 이렇게 하면 진행정도를 보여줄 수 있다 !! 

'iOS' 카테고리의 다른 글

Database인데 Realm을 곁들인  (1) 2022.08.27
App Sandbox 그리고 Files  (2) 2022.08.27
Access Control - Basic to Advanced  (0) 2022.08.16
YPImagePicker  (0) 2022.08.13
0812 Q&A 정리  (0) 2022.08.12