2015年6月29日月曜日

共通モデルのライブラリ化を試作(機能追加編)

共通で使えるモデルクラスをライブラリ化する第1段として、記録カードのモデルクラスを作る話は終わりました。しかし、簡単な検索機能を急に追加したので、続きとして紹介します。

 

今回のモデルクラスは、まだ機能が不足気味です。どんな機能を追加したら良いのか、作り終わっても考え続けていました。やはり検索機能が必要だと感じ、そこだけ集中して検討したのです。凝った機能は実現が非常に難しいと判断し、とりあえず簡単に実現できる方法を追加することにしました。

検索機能では、検索の種類をどれだけ増やせるかが重要です。あらかじめ用意する決められた方法では、やはり限界があります。それよりも、検索で使う判定条件をアプリ側から与えて、モデルクラス側では、与えられた判定条件を実行する形が良いでしょう。

このような仕組みの実現には、判定条件を関数として与える方法が有効です。アプリ側で関数を用意し、その関数をメソッドに渡して、モデルクラス側で実行します。

ただし、なんでも出来るわけではありません。与えられる関数の仕様や、関数の渡し方によって、自ずと限界が生じます。凝った仕組みは大変なので、単純な仕組みを選びました。特定の1項目の値だけを見て、条件に適合しているのか判断する関数です。

関数の仕様は単純です。項目のデータ型がIntの場合は、関数のデータ型が「(Int) -> Bool」となります。つまり、Int型の値を受け取って、何か処理をし、Bool値を返すという関数です。他のデータ型も同様で、データ型がStringであれば、関数のデータ型が「(String) -> Bool」となります。これらの関数は真偽を判定する役割なので、ここでは判定関数と呼びましょう。判定関数の結果がtrueのレコードだけ、検索結果に含めます。

 

具体的なSwiftコードを見てみましょう。データ型が4種類なので、4つのメソッドを追加しましたが、まずはInt型だけ。

// ======================================== 条件検索
func findIntF(rFldIdx:Int, _ rFunc:(Int) -> Bool) -> [Int] {
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    var iAryRecIdx: [Int] = [Int]()
    for i in 0..<cRecCount {   // i=RecIdx
        let iInt: Int = cAryDataInt[iDataIdx][i]
        if rFunc(iInt) {
            iAryRecIdx.append(i)
        }
    }
    return iAryRecIdx
}

4つとも同じような処理なので、Int型だけ説明します。ソースコードも短く、単純な処理です。

引数は、項目インデックス番号FldIdxと判定関数だけです。アプリ側では、FldIdxとして予め用意した変数(定数)名を使うので、FldIdxの範囲の検査は付けていません。

処理の最初では、指定された項目の値が入っている配列の位置を探します。FldIdxからDataIdxを求めるだけです。位置が見つかったので、全部の値を最初から見ていきます。レコード数と同じ回数だけループする処理になります。

ループの直前で、判定関数でtrueとなったレコードの番号RecIdxを入れる配列iAryRecIdxを用意します。ループ内では、データの入った配列cAryDataIntから、項目の値を取り出し、変数iIntに入れます。その値を判定関数に与えて、判定結果がtrueであれば、レコード番号(i=RecIdx)を配列iAryRecIdxに追加します。

ループの最後には、レコード番号の配列iAryRecIdxを返すだけです。もし全部がfalseなら、空の配列を返します。

 

残りの3つのメソッドは、次のとおりです。

// 残り3つのメソッド
func findFloatF(rFldIdx:Int, _ rFunc:(Float) -> Bool) -> [Int] {
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    var iAryRecIdx: [Int] = [Int]()
    for i in 0..<cRecCount {   // i=RecIdx
        let iFloat: Float = cAryDataFloat[iDataIdx][i]
        if rFunc(iFloat) {
            iAryRecIdx.append(i)
        }
    }
    return iAryRecIdx
}
func findBoolF(rFldIdx:Int, _ rBool:Bool) -> [Int] {
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    var iAryRecIdx: [Int] = [Int]()
    for i in 0..<cRecCount {   // i=RecIdx
        let iBool: Bool = cAryDataBool[iDataIdx][i]
        if iBool == rBool {
            iAryRecIdx.append(i)
        }
    }
    return iAryRecIdx
}
func findStrF(rFldIdx:Int, _ rFunc:(String) -> Bool) -> [Int] {
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    var iAryRecIdx: [Int] = [Int]()
    for i in 0..<cRecCount {   // i=RecIdx
        let iStr: String = cAryDataStr[iDataIdx][i]
        if rFunc(iStr) {
            iAryRecIdx.append(i)
        }
    }
    return iAryRecIdx
}

見て分かるように、Bool型だけは関数を使っていません。2種類の値しかないので、指定した値と同じかどうか調べるしかないですから。残りのメソッドの処理内容はInt型と同じなので、説明は省略します。

 

続いて、使用例の紹介です。今回も、iOS実験専用アプリから抜き出しました。

検索するためには、判定関数が必要です。掲載した例では、判定関数をクロージャとして用意し、変数に入れています。それを検索メソッドに渡すだけです。

// アプリ側で検索機能を使ってみる

// 指定された項目の値が、500以下のレコードを検索
let iFuncI1: (Int) -> Bool = { (rInt:Int) -> Bool in return (rInt < 500) }
let iAryRecIdx: [Int] = modelRecCard25.findIntF(fNumber_i, iFuncI1)

// 指定された項目の値が、変数に入っている標準値より大きなレコードを検索
var iIntStd: Int = 100
let iFuncI3: (Int) -> Bool = { (rInt:Int) -> Bool in return (rInt > iIntStd) }
let iAryRecIdx3: [Int] = modelRecCard25.findIntF(fNumber_i, iFuncI3)

// 指定された項目の値が、trueのレコードを検索(関数は不要)
let iAryRecIdxB: [Int] = modelRecCard25.findBoolF(fSpMember_b, true)

// 指定された項目の文字列で、先頭が「C」のレコードを検索
let iFuncS2: (String) -> Bool = { (rStr:String) -> Bool in return rStr.hasPrefix("C") }
let iAryRecIdxS2: [Int] = modelRecCard25.findStrF(fName_s, iFuncS2)

// 指定された項目の文字列で、どこかに「g」があるレコードを検索
let iFuncS3: (String) -> Bool = { (rStr:String) -> Bool
    in if let iRange = rStr.rangeOfString("g") { return true }; return false }
let iAryRecIdxS3: [Int] = modelRecCard25.findStrF(fName_s, iFuncS3)

これらの例のように、関数さえ作れれば、かなり様々な判定が可能です。1つの項目値で判定可能な内容なら、たいていのことができると思います。

アプリ側では、検索条件を満たしたレコード番号RecIdxの配列しか受け取りません。そのレコード番号をもとに、他のgetメソッドを使って、該当するレコードの項目値を取得します。

モデルクラスに多くのデータが入っている場合は、全部の項目値を受け取るよりも、少ないデータ量で済みます。その点が、この検索メソッドの価値でしょう。

 

複数の項目を使った判定も、作ろうと思えば追加できます。しかし、実装は相当に大変そうです。2つの項目値でも、データ型の組み合わせが多く、その組み合わせを全部用意するのか、それともデータ型を指定できる工夫を用意するのか、どちらにしても難しいでしょう。

1つの項目だけを使う検索メソッドなら簡単に作れるので、現実的な選択肢として選びました。これなら、4つのメソッドを用意するだけで済みますから。

 

今回の検索機能は、簡単な機能の追加でしたが、幅広く使えるメソッドになりました。また、何か思い浮かんだら、モデルクラスに機能を追加したいと思います。

 

(使用開発ツール:Xcode 6.3.2, SDK iOS 8.3)

2015年6月28日日曜日

共通モデルのライブラリ化を試作(本編3+使用例編)

共通で使えるモデルクラスをライブラリ化する第1段として、記録カードのモデルクラスを作る話の続きです。Swiftコードの紹介を続けます。

 

取り上げるのは、モデルクラス内のデータをファイルへ保存する機能に関係したメソッドです。以前の投稿で紹介した、ファイルの読み書きを間接参照にするクラスを、そのまま利用します。

ファイルとして保存する場合、どのような形にすべきかを最初に考えます。

まずは、全体像です。データは4つの配列に入っていますが、これを1つにまとめて、1ファイルとして保存するのがベストです。4つよりも1つのほうが、ファイル管理の面で扱いやすいですから。

クラス内のデータは、データ型ごとに4つの配列に分かれています。これを1つのファイルにまとめる場合、どのように結合すれば良いのでしょうか。もとが配列ですから、結合されたデータも配列が良いでしょう。つまり、4つの配列を1つの配列に変換するわけです。

配列では、データの並び順が大事です。1つの考え方ですが、保存されたファイルの形式も、ある程度は意味のある形が良いのではと思います。その場合、全体がレコード順に並んでいるのが、意味のある形の1つでしょう。タブ区切りのテキストと同じ形です。これ以外を思いつかなかったので、レコード順と決めました。

ファイルの保存するためには、保存手段のあるデータ型が必要です。配列として1つにまとめるとなると、NSArrayしかありません。データを結合するのに加工処理が必要ですから、サブクラスのNSMutableArrayを使うことになります。

ファイルとして保存する範囲ですが、項目名やデータ型定義を含みません。これらも含めて保存し、その後にロードしてインスタンス内を書き換えると、データ型の構成や項目名がまったく違うインスタンスになってしまいます。そうなってはアプリが困るでしょう。インスタンス内を書き換えるのは、あくまでデータ部分であって、項目名とデータ型定義は固定したままです。

 

では、具体的なSwiftコードを見ていきましょう。ファイル関連メソッドの先頭部分です。

// ======================================== ファイル保存
// 項目名やデータ型定義は含まない。1つの配列にまとめて、1つのファイルとして保存する。
// 保存ファイルの形式:2次元の配列で、1次元目はレコード、2次元目にレコード内の項目が入る。
// (こんな感じ:[[項目1,項目2,項目3,項目4],[項目1,項目2,項目3,項目4], ... ,[項目1,項目2,項目3,項目4]])
var cLink2Storage: ZSLAry2Storage!

// 保存先の登録
func setStorageF(rStorage:ZSLAry2Storage) {
    cLink2Storage = rStorage
}

先頭のコメント行では、どんな形式で保存するのかを簡単に説明しています。処理内容を理解するのに役立ちます。

続いて、保存先を登録する変数です。NSArrayをそのまま保存するための、ファイルへのリンククラスを指定します。以前に作ったクラスのうち、NSArrayを直接渡せるクラスZSLAry2Storageを使いました。

保存先を登録するメソッドは、受け取ったインスタンスを、そのまま変数に入れているだけです。リンククラスのインスタンスは、アプリ側が生成して、モデルクラスに渡すのが使う際のルールです。

 

続いて、データを保存先に書き出すメソッドです。NSArrey形式の保存データを生成した後、登録してある保存先へ渡します。

// データを保存先へ書き出す
func save2StorageF() -> Bool {
    // 保存先が未登録ならエラー
    if cLink2Storage == nil { zSendErrMsgF("ERROR:ZMR_SV:保存先が未登録"); return false }
    // 出力用に変数を用意
    var iNSAryOut: NSMutableArray = NSMutableArray()
    // 保存用データの生成
    for i in 0..<cRecCount { // i=RecIdx
        var iNSA: NSMutableArray = NSMutableArray()
        for j in 0..<cFldCount { // j=FldIdx
            let iDataIdx: Int = cAryDataIdx[j]
            switch cAryType[j] {
            case .ZmInt :
                let iInt: Int = cAryDataInt[iDataIdx][i]
                iNSA.addObject(iInt)
            case .ZmFloat :
                let iFloat: Float = cAryDataFloat[iDataIdx][i]
                iNSA.addObject(iFloat)
            case .ZmBool :
                let iBool: Bool = cAryDataBool[iDataIdx][i]
                iNSA.addObject(iBool)
            case .ZmStr :
                let iStr: String = cAryDataStr[iDataIdx][i]
                iNSA.addObject(iStr)
            }
        }
        iNSAryOut.addObject(iNSA)
    }
    // 保存用データを渡して、保存してもらう
    let iResult: Bool = cLink2Storage.saveF(iNSAryOut)
    if iResult == false { zSendErrMsgF("ERROR:ZMR_SV:保存先への書き出しが失敗"); return false }
    return true
}

一番最初に、保存先となるリンククラスが登録されているか調べています。未登録ならエラーメッセージを出して終了します。

データ生成の処理では、レコード順に処理しています。空のNSMutableArray(全体用)を用意して、そこへレコード単位で追加する形です。

レコード内の処理では、まず空のNSMutableArray(前とは別なもので、レコード1件分用)を用意して、それに項目を順番に追加します。項目は、それぞれのデータ型に合わせて取り出しています。全部の項目を追加できたら、全体用のNSMutableArrayへ追加するわけです。

最後のレコードを処理し終わると、保存用のNSMutableArray(NSArreyでもある)が完成します。これをリンククラスへ渡して保存してもらいます。成否の結果がBool値で返ってくるので、失敗したときだけエラーメッセージを出します。また、受け取ったBool値は、このメソッドの戻り値として返します。

かなり単純な処理で、データ全部を保存できました。plist形式で保存されるため、plistが開けるアプリなどへ持っていけば、保存されたデータを活用できます。ただし、項目名などが含まれていないため、手作業で補助することになりますが。

 

データをファイルとして保存する場合は、似たようなファイルを読み込んでしまわないように、ファイルの中身を識別できる情報を付加する方法もあります。でも、今回は含めませんでした。

ここで使っているファイル保存は、ファイルへのリンククラスを用いています。呼び出すアプリ側が生成し、ファイル名なども固定しています。そのため、間違ったファイルを読み込むミスは、ほとんど起こらないでしょう。このような理由から、識別情報を含めていません。

逆に、読み込むファイルをユーザー操作で選ぶ方式なら、識別情報をファイルに埋め込む必要があります。入れるべき情報としては、このクラス名、項目名、データ型、任意の識別コードなどの組み合わせになるでしょう。

 

次は、保存したデータを読み込むメソッドです。

大まかな流れは、次のようになります。まず、ファイルへのリンククラスから、NSArreyの形でデータを受け取ります。一応、正しいデータなのか最低限の検査をします。それが通ったら、このモデルクラスの内部変数を初期化して、読み込んだデータを変数に入れていきます。

この種の処理で大事なのは、内部変数を初期化するタイミングです。読み込みが失敗したり、検査を通らない場合もあるので、大丈夫なデータだと確認できてから、変数を初期化しなければなりません。読み込みが失敗し、直前まで持っていたデータも失ったら、悲惨ですから。

具体的なSwiftコードは、次のようになりました。

// 保存先からデータを読み込む
func loadF() -> Bool {
    // 保存先が未登録ならエラー
    if cLink2Storage == nil { zSendErrMsgF("ERROR:ZMR_LD:保存先が未登録"); return false }
    // データを読み込む
    let iNSAryIn: NSArray? = cLink2Storage.loadF()
    if iNSAryIn == nil { zSendErrMsgF("ERROR:ZMR_LD:保存先からの読み込みが失敗"); return false }
    // 最低限の検査:データ件数の検査
    if (iNSAryIn as! [[AnyObject]]).count < 1 {
        zSendErrMsgF("ERROR:ZMR_LD:読み込みデータが0件")
        return false
    }
    if (iNSAryIn as! [[AnyObject]])[0].count != cAryType.count {
        zSendErrMsgF("ERROR:ZMR_LD:読み込んだ項目数が項目定義と不一致")
        return false
    }
    // 内部変数の初期化
    cRecCount = 0
    cAryDataInt = [[Int]]()
    cAryDataFloat = [[Float]]()
    cAryDataBool = [[Bool]]()
    cAryDataStr = [[String]]()
    setupArrayF()
    // 読み込んだデータを、内部変数に入れる
    for i in 0..<iNSAryIn!.count {   // i=RecIdx
        // 空レコードの追加
        let iRecIdx: Int = addRecF()    // iRecIdx=iなので、iを使う
        // レコードへ値を設定
        for j in 0..<cFldCount {     // j=FldIdx
            switch cAryType[j] {
            case .ZmInt :
                let iInt: Int = (iNSAryIn as! [[AnyObject]])[i][j] as! Int
                setDataIntF(i, j, iInt)
            case .ZmFloat :
                let iFloat: Float = (iNSAryIn as! [[AnyObject]])[i][j] as! Float
                setDataFloatF(i, j, iFloat)
            case .ZmBool :
                let iBool: Bool = (iNSAryIn as! [[AnyObject]])[i][j] as! Bool
                setDataBoolF(i, j, iBool)
            case .ZmStr :
                let iStr: String = (iNSAryIn as! [[AnyObject]])[i][j] as! String
                setDataStrF(i, j, iStr)
            }
        }
    }
    return true
}

このメソッドでも、処理結果をBool値で返しています。読み込みが失敗した場合は、もとから入っていたデータがそのまま残ります。

処理の一番最初では、保存先となるリンククラスが登録されているか調べています。未登録ならエラーメッセージを出して終了です。

読み込んだデータの検査としては、まずデータ件数を調べます。含まれるデータがゼロ件なら、当然エラーです。続いて、1レコードの項目数を調べます。データ型定義の項目数と一致していなければ、エラーとして扱います。ファイルを間違う可能性が低いので、これ以上の検査は付けませんでした。

内部変数を初期化した後、読み込んだデータを内部変数に入れます。データがレコード順に並んでいるので、アプリ用に作ったメソッドをそのまま使いました。レコードを追加するメソッドと、項目ごとの値を設定するメソッドです。

読み込んだNSArrayからデータを取り出す部分では、まずNSArrayを、AnyObjectの2次元配列にキャストします。そこから1つの要素を取り出して、それを該当するデータ型(4つのデータ型の1つ)にキャストして、一時変数に入れています。その一時変数の値を、項目設定メソッドにて、クラス内のデータ配列へ入れるという手順です。

実際に試しても、正しく動きました。保存メソッドで書き出したファイルを、このメソッドで読み込んだところ、保存されているデータが再現できました。

 

以上で、モデルクラスのメソッドは全部紹介し終わりました。でも1つだけ、余計なメソッドを追加してあります。

デバッグなどの目的で使う、1レコードを取得するメソッドです。項目値のデータ型がさまざまなので、AnyObjectの配列という形で1レコード分の項目値を返します。具体的なSwiftコードは、次の通りです。

// レコードを取得:このメソッドだけは、値がAnyObjectで返るため、扱いに注意を!(基本的にデバッグ用)
func getRecF(rRecIdx:Int) -> [AnyObject] {
    var iAryAObj: [AnyObject] =  [AnyObject]()
    for i in 0..<cFldCount {   // i=FldIdx
        let iDataIdx: Int = cAryDataIdx[i]
        switch cAryType[i] {
        case .ZmInt :
            let iInt: Int = cAryDataInt[iDataIdx][rRecIdx]
            iAryAObj.append(iInt)
        case .ZmFloat :
            let iFloat: Float = cAryDataFloat[iDataIdx][rRecIdx]
            iAryAObj.append(iFloat)
        case .ZmBool :
            let iBool: Bool = cAryDataBool[iDataIdx][rRecIdx]
            iAryAObj.append(iBool)
        case .ZmStr :
            let iStr: String = cAryDataStr[iDataIdx][rRecIdx]
            iAryAObj.append(iStr)
        }
    }
    return iAryAObj
}

細かな部分の処理は、今まで登場したメソッドに含まれていますから、説明する必要はないでしょう。

 

モデルクラスの説明が終わったので、使用例を紹介します。新たに使用例を作るのは大変なので、iOS実験専用アプリで使っている、テスト用のコードから抜き出して構成しました。

まずは準備から。ここが一番大事です。具体的には、次のように作ります。

// アプリ側で、記録カードのモデルクラスを使う(準備1)

// 変数と定義値を準備
var modelRecCard25: ZModelRecCard!                                          // モデルクラス
let iAryName: [String] = ["Number", "Rate",   "SpMember", "Name", "Place"]  // 項目名の定義値
let iAryType: [ZmType] = [.ZmInt,   .ZmFloat, .ZmBool,    .ZmStr, .ZmStr]  // データ型の定義値

// モデルクラスのインスタンス生成
modelRecCard25 = ZModelRecCard(iAryName, iAryType)

たったこれだけで、モデルクラスが使えるようになります。

項目名とデータ型の定義値を入れた配列でも、上手な作り方があります。配列の要素を並べる部分では、空白文字を入れて位置を揃え、同じ数だけ含まれていることが確認しやすいように仕上げます。

実は、もう1つ大事な準備があります。項目インデックス番号を指定するための変数(定数)を、項目の数と同じだけ準備します。項目指定と同じ並び順で作ります。以下のように。

// アプリ側で、記録カードのモデルクラスを使う(準備2)

// 項目インデックス番号を指定するための変数(定数)
let fNumber_i: Int = 0
let fRate_f: Int = 1
let fSpMember_b: Int = 2
let fName_s: Int = 3
let fPlace_s: Int = 4

ここでの作成ポイントは、変数名の工夫です。項目(フィールド)を示す名前だと分かるように、最初にfを付けました。また、変数名の最後には、データ型を示す1文字を加えてあります(i=Int, f=Float, b=Bool, s=String)。さらに、データ型の文字が目立つようにと、間にアンダースコアを入れました。

メソッドの引数として、項目インデックス番号を指定する場合は、この変数名を必ず使います。この変数は、指定した項目数しかありませんから、項目インデックス番号を間違うことはないのです。結果として、メソッド内での項目インデックス番号の検査を外せました。

 

モデルクラスのインスタンスを生成した直後は、空の状態です。さっそく、1つ目のレコードを追加します。次のような形で。

// アプリ側で、記録カードのモデルクラスを使う(レコードの追加)
let iRecIdx: Int = modelRecCard25.addRecF()
modelRecCard25.setDataIntF(iRecIdx, fNumber_i, 41975)
modelRecCard25.setDataFloatF(iRecIdx, fRate_f, 0.8)
modelRecCard25.setDataBoolF(iRecIdx, fSpMember_b, true)
modelRecCard25.setDataStrF(iRecIdx, fName_s, "T.Sugimoto")
modelRecCard25.setDataStrF(iRecIdx, fPlace_s, "Akita")

レコード追加のメソッドを実行し、レコード番号を受け取ります。続いて、その番号を使い、すべての項目で値を設定します。項目インデックス番号の変数(定数)名には、その項目のデータ型が示されていますから、正しいデータ型のメソッドが選べます。また、後で目視検査するときも、項目インデックス番号の変数名を見ながら、正しいデータ型のメソッド名が選ばれているか確認できます。

このように、項目インデックス番号の変数名を工夫することで、間違ったメソッドを使わなくなります。コーディング中に目視検査しておけば、メソッドのデータ型を選び間違えたエラーは出さないでしょう。

 

続いて、レコードの読み出しです。レコード番号を指定して、ここの項目を呼び出すだけです。次のように。

// アプリ側で、記録カードのモデルクラスを使う(レコードを読み出す)
let iRecIdx: Int = 16
let iInt: Int = modelRecCard25.getDataIntF(iRecIdx, fNumber_i)
let iFloat: Float = modelRecCard25.getDataFloatF(iRecIdx, fRate_f)
let iBool: Bool = modelRecCard25.getDataBoolF(iRecIdx, fSpMember_b)
let iStr: String = modelRecCard25.getDataStrF(iRecIdx, fName_s)
let iStr2: String = modelRecCard25.getDataStrF(iRecIdx, fPlace_s)

ここでも、項目インデックス番号の変数(定数)名を使います。変数名にデータ型が含まれていますから、正しいデータ型のメソッドを選べます。

当たり前のことですが、全部の項目を毎回読み出す必要はありません。利用する項目だけ読み出し、無駄なアクセスを減らします。

 

このモデルクラスに含まれるメソッドは、単純なものがほとんどです。そのため、このブログを読むような方には、全メソッドの使用例の紹介は不要でしょう。これ以降では、大事なメソッドだけで使用例を紹介します。

 

次は、ファイルへ保存するメソッドです。

// アプリ側で、記録カードのモデルクラスを使う(ファイルへ保存)

// 保存先となるリンククラスのインスタンスを生成
let link2File25: ZSLAry2File = ZSLAry2File(.Docs, "rec_card.plist")

// モデルクラスのインスタンスに、保存先を登録
modelRecCard25.setStorageF(link2File25)

// 必要なタイミングで、モデルクラスのインスタンスに入っているデータを保存
let iResult: Bool = modelRecCard25.save2StorageF()
if iResult == true {
    setMsgF(4, "ファイルへの保存が成功。")
} else {
    setMsgF(4, "ファイルへの保存が失敗。")
}

見てのとおり、保存先のインスタンス生成も、モデルクラスへの設定も、ファイルへの保存も、非常に簡単です。

ファイルへの保存が失敗する可能性は、機械的な要素のほとんどないiPadだとゼロに近いでしょう。でも念のために、エラーメッセージを画面へ表示するぐらいの処理は、付けておきたいものです。

 

最後に、前述のように保存したファイルから、モデルクラスのインスタンスへデータを読み込む例を挙げます。保存先となるリンククラスが設定されていることが、使う際の前提条件です。

// アプリ側で、記録カードのモデルクラスを使う(ファイルから読み出す)
let iResult: Bool = modelRecCard25.loadF()
if iResult == true {
    setMsgF(4, "ファイルからの読み込みが成功。")
} else {
    setMsgF(4, "ファイルからの読み込みが失敗。")
}

こちらも非常に簡単です。読み込む処理はたった1行で、その結果を調べて対処する処理が大半を占めます。

 

モデルクラスの内容を簡単にファイル保存できると、定期的なバックアップにも使えます。

たとえばアプリ内で、最後にバックアップした時刻を保存してあるとします。新しいデータを追加した後で、最後のバックアップ時刻と現在の時刻を比べます。1時間以上経過していたら、最新のバックアップを実行し、バックアップ時刻も更新します。

このようにすると、バックアップの間隔が短くなり、最悪の場合にもデータ損失が少なくて済みます。ちなみに、もっともデータを失わない方法は、データを追加するたびにバックアップする方法です。

アプリが安定しているなら、細かくバックアップする方法は不要でしょう。1日の最後に1回とか、お昼も加えて1日2回とか、適切なタイミングでバックアップする方法が適しています。

 

以上で、記録カードのモデルをライブラリ化した内容の説明は終わりです。最後に、今回の試作で感じたことを少し書きます。

まず内部構造から。クラス内のデータを配列に入れていますが、Int型データはIntの配列など、データと同じ型の配列を用意して、それぞれに入れてます。このおかげで、インデックスの処理が複雑になってしまいました。クラス内で持つデータは、同じデータ型にしなくても構わないのではと今は思っています。

データを保存する配列をAnyまたはAnyObjectで作ったら、もっと単純なインデックス処理になりました。ライブラリ用のクラスは、十分にテストができて、品質を確保しやすい特徴もあります。内部データをAnyまたはAnyObjectの配列に入れても、十分な品質で作れるでしょうし。

内部データをどのような型で持つかは、意外に難しい問題です。いつも悩みますし、おそらく正解はないでしょう。また、どちらが良いかの感覚は、実際に作ってみて経験しないと育たないので、今回のように作った意味はあると思います。今回のモデルクラスに関しては、AnyまたはAnyObjectのほうが良さそうだというのが、現時点での感覚です。しかし、実際に作ったわけではありませんから、あくまで予想として。

次にライブラリ化するモデルクラスは決まっていて、その作成では、内部配列をAnyまたはAnyObjectで作ろうと考えています。そうすれば、現時点での感覚が正しいのか、判断できるでしょう。もし正しいと確認できたら、こちらのモデルクラスも、内部配列をAnyまたはAnyObjectに変更するつもりです。

データ型を決めた作り方は、外部からアクセスするメソッドにも適用しました。こちらは、今のままが良いと思います。データ型の定義と照らし合わせて、間違いを発見する機能は有効ですし、必要だと感じています。

ファイルへの保存と読み込みは、以前に作ったリンククラスが活きました。切り替えが可能なのに、簡単に使えて便利です。モデルクラス側では、データを生成したり、復元したりする処理に集中できるのが良いですね。

今回のモデルクラスの全体の出来は、使えるレベルに達していると判断しています。検索機能など、まだ不足している機能があるものの、今の状態でも確実に役立ちます。おそらく、実際のアプリで使ってみることが、必要な機能を見付けることにもつながり、このモデルクラスの改良に役立つでしょう。

出来上がったモデルクラスが簡単に使えるのを見て、モデルクラスまでライブラリ化するという考えが、非常に有効だと感じました。まだ1つだけですが、もし5つぐらいの代表的なモデルクラスをライブラリ化できたとき、アプリ内で新規にコーディングするモデルクラスは半数以下になる(つまり、モデルクラスの半数以上がライブラリから生成する)と予想します。それが実現するように、モデルクラスの種類を増やしながら、各モデルクラスの機能も向上させる予定です。

 

(使用開発ツール:Xcode 6.3.2, SDK iOS 8.3)

2015年6月27日土曜日

共通モデルのライブラリ化を試作(本編2)

共通で使えるモデルクラスをライブラリ化する第1段として、記録カードのモデルクラスを作る話の続きです。Swiftコードの紹介を続けます。

レコート関係メソッドが済んだので、項目関係のメソッドを取り上げます。

まずは、項目の値を取得するgetメソッドから。コンパイラのデータ型検査の機能を生かすために、データ型ごとに用意します。今は4つのデータ型を使ってますから、メソッドも4つになります。次のようなSwiftコードになりました。

// ======================================== 値の取得と設定
// 値の取得
func getDataIntF(rRecIdx:Int, _ rFldIdx:Int) -> Int {
    if cAryType[rFldIdx] != .ZmInt { zSendErrMsgF("ERROR:ZMR_GDI:項目定義のデータ型がIntでない"); return -999 }
    if rRecIdx < 0 || rRecIdx >= cRecCount { zSendErrMsgF("ERROR:ZMR_GDI:レコード位置が範囲外"); return -999 }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    let iInt: Int = cAryDataInt[iDataIdx][rRecIdx]
    return iInt
}
func getDataFloatF(rRecIdx:Int, _ rFldIdx:Int) -> Float {
    if cAryType[rFldIdx] != .ZmFloat { zSendErrMsgF("ERROR:ZMR_GDF:項目定義のデータ型がFloatでない"); return -999.9 }
    if rRecIdx < 0 || rRecIdx >= cRecCount { zSendErrMsgF("ERROR:ZMR_GDF:レコード位置が範囲外"); return -999.9 }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    let iFloat: Float = cAryDataFloat[iDataIdx][rRecIdx]
    return iFloat
}
func getDataBoolF(rRecIdx:Int, _ rFldIdx:Int) -> Bool {
    if cAryType[rFldIdx] != .ZmBool { zSendErrMsgF("ERROR:ZMR_GDB:項目定義のデータ型がBoolでない"); return false }
    if rRecIdx < 0 || rRecIdx >= cRecCount { zSendErrMsgF("ERROR:ZMR_GDB:レコード位置が範囲外"); return false }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    let iBool: Bool = cAryDataBool[iDataIdx][rRecIdx]
    return iBool
}
func getDataStrF(rRecIdx:Int, _ rFldIdx:Int) -> String {
    if cAryType[rFldIdx] != .ZmStr { zSendErrMsgF("ERROR:ZMR_GDS:項目定義のデータ型がStringでない"); return "Error!" }
    if rRecIdx < 0 || rRecIdx >= cRecCount { zSendErrMsgF("ERROR:ZMR_GDS:レコード位置が範囲外"); return "Error!" }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    let iStr: String = cAryDataStr[iDataIdx][rRecIdx]
    return iStr
}

扱うデータ型と、データを取り出す配列が違うだけで、4つとも同じような処理をしています。

最初にチェックするのは、データ型です。指定された項目番号が定義されているデータ型を調べて、メソッドのデータ型と比べています。同じでなければ、エラーメッセージを出して終了です。

続いては、レコード番号の検査です。存在するレコード番号か調べて、範囲内でなければエラーメッセージを出して終了です。

エラーチェックを通ると、FldIdxからDataIdxを求めます。DataIdxは、データが入っている配列のインデックス番号です。それとレコード番号RecIdxにより、目的の項目データを得られます。

鋭い人なら、これら4つのコードを見て、ある疑問が浮かんだと思います。RecIdxは範囲チェックしているのに、FldIdxは範囲チェックしていない、なぜだろうと。実は、FldIdxの範囲チェックを不要にする、使い方の工夫があるのです。使用例編のところで詳しく説明しますので、今は気にしないでください。

 

この4つのメソッドでは、エラーがあったときにnilを返していません。エラーメッセージは出すものの、関係のない値を返しています。このように作った理由は、戻り値をOptional型にしたくなかったからです。

戻り値がOptional型だと、メソッド側でラップし、呼び出し側でアンラップします。両方で無駄な処理が生じます。項目ごとにメソッドを呼び出す方式ですから、もっとも多く使うメソッドになってしまうため、このような無駄を含ませたくないのです。

エラーが出る状況も考えてみました。データ型が間違っているエラーは、メソッドの単なる選択ミスなので、そのコードが最初に動いたときに気付きます。簡単に直して終了です。

もう1つのエラーは、配列の範囲外のインデックス指定です。こちらも、最初に作ったインデックス計算式などが間違っていたときに発生します。その場合は、簡単に直して終了です。でも、隠れているバグにより、インデックス値の計算がミスしてて、間違った値が生じることもあります。この場合は、あきらめるしかないでしょう。ただし、この種のバグが隠れている可能性は極めて低いので、本番時のバグを気にする必要はないと考えます。

このようにエラーが発生する状況を考えた結果、関係のない値を返しても構わないと判断しました。

 

せっかくなので、nilを返した場合の動作も考えてみましょう。戻り値がOptrinal型であっても、正しく動いている前提でプログラミングするため、nilかどうかを検査せず、無条件にアンラップするでしょう。すると、nilが返ったとき、アプリ側でクラッシュします。

配列のインデックス値を検査しないのは、アプリ内で用意した配列を使う場合と同じです。アプリ内の配列でも、インデックス値が範囲外であれば、その場でクラッシュしてしまいます。

つまり、インデックス値が範囲外の場合、アプリ内の配列でも、このモデルクラス内の配列でも、同じようにクラッシュする作り方になるということです。

結果として、Optional型にするかしないかの違いは、インデックス計算のバグが含まれるときに、次のようになります。Optional型でnilを返す場合は、アプリがクラッシュします。Optional型にせず変な値を返す場合は、エラーメッセージが出て、変な値を通常どおり処理し、表示結果などが変な値になるでしょう。また通常時の違いとして、ラップとアンラップの処理がOptional型には含まれます。

インデックス計算のバグは開発時に発見されることがほとんどなので、開発でのバグ発生時にどう動くのが良いのか、ラップとアンラップ処理を気にするのかだけです。最終的な選択は、好みの問題でしょう。私はOptional型にしないほうを選びましたが、それぞれ好きなほうを選べばよいと思います。

 

実行速度が気になる場合は、項目の値の取得や設定で、検査を外すという方法もあります。2つの検査で引っかかるのは、ソースコードを動かし始めたときだけでしょう。アプリ全体が出来上がる直前の段階まで達したら、2つの検査をコメント化することも可能です。次のように。

// 値の取得(2つの検査をコメント化)
func getDataIntF(rRecIdx:Int, _ rFldIdx:Int) -> Int {
    //if cAryType[rFldIdx] != .ZmInt { zSendErrMsgF("ERROR:ZMR_GDI:項目定義のデータ型がIntでない"); return -999 }
    //if rRecIdx < 0 || rRecIdx >= cRecCount { zSendErrMsgF("ERROR:ZMR_GDI:レコード位置が範囲外"); return -999 }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    let iInt: Int = cAryDataInt[iDataIdx][rRecIdx]
    return iInt
}

こうすると無駄な処理が減って、少しは高速になります。検査を外した状態では、戻り値をOptional型にしなかったのが、まるで正解のようにも感じられます。

これは取得メソッドでのコメント化ですが、後述する設定メソッドでも、同様にコメント化が可能です。コメント化するなら、両方のメソッドで実施したほうが良いでしょう。

本番用のコンパイルで一部の機能を外すときは、昔から#ifdefが広く使われています。Swiftでも、同様の目的で#ifが使えます。この機能を使って、本番用だけは自動的に機能をオフする手もあります。詳しい使い方はネットなどで調べてください。

 

このモデルクラスでは、データ型が異なる4つセットのメソッドが何セットも含まれます。データ型が異なるものの、処理内容が似ているため、共通部分を探して外に出し、それを呼び出す形に作り変えたくなります。

でも今回は、データを入れている配列も別々ですし、途中で使う変数もデータ型が異なります。共通部分を抜き出すのが難しいケースです。しかも、それぞれのメソッドは短めで、共通部分を抜き出すメリットは小さめです。

このような場合は、無理して共通部分を抜き出しても、別なバグを生んだり、処理内容の読みやすさが低下したりと、デメリットのほうが大きくなりがちです。かなり似ている処理ですが、そのまま共通化しないで作るのがベストだと思います。

 

続いて、項目へ値を設定するsetメソッドです。これもデータ型ごとに合計4つあります。

// 値を設定
func setDataIntF(rRecIdx:Int, _ rFldIdx:Int, _ rData:Int) {
    if cAryType[rFldIdx] != .ZmInt { zSendErrMsgF("ERROR:ZMR_SDI:項目定義のデータ型がIntでない"); return }
    if rRecIdx < 0 || rRecIdx >= cRecCount { zSendErrMsgF("ERROR:ZMR_SDI:レコード位置が範囲外"); return }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    cAryDataInt[iDataIdx][rRecIdx] = rData
}
func setDataFloatF(rRecIdx:Int, _ rFldIdx:Int, _ rData:Float) {
    if cAryType[rFldIdx] != .ZmFloat { zSendErrMsgF("ERROR:ZMR_SDF:項目定義のデータ型がFloatでない"); return }
    if rRecIdx < 0 || rRecIdx >= cRecCount { zSendErrMsgF("ERROR:ZMR_SDF:レコード位置が範囲外"); return }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    cAryDataFloat[iDataIdx][rRecIdx] = rData
}
func setDataBoolF(rRecIdx:Int, _ rFldIdx:Int, _ rData:Bool) {
    if cAryType[rFldIdx] != .ZmBool { zSendErrMsgF("ERROR:ZMR_SDB:項目定義のデータ型がBoolでない"); return }
    if rRecIdx < 0 || rRecIdx >= cRecCount { zSendErrMsgF("ERROR:ZMR_SDB:レコード位置が範囲外"); return }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    cAryDataBool[iDataIdx][rRecIdx] = rData
}
func setDataStrF(rRecIdx:Int, _ rFldIdx:Int, _ rData:String) {
    if cAryType[rFldIdx] != .ZmStr { zSendErrMsgF("ERROR:ZMR_SDS:項目定義のデータ型がStringでない"); return }
    if rRecIdx < 0 || rRecIdx >= cRecCount { zSendErrMsgF("ERROR:ZMR_SDS:レコード位置が範囲外"); return }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    cAryDataStr[iDataIdx][rRecIdx] = rData
}

検査内容は、getメソッドと同じです。

検査を無事に通過すると、FldIdxからDataIdxを求めます。これもgetメソッドと同じです。あとは、該当するデータ型の配列へ、受け取った値を設定するだけです。

 

次は、苦肉の策として用意したメソッドを取り上げます。

いろいろな使い方への対応も大事です。全部の要望に対応するのは無理なので、アプリ側が何とかできるための手助けを用意するのが、現実的な方法でしょう。

とは言え、どんな手助けが良いのでしょうか。アプリ側の要望を予想して考えてみましょう。たとえばクラス内から、条件に合ったデータを見付けるときです。このクラスで検索機能を用意しても構わないのですが、データ型が4種類あり、それを自由に組み合わせて使えます。検索機能を自由に使える形で実現するのは、難しそうです。

そもそも、検索機能も要望の1つでしかありません。値の傾向を調べたり、中央値を求めたり、要望を考えると次々に出てきます。

そこで思い浮かんだのが、最も簡単に実現でき、かなり幅広く使える機能です。その機能とは、特定の項目の全データを返す機能です。この機能が全部の項目で使えれば、アプリ側で何とでも使いこなせるでしょう。

たとえば、ある項目で上位3つの値を持つレコードだけ探すとしましょう。その項目の全データを得られれば、全データを比較して上位3つを選び出せます。このクラスから受け取ったデータはレコード順に並んでいるため、該当するレコードを特定することは簡単です。あとは、見付かったレコードをメソッドで取得するだけです。レコード単位の取得は用意してないので、項目単位で取得することになりますが。

他の要望も、項目の全レコードを取得することで、何とかなるでしょう。複数の項目で検索する場合も、該当する複数項目の全データがあれば可能です。極端な話、全部の項目で全データを取得すると、クラス内の全データを得ることができます。

以上のように考えて作ったのが、特定項目の全データを返すメソッドです。4つのデータ型ごとに必要なので、4つのメソッドになりました。具体的には、次のようなSwiftコードです。

// ======================================== 特定項目全データを返す
func getDataAllIntF(rFldIdx:Int) -> [Int] {
    if cAryType[rFldIdx] != .ZmInt { zSendErrMsgF("ERROR:ZMR_GDAI:項目定義のデータ型がIntでない"); return [Int]() }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    return cAryDataInt[iDataIdx]
}
func getDataAllFloatF(rFldIdx:Int) -> [Float] {
    if cAryType[rFldIdx] != .ZmFloat { zSendErrMsgF("ERROR:ZMR_GDAF:項目定義のデータ型がFloatでない"); return [Float]() }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    return cAryDataFloat[iDataIdx]
}
func getDataAllBoolF(rFldIdx:Int) -> [Bool] {
    if cAryType[rFldIdx] != .ZmBool { zSendErrMsgF("ERROR:ZMR_GDAB:項目定義のデータ型がBoolでない"); return [Bool]() }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    let iAryBool: [Bool] = cAryDataBool[iDataIdx]
    return cAryDataBool[iDataIdx]
}
func getDataAllStrF(rFldIdx:Int) -> [String] {
    if cAryType[rFldIdx] != .ZmStr { zSendErrMsgF("ERROR:ZMR_GDAS:項目定義のデータ型がStringでない"); return [String]() }
    let iDataIdx: Int = cAryDataIdx[rFldIdx]
    return cAryDataStr[iDataIdx]
}

どのデータ型でも、ほぼ同じです。最初にデータ型を検査してOKなら、DataIdxを求めて、該当する配列を返しています。

いつもなら、return文の前に一時変数を用意して、そこへ戻り値を入れます。でも今回は配列なので、配列がコピーされるという無駄な処理が生じるのを嫌い、返す配列を、return文の中に直接書いています。

あと戻り値のデータ型ですが、Otrional型にしたくなかったので、エラーの際にnilではなく、空の配列を返すようにしました。このエラーが出るのは、データ型を間違えたときだけ、つまり呼び出す側のコードを最初に動かしたときだけですから、データ型の間違いをエラーメッセージで気付くはずです。

全データを返す機能ですが、データ量が少ない場合の特別用であり、データ量が多い場合はお勧めできません。返す配列が大きくなるからです。このモデルクラスを実際にアプリで使ってみて、どのような機能が必要なのか検討し、この機能を使わなくて済むような新機能を追加しようと思っています。

 

このモデルクラス内に入れたデータを活用する目的の機能も、用意しておくと便利でしょう。とりあえず、1つのメソッドを作りました。

データ活用では、タブ区切りのテキストとして生成する機能が、かなり幅広く利用できると思います。そのまま表計算アプリのワークシートへペーストすれば、それぞれの値が個々のセルに入りますから。

というわけで、タブ区切りのテキストを生成して返すメソッドを用意しました。先頭に項目名を入れた、見出し行付きです。具体的には、以下のようなSwiftコードになりました。

// ======================================== データ利用
// タブ区切りのテキストを生成
func getTabTextF() -> String {
    var iStrOut: String = ""   // 出力用
    // テキストの生成:見出し
    for i in 0..<cFldCount {   // j=FldIdx
        iStrOut += cAryName[i]
        if i < (cFldCount - 1) {   // 行末だけタブ文字なし
            iStrOut += CODE_TAB
        }
    }
    iStrOut += CODE_LF
    // テキストの生成:データ
    for i in 0..<cRecCount {   // i=RecIdx
        for j in 0..<cFldCount {   // j=FldIdx
            let iDataIdx: Int = cAryDataIdx[j]
            switch cAryType[j] {
            case .ZmInt :
                let iInt: Int = cAryDataInt[iDataIdx][i]
                let iStr: String = String(iInt)
                iStrOut += iStr
            case .ZmFloat :
                let iFloat: Float = cAryDataFloat[iDataIdx][i]
                let iStr: String = String(stringInterpolationSegment:iFloat)
                iStrOut += iStr
            case .ZmBool :
                let iBool: Bool = cAryDataBool[iDataIdx][i]
                let iStr: String = String(stringInterpolationSegment:iBool)
                iStrOut += iStr
            case .ZmStr :
                let iStr: String = cAryDataStr[iDataIdx][i]
                iStrOut += iStr
            }
            if j < (cFldCount - 1) {   // 行末だけタブ文字なし
                iStrOut += CODE_TAB
            }
        }
        iStrOut += CODE_LF
    }
    return iStrOut
}

タブ区切りのテキストでは、データの並び順が大事です。表計算アプリへペーストする使い方が多いでしょうから、ワークシート上で使う際に一般的な並び方をしていなければなりません。複数レコードのデータなら、もっとも一般的な姿は、1行目に見出しが来て、2行目からデータが並び、1行分が1件のレコードという形でしょう。ですから、その形に合わせて、タブ区切りのテキストを生成します。

まず最初に、項目名をタブで区切った見出し行を生成します。行末には、タブを入れずに、LFコードを入れます。タブもLFも、別な共通ライブラリで定義してあり、その変数を使っています。

続いて、データを順番に追加します。4種類のデータがありますが、どれもStringに変換して入れます。1つのレコードが1行分となり、項目間はタブで区切り、行末はタブなしのLFです。これをレコードの数だけ繰り返します。

最後のレコードが処理し終わったら、テキストの生成も完了です。生成した文字列を呼び出し先へ返して、メソッドは終了します。

 

この機能の使い方ですが、次のようになるでしょう。画面上に、データをコピーするボタンを用意します。そのボタンがタップされたら、このメソッドを呼び出して、タブ区切りのテキストを得ます。それをクリップボードへコピーすれば、アプリ側の処理は完了です。

その際、終了のメッセージも大事ですね。クリップボードへコピーされたこととに加え、何件のレコードが含まれるか表示します。レコード件数が明らかになると、他のアプリへペーストしたとき、漏れなくデータが渡ったと確認できて助かりますから。

 

また長くなってきたので、ここで区切ります。続きは、次回の投稿にて。残りの機能(ファイルへの保存)と使用例を取り上げる予定です。

 

(使用開発ツール:Xcode 6.3.2, SDK iOS 8.3)