
Apple Developer Academy의 매크로 챌린지 앱인 Reazy을 만들던 중 사용하게 된 기능입니다.
저희 앱은 논문을 읽기 쉽게 해주는 앱 입니다. 쉽게 말해 Goodnotes 같은 pdf 필기 앱 + 특별한 기능을 제공하는 앱 입니다.
기본이 PDF 뷰어인 만큼 앱에 pdf 파일을 업로드해야 했습니다.
앱 내에서는 SwiftUI의 fileImporter를 사용해 손 쉽게 기기 내의 Document에 접근할 수 있었습니다.

하지만 다른 앱에서 공유하기를 누르면 ‘xxx에서 불러오기’ 같은 버튼들이 당연히 나올 줄 알고 있었는데 이것이 없어서 불편하다는 피드백을 받았습니다.(위 사진의 네모칸)
그 때가 되어서야 ‘아 원래 제공되는 기능이 아니구나’를 깨닫고 추가하기로 했습니다.

네 세상에 공짜는 없는 법입니다. ㅜㅜ
그래서 찾아본 결과
공유하기에서 앱 아이콘이 나오는 부분은 Share Extension이란 것을 사용해야 하고,

(해당 부분)
밑에 리스트로 되어있는 버튼은 Action Extension을 사용해야 한다고 합니다.

(리스트로 만들어져 있는 이 부분)
그런데 둘 다 이름을 보니 Extension이란 것이 붙어 있는 것을 볼 수 있습니다.
두 기능의 공통점이 있어 보여 Extension에 대해 찾아보았습니다.
애플에서 제공하는 App Extension(앱 확장 프로그램) 이란?
맞춤형 기능과 콘텐츠를 앱 외부로 확장할 수 있으며, 사용자는 다른 앱 또는 시스템과 상호 작용하는 동안에도 앱 기능을 사용할 수 있습니다. 앱을 홈 화면에 위젯으로 표시하거나, 동작 시트에 새 버튼을 추가하거나, 사진 앱에서 사진 필터를 제공합니다.
쉽게 말해 앱 안의 기능을 앱 외부에서도 사용할 수 있게 해주는 프로그램입니다!
이미 저희는 자주 만나고 있는 위젯이 App Extension 중에 하나입니다.

당연하게도 저희 앱 외부에서 저희 앱으로 들어가게 해주는 공유하기 기능 또한 App Extension 중에 하나겠죠??
따로 추가되는 Extension은 앱과는 다르다고 합니다. 그래서 Extension이 포함된 Containing App과는 별개의 시스템이라고 생각해야 합니다.
그래서 생명주기 또한 기존 앱과 다르게 흘러갑니다.

외부 앱에서 Extension을 실행하게 되면 해당 Extension을 포함하고 있는 Containing App이 실행되는 것이 아닌 그 앱의 독자적인 Extension이 실행됩니다.
유저가 extension을 고르면 시스템이 extension을 실행 및 내장되어 있는 코드를 실행하고 해당 동작이 끝나게 되면 종료됩니다.
또한 Host App(외부 앱)과 Containing App(Extension을 가지고 있는 앱)은 서로 연결되어 있지 않기 때문에 앱 간에 정보 공유가 불가합니다.
하지만 몇 가지 Extension들은 간접적으로 통신할 수 있게 방법을 제공하고 있습니다.

Shared resources(앱과 extension이 공유하는 자원)과 extension에서 URL(URL Scheme)을 실행해 앱을 열어 간접적으로 연결할 수 있습니다.
이번에 저희가 구현할 기능인 pdf 업로드 또한 Share와 Action Extension을 사용해 해당 플로우를 적용했습니다.
이번 기능은 다음과 같은 플로우로 적용했습니다.

프로젝트 안에서 File → New → Target을 들어가게 되면


이렇게 다양한 Extension들이 나오게 됩니다.
이 중에서 저희가 사용할 Extension을 추가해서 사용하면 됩니다.
저는 Share Extension 및 Action Extension 둘 다 필요해 두개 다 추가하였습니다!
사실 두 기능 다 비슷한 기능을 제공하지만 사용되는 용도가 조금씩 다릅니다.
Share Extension은 이름 그대로 공유에 초점을 맞추어 있습니다.
그래서 외부 앱에서 전달할 데이터(사진, 파일, url) 등을 Containing App 앱으로 전달하는 목적에 초점을 맞추어져 있습니다.
Action Extension은 공유하기를 통해 받은 콘텐츠에 대해 Containing App의 특정 기능을 수행하기 위해 사용된다고 합니다.
뭐 어찌되었던 간에 저희는 두 extension 다 똑같은 기능을 넣을거기 때문에 상관은 없었습니다.
Extension과 Containing App 간의 직접적인 통신은 불가능합니다. 그래서 두 앱이 공유하는 자원을 통해 주고 받는 식으로 구현해야 하는데 App Groups이 해당 기능을 담당합니다.

프로젝트 파일에서 Signing & Capabilities에 가서 App Groups를 추가하면 됩니다.
그 후 하단에 있는 + 버튼을 통해 새로운 App groups를 추가하면 사용이 가능합니다.
기존에는 Apple Developer에 가서 직접 추가한 후 사용해야 된다고 하는데 최근에는 xcode 내에서 추가하면 자동으로 적용되게 바뀌었다고 합니다!
이렇게 App group을 만든 후 아까전에 추가한 Extension에 들어가 똑같이 App groups을 추가하게 되면 Extension 에서도 같은 공간을 사용할 수 있게 되는 것입니다.

아래 타겟에도 똑같이 적용을 진행합니다.
기능에서 볼 수 있다 싶이 Extension 뿐만 아니라 멀티 플랫폼 앱을 만들 때에도(iOS, watchOS, macOS 등) 앱 간의 정보 공유를 위해 사용할 수도 있다고 합니다!
이제 사전 준비는 완료되었고 기능을 넣어봅시다.
Action Extension과 Share Extension을 추가하게 되면 아래와 같은 파일들이 생기게 됩니다.

그 중 Info.plist 파일을 통해 각 Extension의 설정을 변경할 수 있는데 이번 프로젝트에서는 UI 적인 요소는 필요하지 않아 기존 placeholder를 지우고 새로운 부분을 추가했습니다.
UI적인 요소가 필요할 경우 다른 블로그들이 잘 설명해 놓았으니 검색해보시면 좋을 듯 합니다!
저희 앱은 공유하기를 통해 Extension이 보여질 때 pdf 파일일 경우에만 표시가 되도록 설정해야 합니다.
그런 설정 또한 Info.plist에서 설정이 가능합니다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>TRUEPREDICATE</string>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>
해당 코드에서 NSExtensionActivationRule 키 부분이 파일을 담당합니다. 현재 TRUEPREDICATE로 설정이 되어 있는데 이렇게 되면 모든 파일 형식에 Extension이 표시되게 됩니다. 또한 해당 상태로 빌드를 하게 되면 앱스토어에 올리기 전에 반드시 수정해서 올리라고 메시지 또한 나옵니다.
그래서 저희는 pdf파일 일때만 Extension이 표시되게 해당 부분을 수정했습니다.
<key>NSExtensionActivationRule</key>
<string>
SUBQUERY (
extensionItems,
$extensionItem,
SUBQUERY (
$extensionItem.attachments,
$attachment,
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.pdf"
).@count == $extensionItem.attachments.@count
).@count >= 1
</string>
pdf 만 업로드 할 수 있게 하는 코드를 열심히 찾아봤는데 다 하나씩 안되서 열심히 조합해보다가 성공한 코드입니다.
특정 파일만 사용하고 싶은 경우 쿼리문을 사용해 특정 타입일 때만 사용이 가능하도록 설정하는 것입니다.
만약 다른 파일만 필터링 하고 싶을 경우 UTI 부분에 다른 확장자를 넣으면 됩니다!
이렇게 Action, Share Extension 둘 다 설정한 후 View Controller에서 Extension이 launch 되면 실행될 코드를 작성하면 됩니다.
저희 프로젝트에서는 ViewController의 뷰들이 필요 없기 때문에(extension이 실행되면 바로 앱으로 보낼거기 때문에) viewWillAppear() 메소드에서 코드를 작성했습니다.
class ShareViewController: SLComposeServiceViewController {
override func viewWillAppear(_ animated: Bool) {
guard let item = self.extensionContext?.inputItems.first as? NSExtensionItem,
let providers = item.attachments else { return }
var components = URLComponents(string: "URL Scheme 넣을 자리")!
var queryItems = [URLQueryItem]()
let count = providers.count
var currentCount = 1
for provider in providers {
if provider.hasItemConformingToTypeIdentifier(UTType.pdf.identifier) {
provider.loadFileRepresentation(forTypeIdentifier: UTType.pdf.identifier) { url, error in
if let error = error {
print(String(describing: error))
return
}
if let fileURL = url {
let manager = FileManager.default
let groupFilePath = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "사용할 App Group")!
.appending(path: fileURL.lastPathComponent)
if let _ = try? Data(contentsOf: groupFilePath) {
try! manager.removeItem(at: groupFilePath)
}
try! FileManager.default.copyItem(at: fileURL, to: groupFilePath)
queryItems.append(.init(name: "file", value: fileURL.lastPathComponent))
}
if currentCount == count {
components.queryItems = queryItems
if let url = components.url {
if self.openURL(url) {
print("url scheme success")
} else {
print("url scheme failed")
}
}
self.extensionContext?.completeRequest(returningItems: nil)
}
currentCount += 1
}
}
}
}
@objc
func openURL(_ url: URL) -> Bool {
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return true
}
responder = responder?.next
}
return false
}
}
이 중에서 핵심 코드를 살펴보겠습니다.
guard let item = self.extensionContext?.inputItems.first as? NSExtensionItem,
let providers = item.attachments else { return }
for provider in providers {
if provider.hasItemConformingToTypeIdentifier(UTType.pdf.identifier) {
provider.loadFileRepresentation(forTypeIdentifier: UTType.pdf.identifier) { url, error in
}
}
}
item.attachments에 공유하기로 받은 파일들이 포함되어 있습니다.
providers를 순회하여 파일들을 하나씩 꺼낼 수 있습니다.
그리고 hasItemhasItemConformingToTypeIdentifier() 메소드를 사용해 해당 파일의 확장자를 확인합니다.
저의 경우 pdf만 원하기 때문에 UTType.pdf.identifier를 사용했습니다.
그 후 loadFileRepresentation() 메소드로 파일을 불러옵니다.
if let fileURL = url {
let manager = FileManager.default
let groupFilePath = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "App group id")!
.appending(path: fileURL.lastPathComponent)
if let _ = try? Data(contentsOf: groupFilePath) {
try! manager.removeItem(at: groupFilePath)
}
try! FileManager.default.copyItem(at: fileURL, to: groupFilePath)
queryItems.append(.init(name: "file", value: fileURL.lastPathComponent))
}
그 후 FileManager를 통해 App group url을 얻어내고 거기에 파일 이름을 추가합니다.
만약 App group 폴더에 해당 url이 있을 경우 기존에 있는 파일을 제거하고 추가하려는 새로운 파일을 복사합니다.
if currentCount == count {
components.queryItems = queryItems
if let url = components.url {
if self.openURL(url) {
print("url scheme success")
} else {
print("url scheme failed")
}
}
self.extensionContext?.completeRequest(returningItems: nil)
}
모두 순회가 완료되면 URL Scheme을 완성하고 Containing App을 실행시키기 위해 url을 open 합니다.
사실 이 부분에서 많은 어려움을 겪었습니다. Extension에서 URL을 open 하려고 여러가지 방법을 사용해 보아도 작동이 되지 않아 4시간 정도 찾아본 결과 겨우 해답을 찾았습니다.
@objc
func openURL(_ url: URL) -> Bool {
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return true
}
responder = responder?.next
}
return false
}
해당 메소드를 통해 URL을 open 할 경우 정상적으로 ContainingApp이 실행되었습니다!
URL Scheme을 사용하기 전 간단하게 프로젝트 세팅이 필요합니다.

해당 부분에서 사용할 URL을 만들면 됩니다.
만약 URL Schemes를 thisApp으로 설정해놓으면 thisApp:// 를 실행하면 앱이 실행되게 됩니다!
그 뒤에 추가적인 쿼리문을 붙여 URL로 앱을 실행했을때 수행할 특정 작업등을 만들 수도 있습니다.
URL을 통해서 앱을 실행시키게 되면 해당 URL에 따라 앱을 어떻게 할 지 설정이 가능하고 저는 추가한 file path를 담아주었습니다.
SwiftUI에서는 .openURL()를 통해 손쉽게 사용이 가능합니다.
private func openUrlScheme(_ url: URL) {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let items = components!.queryItems!
let manager = FileManager.default
let containerURL = manager.containerURL(forSecurityApplicationGroupIdentifier: "App groups id")
for item in items {
if let containerFileURL = containerURL?.appending(path: item.value!),
let _ = try? Data(contentsOf: containerFileURL) {
let _ = homeViewModel.uploadPDF(url: [containerFileURL])
try! manager.removeItem(at: containerFileURL)
}
}
}
URL에서 쿼리에 담겨 있는 file path를 뽑아와 하나씩 추가합니다.
그 후 App group에 담겨 있는 파일을 제거 합니다.
어떻게 보면 돌아가는 방식으로 앱 외부에서 파일을 불러오기 기능을 구현했습니다.

처음에는 공유하기 기능이 앱에 포함된 하나의 기능이라고 생각했는데 외부에 떨어져 있는 독자적인 친구란 걸 알게 되어서 되게 신기했습니다.
세상에 당연한 것은 없는 거더군요 ㅜㅜ
이번에 사용한 Extension 말고도 다양한 확장 프로그램들이 있어서 이런 것들을 나중에 찾아 사용해 보아도 재미가 있을 듯 했습니다.
그럼 bye bye~