Issue
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
andUIDelegate
tonil
. - 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.
Answered By - VonC
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.