本文目錄 -
簡介
iOS相關函式庫
資料夾結構
程式進入點
RootViewControl:MasterViewController
XCDYouTubeKit
下載並儲存影片
播放影片
心得
參考資料

簡介

DownTube是一個可以下載Youtube影片的iOS App,但因為受限於Youtube的授權關係,本文以技術分享為主,嚴禁商業使用!

Folder

本文會以三個部分來解析”DownTube”,首先從Youtube的播放連結找出檔案的下載位址,接著開始一個新的下載工作下載的格式是mp4,有一定的儲存路徑與檔案命名原則,最後是播放影音檔案,一樣會從資料結構開始,接著根據App的執行流程解析這個專案.

專案原始碼連結 link

iOS相關函式庫

AVPlayer : AVFoundation
CoreData
NSURLSessionDownloadTask : NSURLSessionTask

資料夾結構

Folder

首先看到第一層的資料夾有個Podfile,表示這個專案有使用第三方的套件管理,使用的函式庫有:

  1. XCDYouTubeKit: 一個可以解析Youtube影片資訊的函式庫,此專案主要使用到各種解析度的檔案下載位址
  2. Fabric: 使用者行為統計的第三方函式褲
  3. Crashlytics: 蒐集App無預警崩框的訊息
  4. MMWormhole: App跟Extension之間溝通的函式庫

在開啟專案之前,要先在Termail下指令”pod install”把第三方函式庫加入專案中,之後再點擊”DownTube.xcworkspace”開啟專案.

開啟專案後我們看到DownTube專案內以下資料夾:

  1. Shared: 一個封裝NSUserDefaults的物件”Constants”
  2. DownTube: 本文主要分析的重點內容
  3. DownTubeUITests: UI測試程式碼,不解析
  4. DownTubeShareExtension: 這個Extension能在Safari中直接啟動DownTube下載影片
  5. Products: 系統自動產生,不解析
  6. Pods: 函式庫管理工具自動產生,不解析
  7. Frameworks: 系統自動產生,不解析

程式進入點

首先我們觀察AppDelegate.m,發現了三個跟系統特性是事件有關的設定:

1.設定聲音播放模式,為’AVAudioSessionCategoryPlayback’,忽略靜音鍵強制發出聲音.

do {
        try AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback)
    } catch {
        print("Background audio not enabled")
    }

2.App結束前,將資料寫入Core Data

func applicationWillTerminate(application: UIApplication) {
    // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
    // Saves changes in the application's managed object context before the application terminates.
    CoreDataController.sharedController.saveContext()
}

3.背景下載

func application(application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: () -> Void) {
    self.backgroundSessionCompletionHandler = completionHandler
}

note: 一篇關於背景下載的文章 link

另外此專案是使用Storyboard實作UI,所以看不到RootViewController的設定.

RootViewControl

Folder

從Storyboard中可以得知,此專案的根頁面是MasterViewController,主要有兩個導覽列的按鈕(Edit,About),跟一個TableView,TableView與上方一個DownLoad New Video的按鈕,我們就從這個下載按鈕說起.
按下Download New Video按鈕會觸發一個函式”askUserForURL”:

@IBAction func askUserForURL(sender: AnyObject)

askUserForURL()是要使用者輸入Youtube影像連結,以Hebe的小幸運為例,連結是”https://www.youtube.com/watch?v=CFTZSSJyy7A

Folder

使用者輸入Youtube播放網址後會從網址列中取得這段影片的ID,以上述的範例網址影片的ID就是”CFTZSSJyy7A”,得到ID後就透過XCDYouTubeKit取得影片資訊,包括各種解析度的影片檔案位置(URL)

XCDYouTubeClient.defaultClient().getVideoWithIdentifier(String(url.characters.suffix(11))) { 
video, error in 
    self.videoObject(video, downloadedForVideoAt: url, error: error)
   UIApplication.sharedApplication().networkActivityIndicatorVisible = false    
}

XCDYouTubeKit

Youtube官方提供了一個取得影片資訊的API(“get_video_info”),內容包括了title, thumbnail與各種解析度/檔案格式的連結…等等,以HEBE的小幸運為例:

http://www.youtube.com/get_video_info?video_id=CFTZSSJyy7A

由於回覆的資訊都是字串,而且不是標準的XML或者是JSON格式,所以利用XCDYouTubeKit將回傳的字串轉換為XCDYouTubeVideo物件:

public class XCDYouTubeVideo : NSObject, NSCopying {

    /**
     *  --------------------------------
     *  @name Accessing video properties
     *  --------------------------------
     */

    /**
     *  The 11 characters YouTube video identifier.
     */
    public var identifier: String { get }
    /**
     *  The title of the video.
     */
    public var title: String { get }
    /**
     *  The duration of the video in seconds.
     */
    public var duration: NSTimeInterval { get }

    /**
     *  A thumbnail URL for an image of small size, i.e. 120×90. May be nil.
     */
    public var smallThumbnailURL: NSURL? { get }
    /**
     *  A thumbnail URL for an image of medium size, i.e. 320×180, 480×360 or 640×480. May be nil.
     */
    public var mediumThumbnailURL: NSURL? { get }
    /**
     *  A thumbnail URL for an image of large size, i.e. 1'280×720 or 1'980×1'080. May be nil.
     */
    public var largeThumbnailURL: NSURL? { get }

    /**
     *  A dictionary of video stream URLs.
     *
     *  The keys are the YouTube [itag](https://en.wikipedia.org/wiki/YouTube#Quality_and_formats) values as `NSNumber` objects. The values are the video URLs as `NSURL` objects. There is also the special `XCDYouTubeVideoQualityHTTPLiveStreaming` key for live videos.
     *
     *  You should not store the URLs for later use since they have a limited lifetime and are bound to an IP address.
     *
     *  @see XCDYouTubeVideoQuality
     *  @see expirationDate
     */

    public var streamURLs: [NSObject : NSURL] { get }

    /**
     *  The expiration date of the video.
     *
     *  After this date, the stream URLs will not be playable. May be nil if it can not be determined, for example in live videos.
     */
    public var expirationDate: NSDate? { get }
}

一篇關於拆解YouTube URL的文章link

在XCDYouTubeKit中的Video物件只有低中高三種解析度的URL資訊,事實上不只這些解析度,讀者可以透過另外一個專案You-Get瞭解更多影片資訊.

kobeyu$ you-get -i https://www.youtube.com/watch?v=CFTZSSJyy7A
site:                YouTube
title:               田馥甄 Hebe Tien [ 小幸運 官方Live版 A Little Happiness] LIVE Version (如果 田馥甄巡迴演唱會)
streams:             # Available quality and codecs
[ DASH ] ____________________________________
- itag:          137
  container:     mp4
  quality:       1920x1080
  size:          87.2 MiB (91407373 bytes)
# download-with: you-get --itag=137 [URL]

- itag:          248
  container:     webm
  quality:       1920x1080
  size:          79.1 MiB (82939675 bytes)
# download-with: you-get --itag=248 [URL]

- itag:          136
  container:     mp4
  quality:       1280x720
  size:          46.1 MiB (48308712 bytes)
# download-with: you-get --itag=136 [URL]

- itag:          247
  container:     webm
  quality:       1280x720
  size:          39.8 MiB (41687812 bytes)
# download-with: you-get --itag=247 [URL]

- itag:          135
  container:     mp4
  quality:       854x480
  size:          25.1 MiB (26370772 bytes)
# download-with: you-get --itag=135 [URL]

- itag:          244
  container:     webm
  quality:       854x480
  size:          21.1 MiB (22146590 bytes)
# download-with: you-get --itag=244 [URL]

- itag:          134
  container:     mp4
  quality:       640x360
  size:          14.3 MiB (15000639 bytes)
# download-with: you-get --itag=134 [URL]

- itag:          243
  container:     webm
  quality:       640x360
  size:          13.9 MiB (14599703 bytes)
# download-with: you-get --itag=243 [URL]

- itag:          133
  container:     mp4
  quality:       426x240
  size:          11.9 MiB (12459119 bytes)
# download-with: you-get --itag=133 [URL]

- itag:          242
  container:     webm
  quality:       426x240
  size:          9.5 MiB (9911538 bytes)
# download-with: you-get --itag=242 [URL]

- itag:          160
  container:     mp4
  quality:       256x144
  size:          7.5 MiB (7910663 bytes)
# download-with: you-get --itag=160 [URL]

- itag:          278
  container:     webm
  quality:       256x144
  size:          7.3 MiB (7653435 bytes)
# download-with: you-get --itag=278 [URL]

[ DEFAULT ] _________________________________
- itag:          22
  container:     mp4
  quality:       hd720
  size:          46.1 MiB (48288653 bytes)
# download-with: you-get --itag=22 [URL]

- itag:          43
  container:     webm
  quality:       medium
# download-with: you-get --itag=43 [URL]

- itag:          18
  container:     mp4
  quality:       medium
# download-with: you-get --itag=18 [URL]

- itag:          36
  container:     3gp
  quality:       small
# download-with: you-get --itag=36 [URL]

- itag:          17
  container:     3gp
  quality:       small
# download-with: you-get --itag=17 [URL]

其中itag代表著不同的解析度與格式(iTag列表)

一篇關於拆解YouTube URL的文章 link

下載並儲存影片

Folder

當成功取得XCDYouTubeVideo物件後,接下來就要下載影片到本地端,這段的程式碼如下:

//MARK: - Helper methods

/**
 Called when the video info for a video is downloaded

 - parameter video:      optional video object that was downloaded, contains stream info, title, etc.
 - parameter youTubeUrl: youtube URL of the video
 - parameter error:      optional error
 */
func videoObject(video: XCDYouTubeVideo?, downloadedForVideoAt youTubeUrl: String, error: NSError?) {
    if let videoTitle = video?.title {
        print("\(videoTitle)")

        var streamUrl: String?

        if let highQualityStream = video?.streamURLs[XCDYouTubeVideoQuality.HD720.rawValue]?.absoluteString {

            //If 720p video exists
            streamUrl = highQualityStream

        } else if let mediumQualityStream = video?.streamURLs[XCDYouTubeVideoQuality.Medium360.rawValue]?.absoluteString {

            //If 360p video exists
            streamUrl = mediumQualityStream

        } else if let lowQualityStream = video?.streamURLs[XCDYouTubeVideoQuality.Small240.rawValue]?.absoluteString {

            //If 240p video exists
            streamUrl = lowQualityStream
        }

        if let video = video, streamUrl = streamUrl {
            self.createObjectInCoreDataAndStartDownloadFor(video, withStreamUrl: streamUrl, andYouTubeUrl: youTubeUrl)

            return
        }   
    }
    //Show error to user and remove all errored out videos
    self.showErrorAndRemoveErroredVideos(error)
}

從程式碼中可以看出來,作者是由高中低解析的判斷是否有相對應的連結存在,如果不存在高中低解析度則顯示錯誤並返回程序,若存在任一解析度連結,則新增一個CoreData物件並開始下載,這段程式碼如下:

/**
     Creates new video object in core data, saves the information for that video, and starts the download of the video stream
 - parameter video:      video object
 - parameter streamUrl:  streaming URL for the video
 - parameter youTubeUrl: youtube URL for the video (youtube.com/watch?=v...)
 */
func createObjectInCoreDataAndStartDownloadFor(video: XCDYouTubeVideo?, withStreamUrl streamUrl: String, andYouTubeUrl youTubeUrl: String) {

    //Make sure the stream URL doesn't exist already
    guard self.videoIndexForYouTubeUrl(youTubeUrl) == nil else {
        self.showErrorAlertControllerWithMessage("Video already downloaded")
        return
    }

    let context = CoreDataController.sharedController.fetchedResultsController.managedObjectContext
    let entity = CoreDataController.sharedController.fetchedResultsController.fetchRequest.entity!
    let newVideo = NSEntityDescription.insertNewObjectForEntityForName(entity.name!, inManagedObjectContext: context) as! Video

    newVideo.created = NSDate()
    newVideo.youtubeUrl = youTubeUrl
    newVideo.title = video?.title
    newVideo.streamUrl = streamUrl
    newVideo.userProgress = 0

    do {
        try context.save()
    } catch {
        abort()
    }

    //Starts the download of the video
    self.startDownload(newVideo)
}

這邊使用了”guard”來判斷URL是否為空,如果為空則顯示錯誤訊息並退出程序,再繼續往下探究程式碼之前,我們先來看看開法者在Core Data的資料結構設定:

Folder

由上圖我們可以知道要存進Core Data的物件有8個特性.

在產生完Core Data來存放影片資訊後,接著就要進入函式”self.startDownload”開始一個新的下載工作:

//MARK: - Downloading methods

/**
 Starts download for video, called when track is added

 - parameter video: Video object
 */
func startDownload(video: Video) {
    print("Starting download of video \(video.title) by \(video.uploader)")
    if let urlString = video.streamUrl, url = NSURL(string: urlString), index = self.videoIndexForStreamUrl(urlString) {
        let download = Download(url: urlString)
        download.downloadTask = self.downloadsSession.downloadTaskWithURL(url)
        download.downloadTask?.resume()
        download.isDownloading = true
        self.activeDownloads[download.url] = download

        self.tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: index, inSection: 0)], withRowAnimation: .None)
    }
}

這段重點在於如何產生一個新的NSURLSessionDownloadTask,並啟動下載程序,並且在過程中要持續更新UI讓使用者清楚的知道現在的進度是多少,在下載完成後將暫存的檔案移至指定的位置並同時更改檔名.

一個新的NSURLSessionDownloadTask是透過以下這行程式初始化的:

download.downloadTask = self.downloadsSession.downloadTaskWithURL(url)

在往前追朔self.downloadsSession的資料結構與初始化的方式:

lazy var downloadsSession: NSURLSession = {
    let configuration = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier("bgSessionConfiguration")
    let session = NSURLSession(configuration: configuration, delegate: self, delegateQueue: nil)
    return session
}()

從上述的設定中可以看到downloadsSession是可以在背景執行下載,並且透過delegate(NSURLSessionDelegate)接收回呼函式,這裡實現的回乎函式有:

1.func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location: NSURL)
2.func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64)

其中的第一個函式在下載結束後會被呼叫,因為下載完的檔案會被暫存在系統中,所以要將這個暫存檔移到指定路徑做永久保存,這段程式如下:

//Download finished
func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location: NSURL) {
    if let originalURL = downloadTask.originalRequest?.URL?.absoluteString {
        if let destinationURL = self.localFilePathForUrl(originalURL) {

            print("Destination URL: \(destinationURL)")
            let fileManager = NSFileManager.defaultManager()
            //Removing the file at the path, just in case one exists
            do {
                try fileManager.removeItemAtURL(destinationURL)
            } catch {
                print("No file to remove. Proceeding...")
            }

            //Moving the downloaded file to the new location
            do {
                try fileManager.copyItemAtURL(location, toURL: destinationURL)
            } catch let error as NSError {
                print("Could not copy file: \(error.localizedDescription)")
            }

            //Updating the cell
            if let url = downloadTask.originalRequest?.URL?.absoluteString {
                self.activeDownloads[url] = nil

                if let videoIndex = self.videoIndexForDownloadTask(downloadTask) {
                    dispatch_async(dispatch_get_main_queue(), {
                        self.tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: videoIndex, inSection: 0)], withRowAnimation: .None)
                    })
                }
            }
        }
    }
}

新的路徑與檔名是透過函式”self.localFilePathForUrl()”來處理,處理的方法是透過連結中取出VideoID作為檔名,而副檔名統一為.mp4格式,因為只下載mp4格式的檔案.

第二個Delegate函式則是在下載時持續更新UI讓使用者了解目前的下載進度,這段的程式碼如下:

//Updating download status
func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {

    if let downloadUrl = downloadTask.originalRequest?.URL?.absoluteString, download = self.activeDownloads[downloadUrl] {
        download.progress = Float(totalBytesWritten)/Float(totalBytesExpectedToWrite)
        let totalSize = NSByteCountFormatter.stringFromByteCount(totalBytesExpectedToWrite, countStyle: NSByteCountFormatterCountStyle.Binary)
        if let trackIndex = self.videoIndexForDownloadTask(downloadTask), let VideoTableViewCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: trackIndex, inSection: 0)) as? VideoTableViewCell {
            dispatch_async(dispatch_get_main_queue(), {

                let done = (download.progress == 1)

                VideoTableViewCell.progressView.hidden = done
                VideoTableViewCell.progressLabel.hidden = done
                VideoTableViewCell.progressView.progress = download.progress
                VideoTableViewCell.progressLabel.text =  String(format: "%.1f%% of %@",  download.progress * 100, totalSize)
            })
        }
    }
}

一篇關於NSURLSessionDownloadTask的文章 link

播放影片

Folder

當檔案下載完畢後點擊曲目就會開始播放,由於曲目是透過TableView呈現,所以點擊後會觸發TableView的”didSelectRowAtIndexPath”,這部分的程式碼如下:

override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    let video = CoreDataController.sharedController.fetchedResultsController.objectAtIndexPath(indexPath) as! Video
    if self.localFileExistsFor(video) {
        self.playDownload(video, atIndexPath: indexPath)
    }
    tableView.deselectRowAtIndexPath(indexPath, animated: true)
}

首先判斷要播放的檔案是否存在,由於檔案的資訊是存在Core Data而影音檔案是存在App的資料夾,所以成功從Core Data取出檔案資訊,不代表檔案一定存在!
若要播放已下載的檔案則進入函式”self.playDownload(video, atIndexPath: indexPath)”這部分的程式碼如下:

/**
 Plays video in fullscreen player

 - parameter video:     video that is going to be played
 - parameter indexPath: index path of the video
 */
func playDownload(video: Video, atIndexPath indexPath: NSIndexPath) {


    if let urlString = video.streamUrl, url = self.localFilePathForUrl(urlString) {

          //路徑是指到一個.mp4的本地端檔案
          //以我自己執行的時候路徑是:
          //file:///var/mobile/Containers/Data/Application/FA30A6EC-AE24-4783-8DC6-4DE254133585/Documents/o-AOzX-MYrc2CU8zM.mp4
        let player = AVPlayer(URL: url)

        //Seek to time if the time is saved
        if let time = video.userProgress as? Double {
            player.seekToTime(CMTime(seconds: time, preferredTimescale: 1))
        }

        let playerViewController = AVPlayerViewController()
        video.userProgress = 0
        player.addPeriodicTimeObserverForInterval(CMTime(seconds: 10, preferredTimescale: 1), queue: dispatch_get_main_queue()) { [weak self] time in

            //Every 5 seconds, update the progress of the video in core data
            let intTime = Int(CMTimeGetSeconds(time))
            let totalVideoTime = CMTimeGetSeconds(player.currentItem!.duration)
            let progressPercent = Double(intTime) / totalVideoTime

            print("User progress on video in seconds: \(intTime)")

            //If user is 95% done with the video, mark it as done
            if progressPercent > 0.95 {
                video.userProgress = nil
            } else {
                video.userProgress = intTime
            }

            CoreDataController.sharedController.saveContext()
            self?.tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: .None)

        }
        playerViewController.player = player
        self.presentViewController(playerViewController, animated: true) {
            playerViewController.player!.play()
        }
    }
}

本專案播放是透過AVFoundation中的AVPlayer與AVPlayerViewController分別作為影音播放的控制與顯示,重點在以下這幾行程式:

1.宣告一個AVPlayer物件,並指定要播放的本地端檔案路徑給這個AVPlayer

let player = AVPlayer(URL: url)

2.宣告一個timer定時依播放進度更新UI與儲存播放資訊

player.addPeriodicTimeObserverForInterval(CMTime(seconds: 10, preferredTimescale: 1), queue: dispatch_get_main_queue()) { [weak self] time in

3.宣告一個playerViewController並將以初始化的player(AVPlayer)指定給playerViewController

playerViewController.player = player

4.顯示畫面並開始播放

self.presentViewController(playerViewController, animated: true) {
            playerViewController.player!.play()
}

note: 一篇關於AVPlayer的文章 link

心得

這個專案的重點在於下載,儲存與播放,除了XCDYouTubeKit之外,核心的部分都是使用iOS提供的框架所完成的,所以有一定經驗的開發者應該能夠輕鬆地掌握這個專案.

在分析專案的過程中發現兩個可以改進的問題或項目:

1.當檔案正在下載中或者是暫停下載的情況下關掉App,重新開啟之後會發現剛剛正在下載的檔案已經不再下載,點及該檔案也無法播放.

2.有其他使用者在這個專案的Github中開了一個Issue希望能夠把已下載的檔案移到系統相簿中(Camera Roll)

參考資料

DownTube專案原始碼 link

一篇關於NSURLSession的文章 link

一篇關於拆解YouTube URL的文章 link

iTag列表 link

XCDYouTubeKit是支援iOS,tvOS與OS X的開源專案 link

一篇關於AVPlayer的文章 link