2015年1月25日日曜日

AnyとAnyObjectの違い(位置付け編)

AnyとAnyObjectの違いが、どうしても気になって、ネットで調べ続けていました。忙しくて、そんなことをしている状況ではないのに。でも、そのおかげで、納得できる答えが見付かりました。というわけで、AnyとAnyObjectの位置付け編です。

 

前に調べたときは、両者の違いを中心に置いていました。今回は方針を変えて、Objective-Cとの関連を中心に検索しましたわけです。そうしたら意外にも簡単に、短期間で謎が解けました。やはり鍵は、Objective-Cにありましたね。

Objective-CとSwiftとを比較しながら分析しているブログの中に、大きなヒントを与えてくれたブログがいくつかありました。SwiftのAnyObjectは、Objective-Cのid型に相当するのではないかと推測しています。これこそ重要な視点だと思いました。このヒントと、仕様確認の結果を組み合わせることで、両者の違いや位置付けが、大まかにですが解明できました。以下が、その内容です。

 

Swiftは新しい言語ですが、既存のiOS APIを使っています。また、Objective-Cで書かれたソフトウェアとの連携も考慮しなければなりません。つまり、新しい言語ではありますが、過去のシガラミを多く抱えたまま出発しなければならなかったわけです。

Objective-Cでは、汎用的なデータ型としてidが使われています。このidをラッピングしてSwiftで使えるようにしたものが、AnyObjectだったのです。Swiftでもidを利用することで、iOSの多くのAPIがそのまま使えます。これは大きな利点です。

ただし、いつくかのブログで報告されているように、AnyObjectがidと完全に同じではないようです。idで使えた一部の機能が、AnyObjectでは使えなくなっているとのことです。ごくごく一部だけのようですが。実装し忘れたのか、仕様上の理由で付けられなかったのかは不明ですが、ずっと使えないままであれば、仕様上の理由で付けられなかった可能性が高いでしょう。まあ、設計方針により外したという可能性もありますけど。

 

続いて、Anyです。過去のシガラミと関係ない汎用データ型も必要と考え、用意したのがAnyと言えます。iOS APIとは互換性がないものの、関数でも何でも入れられるデータ型として、言語としては「なくてはならない存在」です。

もちろん理想としては、汎用的なデータ型が1だけの状況でしょう。過去のiOS APIでも使えて、何でも入れられるデータ型が唯一ある状況がベストです。おそらく、それが実現不可能だったから、Anyを用意して、AnyObjectと共存させる結果となったのでしょう。

 

iOS APIも古いままではありません。一部のAPIは、Swiftで使いやすいように拡張されています。

その1つがDictionaryです。NSDictionaryにキャストできることから、DictionaryはNSDictionaryを拡張したものだと判断できます。その拡張内容の中には、Anyを扱えるようにしたことも含まれるはずです。Anyを用意した以上、いろいろな場面で使える必要がありますから。

ただし、仕様確認で見付かったように、DictionaryにAnyを含めるとキャストできなくなります。拡張された機能を使ったときだけ、キャストできないというのは、なかなか上手い作り方だと思います。Anyを使わないケースがほとんどのため、多くの場合にキャストできて、古いAPIの機能が使えるようになっているわけですから。

 

アップルの技術資料には、AnyObjectはクラスのインスタンスを入れる目的で使い、Anyは関数も含めて何でも入れられるように書いてあります。

このように書いているのに、AnyObjectにはIntやFloatの値が入れられますし、とくにコンパイルエラーも出していません。おそらく、is演算子などの実行結果がAnyObjectでは期待どおり動かないことを知って、クラスのインスタンス用という役割に決めさせたのでしょう。一部に欠点がありつつも使えるままにしながら、形式上は推奨はしないという具合に。

 

以上のような状況が理解できると、AnyとAnyObjectの位置付けが理解できますし、使い方も大まかに決まってきます。

iOSアプリを作る上では、様々なAPIを使うのが必須です。また、たいていのアプリでは、バージョンアップの度に機能を拡張するでしょう。新しいAPIを使う状況に、いつなってもおかしくないはずです。そんなときAnyを使っていたら、使い始めるAPIで使えない状況が生じやすくなります。というわけで、基本的には、AnyではなくAnyObjectを使ったほうが良いでしょう。

ただし、AnyObjectに入れるデータ型に、IntやFloatなどの基本的なデータが含まれる場合は、Anyを検討する必要があります。AnyObjectに入れられないわけではないため、絶対にAnyを使うべきとは言えません。実現する機能に対して、期待どおりの動作が得られないときだけ、Anyを使うという判断で構わないと思います。AnyObjectにIntやFloatを入れたとき、期待どおりの動作が得られるか、仕様確認するのは必須となりますが。

 

IntやFloatなどを入れながら、iOSのAPIを使いたい場合もあるでしょう(まさに、私が作った環境設定機能が該当します)。このような場合には、何か別な解決策を考えないとダメです。

1つ思いついたのは、IntやFloatを専用クラスに入れる方法です。いろいろなデータ型を入れられるクラスを新しく作って、データ自体はそのクラスのインスタンスに持たせ、そのインスタンスをAnyObjectに入れます。AnyObjectから値を取り出すときは、用意したクラスのメソッドを用いる形になります。

用意するクラスの仕様ですが、IntやFloatなどの値を入れる変数を持ちます。そして、どのデータ型の値を持っているのかを、また別な変数として持ちます。そのクラスでは、持っているデータ型の種類を変更したり読み出すメソッドと、実際のデータを読み書きするメソッドで構成します。つまり、どのデータ型を入れたのか保持しているクラスというわけです。使う際に手間が少し増えるものの、問題を解決できる現実的な方法と言えるでしょう。

 

ここまで数回の投稿で、AnyとAnyObjectの違いを調べながら、並行して仕様確認の実験を何度も試しました。これで一応の結論が出たと思います。今後は、iOSのAPIが大きく変わらない限り、AnyではなくAnyObjectを中心に使いたいと思います。

2015年1月23日金曜日

AnyとAnyObjectの違い(シリアライズ編)

環境設定機能の独自ライブラリ化で必要なので、Anyのシリアライズを機能確認しました。そうしたら、また面白いことが見付かりました。これらか紹介する内容としては、AnyとAnyObjectの違いになるので、掲げたようなタイトルにしてあります。

 

AnyObjectではIntやFloatなどの区別ができないので、Anyに切り替えようと、Anyを含んだDictionaryのシリアライズを試しました。シリアライズができるようになれば、AnyObjectをAnyに切り替えられるからです。

シリアライズには、NSKeyedArchiverクラスのarchiveRootObjectメソッドを使いました。コーディングしたら、いきなりコンパイルエラーです。iOS実験専用アプリで機能確認してますから、AnyObjectと比較する意味で、同じようなコードをAnyObjectでも作りました。AnyObjectのほうはエラーが出ませんし、実行させると正常に動きます。

前回の投稿で使ったソースコードに加えて試していますが、追加部分だけ掲載すると意味不明でしょうから、関連するコードだけ残したものが、次のSwiftコードです。

// Dictionaryをファイルとして保存
// 変数の準備
let aryTypeName : [String] = ["Int", "Float", "Bool", "String", "Error!!!"]
let iInt : Int = 5
let iFloat : Float = 3.3
let iBool : Bool = false
let iStr : String = "test"
var dicAny : Dictionary<String,Any> = [String:Any]()
var dicAnyObj : Dictionary<String,AnyObject> = [String:AnyObject]()
dicAny[aryTypeName[0]] = iInt
dicAny[aryTypeName[1]] = iFloat
dicAny[aryTypeName[2]] = iBool
dicAny[aryTypeName[3]] = iStr
dicAnyObj[aryTypeName[0]] = iInt
dicAnyObj[aryTypeName[1]] = iFloat
dicAnyObj[aryTypeName[2]] = iBool
dicAnyObj[aryTypeName[3]] = iStr
// Dictionaryをファイルとして保存
let iPathDocs : AnyObject = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true)
let iPathFile1 : String = iPathDocs[0].stringByAppendingPathComponent("dicAny.plist")
let iPathFile2 : String = iPathDocs[0].stringByAppendingPathComponent("dicAnyObj.plist")
//let iResult1 = NSKeyedArchiver.archiveRootObject(dicAny, toFile:iPathFile1)    // コンパイルエラー
//if !iResult1 { println("ERROR:ファイル書き失敗:Any") }
let iResult2 = NSKeyedArchiver.archiveRootObject(dicAnyObj, toFile:iPathFile2)
if !iResult2 { println("ERROR:ファイル書き失敗:AnyObj") }

上記のコメント化した2行のうち、1行目がコンパイルエラーです。エラーメッセージをから推測すると、シリアライズ処理ではAnyを扱えないようです。つまり、Anyを含んだインスタンスなどは、シリアライズの対象外になるということでしょう。

前回の投稿で書いたとおり、Anyを含んだDictionaryは、NSDictionaryへキャストできませんでした。今回のシリアライズも、根本原因は同じだと思います。AnyはSwiftになってから用意されたものなので、NSDictionaryやNSKeyedArchiverがサポートしていないのでしょう。

 

通常のシリアライズが使えないと判明したものの、別な可能性を探るために、Anyの機能確認を続けてみました。

とりあえず、AnyとAnyObjectで、相互に代入を試しました。これが可能であれば、解決方法が幅広く考えられますので。試したのは、以下のSwiftコードです。

// AnyとAnyObjectでの代入
// 変数の準備
let iInt : Int = 5
let iAnyInt : Any = iInt
let iAnyObjInt : AnyObject = iInt
// 代入
let iAny : Any = iAnyObjInt          // ビルド時にエラー
//let iAnyObj : AnyObject = iAnyInt  // コンパイルエラー(エディタ上で表示)

コメント化した行が、コンパイルエラーが出た部分です。Any変数の値はAnyObject変数に代入できませんでした。テキストエディタへ入力した状態で、コンパイルエラーになりました。

もう片方の、AnyObject変数の値をAny変数に代入するコードは、エディタ上でエラーがでないものの、ビルドするときにエラーが出てビルドが失敗します。通常の書き方では、どちらの方向へも代入できないようです。

 

ここまでの仕様確認の結果から、どんどんと道が閉ざされていると感じます。いったん整理してみましょう。

まず、as演算子でIntなどのデータ型が正しく判定できるのは、Anyだけです。判定が必要なDictionaryでは、Anyを使うしか選択肢がありません。

続いて、Anyを含むDictionaryのシリアライズです。通常の方法では、コンパイルエラーが出て書けません。また、NSDictionaryへキャストしてもエラーが出ます。別な方法を用意して、ファイルに保存する必要があります。

その保存方法に使えないかと考えたのが、Any変数とAnyObject変数を用いた相互の代入です。しかし、どちらの方向への代入でも、エラーが出て使えませんでした。

 

おそらく次のような形なら、ファイルへの保存と復元が実現可能だと思います。

AnyObjectならシリアライズ可能なので、ファイル保存にAnyObjectを用います。ただし、いったんAnyObjectに入れてしまうと、データ型の判定ができません。データ型の情報もファイルへ保存して、Anyへ復元するときに利用するしかないでしょう。

AnyとAnyObjectは相互に代入できないので、通常のデータ型の変数へ代入してから、もう片方のAnyまたはAnyObjectへ代入する方法を使います。ファイル保存時に用いるAnyからAnyObjectへの代入では、Anyがデータ型を判定できるので、その判定結果を用いて、中間変数のデータ型を選びます。続く中間変数からAnyObjectへの代入では、何も判定する必要はありません。

ファイルからの復元時に用いるAnyObjectからAnyへの代入でも、通常のデータ型の変数を通します。AnyObjectではデータ型を正しく判定できないので、保存しておいたデータ型の情報を使って、中間変数のデータ型を選びます。この中間変数を経由してAnyへ代入すれば、データ型が判定可能なAny変数として復元できます。

Dictionaryは、キーと値が対になったデータ構造です。データ型の情報を、どのように保存するのか決めなければなりません。1つの実現方法としては、元のキーから自動生成できる別なキー(元のキーの後ろに決められた文字列を追加するとか)を使って、一緒に入れることも考えられます。しかし、これだとfor文で回すときに、本来のキーかどうかを区別する必要があり、処理が面倒になってお勧めできません。同じキーの別なDictionaryとして用意したほうが、変更への柔軟性が高まるでしょう。まあ、どんな方法を採用するにしても、あまり作りたくない処理ですね。

 

今回、AnyとAnyObjectの違いという話題で、シリアライズに関する機能確認に始まり、相互の代入も調べてみました。

最初に考えていたのとは違って、AnyとAnyObjectは細かな違いがありました。また、相互に代入できないのも驚きでした。せめて代入ができれば、両方の違いを上手に使い分けられるので、非常に残念です。何か別な方法で代入できれば良いのですが。

肝心の機能拡張機能の独自ライブラリですが、改良は延期です。今のままでも普通に使うのぶんには困りませんので、当分は今のままで使いたいと思います。もっとスマートな解決方法が見付かったら、改良するかもしれません。

 

(使用開発ツール:Xcode 6.1.1, SDK iOS 8.1)

2015年1月21日水曜日

AnyとAnyObjectの違い(is演算子編)

以前の投稿で、APIの仕様確認の話を書きました。SwiftやiOS APIのことを、まだ深くは理解していないので、少しずつですが仕様確認を進めています。ちなみに仕様確認とは、APIなどの記述が詳細まで含んでいないため(SwiftやiOSに限らず、一般的なことです)、詳細の動きを調べようと、実際のコードを動作させて確かめる作業のことを意味します。

AnyObjectの仕様確認を行なったとき、面白い結果が得られたので、もう少し実験してみました。AnyObjectと似たようなAnyも一緒に。そうしたら面白い結果が得られたので、ここに紹介します。

 

最初にAnyとAnyObjectの違いをネットで調べたのですが、曖昧な記述が多くて、それぞれの明確な仕様は得られませんでした。かろうじて得られた情報から、大まかには次にような感じだと思います。

AnyもAnyObjectも、いろいろな種類の型を入れられる汎用的なデータ型です。このうちAnyObjectは、クラスのインスタンスを入れるのが役割のようです。もう片方のAnyは、より広いデータ型を入れられ、関数も含むほとんどのデータ型が入れられるようです。

とは言うものの、AnyObjectにもIntなどのデータ型が入れられますし、そういった例が探せば見付かります。また、実際に試してもコンパイルエラーにならず、実行させても正常な様子で動きます。こうなると、違いがますます不明になりますね。

 

以前に紹介した開発例では、AnyObjectに入れたデータでの、is演算子での動きが期待どおりではありませんでした。そこで、同類であるAnyの動作結果も知りたくなり、実際に実行させて試したというわけです。

それぞれに入れるデータ型としては、Int、Float、Bool、Stringの4つを用意しました。それぞれのデータをAnyとAnyObjectの変数に入れて、is演算子での判定結果がどうなるかという試験です。具体的なSwiftコードは次のようにしました。

// AnyとAnyObjectで、is演算子の判定結果を調べる試験(配列)

// 変数の準備
let aryTypeName : [String] = ["Int", "Float", "Bool", "String", "Error!!!"]
let iInt : Int = 5
let iFloat : Float = 3.3
let iBool : Bool = false
let iStr : String = "test"
var aryAny : [Any] = [Any]()
var aryAnyObj : [AnyObject] = [AnyObject]()
aryAny.append(iInt)
aryAny.append(iFloat)
aryAny.append(iBool)
aryAny.append(iStr)
aryAnyObj.append(iInt)
aryAnyObj.append(iFloat)
aryAnyObj.append(iBool)
aryAnyObj.append(iStr)
// is演算子のテスト
println("Test:Any - (Int Float Bool String)\n")
for i in 0..<aryAny.count {
    let iBoolInt : Bool = aryAny[i] is Int
    let iBoolFloat : Bool = aryAny[i] is Float
    let iBoolBool : Bool = aryAny[i] is Bool
    let iBoolStr : Bool = aryAny[i] is String
    println("\(i) \(aryTypeName[i]) -- > \(iBoolInt) \(iBoolFloat) \(iBoolBool) \(iBoolStr) \n")
}
println("\nTest:AnyObject - (Int Float Bool String)\n")
for i in 0..<aryAnyObj.count {
    let iBoolInt : Bool = aryAnyObj[i] is Int
    let iBoolFloat : Bool = aryAnyObj[i] is Float
    let iBoolBool : Bool = aryAnyObj[i] is Bool
    let iBoolStr : Bool = aryAnyObj[i] is String
    println("\(i) \(aryTypeName[i]) -- > \(iBoolInt) \(iBoolFloat) \(iBoolBool) \(iBoolStr) \n")
}

難しい処理ではないので、見て簡単に理解できると思いますが、注意した点だけ書きます。

変数aryTypeNameでは、5番目の要素として、エラー表示の文字列を含めました。これを表示したときは何か間違っていることを意味し、異常終了させずに気付かせるのが目的です。異常終了が嫌いなので、このような作り方をよくやります。

AnyとAnyObjectへ入れる値は、データ型を確定させるために、型を指定した変数に入れました。こうすることで、間違いなくデータ型が確定できます。また、実際のコードでも変数に入れた値を代入することがほとんどでしょうから、実際のコードに近い条件で試験することにもなります。

AnyとAnyObjectの変数は、配列にしました。こちらも、配列の形で使うことが多いと思ったからです。もしかしたら、私だけかもしれませんが。

is演算子の判定テストでは、すべての組み合わせを実行しています。AnyとAnyObjectのそれぞれで、配列に入れた1つのデータに対して、is演算子で4種類のデータ型を順番に判定しました。配列の1要素ごとに、1行の実行結果が得られます。

println文には、実行結果が少しでも見やすくなるように改行を入れてあります。最初は入れてなかったのですが、試しに入れたらだいぶ見やすくなったので、この形で確定しました。

 

上記のコードを実行させて、得られた結果(コンソールへの出力)は次のとおりです。

Test:Any - (Int Float Bool String)

0 Int -- > true false false false 

1 Float -- > false true false false 

2 Bool -- > false false true false 

3 String -- > false false false true 


Test:AnyObject - (Int Float Bool String)

0 Int -- > true true true false 

1 Float -- > true true true false 

2 Bool -- > true true true false 

3 String -- > false false false true 

見てのとおり、AnyとAnyObjectでは違う結果となりました。

Anyに入れたデータは、is演算子の判定結果が期待どおりになっています。IntとFloatとBoolは区別され、一致しているときだけtrueを返します。つまり、データ型の判定が正しく行なわれているということです。

AnyObjectに入れたデータは、is演算子の判定結果が期待どおりではありません。IntとFloatとBoolは区別されず、それらのどの組み合わせでもtrueを返します。データ型が一致しているかではなく、データが代入可能かどうかで判定しているように見えます。

AnyとAnyObjectで、このような違いがあるとは想像もしませんでした。この結果を見たとき、正直、大きな驚きがありました。

 

結果は同じになると予想しますが、配列ではなくDictionaryに入れた場合も試しました。予想外のことが起こらないとは限りませんので、実際に試すことは大事ですから。Swiftコードは、次のとおりです。

// AnyとAnyObjectで、is演算子の判定結果を調べる試験(Dictionary)
// 変数の準備
let aryTypeName : [String] = ["Int", "Float", "Bool", "String", "Error!!!"]
let iInt : Int = 5
let iFloat : Float = 3.3
let iBool : Bool = false
let iStr : String = "test"
var dicAny : Dictionary<String,Any> = [String:Any]()
var dicAnyObj : Dictionary<String,AnyObject> = [String:AnyObject]()
dicAny[aryTypeName[0]] = iInt
dicAny[aryTypeName[1]] = iFloat
dicAny[aryTypeName[2]] = iBool
dicAny[aryTypeName[3]] = iStr
dicAnyObj[aryTypeName[0]] = iInt
dicAnyObj[aryTypeName[1]] = iFloat
dicAnyObj[aryTypeName[2]] = iBool
dicAnyObj[aryTypeName[3]] = iStr
// is演算子のテスト
println("Test:Any - (Int Float Bool String)\n")
for i in 0..<dicAny.count {
    let iBoolInt : Bool = dicAny[aryTypeName[i]] is Int
    let iBoolFloat : Bool = dicAny[aryTypeName[i]] is Float
    let iBoolBool : Bool = dicAny[aryTypeName[i]] is Bool
    let iBoolStr : Bool = dicAny[aryTypeName[i]] is String
    println("\(i) \(aryTypeName[i]) -- > \(iBoolInt) \(iBoolFloat) \(iBoolBool) \(iBoolStr) \n")
}
println("\nTest:AnyObject - (Int Float Bool String)\n")
for i in 0..<dicAnyObj.count {
    let iBoolInt : Bool = dicAnyObj[aryTypeName[i]] is Int
    let iBoolFloat : Bool = dicAnyObj[aryTypeName[i]] is Float
    let iBoolBool : Bool = dicAnyObj[aryTypeName[i]] is Bool
    let iBoolStr : Bool = dicAnyObj[aryTypeName[i]] is String
    println("\(i) \(aryTypeName[i]) -- > \(iBoolInt) \(iBoolFloat) \(iBoolBool) \(iBoolStr) \n")
}

基本的に、配列の場合のコードとほとんど同じです。入れ物をDictionaryに置き換えただけですから、コードの説明は不要でしょう。その実行結果は、次のとおりです。

Test:Any - (Int Float Bool String)

0 Int -- > true false false false 

1 Float -- > false true false false 

2 Bool -- > false false true false 

3 String -- > false false false true 


Test:AnyObject - (Int Float Bool String)

0 Int -- > true true true false 

1 Float -- > true true true false 

2 Bool -- > true true true false 

3 String -- > false false false true 

期待したとおりの結果で、予想外のことは何も起こりませんでした。AnyとAnyObjectでの判定結果は、配列の場合とまったく同じです。

 

実際に試したかったのは、これから先の処理です。Dictionaryに入れたデータをファイルとして保存し、AnyとAnyObjectで違いがあるのか見たかったのです。

以前に紹介した、環境設定機能のライブラリ化でも、Dictionaryの内容をファイルに保存しました。そのコードを、そのまま用います。DictionaryをNSDictionaryにキャストして、writeToFileメソッドで書き出すだけです。

書き出すためには、ファイルの保存場所を指定するパスが必要です。それも加えて、次のようなSwiftコードを、前のコードの後ろに追加しました。

// Dictionaryをファイルとして保存(前のSwiftコードの続き)
let iPathDocs : AnyObject = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true)
let iPathFile1 : String = iPathDocs[0].stringByAppendingPathComponent("dicAny.plist")
let iPathFile2 : String = iPathDocs[0].stringByAppendingPathComponent("dicAnyObj.plist")
//let iResult1 : Bool = (dicAny as NSDictionary).writeToFile(iPathFile1, atomically:true)
//if !iResult1 { println("ERROR:ファイル書き失敗:Any") }
let iResult2 : Bool = (dicAnyObj as NSDictionary).writeToFile(iPathFile2, atomically:true)
if !iResult2 { println("ERROR:ファイル書き失敗:AnyObj") }

Any側でファイルを保存する2行だけを、コメント行にしてあります。実は、コンパイルエラーが出たので、コメント化したわけです。もう片方のAnyObjectでは、コンパイルエラーも発生せず、実行も正常に終了しました。

Anyでだけ、コンパイルエラーが出たのですが、そのエラーメッセージが意味不明でした。おそらく、何か別な原因でエラーが出ているものの、1行の中に複数の命令が含まれているため、エラーメッセージが正常に出ないと推測しました。ということで、試しに次の行を挿入してみました。

// エラーメッセージを変えるために、前のコードでコメント化した2行の前に挿入
let iNSDic : NSDictionary = dicAny as NSDictionary  // コンパイルエラー

エラーメッセージが変わり、NSDictionaryへはキャストできないことが判明しました。どうやら、Anyを含むDictionaryは、NSDictionaryへキャストできないようです。AnyとAnyObjectでは、is演算子の判定結果だけでなく、いろいろな点で細かな違いがありそうですね。

コンパイルエラーになったので、目的の比較が出来ませんでした。別な方法でシリアライズすれば比較できるのですが、最近は忙しいこともあり、また別な機会にしましょう。

ここまで見てきた結果から推測すると、AnyとAnyObjectには、別な違いもありそうです。それを究明する確約は出来ませんが、やる可能性はあるということで、タイトルを(is演算子編)と括弧付きにしました。でも、あまり期待しないでください。正直、誰かが代わりにやってくれると助かります。

 

簡単な実験でしたが、AnyとAnyObjectで予想外の違いがあり、かなり驚きました。

そもそもAnyObjectには、IntやFloatが入れられないということ(本当?)なのに、コンパイルエラーになりません。何か特別に、自動変換して代入しているのでしょうか。変換して代入しているので、is演算子の判定結果が代入可能という形になっているのでしょうか。疑問は深まるばかりです。

AnyObjectのDictionaryは、XML形式のファイルとして保存できました。それを見る限りは、Intは「integer」型の値として、Floatは「real」型の値として、Boolは「true」または「false」のどちらかの値として、Stringは「string」型の値として保存されています。データ型の違いが、きちんと区別されて保存されているのが確認できました。それなのに、is演算子の結果は期待どおりになっていません。やはり、疑問は深まるばかりです。

原因は不明ですが、実行結果がこうなっている限り、それを受け入れて使うしかありません。入れたデータ型を明確に区別したい場合は、AnyObjectではなくAnyを使うしかないでしょう。

また、今回の実験結果から、仕様確認は非常に重要だと改めて感じました。これからも少しずつですが、SwiftとiOS APIの仕様確認を続けたいと思います。また何か発見したら、ここで紹介するつもりです。

 

ちなみに今回の実験も、いつものようにiOS実験専用アプリで行ないました。実験アプリで作るときは、通常ならラベルへ出力します。しかし今回は、コンソールへの出力を使いました。1回しか実行しないので、実行しながら結果を何度も確認する必要はありません。また、行数の多い処理結果に、ラベルは適しません。このような場合は、コンソールへの出力を使うことが多いです。複数行を表示できるTextAreaなどを用意するより簡単ですから。

今回の実験内容はアプリごと保存できるため、いつでも実行が可能です。XcodeやSDKのバージョンが変わる度に、試してみようと思います。

 

(使用開発ツール:Xcode 6.1.1, SDK iOS 8.1)

2015年1月19日月曜日

ファイル閲覧機能のライブラリ化(本編2+使用例編)

Documentsフォルダのファイル閲覧機能をライブラリ化する話の続きです。前回はTableViewだけを生成したので、残りのUI部品の生成を順番に取り上げます。

 

続きの最初は、ファイル内容を表示するTextViewの生成処理です。見出しテキストと文字数を表示する2つのラベルが付きます。ラベルが付きます。

これらの3つのUI部品は、自分で何か処理するわけではなく、TableViewを操作したときに値を設定されるだけの存在です。そのため、単に生成してUIViewに貼付けるだけです。具体的なSwiftコードは、次のようになりました。

// ================================================== ファイル内容を表示
var txvFileText: UITextView! // ファイル内容を表示
var lblFileSize: UILabel!  // 文字数を表示
 
private func setupTxtViewF() {
    // Label
    let iX : CGFloat = W_MARGIN_LEFT + W_TABLE_VIEW + W_MARGIN_CENTER + 5
    let iY : CGFloat = H_MARGIN_TOP
    let iWidth : CGFloat = 300
    let iHeight : CGFloat = H_LABEL_W_MARGIN
    let lblFileText : UILabel = createLblF("選択ファイルの内容", 18, ALIGN_LEFT, iX, iY, iWidth, iHeight)
    self.addSubview(lblFileText)
    // TextView
    let iX2 : CGFloat = W_MARGIN_LEFT + W_TABLE_VIEW + W_MARGIN_CENTER
    let iY2 : CGFloat = H_MARGIN_TOP + H_LABEL_W_MARGIN
    let iWidth2 : CGFloat = viewWidth - (W_MARGIN_LEFT + W_TABLE_VIEW + W_MARGIN_CENTER + W_MARGIN_RIGHT)
    let iHeight2 : CGFloat = viewHeight - (H_MARGIN_TOP + H_LABEL_W_MARGIN + H_BUTTON_MARGIN + H_BUTTON + H_MARGIN_BOTTOM)
    txvFileText = createTxtViewF("", 12, ALIGN_LEFT, iX2, iY2, iWidth2, iHeight2)
    self.addSubview(txvFileText)
    // Label(文字数の表示)
    let iX3 : CGFloat = viewWidth - (W_LABEL_CHAR_COUNT + W_MARGIN_RIGHT + 10)
    let iY3 : CGFloat = H_MARGIN_TOP + 5
    let iWidth3 : CGFloat = W_LABEL_CHAR_COUNT
    let iHeight3 : CGFloat = 20
    lblFileSize = createLblF("", 14, ALIGN_RIGHT, iX3, iY3, iWidth3, iHeight3)
    self.addSubview(lblFileSize)
}

他のUI部品と同じように、表示位置や大きさを4行で計算しています。それぞれのUI部品の生成は、独自ライブラリとして用意しているグローバル関数を使い、どれも1行で書いています。

 

続いて、処理終了やエラーを伝える、メッセージ欄のLabelです。これも同じで、次のようなSwiftコードとなります。

// ================================================== メッセージ欄
var lblMsg : UILabel!

private func setupMsgF() {
    let iX : CGFloat = W_MARGIN_LEFT
    let iY : CGFloat = viewHeight - (H_MARGIN_BOTTOM + 25)
    let iWidth : CGFloat = viewWidth - (W_MARGIN_LEFT + W_BUTTON_COPY + W_MARGIN_RIGHT + 10)
    let iHeight : CGFloat = 25
    lblMsg = createLblF("", 16, ALIGN_RIGHT, iX, iY, iWidth, iHeight)
    self.addSubview(lblMsg)
}

こちらも、他のUI部品と同じような形で生成しています。この次に紹介するコピーボタンの処理結果を、このメッセージ欄に表示します。

 

最後は、TextViewに表示したファイル内容を、クリップボードへとコピーするためのボタンです。ボタンを生成するための関数と、ボタンをタップしたときに呼ばれる関数の2つを用意します。次のようなSwiftコードで。

// ================================================== ファイル内容をコピー
var btnCopy: UIButton!
let psbFileCont : UIPasteboard = UIPasteboard.generalPasteboard()

private func setupBtnCopyF() {
    // ファイル内容をコピーするボタン
    let iX : CGFloat = viewWidth - (W_BUTTON_COPY + W_MARGIN_RIGHT)
    let iY : CGFloat = viewHeight - (H_BUTTON + H_MARGIN_BOTTOM)
    let iWidth : CGFloat = W_BUTTON_COPY
    let iHeight : CGFloat = H_BUTTON
    btnCopy = createBtnF("選択ファイルの内容をコピー", FONT_SIZE_BTN_S, iX, iY, iWidth, iHeight)
    btnCopy.addTarget(self, action:"copyFileTextF:", forControlEvents: .TouchUpInside)
    self.addSubview(btnCopy)
}
// 選択したファイルの内容をコピーする
func copyFileTextF(rSender:UIButton) {
    if enableCopy {
        psbFileCont.setValue(txvFileText.text, forPasteboardType:"public.text") // クリップボードへコピー
        // 完了メッセージを表示
        zBase.setMsgWTimerF(lblMsg, "クリップボードにコピーしました。", .OK, TIME_I_MSG_OK_S)
    } else {
        // 警告メッセージを表示
        zBase.setMsgWTimerF(lblMsg, "コピー可能なファイルを選んでください。", .Caution, TIME_I_MSG_CAU_S)
    }
}

ボタンを生成するsetupBtnCopyF関数では、他のUI部品と同じように、位置と大きさを計算してから生成関数を呼び出しているだけです。ボタンをタップしたときに呼び出される関数を設定している点だけが異なります。

呼び出される関数では、コピーできるファイルかどうかを調べて、可能ならコピーします。コピーの有無に関わらず、結果のメッセージを表示して知らせます。

メッセージの表示には、数秒後に自動で消えるメソッドを使っています。このメソッドは、共通ライブラリとして作ったもので、以前の投稿「数秒後に自動で消えるメッセージ表示」で紹介しました。消えるまでの時間も、メッセージの役割別に複数用意してあり、その中から選んで使っています。メッセージの表示には、このメソッドを使うので、メッセージ欄のLabelにアクセスする関数を別に用意してはいません。

 

ここまで紹介した全てのUI部品は、生成する関数の形で用意して、最初に取り上げた初期処理から呼ばれます。繰り返しになりますが、このような形式で作ることにより、ソースコードがUI部品ごとに区切られ、読みやすさが向上します。

今回作ったクラスのSwiftコードは、以上で終わりです。必要最低限の機能しか作らなかったので、短いソースコードとなりました。これだけでも実用的には十分ですし、iOSの将来の更新にも対応しやすく配慮してもあります。

UIViewの大きさも、ある程度までですが変更可能で、そこそこですが使いやすくできました。このクラスのインスタンスを生成して貼付ければ、Documentsフォルダの中身が見れるようになります。

 

続いて、このクラスの使い方を紹介します。使うのは非常に簡単ですから、手短に。インスタンスを生成してから、位置と大きさを設定して、使う画面に貼り付けるだけです。操作されたときの処理は、TableViewやボタンに付けられるので、生成後に設定する処理はありません。具体的には、次のようなSwiftコードになります。

// 今回のクラスを、アプリで使う例
...
// アプリの初期処理で、共通ライブラリのインスタンス生成
zBase = BaseFunc()      // 基本機能(メッセージなど)
zFiles = BaseFiles()    // ファイル管理機能を開始
...
// この機能を使うUIViewControllerクラス内で、インスタンスを生成して貼り付ける
...
let viewFileBrows = DocsFileView()       // インスタンス生成
viewFileBrows.setupF(50, 100, 900, 600)  // 初期化(表示位置とサイズを指定)
self.view.addSubview(viewFileBrows)      // 親Viewへ貼り付け
...

今回のクラスは、メッセージ表示やファイル処理で、他の共通ライブラリを使っています。それらのインスタンスを先に生成する必要があります。その生成処理は、アプリの初期処理で行ないます。

このクラスを使うのは、対象となる画面表示のUIViewControllerクラス内です。このクラスのインスタンスを生成して、自分のビューに貼り付けるだけです。

貼り付けた画面を使い終わったとき、UIViewControllerのインスタンスが開放されるともに、このクラスのインスタンスも開放されます。

 

大きさが可変のUIViewなので、作成後のテストでは、いろいろな大きさで表示させてみます。最小サイズで正しく動くかはもちろん、サイズを変えてもバランスが悪くならないかなど、主に使い勝手を試します。

もし使い勝手が悪いと感じたら、レイアウトの変更を考えなければなりません。どこを可変にするのか、計算式が複雑になりすぎないか、などを検討します。

ただし、このような機能は、アプリの本来の処理ではありません。何か問題があったときに調べる処理ですから、使い勝手が少しぐらい悪くても、できるだけ単純なアルゴリズムで作れることを重視すべきでしょう。トラブル時に使う処理がトラブったのでは、シャレになりませんので。

 

以上が、独自ライブラリとして作ったファイル閲覧機能クラスの紹介です。今後は最低限の記述だけで、どのアプリにもファイル閲覧機能が付けられます。

サイズの可変にする場合の注意点、レイアウトを想像しやすい定義値の書き方、親クラス自体の機能を拡張しないサブクラスでの配慮点など、作る際に考えていることをいくつか書きました。共感できた点があれば、自分のプログラムでも使ってみてください。

ここまで書き終えてから、改めて気付いた点があります。UIViewを継承しながら、UIView自体を何も拡張していないので、初期化処理としてinit()を付けませんでした。そのような考えは、何か残さないと気付きませんから、ソースコードにコメントとして残すべきですね。これを書き終わった後で、追加しておきます。

 

(使用開発ツール:Xcode 6.1.1, SDK iOS 8.1)

2015年1月14日水曜日

ファイル閲覧機能のライブラリ化(本編1)

Documentsフォルダのファイル閲覧機能をライブラリ化する話の続きです。前回は全体設計まで説明したので、今回は具体的なSwiftコードを紹介します。

 

今回のものは、UIViewのサブクラスとして作ります。また、UITableViewのデリゲートも必要なので、大枠の役目も持つクラスのSwiftコードは次のようになります。

// 今回作るクラス(今回の入れ物:BV_FileView.swift)
class DocsFileView : UIView, UITableViewDelegate, UITableViewDataSource {

}

入れ物だけですから、簡単ですね。

ちなみに、ファイル名は「BV_FileView.swift」としました。独自ライブラリ用の分類コードとして、今までにない分類「BV」の登場です。本当なら「BVC」なのですが、2文字で統一しているために「BV」と短縮しました。「B」で始まる分類コードなので、アプリ独自のSwiftコードではなく、各アプリに共通のライブラリ用Swiftコードだと、ファイル名だけで判別できます。

 

続いてクラスの中身です。最初に、クラス内には定義値を入れます。レイアウトに関係する、余白などの値を定義した変数です。

こうした変数の定義では「定義値の意味を上手に伝える」ことが非常に大事です。データの意味や関係が想像しやすいように、定義値の名前や並べ方などで工夫します。今回は、次のように並べてみました。

// レイアウトに関係する定義値

// 幅の値(左からの並び順)
let W_MARGIN_LEFT : CGFloat = 10     // 左余白
let W_TABLE_VIEW : CGFloat = 260     // TableView(固定幅)
let W_MARGIN_CENTER : CGFloat = 10   // 中央余白
// ここにTextAreaが入る(幅は可変)
let W_MARGIN_RIGHT : CGFloat = 10    // 右余白

// その他の幅の値
let W_LABEL_CHAR_COUNT : CGFloat = 100 // 文字数表示ラベル
let W_BUTTON_COPY : CGFloat = 260      // コピーボタン

// 高さの値(上からの並び順)
let H_MARGIN_TOP : CGFloat = 10        // 上余白
let H_LABEL_W_MARGIN : CGFloat = 30    // 見出しラベル
// ここにTableViewまたはTextAreaが入る(高さは可変)
let H_BUTTON_MARGIN : CGFloat = 10     // ボタン余白
let H_BUTTON : CGFloat = 30
let H_MARGIN_BOTTOM : CGFloat = 0      // 下余白

Wで始まるのが幅(Width)、Hで始まるのが高さ(Height)です。それぞれフルスペルにすると変数名が長くなるので、1文字だけにしました。このように同じ言葉が何度も登場する場合は、1文字に短縮したほうが、コード全体としては読みやすくなりますから。

1つ目のグループは、幅の値を左から右へと並べています。定義しない値もコメント行として挿入し、左右方向での全体構成を想像できるように配慮しました。この構成に含まれない幅の値は、2つ目のグループに入れてあります。

3つ目のグループは、高さの値を上から下へと並べています。こちらも上下方向での全体構成を想像できるように並べていて、定義しない値もコメント行として挿入してあります。

幅でも高さでも、コメント行として挿入した部分が可変する値です。それを示すために、コメント行に「可変」という言葉を入れて表しました。UIViewの大きさを変えると、この部分の値が変わるのだと理解しやすくするためです。

以上のように、全体像が想像しやすい形で定義値を作ると、わざわざ紙に書いて整理する必要がなくなります。ソースコードのテキストエディタ上だけで設計するためには、こうした工夫が欠かせません。同時に、余計なドキュメントが不要なソースコードとして仕上がるメリットも生まれます。

 

続いて、レイアウト関係以外の定義値と、クラスの変数を用意します。次のようになりました。

// クラスの定義値と変数

// TableViewセルのid
let ID_CELL : String = "bvfbCell"

// クラスの重要な値
var viewWidth : CGFloat!   // このViewの幅
var viewHeight : CGFloat!  // このViewの高さ
var aryFName: [String] = [String]()   // ファイル名の一覧
var arySuffix : [String] = ["txt", "plist", "strings", "rtf"] // 内容表示する拡張子の一覧
var enableCopy : Bool = false   // コピー可能なファイルならtrue

ファイル内容を表示するかどうかは、拡張子で判断すると決めました。該当する拡張子は、ここで配列に入れてあります。あまり深く考えていなくて、必要になった追加すればよいと思っています。

 

変数が用意できたので、今度はメソッドです。最初は、初期化から。

サブクラスとして作る場合、親クラスの初期化との関係を考慮して、いろいろと考えるでしょう。しかし、今回のような使い方では、事情が少し違います。親クラスのUIViewをそのまま使っていて、UIView自体には何の拡張も加えていません。UIViewを普通に使い、その上に別なUI部品を追加しているだけです。

このような使い方の場合、サブクラス内では、init()を使った初期化処理は行なわないほうが良いと思います。親クラスの初期化処理をそのまま実行させ、その後でUI部品を追加するという形が、もっとも安全でしょう。もし親クラスで初期化処理などに仕様変更があっても、何の影響も及ぼさないし何の影響も受けないはずですから。

というわけで、init()は作らずに、次のような形で初期化を用意しました。なお、今回の投稿では、私が普段付けている区切り用のコメント行も、ソースコードに含めています。

// ================================================== 初期処理
func setupF(rX:CGFloat, _ rY:CGFloat, _ rWidth:CGFloat, _ rHeight:CGFloat) {
    if (rWidth < 800) { println("ERROR:DFV_STUP:Viewの幅が不足"); return }
    if (rHeight < 500) { println("ERROR:DFV_STUP:Viewの高さが不足"); return }
    viewWidth = rWidth
    viewHeight = rHeight
    self.frame = CGRectMake(rX, rY, rWidth, rHeight)
    // メッセージ欄を作ってから、ファイル名一覧の取得
    setupMsgF()
    aryFName = zFiles.readFNameListF() // ファイル一覧を取得する
    if (aryFName.count < 1) { // ファイルがゼロ個なら終了
        zBase.setMsgWTimerF(lblMsg, "対象ファイルがゼロ個なので、この機能は使えません。", .Error, TIME_I_MSG_LL)
        return
    }
    // ファイル名一覧がOKなら、UI部品を生成
    setupTblViewF()
    setupTxtViewF()
    setupBtnCopyF()
}

処理全体を見ると、自分自身(UIView)の表示位置と大きさを指定して、必要なUI部品を生成しています。

一番最初は、自分自身の大きさのチェックです。最低限の大きさより小さい場合は、エラーメッセージを出して、UI部品を何も生成しません。画面上では空のUIViewが表示されるだけで、コンソールへのエラーメッセージでエラーを知ることになります。

続いて、ファイル名の一覧を得るのですが、その前にメッセージ欄だけは生成します。ファイル名の一覧が空なら(ファイルが1つもないなら)、他のUI部品を生成せずにメッセージだけ出して終了します。この場合は、コンソールにではなく、画面上でメッセージを出しています。そうする理由ですが、Documentsフォルダ内が空なのは、使い方のエラーではなく、あり得る状態だからです。その状態で正常なこともあり得るわけですから、ユーザーから見て状況が知れるメッセージを表示する必要があります。

ライブラリの使い方(プログラム上での使い方)のエラーなら、エラーメッセージはコンソールへ、その他のエラーなら画面上のメッセージ欄へ表示が、私の場合の基本です。

ファイルが1つでもあると確認できたら、UI部品を生成する処理(名前がsetupで始まる関数)を順番に呼び出します。それぞれの具体的な処理内容は後述します。

ファイル名の一覧を得るreadFNameListFメソッドは、ファイル関係の共通ライブラリです。ファイル関係の処理を集め、どのアプリにでも使えるように、独自ライブラリとして作ったものです。これ以降でも、ファイル関係の処理は、すべて同じライブラリのメソッドを使っています。具体的な処理内容は難しいものではないので、ここでは掲載しません。そういう処理が別に用意されていると考えて、処理内容を読み取ってください。

 

続いて、画面上に表示するUI部品の生成処理を1つずつ見ていきます。

その前に、どのUI部品にも共通する書き方を紹介します。それぞれのUI部品の最初には、区切り用のコメント行が付いていて、区切りを明確に表しています。区切りの先頭には、UI部品用の変数が入ります。その後に空白行を1行を入れて、初期化の関数が続きます。このパターンで作ると、UI部品ごとに変数と関数が集まり、ソースコードが見やすく整うからです。

では、最初のUI部品となるTableViewの生成処理を。次のようなSwiftコードになります。

// ================================================== ファイル一覧TableView
var tbvFName: UITableView!

private func setupTblViewF() {
    // Label
    let iX : CGFloat = W_MARGIN_LEFT + 5
    let iY : CGFloat = H_MARGIN_TOP
    let iWidth : CGFloat = W_TABLE_VIEW
    let iHeight : CGFloat = H_LABEL_W_MARGIN
    let lblFileList : UILabel = createLblF("作業場所のファイル一覧", 18, ALIGN_LEFT, iX, iY, iWidth, iHeight)
    self.addSubview(lblFileList)
    // TableView
    let iX2 : CGFloat = W_MARGIN_LEFT
    let iY2 : CGFloat = H_MARGIN_TOP + H_LABEL_W_MARGIN
    let iWidth2 : CGFloat = W_TABLE_VIEW
    let iHeight2 : CGFloat = viewHeight - (H_MARGIN_TOP + H_LABEL_W_MARGIN + H_BUTTON_MARGIN + H_BUTTON + H_MARGIN_BOTTOM)
    tbvFName = createTblViewF(iX2, iY2, iWidth2, iHeight2)
    tbvFName.registerClass(UITableViewCell.self, forCellReuseIdentifier:ID_CELL)
    tbvFName.delegate = self
    tbvFName.dataSource = self
    self.addSubview(tbvFName)
    // 最後のセルまでスクロールさせる
    let iRowCount : Int = tbvFName.numberOfRowsInSection(0)
    if (iRowCount > 0) {
        var pathLastRow : NSIndexPath = NSIndexPath(forRow: iRowCount - 1, inSection:0)
        tbvFName.scrollToRowAtIndexPath(pathLastRow, atScrollPosition: .Bottom, animated:true)
    }
}

どのUI部品でも、表示位置と大きさを指定します。そのための計算式では、単純に変数を代入するだけだったり、複数の変数を加算や減算して求めたりします。どの計算でも同じ形になるように、4つの変数をletで宣言して、その変数に代入する形式で書きました。このように書き方を統一することは、全体での読み取りやすさに繋がるでしょう。

また、4つの変数を使い回しせず、別な4つの変数をletで宣言する形にしています。もしvarで変数を宣言して、以降で使い回しすると、前側の変数を削除するときに、後ろ側も一緒に直さなければなりません。このような手間を生じさせないために、別々のlet変数を用いています。最近のコンパイラーは最適化が優秀なので、letで統一したほうが良いコードを生成しそうですし。まあ、大した差は生じないのですけど。

TableViewを生成して設定する処理では、特別に難しいことをしていません。独自ライブラリのグローバル関数createTblViewFを使ってTableViewを生成し、必要最低限の設定をしているだけです。

ファイル名一覧を生成した後、最後のセルまでスクロールさせています。ファイル名は日付を含んだりして、もっとも新しいファイルが一番下に来ている可能性が高いのです。表示した状態で、それが見えたたほうが使い勝手が良いので、この処理を加えています。なお、念のためにですが、セルの数がゼロでないかをチェックしています。

 

TableViewを使う際には、デリゲートに対応したメソッドを用意する必要があります。生成処理に続けて、次のようなSwiftコードを付けました。

// ================================================== TableViewのデリゲート
// Cellが選択されたとき
func tableView(rTbvFName:UITableView, didSelectRowAtIndexPath rIndexPath:NSIndexPath) {
    let iFName : String = aryFName[rIndexPath.row]
    if checkSuffixF(iFName) {
        let iStr : String? = zFiles.readFileDocsF(aryFName[rIndexPath.row])
        if iStr == nil {
            enableCopy = false
            txvFileText.text = "内部エラー:DFV_TV:選択ファイルの読み込みに失敗"
            lblFileSize.text = "文字数:不明"
        } else {
            enableCopy = true
            txvFileText.text = iStr
            lblFileSize.text = "文字数:" + String(countElements(iStr!))
        }
    } else {
        enableCopy = false
        txvFileText.text = "このファイルは、内容表示の対象外です。"
        lblFileSize.text = ""
    }
}
private func checkSuffixF(rFName:String) -> Bool {
    for iSuffix in arySuffix {
        if (rFName as NSString).pathExtension == iSuffix { return true }
    }
    return false
}
// Cellの選択が外れたとき
func tableView(rTbvFName:UITableView, didDeselectRowAtIndexPath rIndexPath:NSIndexPath) {
    // 将来の仕様変更のために用意(現在の処理は空)
}
// Cellの総数を返す
func tableView(rTbvFName:UITableView, numberOfRowsInSection rSection:Int) -> Int {
    return aryFName.count
}
// Cellに値を設定
func tableView(rTbvFName:UITableView, cellForRowAtIndexPath rIndexPath:NSIndexPath) -> UITableViewCell {
    let iCell = rTbvFName.dequeueReusableCellWithIdentifier(ID_CELL, forIndexPath:rIndexPath) as UITableViewCell
    iCell.textLabel!.text = aryFName[rIndexPath.row]
    return iCell
}

Cellが選択されたときの処理は、選択されたCellからファイル名を得て、登録されている拡張子かどうか調べます。該当する拡張子なら、ファイルを読みにいきます。正常に読めなければエラーメッセージを出し、正常に読めたらTextViewにファイル内容を、Labelに文字数を表示します。また、登録されている拡張子と一致しない場合は、TextViewへのメッセージで知らせます。

ここでは、TextViewやLabelへのアクセスで間接参照を利用せず、値を直接入れています。どちらのUI部品も、ここでしか使わないので、わざわざ間接参照を使いませんでした。このように小さな処理の場合には、間接参照を使わないこともあります。

拡張子を調べる関数checkSuffixFは、NSStringのpathExtensionを使っています。NSStringは機能が充実しているので、SwiftのStringをNSStringにキャストして使うことが多いです。

Cellの選択を外れたときのメソッドは、入れ物だけあって、中身の処理は空のままです。これを付けなくても構わないのですが、あとから仕様変更するときに、わざわざ調べて追加する手間をなくそうと、入れておきました。これを入れても、実害が出るほど遅くならないので入れています。当然ですが、空の状態であることをコメントで表しています。

 

長くなったので、ここで一旦区切ります。続きは、次の投稿にて。

 

(使用開発ツール:Xcode 6.1.1, SDK iOS 8.1)

2015年1月9日金曜日

ファイル閲覧機能のライブラリ化(全体設計編)

今まで作ったアプリには、Documentsフォルダ内のファイルを閲覧する機能を付けました。アプリで作成したデータや、環境設定ファイルなどを、Documentsフォルダに保存しているからです。ファイル閲覧機能があると、どんな状態なのか簡単に調べられ、トラブル発生時に役立つと考えたからです。その機能を最近になって独自ライブラリに加え、どのアプリにも簡単に追加できる形に仕上げました。それを何回かに分けて紹介します。

 

Documentsフォルダのファイル閲覧機能と言っても、作り方は様々です。私が作ったのは、画面の左側にTableViewを配置し、そこにファイル名の一覧を表示させます。その中の1ファイルを選択すると、画面の右側に配置したTextViewにファイルの内容が表示されます。全部を一度に表示できない場合でも、スクロール可能に設定してあるため、ファイルの内容は最後まで見れます。

表示されたファイルは編集できないものの、全体または部分を選択して、クリップボードへコピーする機能を付けています。選択機能はiOSに付属のものを利用して、選択した部分だけコピーする形です。さらに使い勝手を考慮し、ファイル全体をコピーするために専用ボタンも追加しました。

ちなみにクリップボードですが、iOS上ではペーストボードという名前です。しかし、開発したアプリのユーザーの多くがパソコンと併用しているため、アプリが表示する名前をクリップボードで統一してあります。これ以降の説明でもクリップボードで通しますで、ご注意を。

アプリがDocumentsフォルダに保存するファイルはテキスト形式だけなので、該当する形式のファイル名だけをTableViewに表示しています。その中の1つを選択すると、選ばれたファイルの内容をTextViewに表示するという形です。

このように単純な機能なのですが、アプリのDocumentsフォルダの状態が簡単に見れて、iPad実機で何か調べたいときに重宝します。実機のままだと、Documentsフォルダが簡単に覗けませんので。

 

ライブラリ化するために、よく考えて仕様を決める必要があります。まずは、設計方針からです。このような機能を作る場合、画面表示も含めて、いくらでも凝れます。しかし、凝れば凝るほど、iOSが何度も更新されるに従って、互換性が低下する可能性が増します。できるだけ長く、修正せずに済ませられるように、まったく凝らないという設計方針にしました。

具体的には、次のように配慮します。互換性が一番低下しそうなのは、UI部品でしょう。そこで、UI部品は素のまま使い、余計な機能は何も追加しません。TableViewの選択表示も素のまま使い、TextViewの選択機能もそのまま利用します。

実現する形としては、どの画面にでも貼付けやすくしたいので、UIViewのサブクラスとして作ることにします。そうすればUIViewが持つすべての機能が使えるとともに、余計な機能を作らなくて済むため、将来への互換性も高く維持できるはずです。

UIViewではなく、UIViewControllerとして作るという方法もあります。この2つの違いというか使い分けに関して、いろいろと調べましたが、納得できるほどの方法は得られていません。今のところ、画面全体を扱う場合はUIViewControllerを、画面の一部として貼付ける場合はUIViewを用いています。この使い分け方が、それぞれの本来の役割に近い形ではないかと思うからです。今回のものは画面の一部になるため、UIViewを選びました。

 

UIView形式で作るときに問題となるのが、表示する大きさに合わせたレイアウト変更です。指定されたサイズに合わせて、表示するUI部品の大きさも変更しなければなりません。こうしたレイアウトの表示ルールをきちんと決めておかないと、見た目や使いやすさなどが低下してしまいます。

ただし、どんな大きさを指定しても大丈夫に作るのは無理ですから、最初に最低の大きさを決めます。今回の場合は、あえて小さく表示する必要がないため、とりあえず幅が800で高さが500と仮決めしました。このような値は、作成後に使ってみて調整すれば良いでしょう。

次に決めるのが、要素の大きさと配置です。今回のUIViewは配置が決まってますから、各UI部品の大きさだけです。左側のTableViewは、幅を狭くすると長いファイル名が読めなくなるので、幅は固定としました。右側のTextViewの幅だけが可変です。縦方向は、TableViewもTextViewも同じ高さで、両方とも可変です。

その他のUI部品としては、右下にコピーボタンを付けます。TextViewで選択したファイル内容を、クリップボードにコピーするための機能です。さらに、コピーボタンの左側にメッセージ欄を加えます。コピーの成功など、操作結果を知らせるためのメッセージ表示に使います。

あと全体で、余白も付けることにしました。UIViewの境界ギリギリにUI部品などを表示させると、高解像度のモニターで描画が正しくない可能性もあるので、安全のために余白は必須だと思います。

 

境界ギリギリも含めて、描画については少し説明が必要でしょう。iOSの描画機能は、座標をCGFloatで管理し、最終的な表示装置に向けてビットマップ画像を生成します。これが、実は曲者なのです。

すべての線には、描く太さがあります。例として、太さ1(単位の名前は不明:CGFloatの値が1)の黒線を描く場合を考えましょう。描く座標の位置は、値が10の位置とします。解像度が2倍のレチナ・ディスプレイなら、2ピクセル幅の黒線として描かれます。

しかし、解像度が1倍の通常ディスプレイなら、1ピクセル幅の黒線ではなく、2ピクセル幅のグレー線として描かれます。こうなる理由は、描く座標の位置に深く関係しています。座標10の位置は、ディスプレイのピクセルの境目にあります。そこを中心に描くのですから、座標10の位置の両側に、幅0.5の線が描かれる結果となります。しかし、通常ディスプレイの解像度は1です。幅0.5の線は描けず、幅1のグレー線として描かれるわけです。幅0.5の線2本を描いた結果として、2ピクセル幅のグレー線になってしまいます。

逆に、解像度が2倍のレチナ・ディスプレイは、ピクセルの幅が0.5ですから、座標10の両側に幅0.5の線を描くのに適しています。結果として、2ピクセル幅(合わせた線の太さは幅1)の黒線が描かれるというわけです。

以上のように、ディスプレイの解像度の違いにより、また描く座標位置の違いにより、同じ線でも描かれ方は少しずつ変わります。さらに、これがUIViewの境界に接していたら、どうなるでしょうか。考えたくもないですね。描く線の幅にもよりますが、最低でも幅1以上の余白を確保するのが、極めて賢明な選択だと分かるでしょう。

実際には、後から線を太くする変更もあり得るため、その際でも余白の値を変更しなくてよい形に作るべきです。そのためには、もっと多くの余白を確保したほうが安全です。今回は、境界ギリギリまで描く必要がないため、キリのよい値として上下左右とも幅10の余白を付けることにしました。こうすれば、UIView境界の描画を気にする必要はありません。

 

次は、TableViewに表示するファイル一覧に関してです。今まではテキスト形式のファイルだけでしたが、ライブラリ化する以上、それ以外のファイルが存在する前提で考える必要があります。テキスト形式以外のファイルが含まれていても正常に動くのはもちろん、それらが存在することが分かるように作るべきでしょう。

ファイル名の一覧には、別な役割もあります。Documentsフォルダ内のファイル名一覧を表示することは、フォルダ内の状態を見せる意味もあります。表示できないファイルも含めて、フォルダ内にある全部のファイル名を表示したら、それはそれで何かの役に立つでしょう。

ということで、次のような仕様にしました。テキスト形式以外のファイル名も一覧には表示し、テキスト形式ファイルと明らかに分かっているファイルだけ、右側のTextViewに内容を表示します。テキスト形式かどうかは、ファイル名の拡張子で判断します。予め決められた拡張子のファイルだけで、ファイル内容を表示する形です。

 

もう1つ考えなければならないのは、ファイル名一覧の更新機能です。これまで作ったものは、更新機能がありません。表示したときに一覧を表示して、そのまま最後まで使います。

更新機能を付けないのには、理由があります。iOSアプリの場合は、ウィンドウを開いて使い終わったら、消してメモリーを開放するというのがマナーになっています。見終わったら削除し、再び見るときには新たに表示するので、更新機能が不要というわけです。今回も、そのマナーに従い、更新機能は付けないことにしました。

 

ここまでの検討で、全体の仕様が見えてきました。箇条書きで整理すると、次のようになります。

ファイル閲覧機能の大まかな仕様
・UIViewのサブクラスとして作る
・表示サイズをチェックして、最低値より小さいならエラー
・凝った機能は追加せず、UI部品を素のままで使う
・構成するUI部品は以下のとおり
 ・TableView:ファイル名の一覧で、1つを選択可能
 ・TextView:選択したファイルの内容を表示(テキスト形式のファイルのみ)
 ・Button:TextViewで選択したテキストを、クリップボードにコピー
 ・Label:各種メッセージの表示
・レイアウトに関して
 ・上下左右に幅10の余白
 ・横位置の計算:TableViewは固定、TextViewは可変
 ・縦位置の計算:TableViewもTextViewも可変
・実装する機能
 ・ファイル名の一覧を、TableViewに表示する(オープン時に自動で実行)
 ・TableViewで選んだファイルの内容を、TextViewに表示する
 ・TextViewの表示内容を、クリップボードにコピーする

今まで作ってきたクラスとは異なり、用意したメソッドを呼び出して動かす形ではありません。最初に表示した状態で、それぞれのUI部品に機能を割当てます。UI部品が操作されたときに、割り当てられた機能が実行されるという形です。

こうした形なので、仕様では「メソッドの一覧」の代わりに、「実装する機能」としてまとめました。

 

ここまで仕様が固まると、あとは作るだけです。しかし今回は、過去のアプリ内で作ったSwiftコードがあります。それを使えば、最初から作るよりは、はるかに簡単です。

UIViewのサブクラスとして作るわけですから、全体の大枠は変わります。でも、ソースコードの多くは、該当する箇所へコピーして使えました。仕様が変わった部分だけ手直しして、変更も意外に早く終わりました。いったん作ったものなので、構成を変えるだけだと簡単ですね。

次回の投稿では、そのSwiftコードを取り上げます。

2015年1月5日月曜日

当ブログの中心は、アプリ開発の設計視点

当ブログにおける今までの投稿をほとんど読んだ方なら、他のブログとは違った視点から書かれていることに、お気づきだと思います。今回は、その辺の話を少し書いてみます。

 

世の中には、ブログだけでなく雑誌も含め、プログラミングに関する多くの情報が出回っています。また、新しい情報が次々の加わってもいます。それらの情報をたくさん手に入れたら、アプリ開発が上手になるのでしょうか。

残念なことですが、非常に重要な要素が欠けています。アプリ開発において一番重要かもしれない、全体設計の話です。アプリの大まかな構造をどのようにして導きだすのか、どんな点に注意して設計すれば良いか、どのような視点でアプリを改良するべきか、といった話です。残念なことですが、そのような情報はほとんど(まったく?)出回っていません。

ちなみに、ブログや雑誌で扱っている主な情報を挙げると、次のようになります。

世の中に出回っている主な情報
・開発ツールの操作方法
・プログラミング言語の説明
・目的の機能を実現するためのソースコードと解説
・設計の小技(デザインパターンなど) 
・オブジェクト指向などの基礎的な考え方

これらに該当する話は、検索すれば数多く見付けられます。また、どんどんと新しい内容に更新されてもいます。しかし、より上位の設計の話は、ほとんどないのが実状です。

 

ソフトウェア設計の技というと、最近ならデザインパターンを思い浮かべる人が多いと思います。デザインパターンを短い言葉で表現するなら、オブジェクト指向ならではのクラス構成術というのがピッタリでしょう。

デザインパターンも、ソフトウェア設計には含まれますが、アプリ全体の設計を考えるレベルの設計術ではありません。もっと細かな機能を、オブジェクト指向的に作るためのパターンです。あえて最良と書かずに、オブジェクト指向的と書いたのは、それが別な視点で見たとき、最良の設計とは限らないからです(この辺の話は、機会があれば詳しく書きます)。

けっして、デザインパターンが不要だと言っているのではありません。それよりも上位の設計術が大事で、その設計内容に従った形で、デザインパターンが使われるということです。投稿「雑誌などのソースコードは再構成して使う」で書いたように、私の分類では3番目に位置するのがデザインパターンです。

変更への柔軟性や、再利用を重視したソースコードを作る視点(再掲載)
1. 機能の切り分け(インターフェースの標準化も含む)
2. 間接参照を多用する
3. オブジェクト指向の利点を利用する(デザインパターンも含めて)

この3段階の視点は、アプリ設計における重要な順番で並べてあります。このうちの上位2つが、このブログで中心に置いている視点です。

 

ただし、この上位2つの視点も、当ブログが重視している視点の結果でしかありません。もっと基本的な視点により、アプリ開発の重要点をノウハウ化しようと努力しています。具体的な視点を挙げると、次のようになります。

ブログで取り上げている重要な点
・どのような視点で設計しているのか?
・どんな点に注意しながら設計すれば良いのか?
・設計の質を高めるには、どうすれば良いのか?
・トラブルを未然に防ぐためには、どんな点に注意すべきか?
・メンテしやすい設計とは?
・ユーザーが安心して使える設計とは?

思いつくまま挙げたので上手に整理されていませんが、言いたいことは伝わると思います。全体としては、アプリ開発における、より上位の視点を集めたものと言えるでしょう。

 

視点だけが良くても、書く内容の質は高められません。やはり、書き方も重要です。できるだけ役に立つ内容に仕上げようと、次のような点を心掛けています。

書くときに注意している点
・どんな点を考えて設計しているのかを、一番重視して書く
・細かな考慮点も、できるだけ取り上げる
・考えられる選択肢を列挙して、判断基準や選択結果を示す
・仕方なく選択した点なども、その理由とともに判断過程を説明する
・最終的には、ソースコードとして仕上げる
・示したソースコードに仕上げた理由も、できるだけ説明する
・開発資料(API資料やサンプルコードなど)の問題点やその対処方法も取り上げる
・調べれば簡単に分かる内容(APIの基礎的な使い方など)の説明はできるだけ省く

すべて出来ているとは言えませんが、私としては頑張って書いているつもりです。

 

以上のようなことを考えながら、このブログを書いています。時間の制約もありますし、伝えるのが難しい内容だけに、どれぐらい書けているか(読み手にも影響を受けますが、どれぐらい伝わっているか)は、少し不安を持っています。それでも、書ける範囲内で書き続けていたいと思います。同じ方向性のことを書いている人が、ほとんどいませんので。

 

アプリ開発で何が大事かは、作る人によって違うでしょう。もし当ブログで書いてある内容に共感できるなら、役立つ情報(しかも他では得られにくい情報)が数多くあると思います。どんどんと吸収し、自分なりのノウハウとして改良しながら、活用してください。