본문 바로가기
C.W.K.
Stream
Lesson 03 of 06 · published

NSViewRepresentable과 NSHostingView: 객체는 누구 거야

~16 min · appkit-swiftui, nsviewrepresentable, nshostingview, lifetimes, wkwebview

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"페이지도 안 열리고, 델리게이트 콜백도 안 오고, 에러도 없었어. 웹 뷰가 아예 없었거든."

다리 둘, 방향 둘

  • NSHostingView / NSHostingController는 SwiftUI 뷰를 AppKit 안에 넣어. 호스팅 뷰의 수명은 다른 NSView와 똑같이 AppKit 쪽이 관리해. 이 방향으론 놀랄 일이 거의 없어.
  • NSViewRepresentable은 AppKit 뷰를 SwiftUI 안에 넣어. 뷰 트리에 처음 나타날 때 SwiftUI가 makeNSView를 부르고, 입력이 바뀔 때마다 updateNSView를 불러. 트리에서 빠지면 뷰를 부숴. 갱신이 여러 번 일어나도 살아남아야 하는 델리게이트 상태는 Coordinator가 들고 있어.

두 번째 방향엔 까먹기 쉬운 결과가 하나 있어. AppKit 객체의 수명을 SwiftUI 뷰 트리가 쥔다는 거야. representable이 if 안에 있으면, 조건이 거짓인 동안 그 객체는 존재하지 않아.

한 번도 안 만들어진 미리보기 창

Mac 에이전트 클라이언트의 도크에 웹 미리보기 창이 있었어. 화면 없는 스모크 테스트가 그 창의 모델을 조종했지. 작업 공간을 연결하고, 도구를 고르고, openWorkspace(relativePath:)를 부르고, 페이지가 열리길 기다렸어. 영원히 기다렸어. 그런데 그 창 앞에는 뷰 수준의 관문이 있었어. 세션이 연결되지 않았으면 안내 화면을 대신 보여주는 관문이야. 게다가 WKWebView가 만들어지는 유일한 곳이 그 창의 makeNSView 안이었어. 거기서 뷰를 모델에 넘겨주기도 했고. 창이 안 그려지니 모델의 weak var webView는 계속 nil이었고, webView?.load(…)는 에러 하나 없이 아무것도 안 하고 넘어갔어.

고친 건 두 가지고, 둘 다 필요했어. 첫째, 스모크 테스트가 뷰를 건너뛰고 모델을 직접 찌르는 대신, 사람처럼 세션을 열어서 진짜 관문을 통과해. 둘째, 페이지를 이동하기 전에 명시적으로 isAttached를 기다리고, 시간 안에 안 되면 무엇이 안 됐는지 이름을 대며 실패하는 데드라인을 걸어. 쓸모 있는 구별법도 하나 나왔어. 같은 스모크 테스트를 접속할 수 없는 URL로 돌려봐. 웹 뷰가 있으면 1초 안에 이동 실패 콜백이 오고, 아무 반응이 없으면 웹 뷰가 없다는 증거야.

관문은 모델이 판단해야 해

같은 도크에 비슷한 버그가 하나 더 숨어 있었어. 파일 창이랑 터미널 창은 모델의 로컬 루트가 nil이면 "먼저 세션을 시작해"를 보여줬는데, 원격 세션은 원래 그 필드를 nil로 둬. 그래서 Mac에서 원격 도크가 한 번도 안 그려졌는데, 모델 수준 테스트는 전부 통과했어. 고친 방법은 이래. 준비됐는지 판단하는 기준을 모델에 딱 하나만 뒀어(로컬 루트 또는 원격 세션). 그리고 안내 화면을 보여줄지는 뷰가 모델의 static 함수를 불러서 정하게 했어. 이제 그 결정은 함수 안에 있어서, 테스트는 그걸 부를 수 있고 뷰 본문은 우회할 수 없어.

Code

모델은 자기 뷰가 있는지 알고, 관문은 모델이 직접 판단해·swift
import SwiftUI
import WebKit

@MainActor
@Observable
final class PreviewModel {
    var workspaceRoot: URL?
    var remoteSession: String?
    fileprivate(set) weak var webView: WKWebView?

    var isBound: Bool { workspaceRoot != nil || remoteSession != nil }   // one readiness predicate
    var isAttached: Bool { webView != nil }

    static func showsPlaceholder(_ model: PreviewModel) -> Bool { !model.isBound }

    enum PreviewError: Error { case paneNotAttached }

    func open(_ url: URL) throws {
        guard let webView else { throw PreviewError.paneNotAttached }  // not a silent no-op
        webView.load(URLRequest(url: url))
    }

    fileprivate func attach(_ view: WKWebView) { webView = view }
}

struct PreviewPane: View {
    let model: PreviewModel
    var body: some View {
        if PreviewModel.showsPlaceholder(model) {
            ContentUnavailableView("Open a workspace first", systemImage: "folder")
        } else {
            WebViewHost(model: model)
        }
    }
}

struct WebViewHost: NSViewRepresentable {
    let model: PreviewModel
    func makeNSView(context: Context) -> WKWebView {
        let view = WKWebView(frame: .zero)
        model.attach(view)            // the ONLY place the web view comes into existence
        return view
    }
    func updateNSView(_ nsView: WKWebView, context: Context) {}
}

External links

Exercise

Spark Mac 타깃에 PreviewModel, PreviewPane, WebViewHost를 넣어. 창을 그리지 않고 모델만 만들어서 open(_:)을 부르는 테스트를 써. 조용히 넘어가는 대신 paneNotAttached를 던지는지 확인해. 이어서 PreviewModel.showsPlaceholder 단위 테스트를 세 경우로 써. 세션이 없을 때, 로컬 루트가 있을 때, 로컬 루트 없이 원격 세션만 있을 때야. 세 번째 경우가 왜 가족 앱에서 고장 났던 경우인지 한 문장으로 설명해봐.
Hint
안내 화면 테스트엔 뷰 검사 라이브러리가 필요 없어. 결정이 모델의 static 함수니까, 테스트는 필드를 채우고 그걸 부르기만 하면 돼.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.