2015年7月29日水曜日

2Dアニメ機能のライブラリを試作(本編1)

2Dアニメーションのライブラリを試作した話の続きです。UIViewのサブクラスという形を選択し、いろいろなViewと組み合わせて使えることを想定して作りました。

いよいよソースコードの紹介なのですが、クラス本体の紹介の前に、データ定義を紹介します。指定する項目が多すぎて、かなり長いSwiftコードになってしまいました。

 

最初に作ったデータ定義は、たった1つの構造体で、多くの項目が含まれていました。テストコードを作るのが書きづらく感じ、すぐに分割することにしました。

まず2つに分けようと思ったのですが、上手に分ける基準が見付かりません。分割の試行錯誤を重ねているうちに、3つに分けるのが適切ではないかと思うようになりました。表示する要素(文字列や画像など)に関する情報、アニメーションの共通情報(時間や繰り返し回数など)、2Dアニメーションで指定する情報(位置や回転や透明度など)の3つです。

3つに分けたのですが、構造体名の番号はゼロから始まります。3つめが2Dアニメーションの指定情報で、2番を割り当てられるからです。また、3Dアニメーションで指定する情報の構造体を追加したとき、3番が割り当てられるのも良い点だと考えました。

 

データ定義の構造体を紹介する前に、構造体の中に含まれる項目のうち、列挙型に適したものを選んで、最初に定義しました。次のように、3つあります。

// UI部品の種類
enum ZavType: Int {
    case ZtView = 0
    case ZtImg = 1
    case ZtText = 2
}
// アニメーションの種類
enum ZavAnimate: Int {
    case Za2D = 0 // 2Dアニメ
    case Za3D = 1 // 3Dアニメ
    case ZaEtc = 2 // 特殊アニメ(画像処理など)
}
// 動作速度変化(Timing Functions)の種類
enum ZavTF: Int {
    case ZpLinear = 0
    case ZpIn = 1
    case ZpOut = 2
    case ZpInOut = 3
}

最初は、アニメーションとして動かす表示要素の種類です。汎用的に使うUIVew、画像のUIImage、文字列のStringの3つだけです。UIImageもStringも、最終的にはUIViewの形で生成するのですが、もとの形のままでライブラリへ渡せたほうが使いやすいので、このように作ってみました。

2つめのアニメーションの種類は、今のところ、3種類を考えています。2Dと3Dとその他です。その他は、画像処理を加えながらのアニメーションを考えていますが、細かな内容はまだ未定です。3種類のうち、最初は2Dだけを作りました。

3つめの動作速度変化の種類は、iOSのAPIに用意されている設定項目「Timing Functions」です。時間の変化に比例して動くLinearのほかに、動き出しがゆっくりのIn、終わりがゆっくりのOutなどがあります。この項目が指定できると、アニメーションに速度の変化が付けられて、表現力が向上します。必要だと感じて入れました。

 

準備が終わったので、いよいよデータ定義の構造体です。3つあるので、順番に取り上げます。

最初の構造体は、表示する要素(文字列や画像など)の関する情報です。

struct ZavDef0 {
    var sType: ZavType
    var sAnim: ZavAnimate
    var sObj: AnyObject? = nil
    var sText: String = "dummy"
    var sTSize: CGFloat = 24
    var sTColor: UIColor = UIColor.blackColor()
    var sFuncI: ((UIView) -> Void)? = nil   // 表示部品に対する初期化時の加工処理:処理せずはnil
    init(sType:ZavType, sAnim:ZavAnimate, sObj:AnyObject?, sFuncI:((UIView) -> Void)?) {
        self.sType = sType
        self.sAnim = sAnim
        self.sObj = sObj
        self.sFuncI = sFuncI
    }
    init(sType:ZavType, sAnim:ZavAnimate, sObj:AnyObject?) {
        self.sType = sType
        self.sAnim = sAnim
        self.sObj = sObj
    }
    init(sAnim:ZavAnimate, sTSize:CGFloat, sText:String, sTColor:UIColor, sFuncI:((UIView) -> Void)?) {
        self.sType = .ZtText
        self.sAnim = sAnim
        self.sText = sText
        self.sTSize = sTSize
        self.sTColor = sTColor
        self.sFuncI = sFuncI
    }
    init(sAnim:ZavAnimate, sTSize:CGFloat, sText:String, sTColor:UIColor) {
        self.sType = .ZtText
        self.sAnim = sAnim
        self.sText = sText
        self.sTSize = sTSize
        self.sTColor = sTColor
    }
}

最初の2項目は、列挙型で定義した内容なので、説明不要でしょう。

続くsObjは、UIViewまたはUIImageを受け取るために使います。文字列では使いません。文字列のときにnilとなるので、Optional型にして、デフォルト値としてnilを入れてあります。

続く3項目は、文字列の場合の指定です。文字列、文字サイズ、文字色が含まれます。

最後は、ライブラリ内で生成した表示部品に対して、何かの加工を加えるために用意しました。文字列またはUIImageを指定したときも、ライブラリの内部ではUIViewの形で生成します。この生成されたUIViewに対して加工を加えるため、関数のデータ型を「((UIView) -> Void)?」としました。Optional型を選んだのは、nilのときは機能オフにしたいからです。

構造体の中身の後には、初期化の処理を並べてあります。最初の初期化は、画像またはUIViewで使います。関係する項目だけしか含まれてないので、余計なダミー値を指定する必要はありません。2つ目の初期化は、関数を外したバージョンで、関数なしのときに使います。関数なしの場合がほとんどなので、こちらを多く使うでしょう。3つめと4つめの初期化は、文字列の場合に使います。こちらも関数の有無で2つ用意しました。

今まで作ったデータ定義の構造体では、デフォルトで生成される初期化を使っていました。理由は、初期化の処理を書かなくて済むからです。しかし今回は、指定する項目数が多いので、減らす目的で初期化を多く付けてみました。実際に使ってみると、今回のほうが、格段に使いやすいです。過去に作ったライブラリでも、同じような初期化を追加したくなりました。後で追加しようと思っています。

 

続いては、2番目のデータ定義の構造体です。アニメーションの共通情報(時間や繰り返し回数など)を指定するために使います。

// 共通動作定義:各種アニメーションに共通する項目
struct ZavDef1 {
    var sRepeat: Float = 1      // 繰り返し回数
    var sReverse: Bool = false  // アニメ終了後に逆再生
    var sTimeDo: NSTimeInterval // アニメの実行時間(秒)
    var sDelay: NSTimeInterval  // アニメ開始までの遅延(秒)
    var sHid1: Bool = false     // アニメ開始前に隠す
    var sHid2: Bool = false     // アニメ終了後に隠す
    var sFunc1: ((UIView) -> Void)? = nil   // アニメ開始時に実行する処理:処理せずはnil
    var sFunc2: ((UIView) -> Void)? = nil   // アニメ終了時に実行する処理:処理せずはnil
    init(sRepeat:Float, sReverse:Bool, sTimeDo:NSTimeInterval, sDelay:NSTimeInterval, sHid1:Bool, sHid2:Bool, sFunc1:((UIView) -> Void)?, sFunc2:((UIView) -> Void)?) {
        self.sRepeat = sRepeat
        self.sReverse = sReverse
        self.sTimeDo = sTimeDo
        self.sDelay = sDelay
        self.sHid1 = sHid1
        self.sHid2 = sHid2
        self.sFunc1 = sFunc1
        self.sFunc2 = sFunc2
    }
    init(sRepeat:Float, sReverse:Bool, sTimeDo:NSTimeInterval, sDelay:NSTimeInterval, sHid1:Bool, sHid2:Bool) {
        self.sRepeat = sRepeat
        self.sReverse = sReverse
        self.sTimeDo = sTimeDo
        self.sDelay = sDelay
        self.sHid1 = sHid1
        self.sHid2 = sHid2
    }
}

それぞれの項目の役割はコメントとして付けてあるので、簡単に理解できると思います。補足が必要な項目だけ、追加した理由を中心に解説します。

アニメの開始前と終了後に隠す設定は、複雑な動きを実現するために用意しました。それぞれに表示部品に指定できるアニメーションは、回転や異動などを組み合わせても、1種類の動きしか指定できません。もし途中から違う動きをさせたい場合、もっと複雑な指定方法を組み込むことになります。そうせずに、複雑な動きを実現する方法として、アニメーションの前後に消す指定を用意しました。

複数の動きを連続して見せたいときは、次のように指定します。まず最初の動きを指定して、決まった時間だけ動かします。当然、終了時には隠す設定です。この続きは、別なアニメーション指定を追加して実現します。同じ表示部品を使ったアニメーション指定を、終了した時間に開始します。こちらは、開始前に隠す設定です。すると、2つのアニメーションは繋がって再生され、1つの表示部品が、連続した違う動きをしているように見えます。同様に指定すれば、3つ以上の動きを連続して指定できるというわけです。これが、アニメーションの前後に隠す設定を付けた理由です。

最後の項目である2つの関数は、アニメーションの開始と終了時に実行します。表示部品が渡されるので、表示状態を変更することもできます。また、アプリ側の処理を起動するのにも使えます。

 

最後は、3番目のデータ定義の構造体です。2Dアニメーションで指定する情報(位置や回転や透明度など)を入れています。

// アニメ定義:2Dアニメーションの指定項目
struct ZavDef2 {
    var sX1: CGFloat = 0.0      // 位置
    var sX2: CGFloat = 0.0
    var sY1: CGFloat = 0.0
    var sY2: CGFloat = 0.0
    var sZoom1: CGFloat = 1.0   // 拡大縮小
    var sZoom2: CGFloat = 1.0
    var sRoll1: CGFloat = 0.0   // 回転
    var sRoll2: CGFloat = 0.0
    var sOpa1: Float = 1.0      // 透明度
    var sOpa2: Float = 1.0
    var sTF: ZavTF = .ZpLinear  // 動作速度変化
    init(sX1:CGFloat, sX2:CGFloat,sY1:CGFloat, sY2:CGFloat, sZoom1:CGFloat, sZoom2:CGFloat, sRoll1:CGFloat, sRoll2:CGFloat, sOpa1:Float, sOpa2:Float, sTF:ZavTF) {
        self.sX1 = sX1
        self.sX2 = sX2
        self.sY1 = sY1
        self.sY2 = sY2
        self.sZoom1 = sZoom1
        self.sZoom2 = sZoom2
        self.sRoll1 = sRoll1
        self.sRoll2 = sRoll2
        self.sOpa1 = sOpa1
        self.sOpa2 = sOpa2
        self.sTF = sTF
    }
    init(sX1:CGFloat, sX2:CGFloat,sY1:CGFloat, sY2:CGFloat, sTF:ZavTF) {
        self.sX1 = sX1
        self.sX2 = sX2
        self.sY1 = sY1
        self.sY2 = sY2
        self.sTF = sTF
    }
    init(sX1:CGFloat, sX2:CGFloat,sY1:CGFloat, sY2:CGFloat, sZoom1:CGFloat, sZoom2:CGFloat, sTF:ZavTF) {
        self.sX1 = sX1
        self.sX2 = sX2
        self.sY1 = sY1
        self.sY2 = sY2
        self.sZoom1 = sZoom1
        self.sZoom2 = sZoom2
        self.sTF = sTF
    }
    init(sX1:CGFloat, sX2:CGFloat,sY1:CGFloat, sY2:CGFloat, sRoll1:CGFloat, sRoll2:CGFloat, sTF:ZavTF) {
        self.sX1 = sX1
        self.sX2 = sX2
        self.sY1 = sY1
        self.sY2 = sY2
        self.sRoll1 = sRoll1
        self.sRoll2 = sRoll2
        self.sTF = sTF
    }
    init(sX1:CGFloat, sX2:CGFloat,sY1:CGFloat, sY2:CGFloat, sOpa1:Float, sOpa2:Float, sTF:ZavTF) {
        self.sX1 = sX1
        self.sX2 = sX2
        self.sY1 = sY1
        self.sY2 = sY2
        self.sOpa1 = sOpa1
        self.sOpa2 = sOpa2
        self.sTF = sTF
    }
}

表示位置、拡大縮小、回転位置、透明度の4つの項目は、それぞれ開始時と終了時の値を指定します。

ちなみに今回の構造体では、開始が1で終了が2と、変数名を統一してあります。最後に1桁の数字を加える方法なら、変数名を邪魔しない形で、同じ変数名に違いを付けられます。

最後の項目となる動作速度変化だけは、開始と終了という2つの値を持たないので、1桁の数字は付きません。この項目は、2番目の構造体に入れようとも考えたのですが、表示部品の細かな動きを指定する項目なので、3番目の構造体に入れました。

この構造体でも、複数の初期化を用意してあります。単純な動きを指定する際、最小限の項目だけ記述すれば済むようにとの配慮です。まだまだ加えられるので、実際に使ってみて、使う機会の多い組み合わせを、後から追加すると思います。

 

データ定義の構造体を紹介するだけでも、かなり長くなってしまいました。ここで一旦、区切ります。続きは次回の投稿にて。

 

(使用開発ツール:Xcode 6.4, SDK iOS 8.4)

2015年7月27日月曜日

2Dアニメ機能のライブラリを試作(全体設計編)

iOSのAPIには、かなり簡単に使えるアニメーション機能が含まれています。おかげで、いろいろな箇所へアニメーションを付加することが可能です。

それだけにライブラリ化したい重要な候補なのですが、アニメーションが使われる機能や場面が様々で、汎用的なライブラリ化が難しい対象でもあります。

仕方がないので、特定の用途では十分に使えるアニメーション機能として、ライブラリ化してみました。いつものように、UIViewのサブクラスという形でのライブラリ化です。UIViewの中で、指定したアニメーションが動くというわけです。

UIViewであれば、既存のViewに重ねる方法でアニメーションを加えられます。上へ重ねるだけでなく、下に敷いておけば、アニメーションの背景も簡単に実現できるでしょう。

とりあえず、2Dアニメーションの部分だけ試作してみました。どんな形でライブラリ化したのか、どこまでの機能を付けたのか、どれぐらい使いやすいのか、当初期待したほどは良くないのか、いつものように紹介します。

 

まず最初は、大まかな機能を決めます。基本的には、ある程度まで複雑なアニメーションを作れないと、実用にはならないでしょう。そのためには、複数のアニメ部品がそれぞれ独立して動き、全体のアニメーションを構成する形が必須です。

個々のアニメ部品は、それぞれ別々に動きを設定します。使える動きとしては、移動、拡大縮小、回転、透明度変更は必要でしょう。加えて、等速で動くだけでなく、ゆっくり止まるような動きも使いたいはずです。これは、iOSのAPIに含まれるTiming Functionsという指定で可能です。

個々のアニメ部品の動作では、遅延再生や繰り返し再生も必須でしょう。また、再生前と再生後のそれぞれで、部品表示の有無を選べる機能も大事です。これが使えると、いろいろと応用ができますから(この辺の話は使用例で)。

アニメ部品の動きを組み合わせた全体の動作でも、繰り返し再生は必要です。また、一時停止と再生ができると、ユーザーとの対話に役立ちそうです。さらに、キャンセルも必要でしょう。

アニメーションは、動きの指定だけでは実現できません。動かすアニメ部品を用意する必要があります。部品としては、文字列、画像、UIViewの3つを考えました。文字列は、文字列のまま渡すと、文字列の画像が作られて動かせる形です。画像は、画像そのものを動かす形しかないでしょう。UIViewは自由な部品を作れるので、利用範囲を広げるために必須だと考えました。これらをアプリ側が用意して、アニメ機能のライブラリに渡します。

動作の指定は、いつものように定義データとして用意します。その定義データとアニメ部品(文字列、画像、UIView)を組み合わせて、自由なアニメーションを作れるというライブラリを目指しました。

近い将来、3Dアニメーション機能も追加する予定です。そのとき、2Dと3Dを混在させても動くことが必須です。ですから、3D機能を追加しやすいように、また混在しても動くような内部構造で作る必要があります。

 

以上の話をまとめると、次のようになります。

2Dアニメーション機能に求める機能
・UIViewのサブクラスとして作る
・アニメーションの動作は、定義データとして用意する(簡単に使えること)
・定義データ以外には、文字列、画像、UIViewもアニメ部品として使える
・複数の要素を動かせ、それぞれが独立した動きを指定できる
・個々の要素の動きは単純でも構わないが、それを組み合わせて複雑な動きも作れる
・個々の要素の動作として、移動、拡大縮小、回転、透明度を指定できる
・個々の要素の動作として、Timing Functionsを指定できる
・個々の要素の動作として、遅延再生、繰り返しも可能
・個々の要素の動作として、再生前と動作後に、それぞれ非表示も可能
・組み合わせた全体の動作として、繰り返しも可能
・組み合わせた全体の動作として、一時停止と再開、キャンセルも可能
・内部構造では、3D要素を追加しやすく、また2Dと3Dの要素が混在して動くように最初から配慮する

個々のアニメ部品の動きは単純なものばかりですが、それらを組み合わせて使うと、意外に多くのアニメーションを実現できそうです。実際、凝った動きのアニメーションを使う機会はほとんどないですから、これぐらいの機能でも十分だと思います。

 

機能の整理がまとまったので、実現方法の検討に入ります。iOSのAPIに含まれるアニメーション機能は、いろいろな形で用意されているため、どれを使うのか選択しなければなりません。

改めて、いろいろと調べてみたところ、大事な点が分かりました。UIViewから呼び出せるアニメーション機能は、途中でキャンセルできないと判明したのです(もしかしたら間違っているかもしれません。その場合は、お知らせください)。これで、UIViewのアニメーション機能の採用は不可能になりました。

残った候補の中で、一番使いやすいのがCABasicAnimationでした。簡単に使えるものの、機能が意外に多いのが特長です。API上の位置付けとしては、Core Animationを使いやすくするために用意したAPIという感じでしょうか。

UIViewから呼び出せるアニメーション機能よりも、多くの機能があります。将来的な拡張も容易なので、その点でも安心して採用できます。当然ですが、途中のキャンセルも出来ますし、再生の一時停止や再開も簡単です。

 

以上の話をもとに、これから作るクラスの内部構成を作ってみたら、次のようになりました。

2Dアニメーション機能を持つViewクラスの内部構成
・UIViewのサブクラス
・内部データ:定義データ+表示部品、状態保持(再生中、一時停止中)
・初期化(Viewのサイズ、定義データ+表示部品)
・メソッド:
 ・再生開始(繰り返し回数)
 ・一時停止および再開
 ・再生キャンセル
 ・状態問い合わせ(再生中、一時停止中)
・透明ボタン:一時停止と再開
 ・初期状態ではオフ
 ・メソッド:透明ボタンのオンオフ

見て分かるように、機能としては単純です。アニメーション部分だけ作れれば、そのまま完了する感じですね。

アニメーションの再生では、複数の要素が含まれ、それぞれが異なる動作なので、そこさえキッチリと作れれば大丈夫そうです。また、一時停止や再開も、複数の要素を一緒に扱わなければなりません。

実際に作ってみたら、ソースコードは予想外に長くなりました。クラスの本体部分よりも、データ定義のソースが長くなったからです。使い勝手を良くしようと、いろいろな初期化を用意したためです。

 

とりあえず試作したのは、2Dアニメーションだけです。作り終わって一番苦労したと思った点は、アニメーションの処理ではありませんでした。アニメーションの動きを定義する、データ定義の設計に一番苦労したのです。何度か修正を繰り返しながら、やっと落ち着いたという感じでした。

具体的なソースコードは、次回からの投稿で紹介します。

2015年7月18日土曜日

環境設定機能ライブラリもモデルクラス

先日、友人のIT技術者と飲んだとき、このブログの内容に関して、鋭い指摘を受けました。それは、環境設定機能のライブラリも、モデルクラスに含まれるのではないかとの指摘です。

 

言われてみて気付きましたが、確かにそのとおりです。環境設定機能を実現したライブラリは、環境設定機能を汎用化したクラスですが、環境設定機能というモデルでもあります。

ソートや検索といった機能は、必要が無いので持っていません。しかし、定義されたデータ型で値を保持し、アプリとのやりとりを実現しています。また、設定内容をファイルへ保存したり、保存したファイルから環境設定を読み込んだりもできます。まさに、環境設定機能に特化したモデルクラスと言えるでしょう。

 

環境設定機能ライブラリを作ったときは、どのアプリでも簡単に使える環境設定機能を作ろうと考えただけでした。その時点では、モデルクラスまでライブラリ化しようとは考えていません。また、モデルクラスを作り始めたときは、過去に作ったライブラリのどれかが、モデルクラスに該当するなどとは思ってもいませんでした。そのため、友人に指摘されるまで、モデルクラスに該当するとは気付きませんでした。

モデルクラスのライブラリへ環境設定機能が加わったことで、ライブラリは合計3つになりました。また、今回の指摘によって、ライブラリ化するモデルクラスを探す意味で、視野が広がったと思います。どのアプリでも使いそうな機能を洗い出し、それをモデルクラスとしてライブラリ化できそうに感じています。友人の指摘に、大感謝です。

 

環境設定機能ライブラリですが、作ったときのまま改良していません。その後に作った、別なライブラリも増えました。それらを使って、改良できるはずです。

改良点として真っ先に浮かんだのは、ファイルの読み書きを間接参照にするライブラリの利用です。環境設定機能ライブラリでも、ファイルの読み書きを間接参照にして、特定の保存先に依存しない形に改良できます。修正は簡単なので、余裕ができたら実施する予定です。

 

今回の指摘は、一緒に飲んでるときの普通の話として出てきました。でも、このように鋭い指摘をくれる仲間は、本当にありがたいです。お互いに刺激し合いながら、今後も良い関係を続けたいと思います。

2015年7月11日土曜日

UITableViewを簡単に使うライブラリ(機能拡張編)

UITableViewを簡単に使うライブラリの話の続きです。機能を拡張できるように作ってありますが、それとは別な方法での拡張方法を紹介します。

使用例編では、チェックマークを表示させるように拡張した例を取り上げました。また、それを常に使うのなら、もとのライブラリに入れたほうが良いと書きました。その入れ方の話です。

 

ライブラリとして作ったクラスは、UITableViewを素のままで使うのが基本となっています。それ以外の使い方は、用意したカスタマイズ機能を使うのが普通でしょう。

ただし、同じカスタマイズを何度も使うようであれば、カスタマイズ機能で実現するよりも、ライブラリ側に組み込んだほうが、形としてスッキリします。組み込んでしまえば、同じカスタマイズを何度も指定する必要がなくなりますから。

その際に大事なのは、組み込み方です。最初に作ったライブラリのクラスを変更する手もありますが、そのクラスはそのままにしておき、サブクラスの形で組み込むのが賢い選択でしょう。そうすれば、UITableViewを素の状態で使う方法も残りますから。

 

では、実際のSwiftコードを見てみましょう。

// 常に使うようなカスタマイズは、サブクラスの形で組み込む

// サブクラス:カスタマイズ:セルの選択状態を変更:背景を白に変え、代わりにチェックマークを表示する
class ZPTableViewCheckMk: ZPTableView {
    // ======================================== TableViewデリゲート
    // Cellに値を設定
    override func tableView(rTbv:UITableView, cellForRowAtIndexPath rIndexPath:NSIndexPath) -> UITableViewCell {
        let iCell = rTbv.dequeueReusableCellWithIdentifier(ID_CELL, forIndexPath:rIndexPath) as! UITableViewCell
        let iIdx: Int = rIndexPath.row
        iCell.textLabel!.text = cAryTableStr[iIdx]
        iCell.selectionStyle = UITableViewCellSelectionStyle.None  // 追加:セルの背景を白に
        if let iFunc = cFuncEditCell { iFunc(iCell, iIdx) }  // nilでなければ実行
        return iCell
    }
    // Cellが選択されたとき
    override func tableView(rTbv:UITableView, didSelectRowAtIndexPath rIndexPath:NSIndexPath) {
        let iCell: UITableViewCell = rTbv.cellForRowAtIndexPath(rIndexPath)!
        iCell.accessoryType = UITableViewCellAccessoryType.Checkmark  // 追加:チェックマーク付きに
        super.tableView(rTbv, didSelectRowAtIndexPath:rIndexPath)
    }
    // Cellの選択が外れたとき
    override func tableView(rTbv:UITableView, didDeselectRowAtIndexPath rIndexPath:NSIndexPath) {
        let iCell: UITableViewCell = rTbv.cellForRowAtIndexPath(rIndexPath)!
        iCell.accessoryType = UITableViewCellAccessoryType.None   // 追加:チェックマークなしに
        super.tableView(rTbv, didDeselectRowAtIndexPath:rIndexPath)
    }
    // ======================================== テーブル更新
    override func updateTableF(rAryTableStr:[String]) {
        resetAllCellF()   // 追加:残っているチェックマークをクリアーする
        super.updateTableF(rAryTableStr)
    }
    // 追加:すべてのセルをリセット(チェックマークをクリアーする)
    private func resetAllCellF() {
        let iAryCell: [AnyObject] = cTableView.visibleCells()
        (iAryCell as! [UITableViewCell]).map {
            (rCell:UITableViewCell) -> Void in rCell.accessoryType = UITableViewCellAccessoryType.None }
    }
}

見てのとおり、4つのメソッドを上書きしています。使用例のときに関数として追加したコードを、サブクラス内に入れました。コメント部分に「追加」の文字を入れた箇所です。

それぞれのメソッドでは、親クラスのメソッドを呼んで、追加分のコードを書き加えるのが基本です。そうすれば、親クラスとのコード重複がなくなり、メンテしやすいコードになります。しかし、セルに値を設定するメソッド(1番目のメソッド)だけは、親クラスのメソッドを呼んでいません。その理由が一番大事なので、それを先に説明します。

 

カスタマイズ機能を提供する場合は、カスタマイズする人が、全体の動きを理解しやすく作っておかなければなりません。カスタマイズ方法もいろいろあります。カスタマイズ関数を挿入する形の機能であれば、カスタマイズ前の機能がすべて実行された後に、カスタマイズ関数を実行する形が、もっとも理解しやすいくなります。

このことは、そうでない形を考えれば良く分かります。もしカスタマイズ関数の実行後にも何かが実行される形になっていたら、その実行に何が含まれるのか、メソッドごとに意識しなければなりません。カスタマイズする機能によっては、せっかくカスタマイズした内容が、打ち消されてしまうかもしれないからです。

やはり、もとの機能(カスタマイズされる側の機能)がすべて実行されてから、カスマイズの機能が実行されるルールのほうが、一貫性がありますし、理解もしやすいです。これがベストのルールなのです。

実際、親クラスのカスタマイズ関数も、そのように作ってあります。各メソッドの最後に実行する位置に、カスタマイズ関数を挿入しました。

当然ですが、サブクラスとして作るメソッドも、同じように作らなければなりません。各メソッドで、一番最後に実行されるのがカスタマイズ関数という形で。

 

以上の点を意識しながら、サブクラスのメソッドを順番に見ていきましょう。

1番目のメソッドは特別なので、後回しにします。2番目と3番目のメソッド(セルが選択または解除されたときのメソッド)では、カスタマイズ関数が含まれる親のメソッドを、最後に呼んでいます。こうすることでサブクラスでも、カスタマイズ関数が最後に実行されます。当然ですが、このような順序で実行しても(サブクラスに追加したコードを先に実行しても)問題ないことを、親クラスのメソッドを見ながら確認しています。

サブクラスで追加した機能には、セルのインスタンスが必要です。それを得るためのコードも、追加するコードの前に挿入してあります。

続いて、一番最後のメソッド(テーブル更新)です。これには、カスタマイズ関数が含まれません。しかし、セルのチェックマークを先にクリーアする必要があるため、親クラスを呼び出す前に実行してています。また、セルのチェックマークをクリアーする処理を独立させ、メソッドから呼び出す形にしています。ソースコードを見やすくする目的と、もしかしたら別な処理でも使う可能性もあるかなと考えてのことです。

いよいよ、特別扱いした1番目のメソッドです。親クラスのメソッドでは、UITableViewからセルのインスタンスを取得して、そのインスタンスに文字列を設定をしています。親のメソッドの中に、セルのインスタンスを得る処理が含まれているのです。つまり、親のメソッドを実行しない限り、セルのインスタンスが得られないということです。

サブクラスの追加機能では、セルのインスタンスが必要ですから、親のメソッド前に実行できません。親のメソッドを無理して呼ぶ形で作ると、次のようなコード(上側:採用しなかったコード)になります。

// 採用しなかったコード:親クラスのメソッドを呼ぶ形
let iCell:UITableViewCell = super.tableView(rTbv, cellForRowAtIndexPath:rIndexPath) // この中でカスタマイズ関数を実行
iCell.selectionStyle = UITableViewCellSelectionStyle.None  // 追加:セルの背景を白に
return iCell

// 採用したコード:親クラスのメソッドを呼ばない形
let iCell = rTbv.dequeueReusableCellWithIdentifier(ID_CELL, forIndexPath:rIndexPath) as! UITableViewCell
let iIdx: Int = rIndexPath.row
iCell.textLabel!.text = cAryTableStr[iIdx]
iCell.selectionStyle = UITableViewCellSelectionStyle.None  // 追加:セルの背景を白に
if let iFunc = cFuncEditCell { iFunc(iCell, iIdx) }  // nilでなければ実行
return iCell

これでは、カスタマイズ用の関数を最後に実行する形を実現できません。仕方なく、親のメソッドを呼ぶのを諦め、親のメソッドのコードをコピーして、サブクラスのメソッド(下側:採用したコード)を作りました。コードが親クラスと重複してしまいましたが、実現する機能のほうが大事ですから、これしか選択肢はないでしょう。

以上のようなことを考えて作ったのが、今回のコードです。サブクラスが出来上がったら、単体でテストします。拡張した使い方ではなく、クラスのなので、テストしやすいでしょう。

 

続いて、サブクラスの使用例です。いつものように、iOS実験専用アプリから一部のコードを抜き出してきました。次のような感じで使います。

// サブクラスを使う

// 変数を用意する
var tableView14: ZPTableView!   // データ型は親クラスを指定
let aryStr14: [String] = ["acb", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx"]

// このクラスを使う(テーブルビューを作る)
tableView14 = ZPTableViewCheckMk()              // サブクラスのインスタンス生成
tableView14.setupF(10, 10, 200, 400, aryStr14)  // 初期化(サイズ設定と配列渡し)
tableView14.setFuncSelF(setMsgFromCellF)        // セルを選択した際の関数を設定(下記の関数)
viewBase.addSubview(tableView14)                // アプリ側のビューに貼り付ける

// セルを選択した際に呼び出される関数(アプリ側で用意する)
func setMsgFromCellF(rCell:UITableViewCell, rIdx:Int) {
    vcBase.setMsgF(5, "CellText=" + rCell.textLabel!.text!)  // 選択されたセルの文字列を、アプリ側で表示する
}

見てのとおり、使い方は親クラスと同じです。選択中の表示だけが違い、背景が白くなって、チェックマークが付きます。

カスタマイズ関数が不要になったため、使用例のコードがスッキリしました。テーブルビューを素の状態で使ったときと、同じコードになっています。これが、拡張機能をサブクラスとして組み込んだときの効果です。

今回のサブクラスは、メソッドの構成が親クラスと同じです。そのようなサブクラスを使う際には、変数のデータ型を親クラスにしておき、生成するインスタンスをサブクラスにして代入します。これで問題なく動きます。

変数のデータ型を親クラスにするのは、代入するインスタンスのデータ型を変更したくなったとき、インスタンス生成している箇所のコードだけ変更すれば済むからです。親クラスのインスタンスに変更しても構わないですし、別なサブクラスのインスタンスに切り替えても大丈夫です。

 

以上のように、UITableViewを素の状態ではなく、ある決まったパターンで何度も使う場合は、サブクラスとして拡張するのが良いと思います。

サブクラスとして拡張すれば、拡張のための関数は空のままなので、さらに拡張することも可能です。前回の例のように、最初のカスタマイズを関数として拡張した後、さらに関数へ拡張を追加する場合よりも、見やすく仕上がるでしょう。

 

UITableViewを簡単に使うライブラリの話は、これで一旦終了します。

 

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

2015年7月10日金曜日

UITableViewを簡単に使うライブラリ(使用例編)

UITableViewを簡単に使うライブラリの話の続きです。ライブラリの中身を説明し終わったので、使い方を紹介します。

 

まず最初は、テーブルビューで選択した内容を、アプリ側で受け取る話から。

テーブルビューを使う際には、ユーザーが選択したセルの値を得る手段として、2つの方法があります。1つは、セルを選択した際に処理を実行し、その処理の中で、アプリ側の変数などへ値を設定する方法です。こちらは、セルの選択と同時に、値が設定されます。

今回のクラスで、この方法を実現するためには、セルが選択されたときに実行される関数を用います。アプリ側の変数に値を代入する関数を用意して、関数を設定するメソッドを呼び出すだけです。簡単に実現できます。

もう1つは、アプリ側が値を得るために「登録」などのボタンを用意し、そのボタンがタップされたときに、選ばれている値を取得する方法です。複数のセルを選択できる使い方の場合、こちらのほうが適しています。

今回のクラスで、この方法を実現するためには、選択中の値を取得するメソッドを利用します。アプリ側のボタンがタップされたとき、今回のクラスの取得メソッドを使って、インデックス番号の配列か、セルに表示されている文字列の配列を得ます。

これら2つの使い方は、あくまで基本的な利用方法です。もっと凝った使い方も可能です。たとえば、セルが選択されたときに、選択された画像を表示する必要があるとします。すると、セルを選択したときの関数を用意して、画像表示を実行させるでしょう。このように、選択したセルの値を取得するのとは関係なく、関数を用意して利用する機能を実現する使い方も考えられます。

いろいろな使い方が考えられますが、簡単なものを中心に、いくつかの使用例を挙げてみます。

 

では、最初の使用例を紹介しましょう。最も単純な、巣のままで使う例です。いつものように、iOS実験専用アプリのテストコードから抜き出した例を用いました。

この例では、テーブルビューはそのまま使い、選択されたセルの文字列をアプリ側に表示しています。具体的なSwiftコードは、次のようになります。

// UITableViewに表示する文字列の配列を用意する
let aryStr14: [String] = ["acb", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx"]

// このクラスを使う
tableView14 = ZPTableView()                     // インスタンス生成
tableView14.setupF(10, 10, 200, 400, aryStr14)  // 初期化(サイズ設定と配列渡し)
tableView14.setFuncSelF(setMsgFromCellF)        // セルを選択した際の関数を設定(下記の関数)
viewBase.addSubview(tableView14)                // アプリ側のビューに貼り付ける

// セルを選択した際に呼び出される関数(アプリ側で用意する)
func setMsgFromCellF(rCell:UITableViewCell, rIdx:Int) {
    vcBase.setMsgF(5, "CellText=" + rCell.textLabel!.text!)  // 選択されたセルの文字列を、アプリ側で表示する
}

使い方や処理内容を理解する前に、ソースコードの短さを見てください。たったこれだけのソースコードで、テーブルビューが表示され、普通に動作するのです。デリゲートを書く必要もありません。テーブルビューに表示する文字列の配列を用意するだけです。しかも、テーブルビューを操作して選択したセルの文字列が、アプリ側でちゃんと表示してくれます。そんな動作も含んでのソースコードです。

こんな短いソースコードで、素の状態という条件付きですが、テーブルビューが使えてしまうのです。ちょっと驚きませんか。これこそが、ライブラリのパワーなのです。

 

本題に戻って、使用例のコードを説明しましょう。

まずは、テーブルビューに表示する文字列の配列です。テスト用のコードなので、配列を手動で用意しました。通常のアプリであれば、何らかの処理の結果として、文字列の配列を生成するはずです。その配列をそのまま使い、テーブルビューに表示することになります。

続いては、テーブルビューを使うクラス(今回のクラス)のインスタンス生成とセットアップです。生成した後で、ビューサイズや配列を指定して初期化しています。必要な関数を設定した後は、アプリ側のビューに貼り付けるだけです。

セルを選択した際の関数では、セルのインスタンス(rCell)と、インデックス番号(rIdx)にアクセスできます。これらの情報を使って、いろいろな機能が実現できるはずです。

 

次の例として、テーブルビューを少しカスタマイズした使い方を取り上げましょう。

素のままのテーブルビューは、選択されたセルの背景色がグレーになるだけです。その背景色を白くして、選択したセルにチェックマークをつける形にカスタマイズします。また、複数のセルを選択可能にしてみます。具体的なSwiftコードは、次のとおりです。

// 別な文字列の配列を用意する
let iAryItems: [String] = ["a", "bb", "ccc", "dddd", "eeeee", "ffffff", "ggggggg", "hhhhhhhh"]

// このクラスを使う(少しカスタマイズした使い方)
tableView14 = ZPTableView()
tableView14.setupF(10, 10, 250, 400, iAryItems)
tableView14.setModeMultiSelectionF()   // 複数選択を可能に設定
// 3つの関数をクロージャとして用意する
let iFuncEditCell: (UITableViewCell, rIdx:Int) -> Void = {   // 背景を白に
    (rCell:UITableViewCell, Int) -> Void in rCell.selectionStyle = UITableViewCellSelectionStyle.None }
let iFuncSel: (UITableViewCell, Int) -> Void = { (rCell:UITableViewCell, rInt:Int) -> Void   // 選択時にチェックマークをつける
    in rCell.accessoryType = UITableViewCellAccessoryType.Checkmark; self.vcBase.setMsgF(5, rCell.textLabel!.text!) }
let iFuncUnsel: (UITableViewCell, Int) -> Void = {   // 選択解除でチェックマークを外す
    (rCell:UITableViewCell, rInt:Int) -> Void in rCell.accessoryType = UITableViewCellAccessoryType.None }
// 3つの関数を設定する
tableView14.setFuncEditCellF(iFuncEditCell)
tableView14.setFuncSelF(iFuncSel)
tableView14.setFuncUnselF(iFuncUnsel)
// アプリ側のビューに貼り付ける
viewBase.addSubview(tableView14)

簡単なカスタマイズを加えましたが、やはり短いソースコードで済みました。

これを動かすと、デフォルトの状態とは表示が異なります。セルを選択したとき、背景色がグレーになりません。その代わりに、選択されたセルには水色のチェックマークが付きます。また、選択を解除すると、チェックマークが消えます。加えて、複数のセルを選択可能なモードになっています。

 

上記の使い方ですが、ここまでの動きには問題ありません。しかし、テーブルビューをリロードしたときに、大きな問題が発生します。

その問題とは、リロード後の表示です。リロードが終わった状態では何も選択されていないはずが、リロード前に選択したチェックマークが、そのまま残っているのです。もちろん、実際には何も選択されていません。ただ表示だけが間違っていて、チェックマークが付いています。

この表示ですが、プログラミングされたとおりに動いた結果なのです。よく考えると、そうなって当然だと理解できます。チェックマークの表示は、セルの選択状態と連動してはいないのです。セルの選択や解除で呼ばれる関数内で、チェックマークを付けたり外したりしています。セルの選択が勝手に変わった場合には、チェックマークを消してくれません。というわけで、当然の動作結果なのです。

こうした状況は、テーブルビューを使う側が解消するしかありません。具体的には、テーブルビューをリロードする前に、すべてのセルでチェックマークを外します。次のようなSwiftコードで。

// テーブルビューの内容を入れ替える(チェックマーク使用時は、事前にお掃除が必要)

// テーブルビューの更新前に、セルをお掃除
let iTvb: UITableView = tableView14.getTableViewObjF()!  // インスタンス取得
let iAryCell: [AnyObject] = iTvb.visibleCells()
(iAryCell as! [UITableViewCell]).map {   // すべてのセルで、チェックマークを外す
    (rCell:UITableViewCell) -> Void in rCell.accessoryType = UITableViewCellAccessoryType.None }
// テーブルビューを、新しい内容に更新
let iAryItemsNew: [String] = ["a001", "b002", "c003", "d004", "e005", "f006", "g007"]
tableView14.updateTableF(iAryItemsNew)  // UITableViewを更新(リロード)

セルのチェックマークを外すために、UITableViewのインスタンスを取得しています。そのインスタンスから全セルの配列を得て、個々のセルでチェックマークを外します。

全セルのチェックマークを外す方法ですが、もっと簡単な方法がありそうだと思い、いろいろと探しました。しかし見付かりませんでした。仕方なく、地道な方法でチェックマークを外しています。今回は、mapメソッドを使ってみました。

セルの掃除が終わったら、セルに表示する新しい配列を用意して、テーブルを更新するメソッドを呼び出します。結果の表示は、各セルの文字列が入れ替わり、何も選択されてない状態に変わっています。

 

上記の例は、言われてみれば当たり前なのに忘れがちな、大事な点を示しています。

ライブラリ自体にバグがないとしても、使い方が悪ければバグが生じてしまいます。そして、関数で拡張するタイプのライブラリは、使い方によるバグが発生しやすいということです。また、クラスのインスタンスをライブラリから受け取って拡張する方法も、同様にバグを作り出しやすいでしょう。

この種のバグを防ぐために、次のような開発手順をお勧めします。まず、ライブラリを拡張して使う際には、ライブラリと拡張した部分だけを作ります。そして拡張込みのライブラリを、1つの機能のようにテストするのです。そうすれば、拡張によるバグを発見しやすくなります。

テストをパスできてから、拡張込みのライブラリをアプリにコピーして利用します。この手順なら、バグが残っている可能性はかなり減らせるはずです。

 

ちょっと横道に逸れますが、mapやfilerといったメソッド(およびグローバル関数)の話を。正直に言うと、機能が少し不足するために使えない場面が多くて、残念に思っています。

まずは、配列のインデックス番号にアクセスできない点です。インデックス番号を返す処理が多く、まったく使えないでいます。抜け道として、enumerate関数を使う方法がありますが、結果がタプルになってしまい、最終結果を配列にしたいことがほとんどなので、これまた使いにくいです。やはり、mapやfilerの巣の状態で、インデックス番号を取得できる手段が必要でしょう。たとえば別な名前、iMapとかiFilterとかで用意する形でも構いません。

もう1つの不満は、mapやfilerが別々になっている点です。両者が合体したメソッドがあれば、利用範囲は格段に広がります。他の言語には存在するので、Swiftでも早急に追加してもらいたいです。Swift 2.0に追加されていると期待したのですが、ありませんでした。

 

本題に戻りましょう。次は、テーブルビューから選択中の要素を得る使い方です。

テーブルビューが使われる状況としては、アプリ側にボタンがあり、そのボタンをタップしたときに取得する形です。また、複数のセルを選択できる状態で使っていることも前提にします。

テーブルビューからの取得は簡単です。それ専用のメソッドが2つあります。インデックス番号の配列を返すメソッドと、セルに表示している文字列の配列を返すメソッドです。インデックス番号を返すメソッドを使うと、次のようになります。

// 追加ボタンをタップしたときに呼ばれる関数
func buttonAddF(rSender:UIButton) {
    // テーブルビューから、選択されたセルのインデックス番号を配列で取得
    let iAryInt: [Int] = tableView14.getSelectionNumF()
    // 取得した番号の数を調べ、ゼロ件ならメッセージを表示
    if iAryInt.count == 0 {
        vcBase.setMsgF(5, "何も選択されていません。")
        return
    }
    // 取得したインデックス番号を使って登録する処理
    for i in 0..<iAryInt.count {
        ...  // 個々のインデックス番号で登録処理
    }
    ...  // 登録の後処理
}

インデックス番号の配列を受け取ったら、最初に件数を調べています。もしゼロ件なら、選択を促すメッセージを表示します。逆に入っている場合は、インデックス番号ごとの処理へ進みます。

この例では、ボタンのタップで呼ばれる関数内に、登録の処理を書いています。しかし実際に作るときは、このような形では作りません。登録処理を独立した関数として作り、ボタンがタップされた時に呼び出させる関数が、登録処理の関数を呼び出す形にします。そうすると、全体がスッキリ整いますし、変更しやすい形になりますから。

 

今回作成したライブラリは、UITableViewを素のままで使うことを大前提としています。もし、チェックマーク付きの形で常に使うのであれば、その設定を加えた形でライブラリを作ったほうが良いでしょう。

使用例では、関数を用意して、チェックマークを付けたり外したりしています。そのコードを、ライブラリの中に組み込めば、ライブラリを呼び出すだけで、チェックマークを使った状態になります。

また、UITableViewをリロードする際には、全部のチェックマークを外す処理が必須です。これも、ライブラリ内のリロードする処理に組み込んでしまえば、問題なく動くでしょう。

以上のように、自分が一番使う設定でライブラリを作り、カスタマイズしないで使う率を増やすのが、上手な作り方となります。カスタマイズ可能に作るのですが、カスタマイズして使うのを極力減らすように作るのが、ライブラリ作りの鉄則と言えるでしょう。

 

単純な例でしたが、今回作ったクラスの使い方を紹介しました。とても簡単に使えることだけは伝わったと思います。

とくに注目したいのが、素の状態で使う場合です。たった数行のコードを書くだけで、テーブルビューが使えてしまいます。デリゲートを書く必要もないどころか、UITableViewクラスの中身を知らなくても使えてしまいます。

さすがにカスタマイズする場合は、UITableViewクラスの中身を知る必要があります。それでも、最初から全部を書くのに比べたら、必要なコード量は格段に減っています。しかも、ライブラリに含まれるコードはテスト済みです。

もう同じようなコードを何度も書く必要はありません。このライブラリを使えば、最低限のコードで、UITableViewが使えます。必要なら、ある程度のカスタマイズも可能です。

今回のクラスを作った後、その直前に作ったクラス(フォトアルバムの中から画像を得るライブラリ)で、さっそく使ってみました。UITableViewを2つ使っているクラスです。使用後はデリゲートが消え、クラス全体がスッキリした形に変わりました。他のライブラリでも、今回のクラスを使って、ソースコードをスッキリさせる予定です。

 

今回のライブラリ化では、デリゲートを含むUI部品であるUITableViewを対象としました。デリゲートを含むUI部品は、他にもあります。同じような考え方でライブラリ化すれば、デリゲートを書かなくて簡単に使えるようになるでしょう。

その際には、少し注意が必要です。UI部品単体での使用に限定せず、一緒に使いそうな機能も含めて、ライブラリ化することです。それもカスタマイズ可能な形で。例えばUITextFieldなら、入力値を検査する機能も一緒に組み込み、カスタマイズできるように作るとかです。そうすれば、ライブラリの価値がさらに高まるでしょう。

 

今回のライブラリを作ったきっかけは、「また同じコードを書くのは面倒だ」と感じたことです。同様に感じる機会は、意外に多いと思います。面倒に感じたときには、ライブラリ化で解消できないか検討したほうが良さそうですね。

 

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

2015年7月9日木曜日

UITableViewを簡単に使うライブラリ(本編)

iOSのAPIには、いろいろなタイプのものが含まれます。その中でも少し面倒なのが、デリゲートを含むAPIです。とくにUITableViewは使用頻度が高く、私が独自開発したライブラリの中でも、何度か使いました。

以前に紹介した、フォトアルバムの中から画像を得るライブラリでも、2つのUITableViewを使っています。それを作った後で、もっと簡単に使えるようにと、UITableViewを使う機能だけのライブラリ化を行いました。UITableViewを超簡単に使えるライブラリとして作ったわけです。凝った使い方をしなければ、たった数行のコードを書くだけで、UITableViewが普通に使えてしまいます。もちろん、デリゲートを書く必要はありません。少し変わったライブラリの例として面白いので、詳しく紹介します。

 

いつものように作り始める前には、大まかな仕様を決定します。とは言うものの、UITableViewを使うだけのライブラリなので、検討する内容はほとんどありませんでした。

大事なのは、デリゲートを書かなくて済む点だけでしょう。すると必然的に、UIViewに貼り付けたUITableViewという形が浮かんできます。つまり、UIViewのサブクラスの形で作り、その中にUITableViewを付ける形です。もっとも簡単な実現方法を選ぶとすると、それしかないと思います。

他に考慮すべき点としては、ある程度の拡張性を持たせることです。UITableViewを使う際、機能を追加しそうな箇所がいくつかあるので、そこに関数を挿入します。その関数を、後から書き換える形にすれば、拡張性は確保できそうです。また、UITableViewのインスタンスをアプリ側で取得できれば、様々な設定変更や機能追加が可能でしょう。この方法でも、拡張性を持たせられます。両方とも採用して、拡張性を高めることにしました。

という感じのことだけ決めて、作り始めました。UITableViewを使った直後だったので、あっさりと仕上がったのを覚えています。いつものように、Swiftコードを挙げながら説明しましょう。

 

まず最初は、入れ物となるクラスからです。UIViewのサブクラスとして作り、UITableViewのデリゲートを作るので、次のような形になりました。

// UITableViewを簡単に使うためのクラス(この中に、後述するコードが入る)
class ZPTableView: UIView, UITableViewDelegate, UITableViewDataSource {
    // クラス内の主要変数
    var cTableView: UITableView!     // TableView
    var cAryTableStr: [String] = []  // TableViewの表示内容
    var cArySelected: [Bool] = []    // 各セルの選択状態
    let ID_CELL: String = "zptvCell"
    // UITableViewデリゲートの中で呼ばれる関数
    var cFuncEditCell: ((UITableViewCell) -> Void)? = nil
    var cFuncSelectCell: ((UITableViewCell, Int) -> Void)? = nil
    var cFuncUnselectCell: ((UITableViewCell, Int) -> Void)? = nil
}

クラス宣言の部分では、UIViewのサブクラスであることと、UITableViewのデリゲートなどを指定しています。

クラス内に用意した変数は、UITableViewに加えて、セル内に表示する文字列の配列、各セルの選択状態を記録するBool型の配列、セルのIDとなる文字列です。

さらに、デリゲートの中で呼ばれる関数も3つ用意しました。より多く用意できるのですが、最低限のカスタマイズを可能にする形として、3つの箇所に関数を挿入しています。どの関数もOptional型で、初期値にはnilを入れてあります。なぜOptional型にしたのかは、後で説明します。

 

続いて、クラスの初期化です。いつものように、initとは別な名前で用意しています。

// ======================================== 初期化
// UIViewの最小サイズ
let VIEW_WIDTH_MIN: CGFloat = 100
let VIEW_HEIGHT_MIN: CGFloat = 350

func setupF(rX:CGFloat, _ rY:CGFloat, _ rWidth:CGFloat, _ rHeight:CGFloat, _ rAryTableStr:[String]) {
    if (rWidth < VIEW_WIDTH_MIN) { zSendErrMsgF("ERROR:ZPTV_STUP:Viewの幅が不足"); return }
    if (rHeight < VIEW_HEIGHT_MIN) { zSendErrMsgF("ERROR:ZPTV_STUP:Viewの高さが不足"); return }
    self.frame = CGRectMake(rX, rY, rWidth, rHeight)
    // その他の処理
    cAryTableStr = rAryTableStr    // テーブル表示内容の設定
    setupTblViewF()                // TableViewの生成
    setupEtcF()                    // その他
}
// ======================================== TableView生成
private func setupTblViewF() {
    // TableViewの生成:UIViewと同じ位置とサイズで生成する
    let iX: CGFloat = 0
    let iY: CGFloat = 0
    let iWidth: CGFloat = self.frame.size.width
    let iHeight: CGFloat = self.frame.size.height
    cTableView = zCreateTblViewF(iX, iY, iWidth, iHeight)
    cTableView.registerClass(UITableViewCell.self, forCellReuseIdentifier:ID_CELL)
    cTableView.delegate = self
    cTableView.dataSource = self
    self.addSubview(cTableView)
}
// ======================================== その他の初期化
private func setupEtcF() {
    // TableViewに表示する文字列配列と同じ要素数の、false配列を作る
    cArySelected = [Bool](count:cAryTableStr.count, repeatedValue:false)
}

短いので3つに分ける必要はないのですが、長さに関係なく、このようなスタイルで作るのが癖になっています。3つ目の処理だけは、別な処理でも呼ばれるので分けてあります。

初期化の最初では、ビューの大きさを検査しています。最小値より小さければ、エラーメッセージを出して終了します。単純な指定ミスを発見するための検査です。エラー検査を通ると、ビューの位置と大きさを設定して、テーブルビューの文字列も保存します。

続いて、テーブルビューを生成します。テーブルビューの大きさは、クラス自体のビューと同一です。テーブルビューの生成関数を呼び出して生成し、最低限の属性設定も行います。最後に、クラスのビューに追加して終わりです。

その他の初期化では、各セルが選択中かを保存する配列を、初期化しています。同じ値を入れるだけなので、配列に備わっている初期化機能を使いました。

 

テーブルビューができたので、次はデリゲート関係です。

// ======================================== TableViewデリゲート
// Cellの総数を返す
func tableView(rTbvName:UITableView, numberOfRowsInSection rSection:Int) -> Int {
    return cAryTableStr.count
}
// Cellに値を設定
func tableView(rTbvName:UITableView, cellForRowAtIndexPath rIndexPath:NSIndexPath) -> UITableViewCell {
    let iCell = rTbvName.dequeueReusableCellWithIdentifier(ID_CELL, forIndexPath:rIndexPath) as! UITableViewCell
    let iIdx: Int = rIndexPath.row
    iCell.textLabel!.text = cAryTableStr[iIdx]
    if let iFunc = cFuncEditCell { iFunc(iCell, iIdx) }       // nilでなければ実行
    return iCell
}
// Cellが選択されたとき
func tableView(rTbvName:UITableView, didSelectRowAtIndexPath rIndexPath:NSIndexPath) {
    let iCell: UITableViewCell = rTbvName.cellForRowAtIndexPath(rIndexPath)!
    let iIdx: Int = rIndexPath.row
    cArySelected[iIdx] = true // 選択された
    if let iFunc = cFuncSelectCell { iFunc(iCell, iIdx) }    // nilでなければ実行
}
// Cellの選択が外れたとき
func tableView(rTbvName:UITableView, didDeselectRowAtIndexPath rIndexPath:NSIndexPath) {
    let iCell: UITableViewCell = rTbvName.cellForRowAtIndexPath(rIndexPath)!
    let iIdx: Int = rIndexPath.row
    cArySelected[iIdx] = false // 選択が解除された
    if let iFunc = cFuncUnselectCell { iFunc(iCell, iIdx) }  // nilでなければ実行
}

どの処理も、テーブルビューを使う際の最低限のことしか行っていません。テーブルビューを素の状態で使うために必要なことばかりです。

ただし2点だけ、機能を追加しています。その1つが、各セルの選択状態を記録する機能です。選択されたらtrueに、選択が解除されたらfalseに設定しています。

もう1つの追加機能は、関数の挿入です。ただ関数を実行する形ではなく、条件によって実行する形です。具体的には、関数を入れる変数の値がnilなら実行せず、変数に処理が入っていれば実行しています。つまり、関数用の変数にnilを入れることで、関数の機能をオフできる形なのです。このように動作さセルために、関数用の変数をOptional型にしました。

これとは逆に、関数用の変数をOptional型にしない場合は、何もしない関数を入れておく必要があります。そして、何もしない関数が毎回実行されることにもなります。そのような動作を嫌って、関数変数をOptional型にしたわけです。何もさせたくないなら、ただnilを入れておけば、その判定だけしか実行されないはずです。

また、このクラスを使う側にも良い点があります。一度設定した関数をオフしたい場合、関数をnilに設定すれば済みます。何もしない関数を用意する必要はありません。そのように使う機会は少ないと思いますが。

実は、このライブラリを作った時点では、Optional型の変数を使わずに、何もしない関数をデフォルトで入れていました。別なライブラリを作った際に、変数をOptional型で作れば良いと気付きました。そして、このライブラリも同様に修正したという流れです。いろいろなライブラリを作っていると、より良い方法が少しずつ見付かります。そして、別なライブラリも同様の形に修正するというのが、よくあるパターンですね。

 

セルが選択されている状態は、クラス内にBool型の配列を用意して保存しています。そのため、セルが選択されたときと解除されたときに、この配列の値を更新しています。少し面倒です。

その代わりのメリットとして、セルの選択状態を調べたいとき、個々のセルにアクセスしなくて済みます。当然ですが、セルへアクセスする処理では、iOSのAPIを使います。もし使っているAPIの仕様が変われば、アクセスする処理も変更を求められます。

こんな点まで考慮すると、Bool型の配列を使う良さが理解できます。APIを使わないことで、APIの仕様変更による修正を、少しでも減らす意味があるわけです。もちろん、Bool型の配列も、何かの仕様変更で変更になる可能性はあります。ただ、今回のようなコードでは、可能性がかなり低いでしょうし、あったとしても面倒な変更にはならないでしょう。

単純な機能ですが、こんな感じで考えて作りました。私の場合は、少し面倒な方法であっても、APIにアクセスするコードを減らす方針でプログラミングしています。

 

デリゲートに挿入した関数を、設定するためのメソッドも必要です。関数が3つあるので、メソッドも3つ用意しました。それぞれの関数はOptional型なので、nilを設定することもできます。

// ======================================== 関数の設定
// セルに値を設定するときに呼ばれる関数
func setFuncEditCellF(rFunc:((UITableViewCell, Int) -> Void)?) {
    cFuncEditCell = rFunc
}
// セルが選択されたときに呼ばれる関数
func setFuncSelF(rFunc:((UITableViewCell, Int) -> Void)?) {
    cFuncSelectCell = rFunc
}
// セルの選択が解除されたときに呼ばれる関数
func setFuncUnselF(rFunc:((UITableViewCell, Int) -> Void)?) {
    cFuncUnselectCell = rFunc
}

3つの関数はデータ型が同じなので、1つの関数にまとめて、配列で渡す方法も考えました。しかし、このクラスを拡張する場合、おそらく関数が増えるでしょう。その際、関数のデータ型が違うと、まとめるのが面倒になります。メソッドを最初から3つに分けて、拡張時に関数が追加されたときも、各関数の同じ位置付けになるようにしておいたほうが良いと判断しました。

 

テーブルビューのデフォルトは、1つのセルだけ選択できるモードです。しかし設定を変更すると、複数を同時に選択するモードでも使えます。このようなモード変更は、使用頻度が高いので、専用のメソッドを用意しました。

// ======================================== 選択可能数のモード変更
func setModeSingleSelectionF() {
    cTableView.allowsMultipleSelection = false   // 初期状態では、こちらになっている
}
func setModeMultiSelectionF() {
    cTableView.allowsMultipleSelection = true
}

処理内容としては、UITableViewのプロパティを変更しているだけです。このようなメソッドを用意すれば、長いプロパティ名を調べる手間が省けるでしょう。

このメソッドの一般的な使い方は、このクラスのインスタンスを生成した後、すぐに実行するものです。けっして、途中で切り替えるものではありません。また、デフォルトのままで構わない場合は、おそらく使うことはないでしょう。

 

ライブラリとして作るわけですから、取得関係のメソッドも何種類か用意しました。大きく分けて、以下の3種類です。

// ======================================== TableViewの取得
func getTableViewObjF() -> UITableView? {
    return cTableView
}
// ======================================== 全要素の取得
func getCellCountF() -> Int {     // 要素数を返す
    return cAryTableStr.count
}
func getAllStrF() -> [String] {   // 全セルの文字列を、配列で返す
    return cAryTableStr
}
// ======================================== 選択された要素の取得
func getSelectionNumF() -> [Int] {  // 選択されたセルのインデックス番号を、配列で返す
    var iAryInt: [Int] = []
    for i in 0..<cArySelected.count {
        if cArySelected[i] == true {
            iAryInt.append(i)
        }
    }
    return iAryInt
}
func getSelectionStrF() -> [String] {  // 選択されたセルの文字列を、配列で返す
    var iAryStr: [String] = []
    for i in 0..<cArySelected.count {
        if cArySelected[i] == true {
            iAryStr.append(cAryTableStr[i])
        }
    }
    return iAryStr
}

最初のメソッドは、UITableView自体のインスタンスを返します。初期化が失敗するとインスタンスが生成されていませんから、戻り値をOptional型にしてあります。Optional型にしないと、このクラス自身がクラッシュしますので。

UITableViewのインスタンスを返すメソッドには、他のメソッドとは根本的に異なる、大きな役割があります。アプリ側は、UITableViewのインスタンスを得られることで、UITableViewに対する様々な設定変更が可能になります。おかげで、このクラスが用意していない機能や設定も、ある程度までですが、実現できる手段が得られます。このようなメソッドを用意することで、クラスの機能不足を補うことが可能となります。そんなわけで、かなり重要なメソッドなのです。

全要素の取得は2つのメソッドです。1つめのメソッドは、インデックス番号の配列を返しても構わないのですが、ゼロから始まる連続数になるだけです。それは無駄なので、要素数を返す形にしました。セルの文字列を返すほうのメソッドは、文字列の配列を返しています。

選択された要素を返すメソッドも2つです。インデックス番号を返すメソッドでは、番号の配列を返します。セルの文字列を返すメソッドでは、文字列の配列となります。どちらのメソッドも、1つだけ選択するモードと、複数を選択するモードの両方に対応しています。1つだけ選択するモードでは、空の配列か、要素が1つだけの配列を返します。

 

最後は、テーブルを更新するメソッドです。セルに表示する文字列の配列を受け取って、テーブルビューをリセットします。

// ======================================== テーブル更新
func updateTableF(rAryTableStr:[String]) {
    cAryTableStr = rAryTableStr
    cTableView.reloadData()
    setupEtcF()     // その他の初期化:全セルの状態記録を非選択に
}

処理内容としては、まず文字列の配列をクラス内に保存します。続いて、そのデータを、テーブルビューにリロードさせます。

この状態では、セルが何も選択されていません。セルの選択状態を保存しているBool型の配列を、初期化する必要があります。クラスを初期化する際に使った関数を呼び出して、初期化しています。

 

以上で、今回のクラスに含まれるソースコードを全部紹介しました。このライブラリのおかげで、テーブルビューがどれだけ簡単に使えるのか、それこそが最大のポイントでしょう。その話に関係する使用例は、次回の投稿にて。

 

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

2015年7月7日火曜日

ライブラリ化の当面のゴール

このブログで紹介しているように、Swiftで書いた独自ライブラリを少しずつ作り進めています。まだまだ途中の段階ですが、どんなゴールを描いているのか、簡単に紹介したいと思います。

最初に言っておきますが、これから書く話は、ゲームなどのアプリを作るのが前提ではありません。あくまで業務アプリなどの、遊び要素の少ないアプリの開発に関しての話です。ゲームのアプリなら、大規模な開発ツールが既に作られていて、それを使うのが賢い選択でしょうから。

 

新しいOSや開発ツールを使い始めたとき、独自のライブラリ化を進めるのは、開発経験が長い人なら一般的でしょう。ただ、どのようにライブラリ化するのか、どんなゴールを目指して進めるのかは、人によって異なると思います。

私の場合は、開発やメンテの手間を減らすことです。それにより、開発期間が短くなりますし、完成後のメンテの負荷も小さくなります。とにかく幅広く使えるライブラリを増やして、アプリ開発の手間を大きく減らすことが目的です。

ライブラリとして作ったコードは、単独で十分なテストをされているのに加えて、複数のアプリで使われているため、品質は高く保たれがちです。また、OSやツールのバージョンアップで変更が生じたときも、ライブラリ単体で更新してテストを済ませれば、あとはアプリ側のライブラリを入れ替えるだけです。複数アプリを合計した際の作業量は、かなり減るでしょう。

あと、ライブラリの形式として、ソースコードのままで使う方法を重視しています。ソースコードのままアプリに追加する形なので、ライブラリにバグがあっても簡単に見付かりますし、ライブラリを拡張したくなったときも、可能性が簡単に判断できます。ソースコードのまま追加するものの、基本的にはライブラリに手を加えません。ただし、どうしても手を加えなければならないケースでは、ソースの先頭に変更内容を明記する形で、認めてはいます(実際には、めったにやりません)。当然ながら、ライブラリを改良して対応するのが、第一の選択肢です。

以上は、あくまで、目先の目的としての話です(もっと大きな目的は、最後のほうに出てきます)。

 

いろいろなアプリを作っていると、似たようなコードを数多く書いている自分に気付きます。たいていは1から書かず、前に作ったソースコードをコピーしてから修正するでしょう。コピーによって手間は減っていますが、似たようなコードを書いている現状には変わりません。

そんな風に考えていると、似たようなコードを書かなくて済む方法はないか、深く考えるようになります。部分的にコピーして作るのではなく、ソースコード全体をファイル単位でアプリに持って行き、そのままで使える状態に作れないかと。これがライブラリ化の始まりです。おそらく、独自のライブラリを作っている人は、似たような経験や考えがあるはずです。

実際にライブラリ化を進めると、かなりの効果があると実感できます。似たようなコードを書くことが減り、ライブラリで済ませられる範囲が少しずつ増えていきます。もし既存のライブラリで機能が不足する場合も、ライブラリのほうを拡張して、新しいアプリに対応させます。その結果、ライブラリが適用できる範囲が少しずつ増えていきます。

Swiftを使い始めてから、それほど時間が経過していません。1.0の正式版が登場してからですから、まだ1年未満です。それでも、ライブラリを少しずつ作り続けたので、だんだん充実してきました。モデルクラスのライブラリも2つ作り、ライブラリの対象範囲も広がっています。今後の充実が、ますます楽しみです。

 

続いて、別な視点でライブラリを考えてみます。ライブラリ化する機能の種類で考えると、ライブラリ化のゴールが見えやすくなります。

真っ先に作ったのは、どのアプリでも必ず使う機能でした。UI部品の生成、日付と時間、ファイルの閲覧、電子メールの送信、描画機能などです。それらが作り終わると、環境設定など、どのアプリでも作りそうな機能をライブラリ化しました。こうした種類のライブラリ化は今も続けていて、ライブラリによるサポート範囲をさらに広げようとしています。

アプリ独自の要素として作らなければならない機能は、扱うデータを操作するモデルクラス、それを画面に表示して操作するビューとコントローラのクラスでしょう。作る量が大きい順に並べると、ビュー、モデル、コントローラとなります。

このうち、モデルのクラスは、すでにライブラリ化を進めています。まだ種類は少ないですが、少しずつ種類を増やして、ライブラリで済ませる率を向上させる予定です。

ビューは、モデルのように、個々のモデル全体をライブラリで作る形が難しい分野です。ビューの一部をライブラリとして用意し、それらを組み合わせて1つのビューを実現します。つまり、部品として作るのが適している分野です。ただし、機能の種類が多くあるため、ライブラリでカバーできる比率は、なかなか上がらないでしょう。

当面の方向性としては、次のように考えています。ビューを実現するための要素を、幾つかのタイプに分類して、タイプごとにライブラリを増やすつもりです。今までは、部品となる小さなビューだけでした。しかし、モーダルビューのように、動きの実現がメインとなるライブラリも、今後は増えていくでしょう。また、ビュー上にUI部品を繰り返して並べるような機能も、ライブラリとして作りました。これなどは、配置機能に特化したライブラリです。さらには、いろいろなUI部品を生成する関数群、同じUI部品を一気に生成する関数群など、ビューの構成部品の生成もライブラリ化しています。

あと、紹介していませんが、環境設定の専用ビューもライブラリを試作しました。以前に紹介した、環境設定の機能をライブラリ化したものと連動して、環境設定の値を変更するためのビューを生成します。凝った機能には対応できないレベルですが、一般的な設定の範囲であれば、もう使える状態です。以前紹介した環境設定のライブラリを使うと、環境設定の値を定義するだけで、環境設定の機能が作れます。ビューも同様で、環境設定画面の定義を用意するだけで、環境設定を変更するビューが作れてしまいます。これら2つは連動するので、環境設定機能とビュー機能のやり取りは、コーディングする必要がありません。このように、特定の機能を実現するビューなら、ライブラリとして、まだまだ作れる範囲があると思います。

 

アプリ全体として見た場合、ライブラリが適用できなくて、そのアプリ専用にコーディングが必要となる領域は、ビュー関係に一番多く含まれます。つまり、ライブラリ化の一番の肝は、ビューに関する機能のライブラリ化をどれだけ実現できるかです。モデルに関しては、5種類ほど作れれば、半分以上のモデルをライブラリで作れるでしょう。ビューでは、そのような考え方が通用しません。どんな切り口でライブラリ化したら良いのか、どうすれば新しい工夫ができるかが、ビューに関するライブラリ化の度合いを決めます。

今後は、さらにレベルアップできないか検討中です。もう少し違った切り分け方で、ビューの機能を実現できないかと、かなり考えています。まだ結論は出ていないものの、もしかすれば何とかなりそうなヒントだけは思い浮かびました。これを発展させて、新しい種類のライブラリが作れたら、ビューに含まれる機能のライブラリ比率が、かなり向上させられそうです。

 

アプリとして作る機能のうち、モデルとビューでのライブラリ比率が向上できると、アプリ全体でのライブラリ化率がかなり向上できます。

ライブラリなしで開発したときのコード量を10割としたとき、ライブラリの使用により何割減らせるかが、ライブラリの効果を評価する際の数値になると考えています。ライブラリなしで開発したときのコード量というのも、やや大雑把ですね。あえて説明すると、すべての機能をベタで書いたとき(雑誌やウェブでのサンプルのようなコード)のコード量という意味です。

Swiftのライブラリ化を始めた頃は、どの程度まで作れるのか予想が付きませんでした。漠然とですが、遠い将来は5割が目標だけど、当面は3割ぐらいかなと考えていました。

ところが、ライブラリが増えるごとに、それもライブラリの対象範囲が広がるとともに、5割は簡単に実現できると思うようになりました。実際、今まで作ったライブラリの機能だけに限定したアプリを作るなら、5割ぐらいはライブラリで済ませられ、残りの5割をコーディングする形になりそうです。

もし新しい機能を使うアプリを開発する場合は、その新しい機能の部分も、ライブラリとして作ります。そうすると、ライブラリが対応できる機能が増え、似たようなアプリを再び開発する場合は、やはり5割ぐらいがライブラリで対応できる状態になります。

今では、将来的に7割ぐらいも可能じゃないのかと、思い始めています。前述のビュー機能のライブラリ化が、どこまで実現できるかにかかっています。ただし、ライブラリがいくら充実しても、7割強ぐらいが限界ではないかと思います。ライブラリとして作りにくい機能が、ある程度は残りますから。

 

以上の話は、コード量に関してだけです。アプリ開発では、コードを作る以外の作業が多く含まれます。それが減らない限り、開発の負荷は大幅に減りません。その部分に関しては、かなり難しいでしょう。

それとは別に、開発スタイルが大きく変わる可能性はあります。新規に作らなければならないコードの開発量が減ると、とりあえず試しては変更し、異なる仕様を何度も試せます。ライブラリで実現した機能は、使う際の定義データや使い方を変更するだけで、ある程度まで機能を変えられます。もし機能が不足する場合は、ライブラリを拡張して、新しい機能を追加するでしょう。どちらにしても、いろいろと変えながら試せる可能性は、今よりもずっと高まります。

このような開発スタイルが、ライブラリ化を進めている当面のゴールです。それを実現するために、ライブラリで作るコードの割合を7割ぐらいにするのが、別な視点での当面のゴールとなります。

ライブラリを使って作ったアプリは、俗に言うプロトタイプではありません。本番でも使える、実際に動くアプリです。本番用のアプリを、プロトタイプ的に作ろうというのが、ライブラリ化の本当の目的と言えるかもしれません。

 

今回は趣向を変えて、ライプラリが充実したときの状態を、当面のゴールという形で書いてみました。具体的なコードばかり見ていると、大きな目標を見失ったりします。せっかくですから、大きな目標を掲げて、それに向かって努力したほうが、良い結果が得られるでしょう。これを読んだのを良い機会にして、自分なりの当面のゴールを、考えてみてはいかがでしょうか。

2015年7月2日木曜日

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

共通で使えるモデルクラスをライブラリ化する第1段として、記録カードのモデルクラスを作る話を紹介しました。前回の投稿で書いたとおり、内部配列を変更しました。その後に、新しい機能を追加したので、また紹介します。

 

今回追加したのは、ソート機能。説明する必要のないでしょうが、指定された項目値の大きさで、レコードを並び替える機能です。

最初に決めなければならないのは、ソート機能の組み込み方でしょう。ソートしたデータをどのように利用するのか、いろいろと考えました。ソート結果をモデルクラス内で持ち、順番に取り出す方法も考えられます。しかし、そうするとソート以外の機能を用意しなければならず、作る量が増えてしまいます。また、用意した機能で不足する状況も考えられます。

それを避けるために、ソート後のレコード番号を配列の形で渡すことにしました。レコード番号さえあれば、あとは自由に使えます。レコード番号を指定して、レコード内の項目値が得られますし。

ソートには昇順と降順があります。昇順の配列は、逆から読むと降順として使えます。そのため、昇順のソートだけを作ることにしました。

 

実行に必要なのは、どの項目でソートするかの指定だけですから、引数は項目番号だけで済みます。また、戻り値はレコード番号の配列です。データ型が違っても、まったく同じです。というわけで、メソッドは1つだけになりました。

ただし、Bool型だけは値が2種類しかないので、ソートする意味がありません。無駄な処理をさせないために、エラーメッセージを出して終了することにしました。

では、具体的なコードを見ましょう。次のようなSwiftコードを追加しました。

// ======================================== ソート:レコード番号の配列を返す
func sortF(rFldIdx:Int) -> [Int] {
    if cAryType[rFldIdx] == .ZmBool { zSendErrMsgF("ERROR:ZMR_SORT:項目定義のデータ型がBoolでは使用不可"); return [Int]() }

    // Step1:指定された項目値とレコード番号のペア配列を要素とする、レコード全体の2次元配列を作る
    var iAryAnySort: [[AnyObject]] = []  // ソートで使う2次元配列:データは[[項目値, RecIdx], ...]の形になる
    for i in 0..<cRecCount {   // i=RecIdx
        var iAnyPair: [AnyObject] = []        // 項目値とRecIdxのペアを入れる配列
        iAnyPair.append(cAryDataAny[rFldIdx][i])  // 指定された項目の値
        iAnyPair.append(i)                        // RecIdx
        iAryAnySort.append(iAnyPair)          // ペア配列をソート用配列に追加
    }

    // Step2:指定された項目値で、2次元配列をソートする:ペア配列の最初の要素を取り出して、大小を比較する
    // ペア配列(a0とa1)の最初の要素(a0[0]とa1[0])を取り出して、大小を比較する
    switch cAryType[rFldIdx] {
    case .ZmInt:
        iAryAnySort.sort { (a0:[AnyObject], a1:[AnyObject]) -> Bool in return ((a0[0] as! Int) < (a1[0] as! Int)) }
    case .ZmFloat:
        iAryAnySort.sort { (a0:[AnyObject], a1:[AnyObject]) -> Bool in return ((a0[0] as! Float) < (a1[0] as! Float)) }
    case .ZmStr:
        // 英字の大文字小文字に加え、全角英字の大文字小文字の違いを、すべて無視してくれるソート
        iAryAnySort.sort { (a0:[AnyObject], a1:[AnyObject]) -> Bool in return
            (a0[0] as! String).localizedCaseInsensitiveCompare(a1[0] as! String) == NSComparisonResult.OrderedAscending }
    default:
        zSendErrMsgF("ERROR:ZMR_SORT:ここは通らないはず:実行されたらバグあり")
        return [Int]()
    }

    // Step3:2次元配列から、レコード番号だけの配列を作る
    var iAryRecIdx: [Int] = []
    for i in 0..<cRecCount {   // i=RecIdx
        let iRexIdx: Int = iAryAnySort[i][1] as! Int  // ソートされたRecIdxを取り出す
        iAryRecIdx.append(iRexIdx)
    }
    return iAryRecIdx
}

いつもより長めのコードで、全体が3つの処理に分かれているので、Step1〜3の表記をコメントにつけています。これで、処理の区切りが明確に伝わります。

3つの処理の前に、指定された項目がBool型かを検査します。該当する場合は、エラーメッセージを出して空の配列を返します。空の配列を返すのは、戻り値をOptional型にしたくないからです。

 

3つのステップを順番に見ていきましょう。

まずは、ステップ1です。1つの配列をソートするだけでは、並び替えたレコード番号が得られません。また、項目値を入れたAnyObject配列には、レコード番号が入っていません。

レコード番号と一緒にソートできる配列を、新しく用意する必要があります。ソートする項目値とレコード番号をペアにして配列に入れ、それをレコード数の分だけ並べます。その集まりを、ペアの中の項目値でソートすると、レコード番号も一緒に並び替えられ、ソート後のレコード番号が得られるというわけです。

つまり、2次元の配列を用意する必要があります。このような配列を作るために、レコード数だけforループさせ、ペアとなる配列iAnyPairを毎回作って、ソート用の配列iAryAnySortへ次々と入れていきます。

続いて、ステップ2です。配列iAryAnySorが出来上がったら、その配列自体をソートします。sortメソッドを使い、値の大きさを判定するための関数(クロージャ)を渡すだけです。ここでは、データ型を指定する必要があるので、項目のデータ型ごとに処理を分けています。

このクロージャの作り方は、少し注意しなければなりません。ソートする配列は、2次元のAnyObject配列です。ですからsortメソッドが取り出して比較するのは、1次元のAnyObject配列になります。そのままだと比較できないので、1次元配列の最初の要素(項目値)を取り出し、Int型にキャストして比べる必要があります。そのため、(a0[0] as! Int)や(a1[0] as! Int)という形になっているわけです。また、引数のデータ型も、ちゃんと[AnyObject]を指定しています。

このsortメソッドが実行されると、2次元配列iAryAnySort内で、ペア配列が項目値の順に並べ替えられます。あとは、順番に取り出すだけです。

ステップ3の取り出しの処理では、戻り値となるレコード番号の配列を作りながら、レコード数の回数だけforループを実行します。ループが終了したら、レコード番号の配列を返して終了です。

 

今回のメソッドの中で一番理解しづらいのが、ステップ2で使っているsortメソッドのクロージャでしょう。普通の配列をソートするのではなく、2次元配列をソートしていますから。

処理内容が少しでも理解しやすいようにと、クロージャはかなり親切に書いています。何も省略しない書き方なので、これが精一杯でしょう。あとはコメントで補足するしかありません。コメントも、短いながら丁寧に書きました。

// 省略しない書き方
iAryAnySort.sort { (a0:[AnyObject], a1:[AnyObject]) -> Bool in return ((a0[0] as! Int) < (a1[0] as! Int)) }

// 省略した書き方
iAryAnySort.sort { ($0[0] as! Int) < ($1[0] as! Int) }

逆に省略した書き方も可能です。でも今回のような使い方では、クロージャ内の引数のデータ型が書かれていないと、前の処理を見ながらデータ型を調べる必要があります。結果として、何をしているのか理解しづらくなります。

少し変わった形でソートする場合は、sortメソッドに渡すクロージャを、できるだけ理解しやすく(省略なしで)書いたほうが良いと思います。もちろん、コメントでの補足も忘れずに。

 

ステップ2のソートの処理では、String型だけが違う書き方になっています。

他のデータ型のような不等号だけでは、期待したようにソートされず、使いものになりません。英字には大文字と小文字があり、それらを区別してソートしてしまうからです。さらに、日本語にも全角の英字が含まれ、それにも大文字と小文字があります。これらも区別してソートしてしまいます。数字の全角と半角も同様に、区別してソートしてしまいます。

望まれているソートの形は、もっと違うものです。半角英字の大文字と小文字、全角英字の大文字と小文字、それら4つの種類を区別しないで、同じ英字として処理してくれるソートなのです。

このような目的のために、ローカライズされた文字比較の機能が用意されていて、String型で使えます。具体的にはlocalizedCaseInsensitiveCompareメソッドで、比較したい文字列を渡し、判定結果がNSComparisonResult型で返ってきます。

2種類の比較方法を、あえて並べて書くと、次のようになります。

// 英字の大文字小文字に加え、全角英字の大文字小文字の違いを、すべて区別するソート
iAryAnySort.sort { (a0:[AnyObject], a1:[AnyObject]) -> Bool in return ((a0[0] as! String) < (a1[0] as! String)) }

// 英字の大文字小文字に加え、全角英字の大文字小文字の違いを、すべて無視してくれるソート
iAryAnySort.sort { (a0:[AnyObject], a1:[AnyObject]) -> Bool in return
    (a0[0] as! String).localizedCaseInsensitiveCompare(a1[0] as! String) == NSComparisonResult.OrderedAscending }

今回のソート機能では、下側のほうを使いました。これで、英字の大文字や小文字が含まれていても、全角の英字や数字が含まれていても、アルファベット順や数字順で正しくソートしてくれます。

 

String用ソートでも、できるだけ理解しやすいようにと、省略しない形で記述しています。逆に、少し短くした記述方法も可能です。今回の場合は、あまり短くなりませんが。

// 省略しない書き方
iAryAnySort.sort { (a0:[AnyObject], a1:[AnyObject]) -> Bool in return
    (a0[0] as! String).localizedCaseInsensitiveCompare(a1[0] as! String) == NSComparisonResult.OrderedAscending }

// 省略した書き方
iAryAnySort.sort { ($0[0] as! String).localizedCaseInsensitiveCompare($1[0] as! String) == NSComparisonResult.OrderedAscending }

String用の場合も、少し変わった形のソートですから、省略した記述は避けたほうが良いでしょう。

 

一応、使用例も紹介します。非常に簡単です。いつものように、iOS実験専用アプリから抜き出しました。

// ソート用メソッドを使ってみる
let iAryKey1: [Int] = modelRecCard25.sortF(fNumber_i)  // Int型の項目でソート
let iAryKey2: [Int] = modelRecCard25.sortF(fRate_f)    // Float型の項目でソート
let iAryKey3: [Int] = modelRecCard25.sortF(fName_s)    // String型の項目でソート

メソッドの引数として、項目番号を指定します。項目番号を間違えないようにと、項目番号に名前をつけた変数(定数)を使うのが基本です。

どの項目のソートでも、たった1行書くだけで、ソート結果が得られます。昇順に並んだレコード番号の配列です。あとは、そのレコード番号を使ってモデルクラスにアクセスするだけです。

 

今回の機能追加により、記録カードのモデルクラスには、各項目でのソート機能が追加できました。少し前に検索機能も追加していますから、基本的な機能として、検索とソートが揃ったことになります。他に、ファイルへの保存や、タブ区切りテキストの生成もあり、最低限の機能は満たせたと思います。

あとは実際のアプリで使ってみて、不足する機能を追加するだけです。十分に使い物になると判断していますから、積極的に使いたいと思います。過去に作ったアプリでも、機能追加の際に、このモデルクラスと置き換えてみようかと考えています。

ライブラリ化したモデルクラスは2つに増えましたから、3つ目のモデルクラスとなるモデル候補を物色中です。もし変わったモデルクラスが出来上がったら、また紹介したいと思います。

 

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

2015年7月1日水曜日

共通モデルのライブラリ化を試作(内部変更編)

共通で使えるモデルクラスをライブラリ化する第1段として、記録カードのモデルクラスを作る話を紹介しました。今回は、その後の状況を報告します。

 

記録カードのモデルクラスが一段落したので、2つ目のモデルクラスを作りました。記録カードと少し似ている、キー付きカードのモデルクラスです。記録カードにキーが追加されたと考えて、ほぼ間違いありません。記録カードはArreyに相当し、キー付きカードはDictionaryに相当すると考えれば、中身を想像しやすいと思います。

記録カードを作った経験があるので、テストコードも含めて、おおよそ1/3の時間で作り終わりました。クラスに限らず何かのソフトを作る場合には、コーディングしている時間よりも、仕様を考えている時間のほうが多いです。

2つ目のキー付きカードは、記録カードという前例があります。そのため、どんな形で内部データを保存するのか、どんなメソッドがあればよいのか、どんな検査機能を付けなければならないのか、などで悩みませんでした。また、テストコードも似た形で作れるので、その点でも悩みませんでした。まさに、スイスイと作れたという感じです。

肝心なのは、内部データの持ち方ですね。最初からAnyかAnyObjectと決めていたので、どちらにするか決めるだけです。4つの基本データ型だけでなく、将来はクラスのインスタンスも持てるようにしたいはずです。ほとんどのクラスがNSObjectのサブクラスですから、それと相性の良いAnyObjectが適しているでしょう。というわけでAnyObject配列を選びました。

キー付きというモデルなのも関係しているのか、記録カードのモデルクラスよりも、少しスッキリした内部構造に仕上がりました。AnyObject配列を選んで正解だったようです。

 

2つ目のモデルクラスの結果が良かったので、記録カードのモデルクラスも、内部データを持つ配列をAnyObject型に変更してみました。

今までは、データ型ごとに配列を用意したので、配列が4つもあります。それを、1つのAnyObject配列に置き換えるだけです。余計なインデックス変換も不要になり、コードが少し短くなります。

変更前のモデルクラスは別名で保存しておき、変更作業を進めました。実際に作業してみると、30分ぐらいで終了です。内部の仕様だけが変更なので、テストコードはそのまま使えます。コンパイルエラーを全部消して実行すると、一発で通りました。変更完了です。

よく考えると、変更する箇所は多いものの、変更の内容自体は単純でした。4つの配列を消して、1つのAnyObject配列を追加します。インデックスを変換している箇所では、変換している1行を削除し、新しいインデックスに置き換えるだけでした。また、AnyObject配列からデータを読む箇所では、それぞれのデータ型へキャストする指定を追加します。

このようにパターン化できる作業しかないため、かなり慎重に進めても、30分ぐらいの時間で終わりました。当然ですが、一番最初に作ったのは、新しいAnyObject配列の追加と、インデックスの使い方を説明するコメントです。このコメントを見ながら作業するので、間違いを起こさず更新できたのでしょう。

 

そのコメント行を紹介します。まずは、変更前のコメントから。

// ======================================== 3つのインデックスがある
// RecIdx:レコードの並び順を示すインデックス
// FldIdx:項目(フィールド)の並び順を示すインデックス:この値からDataIdxを得て、それでデータへアクセスする
// DataIdx:データ配列内で、どの行(位置)に入っているかを示すインデックス:FldIdxをキーにして、cAryDataIdxから得る
// ============================== 次のように利用する
// cAryName[FldIdx] -> String (項目名)
// cAryType[FldIdx] -> ZmType (データ型)
// cAryDataIdx[FldIdx] -> DataIdx
// cAryDataXXX[DataIdx][RecIdx] -> Data (項目値:4種類のデータ型)
// ========================================

インデックスを変換するDataIdxが使われています。続いて、変更後のコメントです。

// ======================================== 2つのインデックスがある
// RecIdx:レコードの並び順を示すインデックス
// FldIdx:項目(フィールド)の並び順を示すインデックス
// ============================== 次のように利用する
// cAryName[FldIdx] -> String (項目名)
// cAryType[FldIdx] -> ZmType (データ型)
// cAryDataAny[FldIdx][RecIdx] -> AnyObject (項目値:中身は4種類のデータ型)
// ========================================

3つのインデックスから、2つのインデックスへと変更されたのが、もっとも大きな点でしょう。DataIdxがなくなり、FldIdxで直接指定できる形になりました。また、項目値のデータ型が、変数上はAnyObjectだけに変わっています。

 

変更したメソッドのうち、主なものも紹介します。まずは、getメソッドから。

// 値の取得(変更前)
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 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 iInt: Int = cAryDataAny[rFldIdx][rRecIdx] as! Int
    return iInt
}

変更後の処理では、インデックスを変換している行が消えて、メソッドが1行分だけ短くなりました。ソースコード全体を眺めても、たいして短くならなかったというのが感想です。内部構造としては、余計なインデックス変換がなくなった点が一番大きいでしょう。

AnyObject型の配列cAryDataAnyから、データを取り出す際にはキャストが必要です。でも、キャストのために数文字を追加するだけですから、あまり変わっていない印象ですね。

getメソッドと違って、setメソッドは変更ありません。Intなどのデータ型の値を、AnyObject配列へ入れるだけですから、特別な指定は不要です。

 

続いて、データを保存する配列の初期化です。

// データ用の配列の初期化(変更前)
// 項目ごとに配列を生成して、その位置(インデックス番号)を記憶する
private func setupArrayF() {
    for i in 0..<cFldCount {  // i=FldIdx
        switch cAryType[i] {
        case .ZmInt :
            cAryDataIdx.append(cAryDataInt.count)  // count値がインデックス値より1つ大きいので、作成前に保存する
            cAryDataInt.append([Int]())
        case .ZmFloat :
            cAryDataIdx.append(cAryDataFloat.count)
            cAryDataFloat.append([Float]())
        case .ZmBool :
            cAryDataIdx.append(cAryDataBool.count)
            cAryDataBool.append([Bool]())
        case .ZmStr :
            cAryDataIdx.append(cAryDataStr.count)
            cAryDataStr.append([String]())
        }
    }
}
// データ用の配列の初期化(変更後)
// データ保存用の配列を初期化して、項目ごとに配列を生成する
private func setupArrayF() {
    cAryDataAny = [[AnyObject]]()      // データ保存用の配列を初期化
    for i in 0..<cFldCount {  // i=FldIdx
        cAryDataAny.append([AnyObject]())    // 項目ごとに、空の配列を追加
    }
}

保存場所を指すインデックス値DataIdxを保存する必要がなくなったのと、配列が1つになったので、かなり短くなりました。ちなみに、これだけ短くなったのは、ここの1箇所だけです。

 

このような感じで、無事に内部データをAnyObject配列に更新できました。

ただ更新しただけでは、とくにメリットはありません。しかし、内部配列がAnyObject型になったことで、いろいろなデータ型を簡単に入れる準備ができました。4つのデータ型とは違うデータ型へも、苦労せずに対応できそうです。

もちろん、以前の内部配列の構造でも、新しいデータ型へは対応できます。AnyObject配列を追加して、そこへ入れれば良いだけです。でも、データ保存用の配列が少しずつ増えていくのは、あまり良い形とは言えません。スッキリした構造から、どんどんと離れていきますから。

今回の変更は、将来の機能拡張の準備として、大きな意味があったと思います。今後も、このモデルクラスを改良して、少しずつ良くしていく予定です。

 

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

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)

2015年6月25日木曜日

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

共通で使えるモデルクラスをライブラリ化する第1段として、記録カードのモデルクラスを作る話の続きです。クラスの大まかな説明が終わったので、具体的なSwiftコードの紹介に入ります。

 

ソースコードの中身の前に、そのファイル名から。古い投稿で紹介したように、私はファイル名に分類情報を入れています。先頭の2文字が分類コードです。ライブラリ用としては、View型のクラスはBV、それ以外のクラスがBCでした。今回作るモデルクラスは特別なので、新しい分類コードとしてBMを割り当てました。モデルクラスのライブラリを、今後も増やしていく予定だからです。

今回のモデルクラスは記録カードなので、ファイル名は「BM_RecCard.swift」としました。ファイル名を見るだけで、基本ライブラリ(B)のモデルクラス(M)に属する、記録カード(RecCard)であることが理解できます。また、ファイル名の一覧でも、モデルクラスのBMが連続して並ぶため、整理された状態で見えます。

 

ファイル名が決まったので、いよいよSwiftコードです。まずは入れ物となるクラスから。

ライブラリのクラス名はZで始めるルールを採用していて、今回はモデルクラスのRecCardなので、クラス名はZModelRecCardとしました。管理用とデータ用の配列に加え、レコード数の変数も必要です。それらを入れた状態で、次のようになりました。

// 記録カードのモデルクラス(これは入れ物で、この中に後述するコードを追加する)
class ZModelRecCard {
    // 主要な数
    var cFldCount: Int = 0                // 項目数
    var cRecCount: Int = 0                // レコード数
    // 管理用の配列
    var cAryName: [String] = [String]()   // 項目名
    var cAryType: [ZmType] = [ZmType]()   // 項目のデータ型の定義
    var cAryDataIdx: [Int] = [Int]()      // データ配列内のインデックス番号
    // データを入れる配列
    var cAryDataInt: [[Int]] = [[Int]]()         // データ配列
    var cAryDataFloat: [[Float]] = [[Float]]()
    var cAryDataBool: [[Bool]] = [[Bool]]()
    var cAryDataStr: [[String]] = [[String]]()
}

Optional型にしたくないので、配列も含めて変数宣言時に初期化しています。

このモデルクラスでは、主に3種類のインデックスを使い分けます。それぞれの意味を、きっちりと整理しておかなければなりません。作っている途中で間違えやすいですから。

そんなミスを防ぐために役立つのが、インデックスの名前と役割を書いたコメントです。どんな名前のインデックスがあり、それぞれが何を指しているのか整理した内容を、コメント行として変数の直前に入れます。以下のように。

// =================================== 3つのインデックスがある
// RecIdx:レコードの並び順を示すインデックス
// FldIdx:項目(フィールド)の並び順を示すインデックス:この値からDataIdxを得て、それでデータへアクセスする
// DataIdx:データ配列内で、どの行(位置)に入っているかを示すインデックス:FldIdxをキーにして、cAryDataIdxから得る
// ==================== 次のように利用する
// cAryName[FldIdx] -> String (項目名)
// cAryType[FldIdx] -> ZmType (データ型)
// cAryDataIdx[FldIdx] -> DataIdx
// cAryDataXXX[DataIdx][RecIdx] -> Data (4種類のデータ型)
// ===================================

このコメントの後半では、用意した配列とインデックスの関係を示しています。どの配列に、どのインデックスを使い、どんな値が得られるのか、利用例の形で説明しています。

各インデックスの説明文だけでは理解しづらいですが、配列とインデックスと取得値の関係を示されると、格段に理解しやすくなります。また、このコメント行を見ながらプログラミングすると、インデックスの使い方を間違う可能性が大きく減ります。ですから、インデックスと配列の利用例のコメント行は、最初に作るべきでしょう。

当然ですが、インデックスとして用いる変数名も、ここで用意したインデックスの名前を使います。そうしないと、正しいインデックスが使われているかどうか、後で目視検査できませんから。

 

データ型を指定する列挙型も、専用に作りました。こちらもZで始まる名前です。

// 項目の定義に使うデータ型
enum ZmType: Int {
    case ZmInt = 0
    case ZmFloat = 1
    case ZmBool = 2
    case ZmStr = 3
}

列挙型の値ですが、先頭にZmを付けています。Zmが付かない、ただのIntでも構わないのですが、検索するときに、関係のない多量のIntが含まれます。それを嫌ってZIntとしたのですが、大文字が続いて読みづらく感じます。仕方なく、Zmと2文字を加えた形に落ち着きました。

本音ではzIntとしたかったのですが、列挙型の値は大文字で始めるルールがあるようで、zIntは避けました。一応、Swiftのコーディングルールは、できるだけ守りたいですからね。

 

続いて、モデルクラスの初期化です。モデルの定義として必要な、2つの配列を受け取ります。項目名の一覧と、項目ごとのデータ型の一覧です。この2つの配列だけで、モデルクラスの定義が完了します。

まずデータ型の一覧ですが、上記ZmTypeの配列です。使いたい項目の数だけ、データ型を並べます。8項目を使いたい場合は、8つのデータ型を入れます。すべて同じデータ型でも構いません。並び順は自由で、自分が理解しやすい順番を選びます。

データを入れたり出したりする際に、並び順で決まったインデックス番号(FldIdx)で、目的の項目を指定するだけです。その点にだけ注意していれば、あとは自由に使えます。

もう片方の項目名は、処理の中では少ししか使っていません。項目名付きのデータを生成したいときなど、このクラスを参照するだけで済むようにと用意しただけです。ただし、1つだけ重要な役割があります。項目名とデータ型の数を比べて、一致しているか調べます。初期化データの作成ミスを発見するのが目的です。

以上のように考えて作った初期化処理が、次のようなSwiftコードです。

// 初期化:モデルの定義(名前とデータ型)を受け取る
init(_ rAryName:[String], _ rAryType:[ZmType]) {
    if rAryName.count == 0 { zSendErrMsgF("ERROR:ZMR_INI:項目名が空"); return }
    if rAryName.count != rAryType.count { zSendErrMsgF("ERROR:ZMR_INI:項目名とデータ型の数が不一致"); return }
    cAryName = rAryName
    cAryType = rAryType
    cFldCount = cAryType.count
    // 配列の準備
    setupArrayF()
}
// 項目ごとに配列を生成して、その位置(インデックス番号)を記憶する
private func setupArrayF() {
    for i in 0..<cFldCount {   // i=FldIdx
        switch cAryType[i] {
        case .ZmInt :
            cAryDataIdx.append(cAryDataInt.count) // count値がインデックス値より1つ大きいので、作成前に保存する
            cAryDataInt.append([Int]())
        case .ZmFloat :
            cAryDataIdx.append(cAryDataFloat.count)
            cAryDataFloat.append([Float]())
        case .ZmBool :
            cAryDataIdx.append(cAryDataBool.count)
            cAryDataBool.append([Bool]())
        case .ZmStr :
            cAryDataIdx.append(cAryDataStr.count)
            cAryDataStr.append([String]())
        }
    }
}

項目名とデータ型の数が一致するかだけでなく、受け取った配列が空でないかも調べています。これらの検査を通ったら、クラス内の変数に代入するだけです。

続いて、配列の準備処理が続きます。cAryTypeに入っているデータ型を参照しながら、該当するデータ型の2次元配列に、初期化した1次元配列を追加していきます。これが済むと、データをappendできる状態になります。

このように、初期化で指定されたデータ型を見て、それに対応した1次元配列を追加するため、余計な配列は作りません。必要なものしか作らないように、つまり無駄なものを生まない形にしてあります。

以上の初期化処理が終わると、もうデータを追加できる状態になっています。あとは、データを追加して溜め込み、必要なときにデータを参照するだけです。

 

続いて、メソッドを順番に取り上げましょう。まずは、項目名とデータ型を扱うメソッドから。次のように、2つしか用意していません。

// ======================================== 基本情報
// 項目名の一覧を返す
func getNameF() -> [String] {
    return cAryName
}
// データ型の一覧を返す
func getTypeF() -> [ZmType] {
    return cAryType
}

これに説明は不要でしょう。保存していたままというか、初期化の際に受け取ったままを返します。

 

次は、レコード単位の処理です。ここが2番目に悩んだ点です(ちなみに、1番目に悩んだ点は、前の投稿で紹介したデータの持ち方)。

レコード内のデータ型の構成は、使うアプリごとに異なります。その状態で、共通のレコード用メソッドを作ると、AuyやAnyObjectを使うしかないのです。それ以外だと、データ型の組み合わせを全部用意するしかありません。これこそ非現実的でしょう。

発想を切り替えて、レコードの追加は次のようにしました。まずダミー値で、新規のレコードを追加します。その直後に、項目ごとの値を設定するメソッドで、すべての項目の値を設定します。1つの値だけを設定するメソッドなら、データ型を固定した形で作れます。データ型の種類が4つなので、項目の値を設定するメソッドは4つで済みます。

レコードの更新も同様です。レコード単位で更新する場合も、すべての項目を1つずつ更新して、レコード全体を更新します。削除だけは例外で、新しい項目値を用意する必要がないため、1つのメソッドで完了できます。

項目ごとに値を設定する方法は、少し面倒に感じるかもしれませんが、それほどではありません。アプリ側でデータを用意する処理では、項目ごとにデータを集めて、1つのレコードに整えます。その処理を変更して、1つのレコードに整えず、項目ごとのデータを集める処理内で、そのままデータを更新してしまえば良いのです。アプリ側の処理全体として比べると、あまり差がないはずです。

以上のように考えて、作成したのが次の3つのSwiftコードで、1つずつ順番に要点だけ解説します。

// ======================================== レコード処理
// レコード数を返す
func getRecCountF() -> Int {
    return cRecCount
}

レコード数を返す処理は、見たとおりです。モデルクラス内で持っている値を、そのまま返しています。

 

レコードを追加する処理では、ダミーの値を入れています。それぞれの保存先では、初期化処理の中で空の配列を追加しているため、appendメソッドで追加するだけで済みます。

// レコードを追加:追加したレコードのインデックス番号を返す
func addRecF() -> Int {
    for i in 0..<cFldCount {   // i=FldIdx
        let iDataIdx: Int = cAryDataIdx[i]
        switch cAryType[i] {
        case .ZmInt :
            cAryDataInt[iDataIdx].append(-999)
        case .ZmFloat :
            cAryDataFloat[iDataIdx].append(-999.9)
        case .ZmBool :
            cAryDataBool[iDataIdx].append(false)
        case .ZmStr :
            cAryDataStr[iDataIdx].append("empty")
        }
    }
    ++cRecCount
    return (cRecCount - 1)
}

一番の肝は、データ型を調べることと、保存先を求めることです。それを項目の数だけ実行しなければなりません。項目の数はcFldCountに入れてあるので、その数だけfor文で回します。このような、項目の数だけ実行するという処理が何度も登場します。このfor文は、このモデルクラスでの定番パターンと言えるでしょう。

また、このfor文の変数iは、FldIdxを意味しています。そのことを伝えるために、コメントを追加しています。このおかげで、インデックスの使い方を間違えるミスが減らせます。別な方法として、変数名をiではなく、iFldIdxと変える方法も考えられます。しかし、ループ処理の中でのiやjは特別な存在で、処理内容を理解しやすくする効果も持っています。iやjのままのほうが良いと考え、コメントで補足する方法を選びました。

データ型はcAryTypeに入っています。その内容を調べることで、データ型を特定できます。switch文で分岐させ、該当するデータ型の処理に進めます。

ダミー値の保存先は、FldIdx値から求めます。保存先といっても、保存されているのはインデックス番号DataIdxで、それはcAryDataIdxに入っています。ここに入っている値DataIdxは、それぞれのデータ型の配列で、何行目なのか示しているだけです。ですから、この値DataIdxを取り出して、データ用の配列を参照するために使う必要があります。保存先のDataIdxが明らかになると、appendメソッドが実行できます。

最後に、追加したレコード番号RecIdxを返しています。レコード追加の直後に項目の値を更新するので、追加したレコード番号が分かると非常に助かるからです。

 

レコードを削除する処理ですが、私の場合はほとんど使わないものの、とりあえず付けておきました。

// レコードを削除
func deleteRecF(rRecIdx:Int) {
    if rRecIdx < 0 || rRecIdx >= cRecCount { zSendErrMsgF("ERROR:ZMR_DR:インデックスが範囲外"); return }
    for i in 0..<cFldCount {   // i=FldIdx
        let iDataIdx: Int = cAryDataIdx[i]
        switch cAryType[i] {
        case .ZmInt :
            cAryDataInt[iDataIdx].removeAtIndex(rRecIdx)
        case .ZmFloat :
            cAryDataFloat[iDataIdx].removeAtIndex(rRecIdx)
        case .ZmBool :
            cAryDataBool[iDataIdx].removeAtIndex(rRecIdx)
        case .ZmStr :
            cAryDataStr[iDataIdx].removeAtIndex(rRecIdx)
        }
    }
    --cRecCount
}

まず最初に、レコード番号が範囲外か調べて、範囲外ならエラーメッセージを出して終了します。

あとは全体的に追加の処理と似ていて、データを削除する部分だけが異なります。データが入っている配列で、appendではなくremoveAtIndexを実行します。最後にレコード数を減らして終了です。

 

ここまででも、かなり長くなってしまいました。続きは次回の投稿にて。

 

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

2015年6月23日火曜日

共通モデルのライブラリ化を試作(全体検討編)

アプリを作る際には、扱う情報ごとにモデル(MVCのM)のクラスを用意して、アプリ内の構造をスッキリさせます。オブジェクト指向では一般的な考え方でしょう。

情報ごとにモデルを作るのですが、同じようなパターンの情報も意外に多くあります。それらを毎回作っても構わないのですが、もしライブラリ化できれば、クラスを毎回作らずに済むのでは、と考えるようになりました。

もちろん、ライブラリ用として用意するモデルクラスが、1つで済むとは考えていません。よく使いそうなモデルを選び、おそらく数種類のモデルをライブラリ化します。その結果、半分ぐらいのモデルクラスを作らなくて済むと予想しています。

というわけで、試しに作ってみました。予想外に苦労したので、その過程も含めて紹介します。

 

モデルクラスをライブラリ化と言っても、どんな感じなのか想像できない人もいると思います。そこで、大まかな姿を最初に紹介します。それが想像できないと、これからの話が意味不明に感じるかもしれませんので。

ライブラリ化するのですから、モデルクラスで扱うデータの種類は、それぞれのアプリで異なります。まずは基本的なデータ型である、Int、Float、Bool、Stringを考えます。アプリ側では、この中のどれをどんな組み合わせで使うのか、モデルクラスのインスタンス生成時に指定します。データベースのフィールド定義と似たようなものです。このような形で作ると、モデルごとに異なるデータ型の組み合わせに対応でき、より多くのモデルで利用できるように仕上げられます。

インスタンス生成でデータ型の定義が終わったら、どんどん使い始めます。モデルクラスに用意されたメソッドで、新しいデータを追加したり、前に追加したデータを取得したり、データの一部を更新したりします。つまり、データ型の組み合わせ設定を用意し、それでインスタンス生成した後は、ただ使うだけなのです。

ライブラリ化したクラスで、付加機能を最初から用意すれば、さらに使いやすくなります。たとえば、入力したデータをタブ区切りのテキストに変換してくれる機能を付けると、そのデータをアプリ側でコピーして、他のアプリ(表計算アプリなど)へ持っていけます。また、少し前に紹介したファイル保存の機能に対応させておけば、そのメソッドを呼び出すだけでファイルやネットへの保存もできます。わざわざ作る必要はありません。

おそらく一部のアプリでは、特別に追加したい機能も出てくるでしょう。その場合は、ライブラリ化したモデルクラスのサブクラスを作って、必要な機能を追加する方法が可能です。独自要望の多くは、この方法で何とかなると思います。

 

ライブラリ化すると良いことが多いものの、上手に作らないと、デメリットも生じます。その点も最初から考えておかなければなりません。

真っ先に思い浮かんだのが、データ型の検査です。複数のデータ型を自由に組み合わせる場合、AnyやAnyObjectなどの汎用データ型に入れて扱いたくなります。モデルクラス内だけでなく、getなどのメソッドでも汎用データ型を使うと、もう大変です。コンパイラのデータ型検査機能が無効になり、実行時エラーの続出といった悲惨な状況へ向かうかもしれません。できる限り汎用データ型を使わず、データ型検査機能を有効に働かせることが大事です。

と書きましたが、実際に作るとなると大変です。なにしろ、データ型を自由に組み合わせて使うわけですから、汎用データ型を使わずに作ると、組み合わせ数が膨大になりそうです。どうすれば良いのでしょう。

ライブラリ化する際には、ライブラリではない形で作るよりも多くのエラー検査機能を入れます。でもモデルクラスの場合、アクセスが多いことも考えられ、検査が多いと処理が遅くなってしまいます。どの程度の検査を入れるのか、注意深く決める必要があるでしょう。現実的には、検査内容は最低限だけに絞り、あまり入れない方向で進めると思います。

 

以上の点を踏まえた上で、いよいよ具体的なモデルクラスの話に移りましょう。

どんなモデルを最初に作るかは、決めてあります。もっとも簡単そうなモデルを選びました。記録カードを扱うようなモデルをです。

そのカードは記入内容が予め決められていて、用意された欄にどんどん記入し、カードが増えていくような感じです。言ってみれば、カード型データベースからデータベース機能を抜いたようなモデルでしょう。一般的には、記録カードと呼ばれるものです。

もう少し詳しく書きましょう。まず最初に、カードに記載する内容を決めます。何個の項目を含み、それぞれの項目のデータ型が何なのかを決めるわけです。それぞれの項目に名前も必要でしょう。

具体的には、次のような感じになります。ゲームなどのイベントで、参加者の得点を記録するカードの情報を考えます。会員番号はInt、氏名はString、得点はFloat、特別会員はBoolという感じです。これを参加した人の数だけ、カードへ毎回入力します。どんどん入力すると、参加人数と同じ数のカード情報が保存されます。

別な例として、アクセスログなんかも考えられます。日付はIntに変換、時刻もIntに変換、IPアドレスはStringに変換、アクセス種類はString、アクセス内容もString、エラー有無はBoolとかです。

たいていの情報は、4つの基本データ型に変換して保持できます。それ以外のクラス・インスタンスも扱えるようにするかは、4つのデータ型で作った後に検討することにしました。まずは、4つのデータ型で使えるように仕上げるのが先決です。

 

今回は、どんな内部構造で作ったら良いのか、まったく想像できませんでした。そのため、とりあえず作りながら考えることとしました。どんなメソッドが必要なのかとか、何も考えずに作り始めたわけです。

真っ先に検討したのが、クラス内でのデータの持ち方です。複数の項目があって、それぞれデータ型が異なります。また、どの項目なのか外部から指定できる必要もあるでしょう。項目番号で指定するのか、名前で指定するのか。

最初は、項目番号で指定することを考えました。いろいろなデータ型を入れるのですが、データ型を検査するためには、AnyやAnyObjectを使えません。そのデータ型の配列を用意するしかないでしょう。そして、用意した個々の配列を管理するための親配列も追加しました。親配列は[Any]で用意し、その要素にデータ型の配列([Int]や[String])を入れるという形です。

データ型の配列は追加などがあるので、Varで初期化します。昔のソースコードは決して残ってないので、肝心の部分だけ思い出しながら書くと、次のような感じだったと思います。なお、変数名は完全に忘れたのでデタラメです。

// 親配列に、データ型の配列を入れる(思い出しながら書いたので、一部が間違っているかも)
var cAryMain: [Any] = [Any]()
// 子配列を作って、親配列に入れる
var iAryInt: [Int] = [Int]()
var iAryFloat: [Float] = [Float]()
cAryMain.append(iAryInt)     // 最初の項目はInt型
cAryMain.append(iAryFloat)   // 2番目の項目はFloat型

ここまでは問題なく作れました。さっそく次のような形でデータを追加しようとしたところ(これも思い出しながら書いているので、一部が間違っているかもしれません)、appendの行がコンパイルエラーになりました。

// データ型の配列に要素を追加したいが、コンパイルエラーに(同じく、一部が間違っているかも)
let iInt: Int = 100
(cAryMain[0] as! [Int]).append(iInt)  // この行がエラーに

英語のエラーメッセージから推測すると、どうやら配列[Any]に入れてしまった配列[Int]では、appendなどが使えなくなるようです。間違った使い方を防止するために、安全装置的な検査が組み込まれているのでしょうか。これでは、まったく使い物になりません。

諦めきれないので、もう少し粘ってみました。もしかして、参照渡しでアクセスすると、コンパイルエラーが出ない可能性があるかなと。そこで、cAryOya[0]を関数の引数として渡し、inoutを指定してみました。残念ですが、結果は同じでした。

他にも、思い付いた方法で試してみたのですが、最終的にはコンパイルエラーが出てしまいます。やはり、どこかで安全装置が働いて、許してくれないのでしょう。変数[Int]を変数[Any]に入れる方法は、完全に諦めました。

ここまでの段階で、他のメソッドなども少し作っていました。中心となる構造が使用不可になったため、すべて最初からやり直しです。

 

気を取り直して、もっと確実な方法で作ることにしました。データを入れる配列[Int]や配列[Float]などは、配列[Any]に入れず、配列[Int]や配列[Float]のままで扱うことにしました。これが一番確実な方法ですから。

ただし、そのままだと何番目の項目がどこに入っているか不明なので、参照インデックスの役目を持つインデックス用配列[Int]を用意します。このインデックス用配列に、それぞれのデータ配列のインデックス番号を記録しておきます。これとは別にデータ型を示す配列変数も用意します。これら2つが管理用の配列で、両方の値を参照すれば、どのデータ型なのかと、どのデータ位置に保存されているのかが分かるというわけです。

具体的に仕上がったコードは、次のようになりました。

// 管理用の配列
var cAryType: [ZmType] = [ZmType]()  // 項目のデータ型の定義
var cAryDataIdx: [Int] = [Int]()     // データ配列内のインデックス番号
// データを入れる配列
var cAryDataInt: [[Int]] = [[Int]]()        // データ配列
var cAryDataFloat: [[Float]] = [[Float]]()
var cAryDataBool: [[Bool]] = [[Bool]]()
var cAryDataStr: [[String]] = [[String]]()

この中のZmTypeは、データ型を指定するために作った列挙型です。決められたデータ型しか指定できないようにするためです。

ここで、データ型の定義として「Int,Float,Int,String」が指定されたとします。するとcAryDataIdxには、[0, 0, 1, 0]の値が入ります。意味としては「Int型のインデックス0、Float型のインデックス0、Int型のインデックス1、String型のインデックス0」となります。データ型の定義にIntが2つ含まれるので、Intだけはインデックス0とインデックス1が作られたわけです。

4つのデータ型の配列は、どれも2次元で作りました。それぞれ複数の項目で使われる可能性があるからです。「Int型のインデックス0」で使うときは、cAryDataInt[0][0], cAryDataInt[0][1], cAryDataInt[0][2], ...とアクセスし、「Int型のインデックス1」で使うときは、cAryDataInt[1][0], cAryDataInt[1][1], cAryDataInt[1][2], ...とアクセスします。

どのデータ配列もデータ型が指定されているので、データ型が決まった形で使われます。また、すべてのメソッドを、データ型を指定した形で作ります。結果として、コンパイラがデータ型を検査できる形に仕上がりました。設計上の一番大事な点を、何とかクリアーできました。

 

キリが良さそうなので、ここで一旦区切ります。具体的なSwiftコードは、次回の投稿で紹介します。今回の内容は全体設計という形ではないため、「全体設計編」ではなく「全体検討編」としました。

 

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

2015年6月15日月曜日

ファイルの読み書きも間接参照で(追加修正編)

ファイルの読み書きを間接参照で作る話は一旦終わったのですが、使う際の状況をよく考えたら、リンククラスに親クラスが必要だと気付きました。その辺の話を追加で書きます。

 

本題に入る前に、私の普段の作り方を少しだけ。

似たようなクラスを複数作る場合、共通部分を親クラスとして最初に作り、異なる部分はサブクラスとして追加するでしょう。私も同じように作りますが、最初から親クラスを用意することはほとんどありません。似たクラスを作るかどうか不確かなので、似たクラスを作る段階になってから、親クラスを作るのが基本です。

このようにする理由は、無駄な作業を少しでも減らしたいからです。共通部分を親クラスに持つといっても、どの辺までが共通なのかは、似たクラスを実際に作る段階で明確になります。ですから、明確になったときに親クラスを作ったほうが、全体としての作業量が最小限で済みます。そもそも、似たクラスを作るかどうかが、不確実ですから。こう考えているため、最初から親クラスを作ることは、めったにありません。

 

で、今回のリンククラスです。いつもと同じように考えて、親クラスを用意しませんでした。

しかし、よくよく考えると、切り替え可能に作ることが大前提のリンククラスです。切り替え時の変更を最小限に済ませるなら、最初から親クラスを用意するのが、もっとも効率的だと思います。もちろん、切り替え用のリンククラスを作るとき、モデルクラスの中まで含めて変更しても構わないのですが、モデルクラスを変更せずにと考えている以上、やっぱり親クラスは必要でしょう。それに、リンククラスの仕様変更は、生じそうもないですし。

 

というわけで、さっそく親リンククラスを作ってしまいました。出来立てです。

まず、親クラスの役割を説明します。親のリンククラスは、リンククラスの共通部分を持っていますから、どのリンククラスにも使える機能を持ちます。そして、モデルクラスのように、サブのリンククラスを切り替えて使う側に利用されます。このような役割なので、プロトコル的なクラスと考えれば理解しやすいでしょう。

切り替えて使われるサブのリンククラスは、親クラスとは異なる機能を持つだけでなく、共通機能の実装という役割も持ちます。これら2つのうち後半の役割は、プロトコルの実装と考えれば理解しやすいでしょう。

親子クラスなのですが、リンククラスは間接参照が目的なので、プロトコル的な形になるのだと思います。でも、親子クラスとして実現するほうが、作りやすくて扱いやすいので選びました。

 

さて、実際のSwiftコードです。データ形式が4つありますから、それぞれに親クラスを作らなければなりません。

まずは、テキストを扱うリンククラスから。次のようなSwiftコードになりました。

// ============================== 文字列をファイルへ読み書き
// 親クラス(切り替えて利用する側で使う)
class ZSLText2Storage {
    // アプリ内のリソースからロード
    func loadDefaultF() -> String? {
        return nil
    }
    // ロード
    func loadF() -> String? {
        return nil
    }
    // 保存
    func saveF(rStrData:String) -> Bool {
        return false
    }
}
// サブクラス(インスタンスを生成する側で使う)
class ZSLText2File: ZSLText2Storage {
    var cDir: ZFDir = .Docs              // 仮の値
    var cFileName: String = "dummy.txt"
    //初期化
    init(_ rDir:ZFDir, _ rFileName:String) {
        cDir = rDir
        cFileName = rFileName
    }
    // アプリ内のリソースから
    override func loadDefaultF() -> String? {
        let iFileNameN: String = cFileName.stringByDeletingPathExtension
        let iFileNameS: String = cFileName.pathExtension
        let iStr: String? = zFReadFileTextResF(iFileNameN, iFileNameS)
        return iStr
    }
    // 指定されたディレクトリから読み込む
    override func loadF() -> String? {
        let iStr: String? = zFReadFileTextF(cDir, cFileName)
        return iStr
    }
    // 指定されたディレクトリへ書き出す
    override func saveF(rStrData:String) -> Bool {
        let iBool: Bool = zFWriteFileTextF(cDir, cFileName, rStrData)
        return iBool
    }
}

親クラスには、共通する3つのメソッドだけで、初期化は含まれていません。リンククラスの初期化は、保存先が何なのかに依存するため、共通化できないからです。

3つの共通メソッドは、基本的に何もしません。ただし、処理が失敗したときの戻り値を返すように作ってあります。上書きされない場合でも、それなりに良い形で正常に動作するようにとの配慮です。「上書きされてません」とエラーメッセージを出すことも可能ですが、それはやりすぎでしょう。

サブクラスは、基本的に何も変えていません。親クラスが持つ3つのメソッドを上書きするので、メソッドの先頭にoverrideを加えただけです。機能的にも同じですし、使い方も前と変わりません。

 

続いて、NSDictionaryを扱うリンククラスです。こちらも親クラスを追加して、次のようなSwiftコードになりました。

// ============================== NSDictionaryをファイルへ読み書き
// 親クラス(切り替えて利用する側で使う)
class ZSLDic2Storage {
    // アプリ内のリソースからロード
    func loadDefaultF() -> NSDictionary? {
        return nil
    }
    // ロード
    func loadF() -> NSDictionary? {
        return nil
    }
    // 保存
    func saveF(rNSDic:NSDictionary) -> Bool {
        return false
    }
}
// サブクラス(インスタンスを生成する側で使う)
class ZSLDic2File: ZSLDic2Storage {
    var cDir: ZFDir = .Docs                // 仮の値
    var cFileName: String = "dummy.plist"
    //初期化
    init(_ rDir:ZFDir, _ rFileName:String) {
        cDir = rDir
        cFileName = rFileName
    }
    // アプリ内のリソースから
    override func loadDefaultF() -> NSDictionary? {
        let iFileNameN: String = cFileName.stringByDeletingPathExtension
        let iFileNameS: String = cFileName.pathExtension
        let iNSDic: NSDictionary? = zFReadFileDicResF(iFileNameN, iFileNameS)
        return iNSDic
    }
    // 指定されたディレクトリから読み込む
    override func loadF() -> NSDictionary? {
        let iNSDic: NSDictionary? = zFReadFileDicF(cDir, cFileName)
        return iNSDic
    }
    // 指定されたディレクトリへ書き出す
    override func saveF(rNSDic:NSDictionary) -> Bool {
        let iBool: Bool = zFWriteFileDicF(cDir, cFileName, rNSDic)
        return iBool
    }
}

変更点は、テキストを扱うリンククラスと同じです。説明は不要でしょう。

 

このように親リンククラスを追加したので、一部の使い方は変更になります。それは、リンククラスを交換して使うモデルクラスです。今まではリンククラスのデータ型としてサブのリンククラスを指定していましたが、その代わりに、親のリンククラスを指定します。たったこれだけです。

モデルクラスが、親のリンククラスを扱う形になるので、すべてのサブのリンククラスを切り替えて使えるようになり、モデルクラスの変更は生じません。リンククラスの仕様が変更されない限り、モデルクラスはそのまま使い続けられます。

モデルクラス以外では、前に説明したとおりの使い方です。中央処理では、サブのリンククラスとしてインスタンスを生成し、モデルクラスに渡します。また、一括バックアップなどの目的で、サブのリンククラスを操作するときも、前と同じように操作できます。つまり、親クラスの追加で変わったのは、モデルクラス内のデータ型の指定だけなのです。

 

当初の予定外に、親のリンククラスを追加することになりました。ちょっと気付くのが遅かったですが、早めに気付いて良かったです。モデルクラスを使う部分は実際に作ってないため、気付くのが遅くなったのでしょう。もし作ってれば簡単に気付いたのですが。まあ、こんなことは、たまにありますね。素直に修正することが大事です。

以上のように、リンク先の切り替え部分も、将来の変更が最小限になりました。これからも、間接参照の考え方を積極的に活用して、いろいろな機能を実現したいと思います。

 

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