34281


Swift3でのUITableView時のプログレスバーの振る舞いについて質問します。  
データ取得中の真っ白な(何も表示されていない)tableViewの時にプログレスバーで、データ取得の進捗率を表示させたいのですが、下記のコードですと、tableViewにデータが表示された後にプログレスバーが表示されてしまいます。どのように修正したら良いでしょうか。  
また、プログレスバーが終了後(.progressが1.0になった時)、プログレスバーをviewから消したいのですが、self.table.reloadData()の下にprogressView.removeFromSuperView()を書くとプログレスバーがそもそも表示されません。終了処理の書き方についてもアドバイスください。

[code]

    override func viewDidLoad() {
        super.viewDidLoad()
        /* 諸々の設定(省略) */
    
        let navigationBarHeight = navigationController?.navigationBar.frame.size.height
        let statusBarHeight = UIApplication.shared.statusBarFrame.height
    
        progressView = UIProgressView(frame: CGRect(x: 0, y: navigationBarHeight! + statusBarHeight, width: self.view.frame.size.width, height: 5)) // プログレスバー
        progressView.progressViewStyle = .default
        progressView.progressTintColor = appleBlueColor
        progressView.trackTintColor = .white
        progressView.transform = CGAffineTransform(scaleX: 1.0, y: 2.0) // 長さと太さの設定
    
        self.view.addSubview(progressView)
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        fetchData()
        self.table.reloadData()
    }
    
    func fetchData() {
        let request = URLRequest(url: rankUrl!)
        do {
            html = try String(contentsOf: request.url!, encoding: .utf8)
        }catch{
        }
        let jiDoc = Ji(htmlString: html)
        let parsedList = jiDoc?.xPath("//body/div/div/div/ul[@class='list']")?.first
        if parsedList != nil {
            for li in (parsedList?.enumerated())! {
                var item = [String]()
                let title = li.element.lastChild?.children.first?.content
                item.append(title!)
                let url = li.element.lastChild?.children.first?.children.first?.attributes["href"]
                item.append(url!)
                let site = li.element.lastChild?.children.last?.content
                item.append(site!)
                let newsDate = date_to_string(cellDate: Date())
                item.append(newsDate)
    
                self.items.append(item)
                progressView.setProgress(Float(self.items.count)/10, animated: true)
            }
        }
    
    }
    
[/code]


CC BY-SA 3.0
2017-04-24T11:16:53.680
34242
2017-04-26T14:19:05.933
2017-04-24T23:22:35.720
21189
21189
question
swift ios
UITableViewでのProgressBarの実装について


やたらと長くなってしまったので、最初に結論を。

###
実際のアプリでは、ループ部分で(掲載のコードよりも)はるかに複雑な計算を行って、HTMLの受信よりもずーっと時間のかかる処理になるんだ、と言うのでない限り、`UIProgressView`にはこだわらず`UIActivityIndicatorView`などを表示するだけで良いのではないでしょうか。

この結論に納得できない場合は、あちこちすっ飛ばしながらでも良いので以下の部分に目を通してみてください。  
(コードはすべてこちらの環境でコンパイル・実行ができることを確認したものですので、わずかの修正が必要かもしれませんが、そちらでも試してみることができるはずです。)

* * *

まずは、`UIProgressView`の基本をしっかり理解しておいてください。

  * 進捗表示の対象となる処理は必ずバックグラウンドスレッドで実行する
  * 進捗表示の契機となるごとにメインスレッド内で`setProgress(_:animated:)`を呼ぶ

なぜ、こんな面倒なことになっているのかというと、そもそも
**進捗表示が必要となるほど時間のかかる処理をメインスレッド(UIスレッド)内で行ってはならない**
と言うのがiOSの基本中の基本とも言える設計ポリシーだからです。(UIの更新に関する処理はメインスレッドで行う必要があるというのも別の「基本」ですね。)

(ちなみに、`String(contentsOf:)`は、 **メインスレッドで使ってはいけない**
の代表メソッドの一つです。こちらも覚えておいてください。)

* * *

あなたのコードは以下の2点で`UIProgressView`をうまく表示できないことになっています。

  1. 上記の **`UIProgressView`の基本** を踏まえたコードになっていない
  2. 実際の処理にかかる時間ではなく、取得したデータの処理に合わせて進捗を表示している

このうち2.については、実際のアクセス先(応答速度と取得するHTMLのサイズ)や使用している通信回線、その混み具合等に大きな影響を受けますので、もう少し情報がないとはっきりしたことはいえませんから、まずは1.についてのみ解決できるようにしてみます。

あなたのコードから無駄に見える部分を省いたこんなコードからスタートしてみます。

[code]

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        fetchData()
        self.table.reloadData()
    }
    
    func fetchData() {
        //この`html`はローカル変数で良いはず
        let html: String
        do {
            //一度URLRequestを作って、その中からまたurlを取り出すのは無駄
            html = try String(contentsOf: rankUrl!, encoding: .utf8)
        } catch {
            //エラーを黙って無視するのは非常によくない
            fatalError("error=\(error)")
        }
        let jiDoc = Ji(htmlString: html)
        //条件付きバインディングが有効に使える場所
        if let parsedList = jiDoc?.xPath("//body/div/div/div/ul[@class='list']")?.first {
            //offsetを使わないのにenumerated()を使う意味がない
            for li in parsedList {
                var item: [String] = []
                //危険な`!`を使うより、`??`でデフォルト値を指定
                let title = li.lastChild?.firstChild?.content ?? ""
                item.append(title)
                let url = li.lastChild?.firstChild?.firstChild?.attributes["href"] ?? ""
                item.append(url)
                let site = li.lastChild?.lastChild?.content ?? ""
                item.append(site)
                let newsDate = date_to_string(cellDate: Date())
                item.append(newsDate)
                
                self.items.append(item)
                progressView.setProgress(Float(self.items.count)/Float(parsedList.children.count), animated: true)
    
                usleep(300_000) //<-本文参照
            }
        }
    }
    
[/code]

`children.first`や`children.last`は`firstChild`や`lastChild`で良いように思われたので、そのように書き換えてあります。また危険な強制アンラップ(`!`)はできるだけ使わないように書き換えました。(`rankUrl!`だけは元がわからないので、そのまま使用していますが。)やりすぎで動きが変わってしまっている部分がありましたら、修正の上おためしください。

**`usleep(300_000)`**
の行が追加されていると言うのが大きな変更点ですが、これは上の2.で述べた事柄が大きく影響することをご理解いただくためのものです。

上記のコード、ほぼあなたがご質問中に書かれたような挙動をします。

* * *

最初に書いた **`UIProgressView`の基本**
に従って書き換えるなら、進捗表示の対象となるループ処理と、本来メインスレッドで実行してはいけないはずの`String(contentsOf:)`の両方を含む`fetchData()`自体をバックグラウンドスレッドで実行するというのが簡単でしょう。

デフォルトのバックグラウンドキューである`DispatchQueue.global()`を使えば、大変なコードにはなりません。「UI更新はメインスレッドで」を実現する場合には、メインスレッドで管理されている`DispatchQueue.main`を同様に使うだけです。

[code]

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        //デフォルトのglobal()キューを使って処理をバックグラウンドで実行
        DispatchQueue.global().async {
            self.fetchData()
            DispatchQueue.main.async {
                self.table.reloadData()
                //reloadData()完了後に実行する処理をメインキューに登録
                DispatchQueue.main.async {
                    self.progressView.removeFromSuperview()
                    self.progressView = nil
                }
            }
        }
    }
    
    //このfetchData()は、必ずバックグラウンドで実行する
    func fetchData() {
        let html: String
        do {
            html = try String(contentsOf: rankUrl!, encoding: .utf8)
        } catch {
            fatalError("error=\(error)")
        }
        let jiDoc = Ji(htmlString: html)
        if let parsedList = jiDoc?.xPath("//body/div/div/div/ul[@class='list']")?.first {
            for li in parsedList {
                var item: [String] = []
                let title = li.lastChild?.firstChild?.content ?? ""
                item.append(title)
                let url = li.lastChild?.firstChild?.firstChild?.attributes["href"] ?? ""
                item.append(url)
                let site = li.lastChild?.lastChild?.content ?? ""
                item.append(site)
                let newsDate = date_to_string(cellDate: Date())
                item.append(newsDate)
                
                //Jiライブラリーの要素は複数スレッドからアクセスすると不具合が出るようなので、バックグラウンド側で計算を済ませる
                let progressRatio = Float(self.items.count)/Float(parsedList.children.count)
                DispatchQueue.main.async {
                    //SwiftのArrayはスレッドセーフではないので、こちらは必ずメインスレッド内で更新
                    self.items.append(item)
                    //`setProgress(_:animated:)`もメインスレッド内で呼ぶ
                    self.progressView.setProgress(progressRatio, animated: true)
                }
                usleep(300_000)
            }
        }
    }
    
[/code]

こちらのコードをテスト環境(通信先のサーバはlocalhostのPHP、返すHTMLは上記のコードを試すのに必要最小限の内容)で実行したところ、`progressView`が少しずつ(300_000μ秒ごと)伸びていきtableViewの表示とほぼ同時に`progressView`が消えると言うのがわかります。

* * *

さて、`progressView`をはっきり見せるために`usleep(300_000)`でユーザを余計に待たせるなんてことになっては本末転倒ですから、その行を削除してみましょう。`html`の複雑さにもよるので条件により大きく結果は異なるはずなのですが、こちらのテスト環境では全く`progressView`が見えなくなってしまいました。

先の`fetchData()`に処理時間を図るためのコードを埋め込んで実行してみました。

[code]

    func fetchData() {
        var timeNow = Date()
        var timeLast = timeNow
        func showTime(_ label: String) {
            timeNow = Date()
            print(label, Int(timeNow.timeIntervalSince(timeLast) * 1_000_000), "μsec")
            timeLast = timeNow
        }
        
        let html: String
        do {
            html = try String(contentsOf: rankUrl!, encoding: .utf8)
        } catch {
            fatalError("error=\(error)")
        }
        showTime("A")
        let jiDoc = Ji(htmlString: html)
        showTime("B")
        if let parsedList = jiDoc?.xPath("//body/div/div/div/ul[@class='list']")?.first {
            showTime("C")
            for li in parsedList {
                var item: [String] = []
                let title = li.lastChild?.firstChild?.content ?? ""
                item.append(title)
                let url = li.lastChild?.firstChild?.firstChild?.attributes["href"] ?? ""
                item.append(url)
                let site = li.lastChild?.lastChild?.content ?? ""
                item.append(site)
                let newsDate = date_to_string(cellDate: Date())
                item.append(newsDate)
                
                //Jiライブラリーの要素は複数スレッドからアクセスすると不具合を発生するようなので、バックグラウンドで計算
                let progressRatio = Float(self.items.count)/Float(parsedList.children.count)
                DispatchQueue.main.async {
                    //SwiftのArrayはスレッドセーフではないので、こちらは必ずメインスレッド内で更新
                    self.items.append(item)
                    //`setProgress(_:animated:)`もメインスレッド内で呼ぶ
                    self.progressView.setProgress(progressRatio, animated: true)
                }
                showTime("D")
            }
        }
    }
    
[/code]

結果は実行するたびに大きく変動するんですが、傾向としては全く同じなので、ある1回分だけこちらのテスト環境での実行結果を載せておきます。

> A 181083 μsec  
>  B 3473 μsec  
>  C 1659 μsec  
>  D 2997 μsec  
>  D 131 μsec  
>  D 77 μsec  
>  D 75 μsec  
>  D 75 μsec  
>  D 76 μsec  
>  D 76 μsec  
>  D 76 μsec  
>  D 74 μsec  
>  D 78 μsec

一番時間のかかっているAの部分(`String(contentsOf:)`)は、次点のB(HTMLをパースして`Ji`インスタンスを作る部分)の50倍もかかっています。また進捗表示の対象としているループ処理は一番長い最初の1回でさえ、わずか3msecほど、2回目以降は100μsec程度ですから、この速度で`progressView`を更新しても人間の目に見えないどころか液晶表示の更新さえ追いつきません。

つまりここにあげたコードは、処理に必要な時間の95%以上が経過した後で、残りの一瞬を10回に分けて進捗表示しようとしている(それも10等分ではない)、と言うことになります。

実際のアプリでは`rankUrl`は外部のサーバを表すわけですから、Aの部分にかかる処理時間ははるかに長くなります。その部分ではなにも進捗表示を行わないで、HTML取得後のループで0から`progressView`を表示しても、極めて不自然な表示(「全く見えない」を含む)にしかなりません。

さて、これ以上は「どこまで頑張ってどんな表示にしたいのか」がわからないとなんとも言えませんが、所要時間の予測できないネットワークからのデータ取得で、ユーザ目線から自然に見える進捗表示を行うのは極めて困難です。(ダウンロードに何十秒もかかる巨大なファイルの受信なら、時間ではなく、受信容量で進捗表示をする手もあるでしょうが。)多少大きなページでもHTMLそのものは大した大きさにはなりませんので、最初のレスポンスが返ってきてから進捗表示を行ってもあまり意味がないです。

と言うわけで最初の結論につながります。この部分はもうちょっと頑張りたい、だとか、(使うかどうかは別にして)この部分がよくわからない、などの点がありましたら、コメント等でお知らせください。

* * *

2つ目のコードに記載したこの部分:

[code]

        DispatchQueue.main.async {
            self.table.reloadData()
            //reloadData()完了後に実行する処理をメインキューに登録
            DispatchQueue.main.async {
                self.progressView.removeFromSuperview()
                self.progressView = nil
            }
        }
    
[/code]

これはやはり説明なしでは少しわかりにくかったようです。なんせメインキュー(`DispatchQueue.main`)の中で実行されている処理で、またメインキューを指定していますから。

これをきちんと理解するためには一般的なキューベースのイベント処理がどのように実行されているのか、と言ったことがわかっていなくてはいけません。ここでは、その辺りのことをできるだけ直感的にご理解いただけるように努力して見ます。

その場合重要なのは、上の方の注にもあるこちらです:

# UIの更新に関する処理はメインスレッドで行う必要がある

実はこれはあなたがアプリとして書くコードだけではなく、iOSが内側でなんらかの処理を行う際にも成り立つことなのです。例えば`UITableView`の`reloadData()`のようなメソッドを呼ぶと、iOSはテーブル全体を再表示しないといけませんから、かなり膨大な内部処理が行われます。その中には(例えば新しい`UITableViewCell`の生成や内容の変更など)UIの更新を含むものもあるので、iOSは「後でメインスレッドでこの処理をやらないといけない」と言う処理の塊をメインキューに積んで行くのです。

そしてそのようにして積まれた処理が実行されるのは、現在メインキューで実行されている処理が終わってから、なのです。

単に:

[code]

        DispatchQueue.main.async {
            self.table.reloadData()
            self.progressView.removeFromSuperview()
            self.progressView = nil
        }
    
[/code]

と書いてしまうと、そのような`reloadData()`用にキューに積まれたシステム側の処理が走る前に`removeFromSuperview()`が実行されてしまい、期待していた結果が得られなくなることもあるわけです。

なんらかのイベント処理の中でUI更新を含むメソッドを呼んだなら、その効果が現れるのは、そのイベント処理が終わった後になる、と言うのは覚えておいてもらったほうがいいでしょう。私が先に書いたコードでは、内側の`DispatchQueue.main.async
{...}`により、「この処理はあとでやっといてくれ」と渡したら、すぐにイベント処理を終了しているのです。


CC BY-SA 3.0
2017-04-25T21:58:38.083
34281
2017-04-26T14:19:05.933
2020-06-17T08:14:45.997
-1
13972
34242
answer