[WebKit] WKWebView를 사용해서 앱 사용 중, 웹으로 연결시켜보자

2024. 2. 15. 21:35Framework, Library

1️⃣ 이번 글에서 구현하고자 하는 기능은?

이번 글에서는 URL 링크를 저장하고, 해당 링크를 앱 안에서 웹 페이지로 띄울 때 사용한 모든 기능들에 대해서 정리해보겠다.
이번 프로젝트에서 앱 내 웹 콘텐츠를 표출시키는 방식으로 WKWebView를 사용했다.

WKWebView란 Apple의 기본 프레임워크 WebKit를 기반으로 동작하는 뷰이며, 네이티브 앱과 웹 콘텐츠 사이의 상호작용을 도와주는 다양한 기능을 지원하는 화면이다.
대표적으로, 웹 뷰의 내비게이션(뒤로 가기, 앞으로 가기) 동작이나 새로고침, 페이지의 타이틀, URL 같은 기본적인 정보들을 받아올 수 있으며, 심지어는 웹 페이지의 로딩 상태나 스크롤 위치 같은 디테일한 부분도 사용할 수 있다.

이번 글에서 구현해준 화면!

💡 앱 사용에 있어 외부 URL 연결 방식에 대한 생각거리

iOS 앱 사용 중 웹으로 연결시키는 방법은 크게 세 가지이다.
앱 내에서 웹을 표출하는 WKWebView 방식, 앱 내에서 사파리 브라우저를 표출하는 SFSafariVC 방식, 그리고 앱 외부인 사파리를 작동시키는 UIApplication.shared.open 코드 한 줄을 써주는 방식.

"이 중 어떤 방식을 사용할 것인가"에 있어 개발자가 중요하게 고려해야 할 점은 "사용자에게 앱 내에서 어떤 경험을 제공해주고 싶은가"라고 생각한다.

우리 팀의 경우에는 "사용자가 앱 내에서 머무는 시간을 오래 가져가도록 하고 싶다"라는 기획과 개발 간의 공통된 의견이 있어, 마지막 방식을 제외하고 고려했으며,

웹 화면에서도 네트워크 통신이나 액션을 조작해야 하는 부분(링크 열람 여부를 수정하고, 이에 따른 네트워크 통신이 필요한 앱의 플로우)이 있어 최종적으로 WKWebView 방식을 선택하게 되었다.

사파리의 기능(북마크, 자동 로그인 등)을 활용하고 싶은 경우에는 SFSafariVC 방식을 고려해 볼 수도 있겠지만,

보통 앱에 머무는 시간이 중요하고, 웹 페이지의 컨텐츠를 커스텀하고 싶은 경우가 대부분이기 때문에 웹을 표출하는 경우에 WKWebView 방식이 권장되고 있는 분위기라는 것까지 알고 넘어가보자!

 

2️⃣ WebKit 기본 세팅하기

프로젝트 설정 파일 -> Targets 클릭 후, General 탭 진입 -> Frameworks, Libraries, and Embedded Content -> + 버튼 클릭 -> Apple SDKs에 들어있는 WebKit.framework 추가

WKWebView를 사용하기 위해서는 Apple의 내장 프레임워크인 WebKit을 우선 import 해줘야 한다.
Apple SDKs에 들어있는 WebKit.framwork를 아래와 같이 추가해주자.

import가 되었으면, WKWebView 객체를 선언할 수 있을 것이다.
아래와 같이 선언해주고, addSubView와 원하는 레이아웃까지 잡아주도록 하자. (코드에서는 생략)

import WebKit

private let webView = WKWebView()

 

💡 전달받은 URL을 WebView에 띄우는 방법

링크를 웹 뷰에 불러오는 방법은 매우 간단하다.

linkURL이라는 String 객체가 올바른 URL일 경우(nil이 아니거나 url 형식인 경우)에 url이라는 이름의 상수로 할당된다.
*Swift에서 URL 객체를 생성하는 경우에 위와 같은 언래핑 과정이 필수적이다. (역시 Safety..한 언어...)

생성한 URL 객체를 URLRequest 객체로 래핑한다.
여기서 URLRequest는 웹 페이지를 로드하기 위해 필요한 요청 정보(메서드, 헤더, 바디)를 담고 있는 객체를 말한다. 나는 URL 링크만 사용했다.
WKWebView의 load라는 메서드를 이용해 생성해 둔 URLRequest를 로드하고, 웹 페이지에 표시한다.
여기까지 했다면, 링크를 전달받고, 화면에 해당 링크에 대한 웹 페이지가 문제없이 표출될 것이다!

if let url = URL(string: linkURL) {
    let request = URLRequest(url: url)
    webView.load(request)
}

 

3️⃣ 내비게이션바 기능 구현하기

이제 추가적인 웹뷰 기능들을 구현해 주자.

상단 내비게이션 바에 들어갈 기능은 웹뷰를 벗어나게 하는 dismiss Button, 현재의 웹 링크 주소를 표출시키는 UILabel, 웹뷰를 새로 불러오는 새로고침 버튼 이렇게 3가지이다.
이 중 왼쪽의 X 버튼을 제외하고 나머지 2가지 기능에 대해서 설명해 보겠다.


3-1. WKWebView 새로고침

WKWebView에서 현재 웹 뷰를 새로 불러오려면 reload()라는 메서드를 호출해주기만 하면 된다.

/// 네비게이션바 새로고침 버튼 클릭 액션 클로저
navigationView.reloadButtonTapped {
    self.webView.reload()
}


3-2. WKWebView의 현재 웹 페이지 링크 받아오기

WKWebView에서 현재 웹 페이지 링크를 받아오기 위해서는 먼저 웹 페이지가 로드될 때 호출되는 메서드를 불러와야 한다.

이는 WKNavigationDelegate 프로토콜을 준수하는 델리게이트 메서드 webView(_:didCommit:)에서 사용할 수 있다.
webView(_:didCommit:)은 웹 페이지가 로드될 때 호출된다.

이 외에도 뒤에서 사용하게 될 메서드로는 웹 페이지 로딩이 완료되었을 때 호출되는 webView(_:didFinish:)나 웹 페이지 로딩이 시작될 때 호출되는 webView(_:didStartProvisionalNavigation:) 등이 있다. (지금은 간단히 넘어가자!)

위 델리게이트 메서드를 바탕으로 현재 웹 뷰의 URL을 가져올 수 있다. (webView.url)
그리고 해당 URL을 문자열로 변환시켜주는 absoluteString을 통해 문자열로 반환해서, 내비게이션 뷰에 있는 UILabel과 연결해주기만 하면 되겠다.

/// 웹 뷰의 NavigationDelegate self 지정
webView.navigationDelegate = self

extension LinkWebViewController: WKNavigationDelegate {
    /// 현재 웹 페이지 링크를 받아오는 함수
    func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
        if let url = webView.url?.absoluteString {
            navigationView.setupLinkAddress(link: url)
        }
    }
}

 

4️⃣ 툴바 기능 구현하기

이번에는 하단에 있는 커스텀 툴바(ToolBar) 기능을 구현해 보겠다.
툴바에는 웹 뷰 뒤로 가기, 앞으로 가기, 네트워크 통신 버튼 (이 글에서는 다루지 않는다), 사파리 연결 버튼이 포함되어 있었다.


4-1. ToolBar 세팅

UIToolBar를 선언하고 툴바에 들어갈 아이템들을 UIBarButtonItem 타입으로 선언해 주었다.
그렇게 선언해 준 BarButtonItem들을 setItems 메서드를 이용해 배열 형태로 추가해주면 된다. (animated는 차이점을 잘 모르겠다..)

flexibleSpace는 ToolBar에서 유동적인 빈 공간을 채우기 위해서 사용하는 컴포넌트다.
setItems에 포함시키는 것만으로도 툴바 내 각 버튼 사이의 간격을 서로 조절하고 균형을 자동으로 맞춰주게 된다.
private let toolBar = UIToolbar()

private let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
private lazy var backButton = UIBarButtonItem(image: ImageLiterals.Web.backArrow, style: .plain, target: self, action: #selector(goBackInWeb))
private lazy var forwardButton = UIBarButtonItem(image: ImageLiterals.Web.forwardArrow, style: .plain, target: self, action: #selector(goForwardInWeb))
private lazy var readLinkCheckButton = UIBarButtonItem(image: ImageLiterals.Web.document, style: .plain, target: self, action: #selector(checkReadInWeb))
private lazy var safariButton = UIBarButtonItem(image: ImageLiterals.Web.safari, style: .plain, target: self, action: #selector(openInSafari))

toolBar.setItems([backButton, flexibleSpace, 
                  forwardButton, flexibleSpace, 
                  readLinkCheckButton, flexibleSpace, safariButton], animated: false)

 

4-2. WKWebView 내의 뒤로 가기, 앞으로 가기 버튼

버튼의 활성화/비활성화 여부를 담을 Bool형의 변수 2개를 우선 만들어줬다.
didSet을 이용해서 해당 변수의 새로운 값이 설정될 때마다 버튼의 활성화여부와 버튼의 색상을 변경하도록 작성했다.

    private var canGoBack: Bool = false {
        didSet {
            backButton.isEnabled = canGoBack
            backButton.tintColor = canGoBack ? .gray700 : .gray150
        }
    }
    
    private var canGoForward: Bool = false {
        didSet {
            forwardButton.isEnabled = canGoForward
            forwardButton.tintColor = canGoForward ? .gray700 : .gray150
        }
    }

그럼 위에서 만든 두 변수(canGoBack, canGoFront)의 값은 어디서 받을 수 있을까?

바로 위에서도 설명했던 WKNavigationDelegate를 채택한 곳의 webView(:didCommit:) 메서드에서 받아올 수 있다.
webView.canGoBack으로 뒤로 가기가 가능한지, webView.canGoForward로 앞으로 가기가 가능한지를 웹 페이지가 로드될 때마다 호출해서 저장할 수 있겠다.

extension LinkWebViewController: WKNavigationDelegate {
    /// 현재 웹 페이지 링크를 받아오는 함수
    func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
        ...
        canGoBack = webView.canGoBack
        canGoForward = webView.canGoForward
    }
}

마지막으로, canGoBack와 canGoForward가 가능한 경우에 대해 활성화된 버튼을 클릭 시,
각각 WKWebView에서 기본 제공하는 goBack()과 goForward() 메서드로 화면을 이동시킬 수 있다.

    /// 툴바 뒤로가기 버튼 클릭 시
    @objc func goBackInWeb() {
        if webView.canGoBack {
            webView.goBack()
        }
    }
    
    /// 툴바 앞으로가기 버튼 클릭 시
    @objc func goForwardInWeb() {
        if webView.canGoForward {
            webView.goForward()
        }
    }


4-3. WKWebView 사파리 버튼

앱 사용에 있어 외부 URL 연결 방식에 대한 고민방식 중 하나였던,
외부 사파리를 열 수 있는 코드 한 줄 UIApplication.shred.open을 사용해서 외부 사파리로 연결해 줄 수 있었다.

    /// 툴바 사파리 버튼 클릭 시
    @objc func openInSafari() {
        if let url = webView.url {
            UIApplication.shared.open(url)
        }
    }

 

5️⃣ 프로그레스바 기능 구현하기 (웹 페이지 로딩 시 상태 표출)

여기까지 구현하고 보니 무엇인가 허전한 점이 있었다.

처음 웹 페이지로 넘어가고, 링크에 맞는 화면이 표출되기까지 존재하던 딜레이 시간 동안 사용자에게 아무런 액션이 주어지지 않는다는 점.
또한 새로고침 버튼을 눌러도 현재 페이지가 다시 로딩되는 것이 맞는지/버튼이 눌린 것이 맞는지 알 수가 없다는 점 등이 문제였다.

이 방법을 해결하기 위한 방법으로,
상단 내비게이션바 밑에 UIProgressView를 넣어 현재 웹 페이지의 로딩 상태를 사용자에게 표출시켜주는 기능을 생각하게 된다.

항상 해온 것처럼 UIProgressView 객체를 선언해주고 레이아웃을 잡아주자.

private let progressView = UIProgressView().then {
    $0.tintColor = .toasterPrimary
    $0.translatesAutoresizingMaskIntoConstraints = false
}

이 기능을 구현할 때의 핵심 내용은 "웹 페이지의 로딩 상태를 어떻게 가져올 것인가"였다.

해당 부분은 observeValue 메서드를 사용했다.
해당 메서드에서는 KVO라 불리는 Key-Value Observing을 사용하여 WKWebView의 estimatedProgress 속성의 변경을 감지할 수 있었다.
estimatedProgress는 현재 웹 페이지 로딩의 추정 진행 상태를 나타낼 수 있는 Key 값이다.

웹 뷰의 변경이 감지되었을 때, progressView의 progress 속성(NSNumber 타입의 변환과 Float 형변환이 이루어진다.)을 업데이트시키고, 그에 따라 progressView의 상태도 동시에 바뀌는 식으로 코드를 구현했다.

extension LinkWebViewController {
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
        if keyPath == "estimatedProgress" {
            if let newProgress = change?[.newKey] as? NSNumber {
                let progress = Float(truncating: newProgress)
                progressView.progress = progress
            }
        }
    }
}

그리고 Delegate를 이용해서 (3️⃣에서 웹 링크를 가져오기 위해 사용했던 WKNavigationDelegate 그대로 사용한다.)
웹 페이지의 로딩이 시작 (didStartProvisionalNavigation)될 때 프로그레스 뷰를 보이도록 (isHidden = false), 웹 페이지 로딩이 완료 (didFinish navigation)되었을 때 프로그레스 뷰가 사라지도록 (isHidden = true) 설정했다.

// MARK: - WKNavigationDelegate Extensions

extension LinkWebViewController: WKNavigationDelegate {
    ...
    /// 웹 페이지 로딩이 시작할 때 호출
    func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
        progressView.isHidden = false
    }
    
    /// 웹 페이지 로딩이 완료되었을 때 호출
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        progressView.isHidden = true
    }
}

그런데 위와 같은 observeValue 메서드를 사용하기 위해서는 "어떤 WKWebView 객체의 estimatedProgress 속성을 관찰할지 지정하는 로직"이 하나 더 필요하다.
더 정확하게, WKWebView 객체의 관찰하고자 하는 속성(estimatedProgress)을 지정(addObserver)하는 것이다.

webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: .new, context: nil)

위와 같이 등록을 해두게 되면,
웹 페이지가 변경되는 상태에 맞게 로딩 진행 상태를 감시(observer)할 수 있게 되고, 이제 드디어 로딩값을 ProgressView에 담을 수 있게 된다.

그런데 발생하던 Warning!

KVO를 사용한 addObserver를 사용해 본 적이 거의 없었기에 방법을 찾지 못하던 중, KVO(Key-Value Observing)의 특징에 대해서 제대로 이해를 하다 보니 그 해결법을 찾을 수 있었다.

⚠️ KVO를 사용할 때는 반드시 해당 객체가 해제되기 전에 removeObserver(_:forKeyPath:) 메서드를 호출하여 관찰(Observer)을 중단(remove) 해야 한다!
만약 그렇지 않으면 사용하지 않는 객체(= 이 글에서는 사용이 끝난 webView를 의미)에 대해 불필요한 관찰이 계속되기 때문에 메모리 누수가 발생하게 된다.

즉, deinit(객체 해제) 블록에서 웹뷰에 대한 estimatedProgress observer을 제거(removeObserver)함으로써,
불필요한 메모리 누수가 발생하고 있는 이슈이자, 경고를 해결할 수 있었다.

deinit {
    webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress))
}