Skip to content

refactor(article): WebView 분리 + trigger 패턴 적용 - #100

Merged
gomminjae merged 1 commit into
developfrom
refactor/article-webview
May 31, 2026
Merged

gomminjae merged 1 commit into
developfrom
refactor/article-webview

Conversation

@gomminjae

Copy link
Copy Markdown
Collaborator

Summary

  • ArticleDetailView.swift 618줄 → 170줄로 축소
  • WebView 브릿지 코드와 폰트 컨트롤 UI를 별도 파일로 분리
  • @State webViewRef: WKWebView? 제거 → action trigger 패턴으로 대체

Changes

파일 분리

  • Component/ArticleWebView.swift — HighlightableWebView + ArticleWebView + Coordinator
  • Component/FontSizeControlView.swift — FontSizeControlView + BorderedThumbSlider + TrackSlider

webViewRef 제거

  • Before: 부모 View가 WKWebView 인스턴스를 @State로 보유, 직접 evaluateJavaScript 호출
  • After: ArticleWebViewAction enum 배열로 intent 전달, Coordinator가 updateUIView에서 처리

fontSize ViewModel 이동

  • Before: View에서 UserDefaults 직접 읽기/쓰기
  • After: ArticleDetailViewModel.fontSize didSet에서 자동 저장

Test plan

  • 아티클 상세 진입 및 콘텐츠 표시
  • 폰트 크기 조절 (슬라이더 + 버튼)
  • 하이라이트 생성/타입변경/삭제
  • 스크롤 최상단 이동 버튼 동작
  • 북마크 토글
  • 지난 아티클 모드

🤖 Generated with Claude Code

- ArticleDetailView에서 FullWebView/HighlightableWebView → ArticleWebView.swift 분리
- FontSizeControlView/BorderedThumbSlider → FontSizeControlView.swift 분리
- webViewRef(@State WKWebView) 제거 → ArticleWebViewAction trigger 패턴
- fontSize UserDefaults 읽기/쓰기를 ViewModel didSet으로 이동

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors ArticleDetailView by extracting the web view and font size control components into separate files (ArticleWebView and FontSizeControlView) and moving the font size state to ArticleDetailViewModel. It also introduces a trigger pattern (ArticleWebViewAction) to handle web view actions instead of directly evaluating JavaScript. Feedback on the changes includes fixing a potential race condition in ArticleWebView when clearing pendingActions, preventing memory leaks by removing script message handlers in dismantleUIView, removing unused disableHighlight properties, and ensuring the Coordinator in BorderedThumbSlider does not hold a stale reference to its parent.

Comment on lines +76 to +90
if !pendingActions.isEmpty {
for action in pendingActions {
switch action {
case .scrollToTop:
uiView.scrollView.setContentOffset(.zero, animated: true)
case .scrollToHighlight(let text):
let script = ArticleHighlightJS.scrollToHighlightScript(text: text)
uiView.evaluateJavaScript(script, completionHandler: nil)
case .removeHighlight(let text):
let script = ArticleHighlightJS.removeHighlightScript(text: text)
uiView.evaluateJavaScript(script, completionHandler: nil)
}
}
DispatchQueue.main.async { self.pendingActions = [] }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

pendingActions를 비동기적으로 비울 때 self.pendingActions = []와 같이 전체 배열을 초기화하면 레이스 컨디션(Race Condition)이 발생할 수 있습니다. updateUIView가 실행된 시점과 DispatchQueue.main.async 블록이 실행되는 시점 사이에 사용자의 새로운 액션이 추가되는 경우, 해당 새로운 액션이 유실될 위험이 있습니다. 대신 현재 처리한 액션의 개수만큼만 배열 앞부분에서 제거하는 방식(dropFirst)을 권장합니다.

Suggested change
if !pendingActions.isEmpty {
for action in pendingActions {
switch action {
case .scrollToTop:
uiView.scrollView.setContentOffset(.zero, animated: true)
case .scrollToHighlight(let text):
let script = ArticleHighlightJS.scrollToHighlightScript(text: text)
uiView.evaluateJavaScript(script, completionHandler: nil)
case .removeHighlight(let text):
let script = ArticleHighlightJS.removeHighlightScript(text: text)
uiView.evaluateJavaScript(script, completionHandler: nil)
}
}
DispatchQueue.main.async { self.pendingActions = [] }
}
if !pendingActions.isEmpty {
let actions = pendingActions
for action in actions {
switch action {
case .scrollToTop:
uiView.scrollView.setContentOffset(.zero, animated: true)
case .scrollToHighlight(let text):
let script = ArticleHighlightJS.scrollToHighlightScript(text: text)
uiView.evaluateJavaScript(script, completionHandler: nil)
case .removeHighlight(let text):
let script = ArticleHighlightJS.removeHighlightScript(text: text)
uiView.evaluateJavaScript(script, completionHandler: nil)
}
}
DispatchQueue.main.async {
self.pendingActions = Array(self.pendingActions.dropFirst(actions.count))
}
}

context.coordinator.lastFontSize = fontSize
uiView.evaluateJavaScript("adjustFontSize(\(fontSize));", completionHandler: nil)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

WKUserContentController.add(_:name:)는 메시지 핸들러(여기서는 Coordinator)를 강하게 참조(Strong Reference)하므로 메모리 누수(Memory Leak)를 유발할 수 있습니다. UIViewRepresentable이 화면에서 사라질 때 이 참조를 해제할 수 있도록 dismantleUIView 메서드를 구현하여 등록된 스크립트 메시지 핸들러들을 제거해 주는 것이 안전합니다.

    }

    static func dismantleUIView(_ uiView: HighlightableWebView, coordinator: Coordinator) {
        uiView.configuration.userContentController.removeScriptMessageHandler(forName: "textSelected")
        uiView.configuration.userContentController.removeScriptMessageHandler(forName: "getSelectedText")
        uiView.configuration.userContentController.removeScriptMessageHandler(forName: "consoleLog")
        uiView.configuration.userContentController.removeScriptMessageHandler(forName: "highlightTypeChanged")
        uiView.configuration.userContentController.removeScriptMessageHandler(forName: "highlightDeleted")
    }

Comment on lines +15 to +16
class HighlightableWebView: WKWebView {
var disableHighlight: Bool = false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

HighlightableWebView 클래스 내의 disableHighlight 프로퍼티는 정의되어 있고 makeUIView에서 설정되지만, 실제 canPerformAction 메서드 내에서는 사용되지 않고 항상 false를 반환하고 있습니다. 불필요한 데드 코드(Dead Code)를 제거하여 코드를 더 깔끔하게 유지하는 것을 권장합니다.

Suggested change
class HighlightableWebView: WKWebView {
var disableHighlight: Bool = false
class HighlightableWebView: WKWebView {

Comment on lines +56 to +58
let webView = HighlightableWebView(frame: .zero, configuration: config)
webView.disableHighlight = disableHighlight
webView.uiDelegate = context.coordinator

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

HighlightableWebView에서 사용되지 않는 disableHighlight 프로퍼티 설정을 제거하여 코드를 간결하게 유지할 수 있습니다.

Suggested change
let webView = HighlightableWebView(frame: .zero, configuration: config)
webView.disableHighlight = disableHighlight
webView.uiDelegate = context.coordinator
let webView = HighlightableWebView(frame: .zero, configuration: config)
webView.uiDelegate = context.coordinator

Comment on lines +132 to +135
func updateUIView(_ uiView: UISlider, context: Context) {
let stepped = Float(round(value / step) * step)
if uiView.value != stepped { uiView.value = stepped }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

BorderedThumbSlider 내의 updateUIView에서 context.coordinator.parent = self를 업데이트해 주지 않으면, Coordinator가 초기 생성 시점의 parent 구조체 복사본을 계속 참조하게 됩니다. 만약 step이나 range 같은 프로퍼티가 동적으로 변경될 경우, Coordinator 내에서 이전의 오래된(stale) 값을 참조하는 버그가 발생할 수 있습니다. updateUIView에서 parent를 최신 상태로 업데이트해 주는 것이 안전합니다.

Suggested change
func updateUIView(_ uiView: UISlider, context: Context) {
let stepped = Float(round(value / step) * step)
if uiView.value != stepped { uiView.value = stepped }
}
func updateUIView(_ uiView: UISlider, context: Context) {
context.coordinator.parent = self
let stepped = Float(round(value / step) * step)
if uiView.value != stepped { uiView.value = stepped }
}

Comment on lines +156 to +158
class Coordinator: NSObject {
let parent: BorderedThumbSlider
init(_ parent: BorderedThumbSlider) { self.parent = parent }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Coordinator 내의 parent 프로퍼티를 var로 변경하여 updateUIView에서 최신 parent 인스턴스를 전달받을 수 있도록 개선하는 것을 권장합니다.

Suggested change
class Coordinator: NSObject {
let parent: BorderedThumbSlider
init(_ parent: BorderedThumbSlider) { self.parent = parent }
class Coordinator: NSObject {
var parent: BorderedThumbSlider
init(_ parent: BorderedThumbSlider) { self.parent = parent }

@gomminjae
gomminjae merged commit f5aa4b2 into develop May 31, 2026
2 checks passed
@gomminjae
gomminjae deleted the refactor/article-webview branch May 31, 2026 02:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant