使用SwiftUI构建自己的视频App

2,940 阅读5分钟

环境

需求

有时候在电脑上下载好了电影,但是想用手机看,而系统又没有自带这套操作的工具,

于是就干脆自己写一个吧,顺便练习下刚学的Swift UI。

说明一下,这篇文章主要是演示Swift UI,工程里面使用的播放控件是基于IJKMediaFramework封装好的一个ViewController,在Github上找的这个工程 Swift-IJKPlayer小改了下,懒得自己再写🙃,也练习了Swift UI中使用UIKit。

完整工程代码:YYVedioPlayer,github单个文件限制100M,就放到gitlab去了

先来看下最终效果:

分析

需求很简单,思路也很简单,只需要2个界面就ok:

  1. 一个播放器界面
  2. 一个列表页可以展示指定目录的所有文件和子目录
    • 点击文件就将对应url传递给播放器界面
    • 点击目录就push一个新的列表页并展示对应的内容
    • 左滑删除功能

当我们打开App的时候,首先要展示的是Documents下面的内容,因为在Info.plist里面设置UIFileSharingEnabled = true后,电脑里的文件只能拷贝到App的这个目录。

下面就开始撸代码了。

Service层

首先,创建一个VedioManager,提供文件模型和相关的方法:

  • load方法根据传入的路径,返回File数组(目录放在数组前面,普通文件在后面)
  • delete方法删除指定的File,以及它包含的内容

里面用到了YYFile,这个其实是JohnSundell 写的库Files,很好用,我只是照着写了一遍,方便更好理解和使用。

另外他的博客全是Swift相关教程,很屌很炸天。

代码如下:

extension VedioManager {
    struct File: Hashable {
        let name: String
        let path: String
        let isFolder: Bool
    }
}

class VedioManager {
    static let dirDocument: URL = {
        let urls = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        return urls[urls.endIndex - 1]
    }()
    
    static let root = dirDocument.path
    
    static func load(at path: String) -> [File] {
        if let folder = try? YYFile().createFolderIfNeeded(at: path) {
            let folders = folder.subfolders.map {
                File(name: $0.name, path: $0.path, isFolder: true)
            }
            
            let files = folder.files.map {
                File(name: $0.name, path: $0.path, isFolder: false)
            }
            
            return folders + files
        }
        
        return []
    }
    
    static func delete(_ file: File) -> Bool {
        do {
            try FileManager.default.removeItem(atPath: file.path)
            return true
        } catch {
            print(error)
        }
        
        return false
    }
}

列表页

如上图,列表页很简单,主要就是展示[VedioManager.File]数组,让我们看看怎么用Swift UI方式构建。

首先,创建代表列表页的VedioList:

import SwiftUI

struct VedioList {}

为什么是个空结构体?

因为,这里我们会用到Introducing Container views in SwiftUI这篇文章里面提到的思想,引入容器视图和渲染视图,这样做的好处建议大家详细看文章,最后总结大概如下:

这位作者也是介绍Swift相关教程,风格简洁一些,同样很屌很炸天。

容器视图应该只做与数据流相关的事情:

  1. 存储视图的状态
  2. 处理生命周期(onAppear / onDisappear)
  3. 使用ObservableObject获取数据
  4. 为“渲染”视图提供操作处理程序

渲染视图应该只执行与渲染相关的事情:

  1. 使用SwiftUI提供的原始组件构建用户界面。
  2. 使用其他渲染视图构建用户界面。
  3. 使用数据作为输入来呈现用户界面,不存储任何状态。

Content View

先来看看渲染视图,主要工作如下:

  • 根据传入的File数组创建cell
  • 处理左滑删除的响应

按照上面的思想,应该是这样:

extension VedioList {
    struct Content: View {
        var files: [VedioManager.File]
        var delete: (_ offsets: IndexSet) -> Void
        
        var body: some View {
            List {
                ForEach(files, id: \.self) { file in
                    self.cell(for: file)
                }
                .onDelete(perform: delete)
            }
        }
        
        private func cell(for file: VedioManager.File) -> AnyView {
            file.isFolder ?
                NavigationLink(file.name, destination: Container(path: file.path)).eraseToAnyView() :
                NavigationLink(file.name, destination:VedioPlayer.Container(file: file)).eraseToAnyView()
        }
    }
}

可以看到,用Swift UI,代码量真的很少,很少,也比较简单。

需要注意的是,当我们点击一个cell的时候,会根据是否是目录,导航到不同界面,这里用到了AnyView。

不过根据文章How to return different view types,还可以使用Group。使用AnyView会降低性能,建议不要经常使用。

Container View

Container视图的主要工作就是

  • onAppear时加载并更新指定目录的数据
  • 处理删除逻辑

代码也很简单,而且当标记为@State的属性files发生变化时,Content View会自动更新,非常方便!

extension VedioList {
    struct Container: View {
        let path: String
        
        @State
        private var files: [VedioManager.File] = []
        
        var body: some View {
            Content(files: files, delete: delete)
                .navigationBarTitle(path.lastPathComponent)
                .onAppear(perform: loadData)
        }
        
        private func loadData() {
            print(path)
            let files = VedioManager.load(at: path)
            DispatchQueue.main.async {
                self.files = files
            }
        }
        
        private func delete(at offsets: IndexSet) {
            for idx in offsets {
                let file = files[idx]
                if VedioManager.delete(file) {
                    files.remove(at: idx)
                }
            }
        }
    }
}

这里有个坑,注意loadData方法里面的self.files = files这句代码,如果不放到DispatchQueue.main.async里,

当点击目录push到新的VedioLis.Containert时,会报table view Invalid update crash。。

播放器页

同样,先创建代表播放器页的VedioPlayer:

import SwiftUI

struct VedioPlayer {}

Content View

我们的播放器是PlayerViewController,要在SwiftUI中使用,只需要一个类或结构体,实现协议UIViewControllerRepresentable即可,核心方法:

  • makeUIViewController,返回要展示的UIViewController对象,这里是我们的PlayerViewController(),设置好视频地址和标题即可,presentation.wrappedValue.dismiss()表示popViewController()
  • updateUIViewController,是在状态发生改变时,SwiftUI通知UIKit的方式,我们播放器相关的所有逻辑都在PlayerViewController里面,所以是空的

代码如下:

extension VedioPlayer {
    struct Content: UIViewControllerRepresentable {
        @Environment(\.presentationMode) var presentation
        
        let url: String
        let title: String
        
        func makeUIViewController(context: UIViewControllerRepresentableContext<Content>) -> PlayerViewController {
            let vc = PlayerViewController()
            vc.prepareFor(url: url, title: title)
            vc.tapBackHandler = {
                self.presentation.wrappedValue.dismiss()
            }
            
            return vc
        }
        
        func updateUIViewController(_ uiViewController: PlayerViewController, context: UIViewControllerRepresentableContext<Content>) {
            
        }
    }
}

Container View

Container视图的工作就是根据传入的VedioManager.File,配置Content view的视频地址和标题,注意这里也有个坑,SwiftUI中想让导航条隐藏,必须要设置标题。。

代码如下:

extension VedioPlayer {
    struct Container: View {
        let file: VedioManager.File
        
        var body: some View {
            /// For some reason, SwiftUI requires that you also set .navigationBarTitle for .navigationBarHidden to work properly.
            Content(url: file.path, title: file.name)
                .edgesIgnoringSafeArea(.all)
                .navigationBarTitle(Text(file.name), displayMode: .inline)
                .navigationBarHidden(true)
                .onDisappear { print("onDisappear") }
        }
    }
}

Root View

最后就是我们的root View,NavigationView就是UINavigationController了:

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            VedioList.Container(path: VedioManager.root)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

然后,收工!!!