아주 간단하게 모바일 웹페이지 상단에 앱스토어 배너 넣기

요즘 스마트폰이 활성화 되면서 모바일 서비스를 제공시 iOS, Android 등의 스마트폰용 앱 주력으로 사용하는 경우가 많습니다.
하지만 앱 설치 유도, 접근성 등으로 모바일 웹페이지도 함께 제공을 할 수 밖에 없죠.
물론 주력 서비스는 앱이기 때문에 다양한 방법으로 모바일 웹페이지에서 앱으로의 이동을 유도합니다.

iOS에서는 아주 간편하게 해결 할 수 있습니다.

<meta name=”apple-itunes-app” content=”app-id=앱아이디”>

Meta Tag 하나를 추가하여

이런 형태의 배너를 모바일 웹페이지에 추가할 수 있습니다.

Meta Tag 내에 넣어야 하는 앱아이디는 앱스토어 링크에서 알 수 있습니다.

https://itunes.apple.com/us/app/apple-store/id375380948?mt=8

~store/id 뒤에 있는 것을 사용하면 됩니다. 375380948

 

앱을 설치하기 전에는 위 배너가 앱스토어로 이동되지만 앱을 설치한 후에는 앱으로 열어 줍니다.
앱을 연 주소를 가지고 앱에서 대응을 하려면

AppDelegate.swift 에 application(_:open:options:) 를 추가한 후 URL에 맞게 대응할 수 있습니다.

https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623112-application

 

Advertisements

‘Guideline 2.5.1 – Performance – Software Requirements’ ‘Your app uses or references the following non-public APIs:’ 리젝에 대응하는 방법.

애플에서 허용하지 않는 API를 사용하는 경우에 앱이 리젝 당합니다.
당연히 사용하지 말라고 한 API를 사용한 코드를 제거하고 다시 심사요청을 하면 되겠죠.

그런데 iOS 앱 개발자들은 일반적인 앱을 만들 경우 비허용/비공개 API를 사용할 일이 잘 없습니다.
Xcode에서 검색을 해도 안 나오겠죠.
가져다 쓴 라이브러리나 타 서비스 SDK에서 사용한 경우일 것입니다.
간단하게 외부 바이너리 라이브러리에서 비허용 API를 사용하고 있는 지 확인하는 방법을 알려 드리겠습니다.

프로젝트 폴더 혹은 외부 바이너리 라이브러리가 있는 폴더에서
터미널 명령으로

grep -R ‘애플에서 알려 준 non-public API’ *

예를 들자면 광고 대행사 SDK를 많이 가져다 쓰면 발생할 수 있는 리젝 사유인 LSApplicationWorkspace

grep -R ‘LSApplicationWorkspace’ *

어느 광고 대행사 SDK때문에 리젝된 것인지 알 수 있습니다.

 

 

present()로 띄운 UIViewController의 WKWebView에서 사진 첨부를 하면 UIViewController가 닫혀(dismiss()) 버린다.

present() 로 띄운 UIViewController에 있는 UIWebView/WKWebView에서 input type=”file” / 을 눌러서 사진을 첨부하려 하면 해당 UIViewController까지 함께 닫혀 버리는 문제가 있습니다.

이 문제는 iOS 8.0.2 부터 발생한 문제로… iOS 9는 물론 iOS 10에서도 고쳐지지 않았죠.

이쯤되니 OS 자체 버그가 아니라 의도된 것 같다는 생각이 듭니다. 의도된 것이 맞다면 어떤식으로 개발을 해야 되는 건지 모르겠네요.

그래서 사용한 임시 방법.

dismiss()를 override 해버리는 방법입니다.

// MARK: - Variable
var isDismiss: Bool = false

// MARK: - Override
public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
    if !self.isDismiss && (self.presentedViewController == nil || self.presentedViewController == self) {
        return
    }

    self.isDismiss = false
    super.dismiss(animated: flag, completion: completion)
}

// MARK: - Function
func close(animated: Bool, completion: (() -> Void)? = nil) {
    self.isDismiss = true
    self.dismiss(animated: animated, completion: completion)
}
// MARK: - Action
@IBAction func close(_ sender: UIButton) {
    self.close(animated: true)
}

편법이지만 제일 간단한 방법이죠…

WKWebView 사용 시 필수적으로 넣어줘야 하는Delegate 넷.

WKWebView를 사용한다면 아래의 넷은 반드시 넣어 주어야 한다. 복사->붙여 넣기 후 개발을 시작하면 된다.

우선, WKUIDelegate 셋.

func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping () -> Void) {
    let alertController = UIAlertController(title: "", message: message, preferredStyle: .alert)
    alertController.addAction(UIAlertAction(title: "확인", style: .default, handler: { (action) in
        completionHandler()
    }))

    self.present(alertController, animated: true, completion: nil)
}

func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (Bool) -> Void) {
    let alertController = UIAlertController(title: "", message: message, preferredStyle: .alert)
    alertController.addAction(UIAlertAction(title: "확인", style: .default, handler: { (action) in
        completionHandler(true)
    }))
    alertController.addAction(UIAlertAction(title: "취소", style: .default, handler: { (action) in
        completionHandler(false)
    }))

    self.present(alertController, animated: true, completion: nil)
}

func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (String?) -> Void) {
    let alertController = UIAlertController(title: "", message: prompt, preferredStyle: .alert)
    alertController.addTextField { (textField) in
        textField.text = defaultText
    }
    alertController.addAction(UIAlertAction(title: "확인", style: .default, handler: { (action) in
        if let text = alertController.textFields?.first?.text {
            completionHandler(text)
        } else {
            completionHandler(defaultText)
        }
    }))

    alertController.addAction(UIAlertAction(title: "취소", style: .default, handler: { (action) in
        completionHandler(nil)
    }))

    self.present(alertController, animated: true, completion: nil)
}

이건 iOS 9.0 이후 지원이긴 한데… WKNavigationDelegate 하나.

public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
    // 중복적으로 리로드가 일어나지 않도록 처리 필요.
    webView.reload()
}

앱이 백그라운드 상태에서 포그라운드로 돌아 올때 간혹 WKWebView가 하얀 화면으로 되어 있는 경우가 있는데, 보통 자바스크립트 등으로 체크해서 대응을 하고 있지만 iOS 9이상에선 지원하고 있다.

HTTP 주소로 iOS 앱을 실행하여 보자. (Support Universal Links)

아래의 방법은 iOS 9 이상에서 정상적으로 지원합니다. 또한 서버는 HTTPS를 지원해야 합니다.
참조 문서 : Support Universal Links

아이폰으로 서핑하다보면 유투브 링크가 있고 해당 링크를 누르면 유투브 앱이 열리는 상황을 경험한 적이 있습니다.

본인이 만든 앱도 이렇게 하고 싶다는 생각을 했을 것입니다.

앱을 설치하지 않은 사용자는 간략한 정보가 표시되는 모바일웹을, 앱을 설치한 사용자는 풀서비스를 이용할 수 있도록 앱으로 연결을.

애플은 이런 개발자들의 욕구를 수용해 Universal Link 기능을 추가하였습니다.
하지만 간단히 앱에서 설정하는 것만으로 해당 기능을 이용할 수 없습니다.
보안을 중요시 여기는 애플 답게 우선 해당 HTTP 주소에 대해 앱이 권한을 가지고 있는 지 확인을 합니다.

우선 해당 HTTP 주소에 앱이 권한을 가지고 있다는 것을 명시하는 방법 부터 소개 하겠습니다.

서버의 https://[당신의 도메인]/ 또는 https://[당신의 도메인]/.well-known/ 으로 접근 가능하도록 apple-app-site-association 파일을 생성해야 합니다.

https://[당신의 도메인]/apple-app-site-association 또는 https://[당신의 도메인]/.well-known/apple-app-site-association 되겠죠.

명심해야 될 2가지는 https를 지원해야 한다는 것과 파일에 확장자가 없어야 한다는 것입니다. 아래의 예제를 보시면 아시겠지만 apple-app-site-association 파일은 JSON 형태입니다. 하지만 .json 을 붙이지 마세요.

{
"applinks": {
    "apps": [ ],
    "details": [
        {
            "appID": "[Prefix].[앱의 Bundle ID]",
            "paths": [ "*/members/*", "/detail/*", "NOT /detail/old" ]
        },
        {
            "appID": "[Prefix].[두번째 앱의 Bundle ID]",
            "paths": [ "*" ]
        }
    ]
}
}

위의 apple-app-site-association에는 2개의 앱이 선언되어 있습니다.
apps는 비워 둡니다.
details에 등록할 앱 정보를 기입합니다.
appID에 Prefix와 앱의 Bundle ID를 기입합니다.
paths는 중요합니다. 모든 HTTP 주소에 앱을 연결 시킬 수 있고 특정 HTTP 주소에만 앱이 연결 되도록 할 수 있기 때문입니다.
당연히 NOT는 제외할 주소입니다. /detail/*는 허용하지만 /detail/old 는 거부할 수 있죠.
* 만 해 놓으면 모든 주소를 연결 시킬 수 있습니다.

유투브의 경우엔 모든 주소를 앱으로 연결 되도록 하고 제외할 주소를 추가하는 방식을 썼습니다.
실용 예제로 삼아 한번 보시기 바랍니다.(Document Root에 파일을 두기 싫었는 지 .well-known 아래에 두었군요.)

http://youtube.com/.well-known/apple-app-site-association

유투브 앱의 Prefix는 EQHXZ8M8AV 이었군요. 모르시는 분은 없으시겠지만 Prefix는 https://developer.apple.com 에서 알 수 있습니다.

Developer > Certificates, Identifiers & Profiles (https://developer.apple.com/account/ios/certificate) > App IDs (https://developer.apple.com/account/ios/identifier/bundle) > 원하는 앱 선택.

teamid

자, 이제 정상적으로 설정이 끝났는 지 확인을 해봅시다.

https://branch.io/resources/universal-links/

간단히 도메인만 넣어 주면 apple-app-site-association 파일을 찾아 테스트를 하여 줍니다.
모든 항목이 초록색이 나오면 준비 끝.
(애플에서도 테스트를 할 수 있는 툴(https://search.developer.apple.com/appsearch-validation-tool/)을 제공하고 있고 앱 설정까지 확인해 주기 때문에 더 정확합니다. 하지만 개발 단계에서 서버에서의 작업만 확인 하기에는 위 툴이 더 편리합니다.)

다음은 앱으로 넘어 갑니다.

Xcode에서 간단히 처리 가능합니다.

associateddomains

Associated Domains 항목을 활성화 후 Domains에 applinks:도메인 을 입력합니다.

끝!

이제 링크를 눌렀을때 앱이 실행됩니다. Custom Scheme를 이용한 Deep Link 시에는 앱을 열 것이냐는 물음이 뜹니다. Universal Link는 그런 물음도 없이 자연스럽게 앱이 실행됩니다.

위 작업을 모두 끝낸 후 테스트 할때 주의 하실 점.

  1. 사파리에서 직접 주소를 치면 웹으로 가고 싶어 하는 의사로 판단 Universal Link가 작동하지 않습니다.
  2. 테스트를 위해 링크를 삽입한 웹페이지가 동일한 도메인이면 웹 내의 정상적인 서핑으로 판단 Universal Link가 작동하지 않습니다.

테스트에 성공하였나요? 축하 드립니다.

참고로 이거 달고 앱 설치하면 Safari에서 모바일 웹 상단에 ‘열기’가 생깁니다. 사용자의 앱 이용을 유도할 수 있는 것이죠.

앱을 실행 시킬때는 HTTP 주소를 함께 전달해 줍니다. AppDelegate.swift 에서 받아서 처리할 수 있습니다.

func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Swift.Void) -> Bool {
    // 이곳에서 userActivity.activityType 과 userActivity.webpageURL 을 이용하여 필요한 ViewController 를 띄우는 처리를 하면 됩니다.
    return true
}

옵셔널 변수를 적절하게 처리하는 예제들.

var productNo: String?
var productTitle: String?

.
.
.

guard let no: String = self.productNo else {
    print("제폼 번호가 전달자로 부터 대입되지 않음. 이후 행등 실행 종료.")
    return
}

print("제품 번호는 \(no)")

.
.
.

if let no: String = self.productNo, let title: String = self.productTitle {
    print("제품 \(title)의 번호는 \(no)")
}

.
.
.

// 변수 대입 시 nil 일 경우 별도값을 지정.
let no: String = self.productNo ?? "1"

.
.
.

// guard의 else return의 적절한 사용예.
func aaa(a: Int?) -> Bool {
    guard let b: Int = a else {
        return false
    }

    if b > 3 {
        return true
    }
    else {
        return false
    }
}

앱을 다시 시작하면 WKWebView 쿠키가 모두 사라져요.

WKWebView에서 생성된 쿠키가 앱을 재시작할때 모두 사라지는 문제는 여러 해결 방법이 있지만, 그 중 가장 단순한 건 역시 모든 통신 종료 시점에 쿠키를 저장했다가 앱을 시작하면 쿠키를 수동으로 생성 시켜 주는 방법일 것입니다.

이전에도 Javascript를 애용했으므로 이번에도 Javascript를 이용하도록 하겠습니다.

예제 상에서 파일을 저장하고 불러 오는 것은 Devg.framework를 이용하도록 하겠습니다.
(https://bitbucket.org/ZenR/devg.git)

우선 모든 웹 통신이 종료될 시 가지고 있던 쿠키를 평문으로 저장하도록 하겠습니다. 보안에 좀 더 신경을 쓴다면 한번 암호화를 거치는 것도 좋겠죠.

func webView(webView: WKWebView, didFinishNavigation navigation: WKNavigation) {
    webView.evaluateJavaScript("document.cookie") { (object, error) in // 자바스크립트로 모든 쿠키를 가져와서
         if let string: String = object as? String { // 스트링으로 변환 후 저장.
             Devg.createFile("COOKIE", contents: string) // 파일 저장하는 부분이므로 대체 하세요.
         }
    }
}

 

 

이제 저장한 쿠키 정보를 불러와서 웹뷰에서 생성하는 작업을 해주어야 합니다.
적절한 도메인에 쿠키를 생성해 주기 위해 첫 웹뷰 로딩은 그냥 진행 시키도록 할게요.
결국 Javascript 기반이기 때문에 쿠키 생성도 didFinishNavigation 에서 진행해 주어야 하는데 쿠키 파일 저장과 생성하는 것이 한 곳에서 이루어 지므로 구분 하기 위해 isFirstLoad라는 멤버 변수를 만들어 주겠습니다.

var isFirstLoad: Bool = true

이제 isFirstLoad 변수를 참조하여 didFinishNavigation에 쿠키 생성하는 코드를 추가 합니다.

func webView(webView: WKWebView, didFinishNavigation navigation: WKNavigation) {
    if self.isFirstLoad { // 앱 처음 실행인지 판단.
        self.isFirstLoad = false // 쿠키 생성을 다시 하지 않도록 처리.
        if let string: String = Devg.getContents("COOKIE") { // 저장된 쿠키 파일을 불러 옴.
            // 쿠키를 생성하는 Javascript
            let cookies: [String] = string.componentsSeparatedByString(" ")
            var javascript: String = ""
            for cookie: String in cookies {
                javascript = javascript + "document.cookie='\(cookie)';"
            }
            webView.evaluateJavaScript(javascript, completionHandler: { (object, error) in // 쿠키를 생성하는 Javascript를 실행.
                webView.reload() // 쿠기 생성 후 쿠키 정보가 반영된 웹페이지를 불러 올 수 있도록 새고 고침을 해줍니다.
            })
        }
    }
    else {
        webView.evaluateJavaScript("document.cookie") { (object, error) in // 자바스크립트로 모든 쿠키를 가져와서
             if let string: String = object as? String { // 스트링으로 변환 후 저장.
                 Devg.createFile("COOKIE", contents: string) // 파일 저장하는 부분이므로 대체 하세요.
             }
        }
    }
}

https://ip.devg.net 은 사용자 아이피, 헤더 정보 등을 확인할 수 있는 웹페이지인데, 쿠키 정보도 볼 수 있으므로 간단히 테스트 후 실제 URL을 적용하시면 됩니다.