본문 바로가기
개발/swift

앱 상태 복원 (UIViewControllerRestoration)

by 꼬마상어 2020. 5. 4.
반응형

iOS 13이 배포되고 나서 초반에 급증했던 CS가 앱이 자꾸 재시작된다는 점이었다.

관련해서 이전 글에도 작성을 해놓았다. (https://littleshark.tistory.com/57)

 

iOS 13.2 bug ) background에서 앱이 suspend -> terminated되는 이슈

아.. iOS 13.2가 저번주에 업데이트 되고 CS문의가 폭발했다. 고객들의 증상들은 모두 동일하게 "백그라운드 상태로 앱을 뒀다가 다시 진입했더니 앱이 재시작되서 사용할수가 없어요!" 였다. 당연�

littleshark.tistory.com

근데 글 하단에도 적혀있듯이 iOS 13.2.2에서는 수정되었다고 했는데 테스트 해봤을 때 우리앱은 동일하게 발생했다.

그래서 우리앱을 의심하기 시작했다. 메모리를 너무 많이 사용하고 있나..?

그래서 측정해봤는데 보통 다른 앱과 동일한 수준으로 사용하고 있는 것 같았다..

그래도 혹시 모르니 앱 메모리 누수를 잡았고, 기존 70MB에서 시작하던걸 40MB까지 줄였고, 앱 사용하면서도 메모리 해제를 해주어서 100MB는 넘어가지 않도록 하였다..

근데 동일하게 계속 발생했다. 결국 앱 상태 복원을 시도했다.

예전에 시도했었는데 기존 앱에 적용하기 어려워서 재시작되면 팝업으로 동작을 이어갈껀지 팝업을 띄웠었었다. (유저입장에서 불편)


 

iOS 13 이상 지원되는 프로젝트를 사용한다면 https://developer.apple.com/documentation/uikit/uiviewcontroller/restoring_your_app_s_state를 참고하는 것이 도움이 될 것이다.

NSUserActivity를 이용하여 SceneDelegate에서 앱 상태를 복원할 수 있다. 

하지만 내가 개발하고 있는 프로젝트는 최소 지원버전이 iOS 9이어서 사용하지 않았다. (분기처리를 할수있지만 공수가 많이 들어서 최소 지원버전이 iOS 13으로 올라가면 손을 대는 것이 좋을 것이다.)

그래서 나는 https://developer.apple.com/documentation/uikit/view_controllers/preserving_your_app_s_ui_across_launches 문서 기반으로 작성해볼 예정이니 참고하는게 좋을 것이다. 

그니까 요약해보면

* 최소 지원버전이 iOS 13 아래인 경우

* Scene Delegate를 사용하지 않는 경우

* 스토리 보드를 사용하지 않는 경우

위 3가지 사항에 해당하는 프로젝트만 도움이 될 것이다..


Overview 요약

내 앱을 항상 실행상태처럼 보이도록 UI를 유지하는 것이 좋다.

iOS 디바이스에서는 빈번하게 방해당해서 시스템이 나의 앱을 중지시킬 수 있다. (메모리 확보를 위하여)

하지만 유저는 앱을 사용하면서 상태가 달라지는 것을 기대하지 않는다. 

그래서 앱이 중지(terminated)된 후에 재실행(launches again)될 때 상태를 유지할 수 있도록 한다.

UIKit은 복원을 시도하면 뷰와 뷰 컨트롤러를  암호화하여 디스크에 저장한다.

앱이 종료되고 재실행되면 그 디스크에 저장한 데이터를 기반으로 재구성한다.

자동으로 재구성프로세스를 거치지만 수행하기 위하여 개발자가 해야할 것들이 몇가지 있다 :

* state preservation and restoration 을 수행할 수 있도록 하라

* 당신이 원하는 뷰컨트롤러에 restoration identifier를 할당하라

* 복원하는 시간에 뷰컨트롤러를 다시 만들수 있도록 하라

* custom한 데이터를 encode, decode할 수 있도록 하라

만약 스토리보드 기반으로 되어있다면 자동으로 복원과 수행하므로 볼 필요는 없다..

 

Enable State Preservation and Restoration for Your App

여기서부터는 내 경험과 문서를 뒤섞어서 작성해볼까한다.

AppDelegate에 application(_:shouldSaveApplicationState:) , application(_:shouldRestoreApplicationState:)을 implementing해야한다.

func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
    return true
}

func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
    // archive files will end up in this directory – only printing it for debugging
    // purposes so you can quickly `cd` there
    let libDir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.appendingPathComponent("Saved Application State")
    print("Restoration files: \(String(describing: libDir))")

    return true
}

복원을 할 것인지 여부를 return 하는 것이다.

false를 return 한다면 복원이 안될 것이다.

application(_:shouldSaveApplicationState:)에 path 코드는, 이게 위에서 말했듯이 복원할 경우 파일로 데이터가 저장이 되는데 그부분의 path를 말한다. 파일을 찾아서 안에 보면 어떻게 어떤게 복원될 건지 알 수 있다. 

application(_:shouldSaveApplicationState:)에서 심플하게 return true만 해도 되는데 추가적인 데이터를 encoding해도 된다.

아래는 apple 공식 문서에 나와있는 예시이다.

func application(_ application: UIApplication, 
            shouldSaveApplicationState coder: NSCoder) -> Bool {
   // Save the current app version to the archive.
   coder.encode(11.0, forKey: "MyAppVersion")
        
   // Always save state information.
   return true
}
    
func application(_ application: UIApplication, 
            shouldRestoreApplicationState coder: NSCoder) -> Bool {
   // Restore the state only if the app version matches.
   let version = coder.decodeFloat(forKey: "MyAppVersion")
   if version == 11.0 {
      return true
   }
    
   // Do not restore from old data.    
   return false
}

현재 앱 버전을 저장하고, 복원할 때 11버전이 아니면 복원하지 않고 앱 복원 데이터를 초기화 하고 재시작 하는 로직이다.

참고로 application(_:didFinishLaunchingWithOptions:)은 복원할 때도 호출이 된다. 다만 복원할 때는 순서가 좀 달라진다. (2번)

1. application:willFinishLaunchingWithOptions:

2. process application state restore

3. application:didFinishLaunchingWithOptions:

요거에 대해서는 공식 문서를 참고하는 것이 도움이 될 것 같다. 그림도 있어서 자세하다

https://developer.apple.com/documentation/uikit/view_controllers/preserving_your_app_s_ui_across_launches/about_the_ui_preservation_process

 

Assign Restoration Identifiers to Your View Controllers

이제 ViewController에 RestorationIdentifier를 주어야 한다.

복원 식별자로 unique해야 한다. unique하지 않으면 A에 있는 내용이 B에 가서 나타날수도 있다.

인터페이스 빌더를 사용한다면 아래와 같이 복원 식별자를 추가하고

코드로 작성한다면 이렇게 작성한다.

restorationIdentifier = //restorationID

복원할 때 UIKit이 각각 뷰 컨트롤러의 복원 식별자를 기준으로 복원절차를 진행한다. 복원 식별자가 없다면 복원하지 않는다고 생각한다. (default Value == nil)

여기서 주의해야 될 점은 NavigationStack에 A - B - C 뷰 컨트롤러가 있는데, B의 복원식별자가 없다면 C가 복원 식별자가 있다고 해도 무시되고 복원되지 않는다! 

 

Encode and Decode Custom Information for Your App, Create View Controllers When Asked

앱을 사용하면서 필요한 custom한 데이터를 encode, decode 해야한다.

여기서 custom한 데이터란 UI 관련된 상태를 말한다.

유저가 선택한 탭이라던가, 버튼 클릭 상태라던가!

공식 문서에 기준이 있다.

* 뷰와 컨트롤들에 대한 visual한 상태를 자세히 저장해라 

* 복원하고 싶은 child View controller의 참조를 저장해라

* 유저 정보에 영향을 끼치지 않는 정보를 저장해라 

* 앱이 계속해서 유지해야되는 데이터는 저장하지 마라( 유저의 위치 정보라던가..)

우리는 복원하면서 많은것을 바라면 안된다.. 단지 유저가 보고 있는 화면 그대로가 보였으면 하는 것 뿐. 

그러므로 유저 데이터나 그런 민감한 정보는 userDefault, keychain, db 등을 이용하고 여기서는 UI의 상태를 유지하는데만 집중하는 것이 좋다.

 

ViewController는 이미 UIStateRestoring을 채택하고 있으므로 아래 메소드를 overriding하여 사용하면된다.

// UIViewController
override func encodeRestorableState(with coder: NSCoder) {
   super.encodeRestorableState(with: coder)
        
   // Save the user ID so that we can load that user later.
   coder.encode(userID, forKey: "UserID")

   // Write out any temporary data if editing is in progress.
   if firstNameField!.isFirstResponder {
      coder.encode(firstNameField?.text, forKey: "EditedText")
      coder.encode(Int32(1), forKey: "EditField")
   }
   else if lastNameField!.isFirstResponder {
      coder.encode(lastNameField?.text, forKey: "EditedText")
      coder.encode(Int32(2), forKey: "EditField")
   }
   else {
      // No editing was in progress.
      coder.encode(Int32(0), forKey: "EditField")
   }
}

override func decodeRestorableState(with coder: NSCoder) {
   super.decodeRestorableState(with: coder)
   
   // Restore the first name and last name from the user ID
   let identifier = coder.decodeObject(forKey: "UserID") as! String
   setUserID(identifier: identifier)

   // Restore an in-progress values that was not saved
   let activeField = coder.decodeInteger(forKey: "EditField")
   let editedText = coder.decodeObject(forKey: "EditedText") as! 
                         String?

   switch activeField {
      case 1:
         firstNameField?.text = editedText
         firstNameField?.becomeFirstResponder()
         break
            
      case 2:
         lastNameField?.text = editedText
         lastNameField?.becomeFirstResponder()
         break
            
     default:
         break  // Do nothing.
  }
}

 

AppDelegate에서 사용하는 메소드 이다.

// AppDelegate
func application(_ application: UIApplication, willEncodeRestorableStateWith coder: NSCoder) {
	// 복원과 상관 없이 런치스크린 무조건 보이도록 함
	UIApplication.shared.ignoreSnapshotOnNextApplicationLaunch()
    
    // do something
}

func application(_ application: UIApplication, didDecodeRestorableStateWith coder: NSCoder) {
	// AppDelegate 레벨에서의 디코딩한다.
    /**	Decode any application state data at the app delegate level.
        If you plan to do any asynchronous initialization for restoration -
        Use these methods to inform the system that state restoration is occuring
        asynchronously after the application has processed its restoration archive on launch.
        In the event of a crash, the system will be able to detect that it may have been
        caused by a bad restoration archive and arrange to ignore it on a subsequent application launch.
    */
        
    UIApplication.shared.extendStateRestoration()

    DispatchQueue.global(qos: .background).async {
        /**	On background thread:
            Do any additional asynchronous initialization work here.
        */
        
        DispatchQueue.main.async {
            // Back on main thread: Done asynchronously initializing, complete our state restoration.
            UIApplication.shared.completeStateRestoration()
        }
        
    }
}

encodeRestorableState(with:) 에 현재 UI상태를 저장한다. key-value 형식이다.

앱이 background로 내려갈 때 호출된다.

여기서 주의해야될 점은, custom enum, struct같은 경우 encode할 수 있도록 로직을 추가하지 않았다면 크래시가 날 수 있다는 것이다.

또한 클로저도 안된다.

상태 복원 중 크래시가 발생한다면 앱이 재시작된다. (복원 안됨)

 

복원할 때 UIKit이 application(_:shouldRestoreApplicationState:)을 호출하여 복원할 것인지 아닌지 확인한다.

복원한다면 UIKit이 각 뷰 컨트롤러에 decodeRestorableState(with:) 메소드를 호출한다.

 

restorationClass

이제 상태 보존 및 복원을 위한 추가 처리가 거의 다 되간다.

ViewController 에 restrationIdentifier외에 restorationClass도 있다.

restorationIdentifier = "DetailPageVC"
restorationClass = DetailPageVC.self

위와 같이 선언하면 된다.

https://developer.apple.com/documentation/uikit/uiviewcontroller/1621472-restorationclass에 자세한 설명이 나와있지만 요약하자면, 앱의 상태가 복원될 때 restorationClass가 없다면 Appdelegate에서 복원 절차에 대하여 위임하고 복원에 대한 결정을 한다.

restorationClass의  default Value는 nil이며, 만약 위와같이 DetailPageVC 클래스를 할당하면 해당 클래스에서 복원 절차에 대하여 위임받아 결정한다.

없어도 되고 있어도 되는 값이지만 나는 추가하는 걸 추천한다.. AppDelegate에서 모든 복원 절차를 결정한다면 각자 init할 때 넘겨주는 파라미터 같은 것도 모두 decoding하여 create 해야하는데.. 너무 AppDelegate가 비대해지는 것 같기도 하고! 

아래와 같이 선언하면 된다.

identifier가 Array 형식으로오는데 identifier 의 last가 match되면 복원하면 된다.

extension DetailPageVC: UIViewControllerRestoration {
	static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? {
		print("""
		class = \(classForCoder)
		function = \(#function)
		""")
		
		switch identifierComponents.last {
		case "DetailPageVC":
			guard let url = coder.decodeObject(forKey: "pageUrl") as? String else {
            	// 복원하지 않을 거라면.. 단순히 nil를 return 한다.
				return nil
			}
            // 원하는 데이터가 존재한다면 복원절차를 진행한다. 
            // 주의점인 restorationIdentifier와 restorationClass를 추가해야한다.
            
			let isMovingInSearch = coder.decodeObject(forKey: "isMovingInSearch") as? Bool ?? false
			
			let vc = DetailPageVC(url, isMovingInSearch: isMovingInSearch)
			vc.restorationIdentifier = identifierComponents.last
			vc.restorationClass = DetailPageVC.self
			return vc
		
		default:
			return nil
		}
	}
 }

만약 AppDelegate 에서 복원 절차를 진행하고 싶다면 아래와 같이 작성한다 .

func application(
        	_ application: UIApplication,
            viewControllerWithRestorationIdentifierPath identifierComponents: [String],
            coder: NSCoder) -> UIViewController? {
		// return 값이 nil일 경우 각자 뷰컨의 복원로직을 수행한다.
		print("\(classForCoder) \(#function) identifierConponents = \(identifierComponents)")

		switch identifierComponents.last {
		case "DetailPageVC":
			...
            return vc
        default:
        	return nil
        }
  }

 

우선 여기까지가 기본 사용법(?) 이다.

 

혹시 몰라서 이 모든 예제들을 종합해 둔 코드를 남긴다!

// AppDelegate
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

	func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
		
		/* 아래의 순서로 동작
		
		1. application:willFinishLaunchingWithOptions:
		2. process application state restore
		3. application:didFinishLaunchingWithOptions:
		*/
	}
	
	func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
		/* 
		process application state restore이후에 호출되므로 복원여부를 확인 할 수 있다.
		그러므로 복원 여부에 따라 처리가 달라지는 것들은 여기서 처리하면된다.
		*/
		return true
	}
}

	
extension AppDelegate {
	func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
	    // 앱 상태를 저장할 것인가 여부를 return 합니다.

    	    // archive files will end up in this directory – only printing it for debugging
      	    // purposes so you can quickly `cd` there
   	     let libDir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.appendingPathComponent("Saved Application State")
   	     print("Restoration files: \(String(describing: libDir))")

  	      return true
	 }

	 func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
		// 앱 복원을 할 것인지 여부를 return 합니다.
      	  	return true
	 }
	
	func application(_ application: UIApplication, willEncodeRestorableStateWith coder: NSCoder) {
		// AppDelegate 레벨에서의 데이터를 인코딩을 수행합니다.
		// Encode any application state data at the app delegate level.
		print("\(classForCoder) \(#function)")
		
		// 복원과 상관 없이 런치스크린 무조건 보이도록 수정함
		UIApplication.shared.ignoreSnapshotOnNextApplicationLaunch()
    }
    
    func application(_ application: UIApplication, didDecodeRestorableStateWith coder: NSCoder) {
		// AppDelegate 레벨에서의 디코딩한다.
		/**	Decode any application state data at the app delegate level.
    		If you plan to do any asynchronous initialization for restoration -
    		Use these methods to inform the system that state restoration is occuring
   			asynchronously after the application has processed its restoration archive on launch.
    		In the event of a crash, the system will be able to detect that it may have been
   			caused by a bad restoration archive and arrange to ignore it on a subsequent application launch.
		*/
		
        UIApplication.shared.extendStateRestoration()

        DispatchQueue.global(qos: .background).async {
            /**	On background thread:
    			Do any additional asynchronous initialization work here.
            */
			LoginManager.autoLoginAtAppdelegate(successHandler: {
				DispatchQueue.main.async {
					// Back on main thread: Done asynchronously initializing, complete our state restoration.
					UIApplication.shared.completeStateRestoration()
				}
			})
        }
    }
    
	func application(
        	_ application: UIApplication,
            viewControllerWithRestorationIdentifierPath identifierComponents: [String],
            coder: NSCoder) -> UIViewController? {
		// return 값이 nil일 경우 각자 뷰컨의 복원로직을 수행한다.
		print("\(classForCoder) \(#function) identifierConponents = \(identifierComponents)")

		isRestore = true
		
		switch identifierComponents.last {
		case “DetailPageVC”:
			// DetailPageVC 클래스 객체를 만들어 return 합니다.
			return vc
		default:
			return nil
		}
    }
}

}

 

// UIViewController
class DetailPageVC: UIViewController {
  init(_ urlString: String, isMovingInSearch: Bool = false) {
		super.init(URL(string: urlString))
        
		restorationIdentifier = "DetailPageVC"
		restorationClass = DetailPageVC.self
		
        self.isMovingInSearch = isMovingInSearch
    }
    
    init(urlString: String) {
        super.init(URL(string: urlString))
		
		restorationIdentifier = "DetailPageVC"
		restorationClass = DetailPageVC.self
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
		
		restorationIdentifier = "DetailPageVC"
		restorationClass = DetailPageVC.self
    }
}

extension DetailPageVC: UIViewControllerRestoration {
	static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? {
		print("""
		class = \(classForCoder)
		function = \(#function)
		""")
		
		switch identifierComponents.last {
		case "DetailPageVC":
			guard let url = coder.decodeObject(forKey: "pageUrl") as? String else {
				return nil
			}
			let isMovingInSearch = coder.decodeObject(forKey: "isMovingInSearch") as? Bool ?? false
			
			let vc = DetailPageVC(url, isMovingInSearch: isMovingInSearch)
			vc.restorationIdentifier = identifierComponents.last
			vc.restorationClass = DetailPageVC.self
			return vc
		
		default:
			return nil
		}
	}
	
	override func encodeRestorableState(with coder: NSCoder) {
		print("""
			
		class = \(classForCoder)
		restorationIdentifier = \(restorationIdentifier)
		function = \(#function)
			
		""")
		
		guard isViewLoaded else { return }
		
		coder.encode(pageUrl?.absoluteString, forKey: "pageUrl")
		coder.encode(contentTitle, forKey: "contentTitle")
		coder.encode(isMovingInSearch, forKey: "isMovingInSearch")
		
		super.encodeRestorableState(with: coder)
	}
	
	override func decodeRestorableState(with coder: NSCoder) {
		print("""
			
		class = \(classForCoder)
		restorationIdentifier = \(restorationIdentifier)
		function = \(#function)
			
		""")

		if let tmp = coder.decodeObject(forKey: "contentTitle") as? String {
			contentTitle = tmp
		}

		isMovingInSearch = coder.decodeBool(forKey: "isMovingInSearch")
		
		super.decodeRestorableState(with: coder)
	}
	
	override func applicationFinishedRestoringState() {
		print("""
			
		class = \(classForCoder)
		restorationIdentifier = \(restorationIdentifier)
		function = \(#function)
			
		""")
	
		super.applicationFinishedRestoringState()
	}
}

 

내가 겪었던 장애물..

1. ViewController 를 ViewController를 상속받는 구조인 경우 복원은 어떻게?

현재 프로젝트가 뷰 컨트롤러가 뷰 컨트롤러를 상속받는 구조이며 내부에 ChildViewController도 많다.

예를 들어 A가 B를 상속하고있다면, B가 위와 같이UIViewControllerRestoration를 채택하고 있다면 당연한 말이지만 A에서

static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController?를 overriding 할 수 없다. 그렇게 되면 A는 복원될 수 없다.

그러므로 이경우에는 AppDelegate에서 복원 처리를 하던가, 상속 최 하단(?) 에서만 UIViewControllerRestoration를 채택할 수 있도록 하여야한다.

 

2. present ViewController를 복원하는 방법

present된 뷰 컨트롤러의 modalPresentationStyle는 기본적으로 저장되는 데이터가 아니므로 encodeRestrableState에서 스타일을 저장하도록 한다.  

override func encodeRestorableState(with coder: NSCoder) {
    guard isViewLoaded else { return }

    coder.encode(modalPresentationStyle.rawValue, forKey: "modalPresentationStyle")

    super.encodeRestorableState(with: coder)
}

override func decodeRestorableState(with coder: NSCoder) {
    modalPresentationStyle = UIModalPresentationStyle(rawValue: coder.decodeInteger(forKey: "modalPresentationStyle")) ?? UIModalPresentationStyle.overFullScreen

    super.decodeRestorableState(with: coder)
}

 

3. 여러개의 동일 클래스를 사용하는 경우

음.. 현재 프로젝트의 경우 하이브리드 앱이기 때문에  WKWebview를 굉장히 많이 사용한다.

대분류 안에 소분류가 여러가지 있는 구조 (음료 - [우유, 커피, 오렌지쥬스, 포도쥬스, 요쿠르트] 이런식) 이므로 동일 클래스를 소분류 갯수대로 사용하고 있고 안에 url만 바꿔가면서 사용하고 있는데,

restorationIdentifier를 "webview"로 통일하니까 우유의 내용이 요쿠르트에 가있는 식으로 엉망진창이 되어있었다.

그래서 subfix를 webview로 하고 prefix를 "MILK_", "COFFEE_" 이런식으로 해서 unique하게 만들어 주었다 ㅠ

이게 옳은 처리인지는 모르겠지만..ㅎ 나는 이렇게 했다..


기존 프로젝트에 아마 적용하기는 프로젝트 크기에 비례하여 힘들다..

하지만 하고 나면 굉장히 뿌듯하고.. 그렇다..


참고한 링크

https://stackoverflow.com/questions/14376786/uinavigationcontroller-state-restoration-without-storyboards

https://devmjun.github.io/archive/Restorazation-2

https://developer.apple.com/documentation/uikit/uiviewcontrollerrestoration

https://developer.apple.com/documentation/uikit/uiviewcontroller/restoring_your_app_s_state

https://developer.apple.com/documentation/uikit/view_controllers/preserving_your_app_s_ui_across_launches

https://developer.apple.com/documentation/uikit/view_controllers/preserving_your_app_s_ui_across_launches/about_the_ui_preservation_process

https://developer.apple.com/documentation/uikit/uiviewcontroller/1621472-restorationclass

 

 

 

 

 

반응형

'개발 > swift' 카테고리의 다른 글

UISwitch 버그  (0) 2021.06.09
NSPredicate 요상한 버그..  (0) 2021.06.02
하이브리드 앱 개선하기..(노력 1)  (0) 2018.11.12
@ attribute, @ symbol  (0) 2018.06.07
TDD in iOS (#1 - UnitTest)  (1) 2018.05.31

댓글