20200326のSwiftに関する記事は5件です。

[RxSwift]RxSwiftとは

RxSwiftとは

コードが新しいデータに反応、順次的に分離された方式で処理される、非同期プログラム開発が簡素化できる、データの変化に対応しやすい。
observable asynchronous functional via schedulers

RxSwiftの基礎

Rxの3つの構成要素についてみてみる
Observables、Operators、Schedulers

1.Observables

  • Observable クラスはRxの基盤
  • Tタイプのデータを渡す、イベントを非同期に生成
    • 他のクラスでemit(放出?)する値を購読できるようにする
  • ObservableTypeのProtocol(Observable)はシンプルで、以下の3つのイベントのみemitし、observersはこの3つのみ受信できる
    • next:最新/次のデータを渡すイベント
    • completed:成功的にイベントを終了させるイベント。追加イベント生成しない
    • error:Observableがエラー発生。追加イベント生成しない

Finite Observable Sequences (有限の観測可能なシーケンス)

例えば、ファイルをダウンロードするコードをイメージ
流れは
1. ファイルのダウンロードが始まって、データを監視する
2. 続けてファイルをダウンロードする
3. (エラーの場合)ネットワークが切れたらエラーになって停止する
4. (成功した場合)問題なくダウンロードが成功する

API.download(file: "https://www...")
    .subscribe(onNext: { data in
        ... append data to temporary file
    },
    onError: { error in 
        ... display error to user
    },
    onCompleted: {
        ... use downloaded file
    })
  • API.downloadはダウンロードするデータObservable<Data>インスタンスをreturn
  • onNextクロージャでnextイベントを受け取ることができる。
  • onErrorクロージャではerrorイベントが受け取れる。
  • 最終的にはonCompletedクロージャでcompletedイベントを受け取る

Infinite Observable Sequences(無限の観測可能なシーケンス)

ダウンロードのように自然に終了するものとは異なって、無限なsequenceがある。例えばUIイベントなどは無限に観測できる。
例えば、デバイスの横/縦モードに反応するコードをイメージ
流れは
1. UIDeviceOrientationDidChange observerを追加
2. 方向転換が管理できるcallback methodが必要
3. UIDeviceの現在の方向を確認して、画面に表示する
4. 方向転換ができるデバイスが存在する以上、自然に終了することはない
5. 無限なsequenceなので一応初期値が必要

UIDevice.rx.orientation
    .subscribe(onNext: { current in
        switch current {
            case .landscape:
                ... re-arrange UI for landscape
            case .portrait:
                ... re-arrange UI for portrait
        }
    })
  • ここでは、onErroronCompletedは発生しないはずなので、記述も不要

2. Operators

  • ObservableTypeObservableクラスはより複雑な論理を実現するためにたくさんのメソッドが含まれている。それらをOperatorを呼ぶ
  • Operatorは非同期入力を受けて出力のみ生成するだけで、複数のOperatorを併せて使うことができる
    • Observableに、より入ってきたのを処理して放出?する役割

コードを見た方が分かりやすいかも

UIDevice.rx.orientation
  .filter { value in
    return value != .landscape
  }
  .map { _ in
    return "Only Portrait"
  }
  .subscribe(onNext: { string in
    showAlert(string)
  })
  • 上記でみると分かるように filter.landscapeではないものだけ、return
  • .portraitの場合はmapに入るはずで、mapではStringに変換してreturn
  • 最後にsubscribeでは結果としてstringをshowAlertメソッドで表示している

このように複数のOperatorを使って、いろんなことができる。

3.Schedulers

  • SchedulersはRxではdispatch queueと同じだが、もっと使いやすくて強力
  • RxSwiftではいろんなスクジューラーが既に定義されている。99%の状況で使えるので、開発者が自分だけのスケジューラーを生成することはない DOqRvva.png

RxCocoa

RxSwiftは一般的なRx APIで、Cocoaとか特定のUIKitクラスについて何の情報も持たない。RxCocoaはRxSwiftと一緒に使われながら、UIKitやCocoaフレームワーク基盤開発をサポートする全てのクラスを持っているらしい。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[RxSwift] RxSwiftとは

RxSwiftとは

コードが新しいデータに反応、順次的に分離された方式で処理される、非同期プログラム開発が簡素化できる、データの変化に対応しやすい。
observable asynchronous functional via schedulers

RxSwiftの基礎

Rxの3つの構成要素についてみてみる
Observables、Operators、Schedulers

1.Observables

  • Observable クラスはRxの基盤
  • Tタイプのデータを渡す、イベントを非同期に生成
    • 他のクラスでemit(放出?)する値を購読できるようにする
  • ObservableTypeのProtocol(Observable)はシンプルで、以下の3つのイベントのみemitし、observersはこの3つのみ受信できる
    • next:最新/次のデータを渡すイベント
    • completed:成功的にイベントを終了させるイベント。追加イベント生成しない
    • error:Observableがエラー発生。追加イベント生成しない

Finite Observable Sequences (有限の観測可能なシーケンス)

例えば、ファイルをダウンロードするコードをイメージ
流れは
1. ファイルのダウンロードが始まって、データを監視する
2. 続けてファイルをダウンロードする
3. (エラーの場合)ネットワークが切れたらエラーになって停止する
4. (成功した場合)問題なくダウンロードが成功する

API.download(file: "https://www...")
    .subscribe(onNext: { data in
        ... append data to temporary file
    },
    onError: { error in 
        ... display error to user
    },
    onCompleted: {
        ... use downloaded file
    })
  • API.downloadはダウンロードするデータObservable<Data>インスタンスをreturn
  • onNextクロージャでnextイベントを受け取ることができる。
  • onErrorクロージャではerrorイベントが受け取れる。
  • 最終的にはonCompletedクロージャでcompletedイベントを受け取る

Infinite Observable Sequences(無限の観測可能なシーケンス)

ダウンロードのように自然に終了するものとは異なって、無限なsequenceがある。例えばUIイベントなどは無限に観測できる。
例えば、デバイスの横/縦モードに反応するコードをイメージ
流れは
1. UIDeviceOrientationDidChange observerを追加
2. 方向転換が管理できるcallback methodが必要
3. UIDeviceの現在の方向を確認して、画面に表示する
4. 方向転換ができるデバイスが存在する以上、自然に終了することはない
5. 無限なsequenceなので一応初期値が必要

UIDevice.rx.orientation
    .subscribe(onNext: { current in
        switch current {
            case .landscape:
                ... re-arrange UI for landscape
            case .portrait:
                ... re-arrange UI for portrait
        }
    })
  • ここでは、onErroronCompletedは発生しないはずなので、記述も不要

2. Operators

  • ObservableTypeObservableクラスはより複雑な論理を実現するためにたくさんのメソッドが含まれている。それらをOperatorを呼ぶ
  • Operatorは非同期入力を受けて出力のみ生成するだけで、複数のOperatorを併せて使うことができる
    • Observableに、より入ってきたのを処理して放出?する役割

コードを見た方が分かりやすいかも

UIDevice.rx.orientation
  .filter { value in
    return value != .landscape
  }
  .map { _ in
    return "Only Portrait"
  }
  .subscribe(onNext: { string in
    showAlert(string)
  })
  • 上記でみると分かるように filter.landscapeではないものだけ、return
  • .portraitの場合はmapに入るはずで、mapではStringに変換してreturn
  • 最後にsubscribeでは結果としてstringをshowAlertメソッドで表示している

このように複数のOperatorを使って、いろんなことができる。

3.Schedulers

  • SchedulersはRxではdispatch queueと同じだが、もっと使いやすくて強力
  • RxSwiftではいろんなスクジューラーが既に定義されている。99%の状況で使えるので、開発者が自分だけのスケジューラーを生成することはない DOqRvva.png

RxCocoa

RxSwiftは一般的なRx APIで、Cocoaとか特定のUIKitクラスについて何の情報も持たない。RxCocoaはRxSwiftと一緒に使われながら、UIKitやCocoaフレームワーク基盤開発をサポートする全てのクラスを持っているらしい。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swiftでファイルを進捗付きでダウンロード

動画や大きな写真を端末にダウンロードさせる時に、時間がかかるので進捗を表示させたいことがありました:hourglass_flowing_sand:
その時にやったことをまとめます:bookmark_tabs:

コア

URLSessiondownloadTask(with:)を使います。
URLSessionDownloadDelegateというデリゲートで進捗や完了/失敗を受け取ることができます。

参考:
downloadTask(with:)
URLSessionDownloadDelegate

実装サンプル

ファイルダウンロードができる簡単なアプリを作りました。
よければ参考にするなり流用するなりしてください。
https://github.com/Yaruki00/FileDownloader
Mar-26-2020 16-17-09.gifスクリーンショット 2020-03-26 16.23.12.png

テキストフィールドにファイルのURLを入力してGoボタンを押すとダウンロードします。
ダウンロード中は進捗を下のラベルに表示します。
エラー処理など作りは適当ですが、サンプルなのでご容赦ください・・・

簡単に解説

最近はRxSwiftを使うことが個人的に多いので、サンプルにもRxSwiftを使用しています。

ダウンロード部分

downloadTask(with:)を呼んで、URLSessionDownloadDelegateに返ってくる状態がPublishRelayに流れるようにします。

Utility/FileDownloader.swift
...

/// ダウンロードの状態
enum DownloadState: Equatable {
    /// ダウンロード中(進捗)
    case downloading(Float)
    /// 完了(保存先URL)
    case done(URL)
    /// 失敗(エラー詳細)
    case error(Error)

    static func == (lhs: DownloadState, rhs: DownloadState) -> Bool {
        switch (lhs, rhs) {
        case (.downloading(let progressL), .downloading(let progressR)):
            return progressL == progressR
        case (.done, .done):
            return true
        case (.error, .error):
            return true
        default:
            return false
        }
    }
}

final class FileDownloader: NSObject {

    // ダウンロードの状態を流す用
    // 繰り返し使う想定なのでPublishRelayにして、ObservableでいうonErrorは使わないようにする
    private var state = PublishRelay<DownloadState>()
    private var filename = ""
    private var task: URLSessionDownloadTask!

    /// ダウンロード開始
    func download(_ url: URL, name: String? = nil) -> Observable<DownloadState> {
        filename = name ?? url.lastPathComponent
        return Observable.create { [weak self] observer in
            guard let self = self else {
                return Disposables.create()
            }
            // 関数呼び出されるたびにbindしてるけど大丈夫?教えてエラい人!
            let disposable = self.state.bind(to: observer)
            self.startDownload(url)
            return disposable
        }
    }

    /// ダウンロード中断
    func cancel() {
        task.cancel()
    }
}

extension FileDownloader {

    private func startDownload(_ url: URL) {
        let config = URLSessionConfiguration.default
        let session = URLSession(configuration: config, delegate: self, delegateQueue: .main)
        let request = URLRequest(url: url)
        task = session.downloadTask(with: request)
        task.resume()
    }
}

extension FileDownloader: URLSessionDownloadDelegate {

    // ダウンロードが完了した時に呼ばれる
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        state.accept(.done(location))
    }

    // ダウンロードの進捗が変化した時に呼ばれる
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        let current = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
        state.accept(.downloading(current))
    }

    // ダウンロードが失敗した時に呼ばれる
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error = error {
            state.accept(.error(error))
        }
    }
}

呼び出し側(今回はすごく適当なビュー)

ボタンが押されたらダウンロードを開始し、進捗をラベルに反映します。

App/Presentation/ViewController.swift
...

extension ViewController {

    private func bind() {

        ...

        // ボタンが押された
        button.rx.tap
            // テキストフィールドの文字列をURLに変換
            .map { [weak self] _ -> URL? in
                let text = self?.textField.text ?? ""
                return URL(string: text)
            }
            // オプショナルのアンラップ
            .flatMap { $0.flatMap(Observable.just) ?? Observable.empty() }
            // URLをもとにダウンロード
            .flatMap { [weak self] url -> Observable<DownloadState> in
                return self?.fileDownloader.download(url) ?? .error(NSError(domain: "", code: 0, userInfo: nil))
            }
            // 前回と同じ状態なら無視
            .distinctUntilChanged()
            // ダウンロードの状態をもとにラベルに表示する文字列生成
            .map { state -> String in
                switch state {
                case .downloading(let progress):
                    return "downloading(\(String(format: "%.2f", progress * 100) + "%"))..."
                case .done(let url):
                    return "done!\n\(url.absoluteString)"
                case .error(let error):
                    return "error occurred\n\(error.localizedDescription)"
                }
            }
            // ラベルに流す
            .bind(to: label.rx.text)
            .disposed(by: disposeBag)
    }
}

おわりに

進捗がライブラリ等使わずに取得できるというのはありがたいですね:tada:
この記事では進捗状況をラベルに反映しましたが、プログレスバーなりアニメーションを使ったなにかなりにすれば見栄えもよくできると思います:art:
RxSwiftの使い方についてはまだまだ自信ないので、アドバイス等あればぜひお願いします!:pray:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Youtubeのサムネイルを検索できるアプリをリリースしました!

はじめに簡単な自己紹介✍️

主にiOS開発をメインに勉強しております、私立文系の凡人大学生です、、、?
今年の目標は応用情報技術者試験に受かることです〜!

リリースしたアプリ?

YoutubeGif.gif

Youtubeのサムネイルを検索できるアプリになっています。

App Store
https://apps.apple.com/jp/app/%E5%8B%95%E7%94%BB%E5%88%86%E6%9E%90/id1502014149

GitHub
https://github.com/geekshu625/YoutubeAPI
(改善点あればプルリクとか貰えますと泣いて喜びます)

なぜ作ったか??

Youtubeのサムネイルを検索できるプラットフォームがない

最近、僕の友人にもYoutuberになる人が現れ、様々なツールやプラットフォームを駆使することで簡単に高クオリティの動画を制作できる時代になってきました。
僕自身はただの視聴者で、暇さえあればみてしまう中毒者です?
実際にYoutubeを見ている人はわかると思うのですが、動画を「見たい」か「見たくない」かはほぼサムネイルで選択していていると思います。
ですのでサムネイルを製作することは本編動画を作成するのとほぼ同等の重要性を持っていると考えられます。
では、実際に再生数を伸ばすサムネイルをどうやって作っているのかというと、再生数と好評価が多い動画のサムネイルを真似て作るらしいのです。
それらの条件にあったサムネイルをYoutubeで探すとなると、一覧で再生数はわかっても好評価は動画を開かないと見れず煩わしい仕様になっていることに気づきます。
それらの悩みをYoutuberから聞き、
「じゃあサムネイルに特化したプロットフォームを使ったら、、、」と思ったら1つもないことに気づきました。
その経緯があり、頻繁に使いたいからアプリで!ってことで作り始めました。

開発期間?

しっかり取り掛かり始めたのが、今年に入ってからなので
2ヶ月弱ぐらいでメインの機能のみを作ってみました。

使用した技術?

今回の開発にあたって技術的に課題としたのがRxSwift+MVVVMです。
技術書店で買った書籍や尊敬する方々の記事を参考にさせて頂きました(以下でまとめています)

// -- API通信の一部
// Observable化したAPIレスポンスを返す
    static func thubnailRequest(searchText: String, nextPageToken: String) -> Observable<ThubnailRooter> {
        let baseUrl = APIEndpoint.thubnailURL(searchText: searchText, nextPageToken: nextPageToken)
        let url = URL(string: baseUrl)

        return Observable.create { observer -> Disposable in
            Alamofire.request(url!).response { (response) in
                guard let data = response.data else { return }
                do {
                    if let errorModel = try? JSONDecoder().decode(APIErrorRooter.self, from: data) {
                        let apierror = YoutubeAPIError.init(errorCode: errorModel.error.code)
                        observer.onError(apierror)
                    }
                    let model = try JSONDecoder().decode(ThubnailRooter.self, from: data)
                    observer.onNext(model)
                    observer.onCompleted()
                } catch {
                    let connectingError = ConnectionError(errorCode: error._code)
                    observer.on(.error(connectingError))
                }
            }
             return Disposables.create()
        }
    }

Swiftの実践的な書き方がまだまだ身についていないことを自覚しており、自分が完全に理解できるコードのみで実装しました
(ジェネリクスなどをうまく活用してリファクタリングできる箇所がまだまだありますね、、、)
これまでMVCで構築することが多く、ロジックを分轄してもFatViewControllerになりがちで嫌気が指していました。
なので今回はMVVMを採用し、データバインディングにRxを用いることで処理の分担がスッキリさせることを意識しました。
しかし、まだまだRxの勉強は途中で悩む場面も多々ありました?
またUnit TestもAPI通信周り(Model)だけしかできていないのでUnit Test周辺も勉強していきたいです。

その他の技術としては、デプロイ:fastlane、証明書管理:fastlane-match、ビルド:Xcodegenを導入しました。
CI/CD系も以後導入していきたいです。

今回の開発を通して

今回の技術的なテーマであったRxSwiftですが、実用するのに難しい場面がたくさんありました、、、
記事や本を読んで理解したつもりでも、「いざ実践!」ってなった場合には「???」となることが多かったです。
またSwiftの書き方もまだまだなので、コーディングする機会を増やして知見を貯めていければなっと思ってます。

参考文献

基本的なRxSwiftについて
RxSwift研究読本1 入門編
比較して学ぶRxSwift入門

設計や実践的な使い方・記事構成
あとで読むQiitaリーダーアプリをリリースしました

今後追加していく機能

  • いいね機能
  • analyticsでサムネイル人気の統計図る
  • 初期画面にジャンルタブバー設置
  • リファクタリング etc...
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swift:Qiitaに投稿してある記事を全てMarkDownファイルとしてダウンロードするスクリプト

今自分にとって,とても需要のあるスクリプトを書きました.

使い方

まずは下記のSwiftスクリプトをqiita_gyotaku.swiftという名前で保存します.次にTerminalを開いて上記のスクリプトがあるディレクトリにて,

$ swift qiita_gyotaku.swift [Qiitaユーザ名]

と叩けばそのディレクトリにArticlesという名前のフォルダが生成され,その中に指定したユーザ名の全てのQiitaの記事がMarkDownとして保存されます.保存されるファイルの名前は記事の「タイトル名.md」です.画像を埋め込んでいる記事の場合、画像の引用がQiita頼り(もっといえばオンライン頼り)になってしまうので,画像も落としてきてローカルで読めるように書き換えています.

なお,macOS Mojaveにて動作確認をしています.

スクリプトのソース

Gistにあげてあるのでcloneできますヽ(*゚д゚)ノ
https://gist.github.com/Kyome22/9525f59f3bdc8322cf18d0d1633575cb

qiita_gyotaku.swift
import Foundation

class Article {

    let title: String
    var body: String

    init(_ title: String, _ body: String) {
        self.title = title
        self.body = body
    }

    var fileName: String {
        var name = title
        while name.hasPrefix(".") {
            name.removeFirst()
        }
        name = name.replacingOccurrences(of: ":", with: ":")
        name = name.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
        return name + ".md"
    }

}

class Gyotaku {

    private static func createDirectory() {
        var dir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
        dir.appendPathComponent("Articles")
        if !FileManager.default.fileExists(atPath: dir.path) {
            try? FileManager.default.createDirectory(at: dir,
                                                     withIntermediateDirectories: true,
                                                     attributes: nil)
        }
        dir.appendPathComponent("Images")
        if !FileManager.default.fileExists(atPath: dir.path) {
            try? FileManager.default.createDirectory(at: dir,
                                                     withIntermediateDirectories: true,
                                                     attributes: nil)
        }
    }

    private static func getItemsCount(userName: String) -> (urlStr: String, itemsCount: Int)? {
        let semaphore = DispatchSemaphore(value: 0)
        let urlStr = "https://qiita.com/api/v2/users/\(userName)"
        guard let url = URL(string: urlStr) else { return nil }
        var itemsCount: Int = 0
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            defer { semaphore.signal() }
            if let error = error {
                Swift.print("Failur: \(error.localizedDescription)")
                return
            }
            guard
                let data = data,
                let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String : Any],
                let count = json["items_count"] as? Int
                else {
                return
            }
            itemsCount = count
        }
        task.resume()
        semaphore.wait()
        if itemsCount == 0 {
            Swift.print("Failur: \(userName) has no article.")
            return nil
        } else if itemsCount == 1 {
            Swift.print("\(userName) has \(itemsCount) article")
        } else {
            Swift.print("\(userName) has \(itemsCount) articles")
        }
        return (urlStr, itemsCount)
    }

    private static func getArticle(_ urlStr: String, _ page: Int) -> [Article] {
        let semaphore = DispatchSemaphore(value: 0)
        var articles = [Article]()
        guard var components = URLComponents(string: "\(urlStr)/items") else { return articles }
        components.queryItems = [
            URLQueryItem(name: "page", value: String(page)),
            URLQueryItem(name: "per_page", value: "100")
        ]
        let task = URLSession.shared.dataTask(with: components.url!) { (data, response, error) in
            defer { semaphore.signal() }
            if let error = error {
                Swift.print("Failur: \(error.localizedDescription)")
                return
            }
            guard
                let data = data,
                let items = try? JSONSerialization.jsonObject(with: data, options: []) as? NSArray else {
                    return
            }
            articles = items.compactMap { (item) -> Article? in
                guard
                    let obj = item as? [String : Any],
                    let title = obj["title"] as? String,
                    let body = obj["body"] as? String
                    else {
                        return nil
                }
                return Article(title, body)
            }
        }
        task.resume()
        semaphore.wait()
        return articles
    }

    static func renewalImages(body: inout String) {
        guard let regex = try? NSRegularExpression(pattern: "!\\[.+\\]\\((.+)\\)") else {
            return
        }
        let matches = regex.matches(in: body, range: NSRange(location: 0, length: body.count))
        matches.reversed().forEach { (result) in
            let nsbody = NSString(string: body)
            let sentence = nsbody.substring(with: result.range)
            let imageURL = nsbody.substring(with: result.range(at: 1))
            if Gyotaku.getImage(urlStr: imageURL) {
                let url = URL(string: imageURL)!
                let newImageURL = "./Images/\(url.lastPathComponent)"
                let newSentence = sentence.replacingOccurrences(of: imageURL, with: newImageURL)
                body = nsbody.replacingCharacters(in: result.range, with: newSentence)
            }
        }
    }

    static func getImage(urlStr: String) -> Bool {
        guard let url = URL(string: urlStr) else { return false }
        let semaphore = DispatchSemaphore(value: 0)
        var imageData: Data?
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            defer { semaphore.signal() }
            if let error = error {
                Swift.print("Failur: \(error.localizedDescription)")
                return
            }
            imageData = data
        }
        task.resume()
        semaphore.wait()
        guard let data = imageData else { return false }
        let saveUrl = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
            .appendingPathComponent("Articles", isDirectory: true)
            .appendingPathComponent("Images", isDirectory: true)
            .appendingPathComponent(url.lastPathComponent)
        guard let _ = try? data.write(to: saveUrl, options: []) else {
            return false
        }
        return true
    }

    private static func saveArticles(_ articles: [Article]) {
        let dir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
            .appendingPathComponent("Articles", isDirectory: true)
        articles.forEach { (article) in
            let saveURL = dir.appendingPathComponent(article.fileName)
            try? article.body.write(to: saveURL, atomically: true, encoding: .utf8)
        }
        Swift.print("Complete. Check Articles directory.")
    }

    static func main() {
        guard CommandLine.arguments.count == 2 else {
            Swift.print("$ swift qiita_gyotaku.swift [username]")
            return
        }
        guard let response = Gyotaku.getItemsCount(userName: CommandLine.arguments[1]) else {
            return
        }
        let articles = (0 ... Int(response.itemsCount / 100)).flatMap { (i) -> [Article] in
            return Gyotaku.getArticle(response.urlStr, i + 1)
        }
        Gyotaku.createDirectory()
        articles.forEach { (article) in
            Gyotaku.renewalImages(body: &article.body)
        }
        Gyotaku.saveArticles(articles)
    }

}

Gyotaku.main()
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む