htmlswiftswiftuiwebviewwkwebview

Too many web view instances causes phone to overheat Swift


I am making a TikTok clone using YouTube shorts. I present videos in a vertical tabview that allows users to scroll though a list of videos. Since these videos are on the web I use a webview to render them in. As the user scrolls through the tabview new instances of web views are created for the new videos. When the user scrolls backwards they can see the previous videos (already rendered) at the same duration.

This means that the web views are not destroyed when the user swipes away from them. After scrolling for a few minutes the device gets noticeably warm due to the fact that a lot of web view instances require a large sum of resources. How can I destroy these web views when the user is 2 videos beyond?

import SwiftUI
import WebKit
import UIKit

struct AllVideoView: View {
    @State private var selected = ""


    @State private var arr = ["-q6-DxWZnlQ", "Bp3iu47RRJQ", "lXJdgDjw1Ks", "It3ecCpuzgc", "7WNJjr8QM1w", "z2t0W8YSzZo", "w8RBGoH_6BM", "DJNAUBoxW5g", "Gv0X34FZ_8M", "EUTsaD1JFZE",
    "yM9iLvOL2v4", "lnqhfn2n-Jo", "qkUpWwUAFPA", "Uz21KTMGwAI", "682rP7VrMUI",
    "4AOcYT6tnsE", "DEz9ngMqVT0", "VOY2MviU5ig", "F8DvoxgP77M", "LGiRWOawMiw",
    "Ub8j6l35VEM", "0xEQbJxR2hw", "SVow553Lluc", "0cPTM7v0vlw", "G12vO9ziK0k"]


    var body: some View {
        ZStack {
            Color.black.edgesIgnoringSafeArea([.bottom, .top])
            TabView(selection: $selected){
                ForEach(arr, id: \.self){ id in
                    SingleVideoView(link: id).tag(id)
                }
                .rotationEffect(.init(degrees: -90))
                .frame(width: widthOrHeight(width: true), height: widthOrHeight(width: false))
            }
            .offset(x: -10.5) 
            .frame(width: widthOrHeight(width: false), height: widthOrHeight(width: true))
            .rotationEffect(.init(degrees: 90))
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
        }
    }
}

struct SingleVideoView: View {
    let link: String
    @State private var viewIsShowing = false
    @State private var isVideoPlaying = false
    var body: some View {
        ZStack {
            Color.black
            
            SmartReelView(link: link, isPlaying: $isVideoPlaying, viewIsShowing: $viewIsShowing)

            Button("", action: {}).disabled(true)
            
            Color.gray.opacity(0.001)
                .onTapGesture {
                    isVideoPlaying.toggle()
                }
            
        }
        .ignoresSafeArea()
        .onDisappear {
            isVideoPlaying = false
            viewIsShowing = false
        }
        .onAppear {
            viewIsShowing = true
            isVideoPlaying = true
        }
    }
}

struct SmartReelView: UIViewRepresentable {
    let link: String
    @Binding var isPlaying: Bool
    @Binding var viewIsShowing: Bool
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIView(context: Context) -> WKWebView {
        let webConfiguration = WKWebViewConfiguration()
        webConfiguration.allowsInlineMediaPlayback = true
        let webView = WKWebView(frame: .zero, configuration: webConfiguration)
        webView.navigationDelegate = context.coordinator

        let userContentController = WKUserContentController()
        
        webView.configuration.userContentController = userContentController

        loadInitialContent(in: webView)
        
        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        var jsString = """
                isPlaying = \((isPlaying) ? "true" : "false");
                watchPlayingState();
            """
        uiView.evaluateJavaScript(jsString, completionHandler: nil)
    }
    
    class Coordinator: NSObject, WKNavigationDelegate {
        var parent: SmartReelView

        init(_ parent: SmartReelView) {
            self.parent = parent
        }
        
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            if self.parent.viewIsShowing {
                webView.evaluateJavaScript("clickReady()", completionHandler: nil)
            }
        }
    }
    
    private func loadInitialContent(in webView: WKWebView) {
        let embedHTML = """
        <style>
            body {
                margin: 0;
                background-color: black;
            }
            .iframe-container iframe {
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
            }
        </style>
        <div class="iframe-container">
            <div id="player"></div>
        </div>
        <script>
            var tag = document.createElement('script');
            tag.src = "https://www.youtube.com/iframe_api";
            var firstScriptTag = document.getElementsByTagName('script')[0];
            firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);

            var player;
            var isPlaying = false;
            function onYouTubeIframeAPIReady() {
                player = new YT.Player('player', {
                    width: '100%',
                    videoId: '\(link)',
                    playerVars: { 'playsinline': 1, 'controls': 0},
                    events: {
                        'onStateChange': function(event) {
                            if (event.data === YT.PlayerState.ENDED) {
                                player.seekTo(0);
                                player.playVideo();
                            }
                        }
                    }
                });
            }
        
            function clickReady() {
                player.playVideo();
            }
            
            function watchPlayingState() {
                if (isPlaying) {
                    player.playVideo();
                } else {
                    player.pauseVideo();
                }
            }
        
        </script>
        """
        
        webView.scrollView.isScrollEnabled = false
        webView.loadHTMLString(embedHTML, baseURL: nil)
    }
}

func widthOrHeight(width: Bool) -> CGFloat {
    let scenes = UIApplication.shared.connectedScenes
    let windowScene = scenes.first as? UIWindowScene
    let window = windowScene?.windows.first
    
    if width {
        return window?.screen.bounds.width ?? 0
    } else {
        return window?.screen.bounds.height ?? 0
    }
}

Updated Code

struct SingleVideoView: View {
    let link: String
    @State private var isVideoPlaying = false
    @State private var destroy = false

    @EnvironmentObject var viewModel: VideoModel

    var body: some View {
        ZStack {
            SmartReelView(link: link, isPlaying: $isVideoPlaying, destroy: $destroy)
            
            Color.gray.opacity(0.001)
                .onTapGesture {
                    isVideoPlaying.toggle()
                }
        }
        .onDisappear {
            isVideoPlaying = false
        }
        .onAppear {
            if viewModel.selected == link {
                isVideoPlaying = true
                destroy = false
            }
        }
        .onChange(of: viewModel.selected, perform: { _ in
            if viewModel.selected != link {
                isVideoPlaying = false
                
                if let x = viewModel.VideosToShow.firstIndex(where: { $0.videoID == viewModel.selected }), let j = viewModel.VideosToShow.firstIndex(where: { $0.videoID == link }){
                    if (x - j) > 2 && !destroy {
                        destroy = true
                        print("destroy \(j)")
                    }
                }
            }
        })
    }
}

struct SmartReelView: UIViewRepresentable {
    let link: String
    @Binding var isPlaying: Bool
    @Binding var destroy: Bool
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIView(context: Context) -> WKWebView {
        let webConfiguration = WKWebViewConfiguration()
        webConfiguration.allowsInlineMediaPlayback = true
        let webView = WKWebView(frame: .zero, configuration: webConfiguration)
        webView.navigationDelegate = context.coordinator

        let userContentController = WKUserContentController()
        
        webView.configuration.userContentController = userContentController

        loadInitialContent(in: webView)
        
        return webView
    }
    
    func createView(context: Context) { //copy of makeUIView but doesnt return a webview
        let webConfiguration = WKWebViewConfiguration()
        webConfiguration.allowsInlineMediaPlayback = true
        let webView = WKWebView(frame: .zero, configuration: webConfiguration)
        webView.navigationDelegate = context.coordinator
        let userContentController = WKUserContentController()
        webView.configuration.userContentController = userContentController
        loadInitialContent(in: webView)
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        
        if destroy && uiView.navigationDelegate != nil {
            destroyWebView(uiView)
        } else if uiView.navigationDelegate == nil {
            createView(context: context)
        }
        
        //rest of code
    }
    
    private func destroyWebView(_ webView: WKWebView) {
        print("destroyed")
        webView.navigationDelegate = nil
        webView.stopLoading()
        webView.removeFromSuperview()
    }
    
    class Coordinator: NSObject, WKNavigationDelegate {
        var parent: SmartReelView

        init(_ parent: SmartReelView) {
            self.parent = parent
        }
        
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            //rest of code
        }
    }
    
    private func loadInitialContent(in webView: WKWebView) {
        let embedHTML = """
            //unchanged
        """
        
        webView.scrollView.isScrollEnabled = false
        webView.loadHTMLString(embedHTML, baseURL: nil)
    }
}

Solution

  • To optimize, you might consider a view recycling mechanism (a "view pool") so that web view instances are reused instead of creating new instances each time a new video is to be displayed.

    However, since you are specifically asking how to destroy web views when the user is 2 videos beyond, you could implement logic to manually deallocate these web views and clear their content.

    To manually destroy a WKWebView, you would need to:

    • Remove the web view from its superview if it has one.
    • Set its navigationDelegate and UIDelegate to nil.
    • Call the stopLoading method on it.
    • Set the web view itself to nil (this is generally handled by ARC if there are no strong references left to the web view).

    First, add a flag in SmartReelView to check if a web view is active:

    @Binding var isActive: Bool
    

    Update the updateUIView and makeUIView methods to consider the active state:

    func makeUIView(context: Context) -> WKWebView {
        let webView = WKWebView()
        // existing code
        if isActive {
            loadInitialContent(in: webView)
        }
        return webView
    }
    
    func updateUIView(_ uiView: WKWebView, context: Context) {
        if isActive {
            // existing code
        } else {
            destroyWebView(uiView)
        }
    }
    
    private func destroyWebView(_ webView: WKWebView) {
        webView.navigationDelegate = nil
        webView.stopLoading()
        webView.removeFromSuperview()
    }
    

    Then, in SingleVideoView, introduce logic to update the isActive binding based on how far the user has scrolled. You might need to pass an index and calculate if the view is within 2 videos of the currently active one:

    struct SingleVideoView: View {
        // existing properties
        @Binding var activeIndex: Int // The index of the currently active (visible) video
        let index: Int  // The index of this particular video
    
        var body: some View {
            // existing code
    
            SmartReelView(link: link, isPlaying: $isVideoPlaying, viewIsShowing: $viewIsShowing, isActive: .constant(shouldActivate))
        }
    
        private var shouldActivate: Bool {
            return abs(activeIndex - index) <= 2
        }
    }
    

    In AllVideoView, maintain the state for the currently active video index:

    @State private var activeIndex = 0
    

    Pass this index to each SingleVideoView:

    ForEach(arr.indices, id: \.self) { index in
        SingleVideoView(link: arr[index], activeIndex: $activeIndex, index: index)
    }
    

    Finally, update the activeIndex whenever the TabView's selection changes.

    These changes should limit the number of web views in memory to only those that are within 2 videos of the currently active one, which should mitigate the resource issue.

    Changing the value of the isActive in SingleVideoView doesn't always trigger the updateUiView, especially if the view isn't on the screen. If I put a print inside the onChange in the view then this print will run every time the video is 2 indexes away. But adding a print in the destroyWebView doesn't always run. This means that the updateUiView func may not check for changes if the view isn't displayed. If I scroll 2 videos away then scroll back to the video then "deleted" is printed.

    Is there a way to hold the instance SmartReelView from SingleVideoView and call the destroyWebView directly to make sure it runs?

    updateUIView not being called consistently should be due to SwiftUI's optimization; it does not update the views that are not currently on the screen. Since you are working with a TabView, SwiftUI tries to be efficient by not updating the invisible tabs.

    Directly holding a SwiftUI View is not recommended due to SwiftUI's declarative nature. Since manipulating SwiftUI's managed state (@Published, @State) within the update cycle of a UIViewRepresentable can lead to undefined behavior or errors, a different approach would be to encapsulate the WKWebView management logic within a dedicated class that can be observed by SwiftUI. That avoids altering @Published or @State variables within the update cycle.

    Create a dedicated WebViewManager class that can be observed:

    class WebViewManager: ObservableObject {
        var webView: WKWebView?
        var link: String
        
        init(link: String) {
            self.link = link
            createWebView()
        }
        
        func createWebView() {
            let webConfiguration = WKWebViewConfiguration()
            webConfiguration.allowsInlineMediaPlayback = true
            self.webView = WKWebView(frame: .zero, configuration: webConfiguration)
            // additional setup logic here
        }
        
        func destroyWebView() {
            self.webView?.loadHTMLString("", baseURL: nil)
            self.webView = nil
        }
    }
    

    Modify SmartReelView and SingleVideoView to use this WebViewManager:

    struct SmartReelView: UIViewRepresentable {
        @ObservedObject var webViewManager: WebViewManager
    
        func makeUIView(context: Context) -> WKWebView {
            return webViewManager.webView ?? WKWebView()
        }
        
        func updateUIView(_ uiView: WKWebView, context: Context) {
            if webViewManager.webView == nil {
                webViewManager.createWebView()
            }
            // additional logic to load or reload the content
        }
    }
    
    struct SingleVideoView: View {
        let link: String
        @State private var isActive = true
        @ObservedObject var webViewManager: WebViewManager
    
        var body: some View {
            SmartReelView(webViewManager: webViewManager)
                .onAppear {
                    isActive = true
                    if webViewManager.webView == nil {
                        webViewManager.createWebView()
                    }
                }
                .onDisappear {
                    isActive = false
                    webViewManager.destroyWebView()
                }
        }
    }
    

    Modify the ForEach loop to create a WebViewManager for each SingleVideoView:

    ForEach(arr, id: \.self) { id in
        SingleVideoView(link: id, webViewManager: WebViewManager(link: id))
        .tag(id)
    }
    

    That way, the WebViewManager handles the creation and destruction of WKWebView instances. The SmartReelView and SingleVideoView observe this manager and react accordingly, without directly modifying the @State or @Published variables within the update cycle.


    I would still consider instead a pool of WKWebView instances, which involves maintaining a collection of reusable views, handing them out when needed, and returning them to the pool when they are no longer in use.

    A simplified example (focusing on the WebView pool) would include first a WebViewPool Manager.
    That manager will handle the logic for pooling:

    class WebViewPool {
        private var pool: [WKWebView] = []
        
        func getWebView() -> WKWebView {
            if let webView = pool.first {
                pool.removeFirst()
                return webView
            } else {
                // Create a new web view, configure it as needed
                let webView = WKWebView()
                return webView
            }
        }
        
        func returnWebView(_ webView: WKWebView) {
            // Optionally clear the webView content
            webView.loadHTMLString("", baseURL: nil)
            
            pool.append(webView)
        }
    }
    

    You can then create an instance of this manager in your SwiftUI View where the web views are needed, for example in AllVideoView.

    struct AllVideoView: View {
        @State private var webViewPool = WebViewPool()
        //... existing code
    }
    

    And in the SingleVideoView or SmartReelView, you can use the pool to get a web view when the view appears and return it when it disappears.

    struct SingleVideoView: View {
        let link: String
        @Binding var webViewPool: WebViewPool
        //... existing code
        
        var body: some View {
            // existing code
            SmartReelView(link: link, webViewPool: $webViewPool)
                .onAppear {
                    // Check out a WebView when appearing
                }
                .onDisappear {
                    // Return WebView when disappearing
                }
        }
    }
    
    struct SmartReelView: UIViewRepresentable {
        let link: String
        @Binding var webViewPool: WebViewPool
        var webView: WKWebView?
        
        func makeUIView(context: Context) -> WKWebView {
            webView = webViewPool.getWebView()
            // existing code
            return webView!
        }
        
        func updateUIView(_ uiView: WKWebView, context: Context) {
            // existing code
        }
        
        func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
            webViewPool.returnWebView(uiView)
        }
    }
    

    That does not cover all edge cases. And managing the lifecycle (check out and return) of web views needs to be more nuanced. Depending on your needs, you might check out a web view not only when the view appears, but also when a new video needs to be loaded.

    Still, the idea remains: by reusing the web views this way, you would minimize the overhead of creating and destroying web view instances, which should improve the performance of your application.