スポンサーサイト

上記の広告は1ヶ月以上更新のないブログに表示されています。
新しい記事を書く事で広告が消せます。

AndroidにおけるSQLiteを使いやすくする Vol.2

【2011/12/01 追記】
ADT 14.0.0 からライブラリプロジェクトを参照している場合、リソースIDが static final ではなく、ただの static として生成されるようになったので、そのようなプロジェクトの場合はアノテーションでXMLのリソースIDを指定できなくなりました。次回バージョンアップ時に対応致します。バージョンアップ後は、Frontier_android からO/Rマッピングツール部分を FrontierDao という別ライブラリとして分離します。(O/Rマッピング機能だけ利用する場合は、FrontierDao のみをインポートすれば使えるようになります)
-------------------

前回の続きで、実際にO/Rマッピングツールを作成していくことになるのですが、ここで私は「iBATIS (MyBATIS)」とほぼ同じ設計でO/Rマッピングツールを作成しました。このO/Rマッピングツールのいいところは

・SQLを完全に制御できる (速度が遅くなりがちなSQLiteから見ると、ここは非常に重要)
・SQLとソースコードを完全に分離できるため、管理がしやすい
・条件の違いによって動的にSQLを組み立てて実行できる (同じようなSQL文を複数作成する必要がなくなるため、不具合も発生しにくい)

とはいえ、携帯アプリケーションであることを考えると、マッピングしたデータを全てメモリへ保持する(=メモリを大量に消費しがちな)iBATISをそのまま持ってくることは当然できません。完全に一から作成する必要があります。


じゃあ、どう作るんだ!?という話になるのですが、さすがにこればかりはBlogで少しずつ説明するのは規模が大きい内容ですので、すでに私の方で作成したライブラリをご提供させて頂きます。

[Frontier android library]
https://github.com/k-kou/Frontier_android


上記のライブラリ(frontier-android-x.x.x.jar)をプロジェクトにインポートしてご利用ください。
詳しいマニュアルはまだ殆どありませんが、一応上記のgithubにJavadocも同梱しておりますので、ちょっとした参考にはなるかと思います。
(Javadocはお手数ですがJARを解凍して御覧ください)


さて、それでは簡単な使い方をご説明します。


01. SQLiteデータベースを予め作成しておく

Android標準のSQLiteOpenHelperを利用する場合、自前でSQLを記述し、それをアプリ起動時に実行させてデータベースのテーブルを作るという方が多いかと思われます。とはいえ、この方法ではテーブル数が増えれば増えるほど大きな負荷になることは必至です。そこで、データベーステーブルは最初から作成し、assetsフォルダへ格納しておく方法を本ライブラリでは前提としています。

SQLiteデータベースファイルの作成には、私は以下のソフトを利用しています。

[SQLite Expert]
http://www.sqliteexpert.com/


このソフトを利用している理由というのが、使いやすいこともそうですが、なにより「データベースバージョンを予め設定しておける」からです。意外にも、これができるGUIソフトは有償・無償を含めて殆どなく、私が知る限りでは、SQLite Expert以外ではFirefoxプラグインの「SQLite Manager」くらいです。

本ライブラリではDBファイルに設定されたバージョンを見て自動的にバージョンアップ処理を行う仕組みがあります。そのため、このようにバージョンを設定できるGUIソフトを利用しましょう。

ちなみに、SQLite Expertは有料版もありますが、無料版でも機能的には十分です。ただしWindows専用なので、Macユーザの方はFirefoxプラグインの「SQLite Manager」を利用しましょう。


02. エンティティを作成する

エンティティのクラスを作成します。エンティティは、簡単にいえばアクセスするデータベースの構造をJava形式で表現したクラスです。

ここでは例として、以下のようなテーブルへアクセスすることを考えます。

-------------------------
CREATE TABLE [Memos] (
[pk_Memos] INTEGER NOT NULL ON CONFLICT ROLLBACK PRIMARY KEY AUTOINCREMENT,
[Text] VARCHAR(200),
[ImagePath] TEXT,
[ImageThumbnailPath] TEXT)
---------------------------


上記テーブルに対応する、エンティティのソースは以下のようになります。


/**
* メモエンティティ。
*
* @author Kou
*
*/
public class MemosEntity implements Serializable {


/**
* シリアルバージョンUID
*/
private static final long serialVersionUID = -8826714454635917736L;

/**
* メモID
*/
private Integer memosId;

/**
* メモ内容
*/
private String text;

/**
* メモ画像パス
*/
private String imagePath;

/**
* メモ画像サムネイルパス
*/
private String imageThumbnailPath;

/**
* メモIDを取得する。
* @return メモID
*/
public Integer getMemosId() {
return memosId;
}

/**
* メモIDを設定する。
* @param memosId メモID
*/
public void setMemosId(final Integer memosId) {
this.memosId = memosId;
}

/**
* メモ内容を取得する。
* @return メモ内容
*/
public String getText() {
return text;
}

/**
* メモ内容を設定する。
* @param text メモ内容
*/
public void setText(final String text) {
this.text = text;
}

/**
* メモ画像パスを取得する。
* @return メモ画像パス
*/
public String getImagePath() {
return imagePath;
}

/**
* メモ画像パスを設定する。
* @param imagePath メモ画像パス
*/
public void setImagePath(final String imagePath) {
this.imagePath = imagePath;
}

/**
* メモ画像サムネイルパスを取得する。
* @return メモ画像サムネイルパス
*/
public String getImageThumbnailPath() {
return imageThumbnailPath;
}

/**
* メモ画像サムネイルパスを設定する。
* @param imageThumbnailPath メモ画像サムネイルパス
*/
public void setImageThumbnailPath(final String imageThumbnailPath) {
this.imageThumbnailPath = imageThumbnailPath;
}

/**
* 各メンバ変数を文字列表現へ変換する
*
* @return 各メンバ変数の文字列表現
*/
@Override
public String toString() {
return "MemosEntity [memosId=" + memosId + ", historiesId=" + historiesId + ", text=" + text + ", imagePath="
+ imagePath + ", imageThumbnailPath=" + imageThumbnailPath + "]";
}

}


Serializableを実装しているのは、メンバ変数として保持したときに onSaveInstanceState で保存できるようにするためと、startActivityのときにIntentにパラメータとして設定できるようにするためです。


03. SQLマップを作成する

SQLマップとは、SQLを記述したXMLファイルのことです。
iBATISではこれをXMLファイルに記述していましたが、
Frontierライブラリにおいても、XMLへ記述します。

具体的な手順としては以下のとおりです。

1. プロジェクトに res/xml ディレクトリを作成する
2. 作成したディレクトリ内に空のXMLファイルを作成する (ここでは sql_memos.xml という名前で作成したこととします)

そして、以下の内容を記述します。


<?xml version="1.0" encoding="UTF-8"?>

<!-- メモ内容テーブル -->
<mapper namespace="Memos">

<!-- エンティティ取得 -->
<select id="getEntity">
SELECT
pk_Memos AS memosId,
Text AS text,
ImagePath AS imagePath,
ImageThumbnailPath AS imageThumbnailPath
FROM
Memos AS M
<dynamic prepend="WHERE">
<isNotNull prepend="AND" property="memosId">
M.memosId = #memosId#
</isNotNull>
</dynamic>
<isNotNull property="limit">
LIMIT #limit#
</isNotNull>
<isNotNull property="offset">
OFFSET #offset#
</isNotNull>
</select>


<!-- エンティティ数の取得 -->
<select id="getCount">
SELECT
IFNULL(COUNT(M.pk_Memos), 0)
FROM
Memos AS M
</select>


<!-- エンティティ登録 -->
<insert id="insert">

<!-- プライマリキー代入先を指定 -->
<selectKey keyProperty="memosId" />

<!-- 追加SQL指定 -->
INSERT INTO
Memos (
Text,
ImagePath,
ImageThumbnailPath
) VALUES (
#text#,
#imagePath#,
#imageThumbnailPath#
)

</insert>

<!-- エンティティ更新 -->
<update id="update">

UPDATE
Memos
SET
Text = #text#,
ImagePath = #imagePath#,
ImageThumbnailPath = #imageThumbnailPath#
WHERE
pk_Memos = #memosId#

</update>

<!-- エンティティ削除 -->
<delete id="delete">

DELETE FROM
Memos
WHERE
pk_Memos = #memosId#

</delete>

</mapper>


いきなり動的SQL記述が出てきてしまいましたので、
iBATISを使ったことがない方は戸惑われたかも知れません。
ひとまずは、iBATISと同じということでご了承ください。


04. DAOクラスを作成

ここで、データベースへアクセスするためのDAOクラスを作成します。
これも以下を参考にしてください。


/**
* メモDAOクラス。
*
* @author Kou
*
*/
@FRMappingXml(R.xml.sql_memos)
public class MemosDao extends FRDatabaseDao {


/**
* 全エンティティを取得する。
*
* @return 全エンティティ
*/
public List<MemosEntity> getAll() {

return getSqlMapper().selectForList(
"getEntity",
MemosEntity.class
);

}


/**
* 指定されたメモIDのエンティティを取得する。
*
* @param memosId 取得するエンティティのメモID
* @return 指定されたメモIDのエンティティ
*/
public MemosEntity getEntity(
final Integer memosId
) {

return getSqlMapper().selectForObject(
"getEntity",
MemosEntity.class,
new FRNameValuePair("memosId", memosId)
);

}


/**
* 指定されたエンティティを登録する。
*
* @param entity 登録するエンティティ
*/
public void insert(
final MemosEntity entity
) {

getSqlMapper().insert("insert", entity);

}


/**
* 指定されたエンティティを更新する。
*
* @param entity 更新するエンティティ
*/
public void update(
final MemosEntity entity
) {

getSqlMapper().update("update", entity);

}


/**
* 指定されたエンティティを削除する。
*
* @param entity 削除するエンティティ
*/
public void delete(
final MemosEntity entity
) {

getSqlMapper().delete("delete", entity);

}

}


DAOクラスは FRDatabaseDao クラスを継承し、FRMappingXmlアノテーションで、さきほど作成したSQLマップファイルのリソースIDを指定します。こうすることで、このDAOクラスは指定したSQLマップファイルと関連付けることが出来ます。

あとはもうiBATISと同じです。
SQLマップに書かれた ID を指定することで、そのSQLを呼び出したり、呼び出し時にパラメータを渡したりできます。

パラメータの指定を行う場合は、FRNameValuePairクラスを利用し、名前付きの値を渡します。ここで指定した名前が、実際にSQLマップへ渡される変数の名前となるわけです。

iBATISと少し違うのは、戻り値の型の指定はSQLマップでは行わず、DAOのコード上で行うということです。ここで指定した型クラスの各メンバ変数名に対応する情報がテーブルにあるかどうかを判定し、自動的に値を設定して返します。ここで、戻り値の型にIntegerなどのプリミティブラッパーが指定された場合は、変換可能な値であれば自動的に変換されて返されます。


05. DAOのインスタンスを取得してデータベースへアクセス

ここまででようやく準備完了です。
Memosテーブルへアクセスするためのコードを記述してみましょう。


//
// (context変数は Context 型の変数。Activity内であれば、thisを渡してもよい)
//
final String dbFileName = "test.db"; // 予め作成して、assetsフォルダへコピーしてあるDBファイル名

// DBファイルをアセッツからコピーする
// (アプリ起動時に一度だけ行えば良い)
FRDatabaseManager.copyDatabaseFromAssets(
context.getApplicationContext(),
dbFileName,
false
);

// デフォルトDBファイル名を設定する
// (アプリ起動時に一度だけ行えば良い)
FRDatabaseManager.setDefaultDatabaseName(context, dbFileName);


// メモDAOを取得する
// (DAOはローカル変数として作成すること。Activityのメンバ変数としては使ってはいけない)
final MemosDao dao = FRDatabaseManager.getInstance(context).getDao(
context,
MemosDao.class
);

// メモ一覧を返す
final List<MemosEntity> entities = dao.getAll();

// 内容を表示する
Log.d("Database Test", String.valueOf(entities));



DAOのインスタンスは上記のように、
FRDatabaseManagerクラスを利用して取得します。

まず最初に、FRDatabaseManagerクラスのcopyDatabaseFromAssetsメソッドを使用し、assetsフォルダから予め作成しておいたDBファイルをコピーします。次に、FRDatabaseManagerクラスのsetDefaultDatabaseNameメソッドでアクセスするDBファイル名を設定します。

この2つの設定をアプリ起動時に一回だけ行っておくことで、以降指定したDBファイルへのアクセスがデフォルトで行えるようになります。

その後、FRDatabaseManagerクラスのgetInstanceメソッドで、FRDatabaseManagerのインスタンスを取得し、そのインスタンスからgetDaoメソッドをコールすることで、DAOのインスタンスを取得します。もし自前で new をしてDAOを作成して利用したとしても、内部的にSQLマッピングが行われないDAOが作成されて例外が発生するようになっていますので、上記のように FRDatabaseManager を使ってインスタンスを生成してください。




以上で、簡単ですがFrontierライブラリによるデータベースアクセスを紹介しました。
このように、ライブラリを作成することでSQLiteは格段に扱いやすくなり、導入へのしきいも下がるかと思われます。

FrontierライブラリはApache License 2.0で公開しておりますので
ライセンスの範囲内で自由にご利用頂いても結構です。

機能面やマニュアルはこれから充実化していこうと思います。
スポンサーサイト

AndroidにおけるSQLiteを使いやすくする Vol.1

AndroidはSQLite3に対応しており、(ただし、外部キー制約がサポートされていないバージョン)
これを利用することでデータ管理の自由度が高まります。

Androidでは連想配列方式で手軽にデータ管理ができるプリファレンスというものがあるため、意外に使わないことも多いかも知れませんが、プリファレンスは大量のデータ管理には向いていないということと、RDBのようにトランザクション処理を行うことができないので、そのような管理を行いたい場合はやはりSQLiteを利用する必要が出てきます。

ただし、SQLiteは他のRDBと比べて非常に癖のあるシステムです。Oracle, MySQL, PostgreSQLと同じように使っていると大きくパフォーマンスに影響を及ぼす可能性が多々あります。

また、そもそもAndroidそのものがSQLiteは使いやすくしていません。JDBCで組んでいるのとそれほど大差がないため、ソースも乱雑になりがちで、保守もしにくいといった大きな問題を抱えています。

これについては、DB操作をしやすくしたよいフレームワークが存在しないことが一番大きいかと思われます。
一応ORMLiteというO/Rマッピングをサポートしたライブラリがありますが、これは専用のActivityを利用しなければいけなかったり、そもそもAndroidのライフサイクルを考慮しきれていなかったりして、使い勝手に問題があります。

というわけで結局自作!という話になっていくのですが、
これについては長くなるので次回以降に回すとして、
まずはAndroidにおけるSQLiteの問題点について洗いだしてみようかと思います。


01. INSERTやUPDATEが遅い

これはSQLite特有の仕様です。
SQLiteは他のRDBとは異なり、1つのファイルにデータを書き込むRDBです。
そして、INSERTやUPDATEを行うと、fsync()と呼ばれる関数が実行され
結果をファイルへ書き込みます。

これが1回であれば影響はありませんが、例えば10000回INSERTやUPDATEを行うとなると、10000回ディスクアクセスが発生するということになります。ディスクI/Oが大きなボトルネックとなることは皆様もご存知だと思われますが、まさにそのディスクI/Oが大量に発生するために遅くなっているわけです。

じゃあどうすればいい?ということですが、SQLiteにおいてはトランザクション中はこのfsync()が実行されないため、大量の書き込み処理を行う場合は常にトランザクション処理を行うようにします。

こうすることで、理論上はMySQLに近い、またはそれに匹敵する速度を出すことも可能になります。これは何も10000回などの複数回処理を行う場合だけでなく、1回のINSERT、UPDATEにおいてもトランザクション中に処理させることで速度を上げることが出来ます。

そのため、INSERTやUPDATEを行う場合は、常にトランザクション処理を行うようにしましょう。


02. SQLiteDatabaseクラスがイマイチ

Androidでデータベース処理を行う場合に、上記クラスを利用しますが、実はこいつがあまり良く出来たクラスではありません。具体的なポイントを挙げると

・JDBCっぽい組み方をしなければならず、O/Rマッピングツールを利用しがちなWeb系プログラマには相当な苦痛
・SQLiteのトランザクションを開始する beginTransaction メソッドが、「BEGIN EXCLUSIVE」固定となっているため、マルチスレッドプログラミングを利用していると「database is locked」みたいなエラーが発生することがある

どうにも一からすべて作り直したい衝動に駆られてしまいますが、そんなことをしたら大変なことになるので、使えるところだけは使うように上手くお付き合いします。

幸いにも、これらの問題は、O/Rマッピングツールの自作や、SQLiteDatabaseのラッピングをしてしまえば解決しますので、詳しい解決方法は次回以降にポイントを説明させて頂きます。


03. Cursorが遅い

Cursorとは SQLiteDatabase クラスで rawQuery メソッドなどでSELECT文を実行したときに、返却されたレコード1つ1つにアクセスするためのクラスです。これが遅いということは、大量のデータをSELECTすることには向いていないということにもなります。なので、大量のデータを取り出すときに UIスレッドでそのまま実行することはANR発生に繋がる問題となりますので、別スレッドで実行させるようにしたいところです。

とはいえ、プログラム的に問題なくてもUX(User Experience Design)的にはどうなのか?といわれると、何秒も待たされるのはやっぱり苦痛ですよね。なので、このあたりはアプリのUI設計がモノを言います。50件以上は「次のページ」ボタンを設けるなど、ある程度制限を掛けて少しずつユーザに見せていく方がUX的には満足度が高い傾向があります。

なので、「全レコードを一気に取り出す」なんてことはプログラム的に問題なくてもUX的には問題ありなのでオススメできません。LIMIT文やOFFSET文を利用して、約50レコードずつ表示していくようなUIにするように心がけましょう。




簡単ではありましたが、以上3つのポイントがSQLiteと付き合うためのちょっとしたポイントになります。
次回はこれらの問題を解決していくための、O/Rマッピングツールの作成についてご紹介します。

Activityのメンバ変数がクリアされてしまう現象への対策

Androidでは端末がメモリ不足になり、アプリがバックグラウンドにいる状態の場合、アクティビティのメンバ変数がクリアされてしまいます。

これはAndroidというものに長く触れている方であればご存知のことだと思います。しかし、そのクリアされるタイミングというものを端末によっては自発的に再現できなかったため、それを直に経験した人というのはさほど多くはありません。(端末によってはTask Killerを使うと再現可能)
そのため、「どのようなときにメンバ変数がクリアされ、どのようなタイミングで復帰すればいいのか」ということを意識してコーディングしていなかった人が多いのでしょう。前回の static 変数がクリアされる問題と合わせて、こういった問題に対する対処がないことがAndroidアプリケーションの品質低下に繋がっていると考えられます。

では、具体的に対処法について説明していきます。


01. Activityのメンバ変数にしてよい型には制限がある

以前も紹介したとおり、Activityは端末がメモリ不足になり、メンバ変数を保存しなければならないタイミングになると、onSaveInstanceStateメソッドがコールされ、復帰のタイミングになると onRestoreInstanceStateメソッドがコールされます。
これを利用することで、Activityのメンバ変数がクリアされる問題に対処することが可能になります。
(詳細は前回の記事へ)


しかし、ここで問題が生まれます。
onSaveInstanceStateメソッドとonRestoreInstanceStateメソッドは Bundle 型の変数に格納されるインスタンスしか保存&復帰できません。
そうなると、自然と以下の制約が生まれます。

Bundle変数に格納できない型をActivityのメンバ変数にしてはいけない

「まさかそんなバカみたいなことを・・・」と誰もが思うことでしょう。もちろん例外はあるのですが、AndroidはOSの設計上そのようになっているわけです。これこそが、「一般Javaプログラマが陥る最大の盲点」と言えるでしょう。


02. Activityのライフサイクルとメンバ変数の復帰

「Bundle変数に格納できない型はメンバ変数にしてはいけない」と上記で説明したましたが、実はそれ以外の型のメンバ変数であっても限定条件下で復帰することができます。その条件下というのは、「onCreateで初期化されてから変更されない値」です。

どういうことかというと、Activityがメモリ不足後に復帰する場合、onRestoreInstanceStateメソッドがコールされる前に、Activityの onCreate メソッドが実行されるのです。理屈としても、Activityがメモリ上から無くなっているわけですから、onCreateで再生成をしないといけないことがわかりますよね。

つまり、onCreateで初期化しているメンバ変数に関しては、メモリ不足後は onCreate で設定されている初期値に戻るわけです。「じゃあ onCreate で初期化さえすれば Bundle 型に格納できない型の変数でもActivityのメンバ変数にしてもいいじゃん!」って思うかも知れませんが、ここがちょっとした落とし穴です。

結局、「初期値」に戻るわけなので、作業途中の値は復帰されません。これが許されるのは、Activityに配置されたビューのインスタンスや、DBやプリファレンスから取得して設定した値くらいになります。しかし、これを判断できるのは実装者本人となってしまい、その実装者の力量によってはこの条件下に当てはまるメンバ変数型を正確に判断することは非常に難しいです。

そのため、私が仕事や趣味でアプリを作成しているときは「Bundle型に格納できない型をメンバ変数にするな」をルールとして推奨しています。ビューのインスタンスの取得は、findViewByIdメソッドで取得できますし、DBやプリファレンスについては基本的に Context のインスタンスさえあればどこからでも取得ができます。そのため、不都合が起きることは殆どありません。あるとすれば、BroadcastReceiverを扱う場合が考えられますが、それ以外にもしあるとすれば、それはプログラム設計や仕様に問題があるアプリケーションかもしれません。

Androidの仕様を正しく理解した上でプログラム設計や仕様決定を行うことで、高品質なアプリを作成することが可能になりますので、「Bundle型に格納できない型をメンバ変数にするな」のルールを取り入れてみましょう。


03. Activityメンバ変数の自動保存と復帰

さて、実際にこの対応を行う上で、アプリケーション規模によっては結構な問題が浮かび上がります。それは、

Activityが増えれば増えるほど、onSaveInstanceStateメソッドとonRestoreInstanceStateメソッドに保存&復帰処理を書かなければならない

5〜10画面程度ならまだしも、これが20〜50くらいになってくると話は変わってきます。しかも、Activityのメンバ変数というのは開発中に何度も変わっていきます。一度決まったらあとは変わらないものではなく、「いくらでも変わる可能性がある」という時点で修正漏れが発生する可能性が高いわけです。

実際、現場ではクラスの未使用インポートすらも残したままにする人が多いわけですから、保存&復帰処理の修正漏れなんて当たり前のように行う人がいることでしょう。更には、「Bundle型に格納できる変数」というものを、いったいどれだけの人が把握しているのか。こればかりは自動チェックする機構が現状ないので、日々レビューしていく他現状はありません。

というわけで、これを自動化してしまおうと思います。
Androidは幸いなことにCLDC準拠ではなく、Java SE準拠であるため、
リフレクションという機能を利用することができます。

では、実際にコードをみていきましょう。



/**
* アクティビティの共通機能を定義した抽象アクティビティ。
*
* @author Kou
*
*/
public class AbstractActivity extends Activity {


/**
* フィールドキー名フォーマットのクラス名とフィールド名の区切りトークン
*/
private static final String FIELD_TOKEN = ",";

/**
* フィールドキー名フォーマット
*
* 1$ = クラス名
* 2$ = フィールド名
*/
private static final String FIELD_KEY_FORMAT =
"%1$s" + FIELD_TOKEN + "%2$s";


/**
* 指定したパッケージを含むクラス名のクラスオブジェクトを取得する。
*
* @param name パッケージを含むクラス名
* @return クラスオブジェクト
* @throws IllegalArgumentException クラス名が null の場合
* @throws IllegalStateException 指定された名称のクラスがない場合
*/
private static Class<?> getClassObject(
final String name
) {

// nullの場合は例外
if (name == null) {

throw new IllegalArgumentException();

}


try {

// 指定名称のクラスオブジェクトを返す
return Class.forName(name);

} catch (final ClassNotFoundException e) {

throw new IllegalStateException(e);

}

}


/**
* 指定クラスの全スーパークラスを取得する。
*
* 指定クラスの全スーパークラスを取得し、一覧として返却する。
* 最上位のスーパークラスであるObject型を除いた全スーパークラスが返される。
*
* また、返却一覧へ指定した検索開始・終了クラスの情報を含めるかどうかを設定できる。
*
* @param startClass 検索開始クラス
* @param endClass 検索終了クラス
* @param includeStartClass 返却一覧へ検索開始クラスを含めるかどうか
* @param includeEndClass 返却一覧へ検索終了クラスを含めるかどうか
* @return 指定クラスの全スーパークラス
* @throws IllegalArgumentException 検索開始クラスが null の場合
*/
private static List<Class<?>> getSuperClasses(
final Class<?> startClass,
final Class<?> endClass,
final boolean includeStartClass,
final boolean includeEndClass
) {

// 指定クラスが null の場合は例外
if (startClass == null) {

throw new IllegalArgumentException();

}


final List<Class<?>> retClasses = new ArrayList<Class<?>>(); // 返却クラス一覧
Class<?> nowClass; // 現在のクラス


// 返却一覧へ検索開始クラス情報を含める場合
if (includeStartClass) {

// 現在のクラスに指定された検索開始クラスを設定できる
nowClass = startClass;

} else {

// 指定されたクラスのスーパークラスを設定する
nowClass = startClass.getSuperclass();

}


// 検索終了クラスが指定されていない場合
if (endClass == null) {

// スーパークラスが null
// または Object になるまで繰り返し
while ((nowClass != null)
&& !Object.class.equals(nowClass)
) {

// 一覧へ取得したスーパークラスを追加する
retClasses.add(nowClass);

// 現在のクラスのスーパークラスを取得する
nowClass = nowClass.getSuperclass();

}

} else {

// スーパークラスが null
// または 検索終了クラス
// または Object になるまで繰り返し
while ((nowClass != null)
&& !endClass.equals(nowClass)
&& !Object.class.equals(nowClass)
) {

// 一覧へ取得したスーパークラスを追加する
retClasses.add(nowClass);

// 現在のクラスのスーパークラスを取得する
nowClass = nowClass.getSuperclass();

}

// 返却一覧へ検索終了クラス情報を含める場合
if (includeEndClass) {

// 現在のクラスに指定された検索終了クラスを設定できる
retClasses.add(nowClass);

}

}


// 作成した一覧を返却する
return retClasses;

}


/**
* 指定クラスの全インスタンスフィールドを取得する。
*
* 指定クラスの非staticフィールドの一覧を取得する。
*
* @param clazz クラス
* @return 指定クラスのフィールド一覧
* @throws IllegalArgumentException クラスが null の場合
* @throws IllegalStateException フィールド取得失敗エラー時
*/
private static List<Field> getClassInstanceFields(
final Class<?> clazz
) {

// 引数が不正の場合は例外
if (clazz == null) {

throw new IllegalArgumentException();

}


try {

// 公開フィールドと非公開フィールドを取得する
final Field[] fields = clazz.getFields();
final Field[] declaredFields = clazz.getDeclaredFields();

// 返却一覧を作成する
final List<Field> retFields = new ArrayList<Field>();

// 公開フィールド分だけ処理をする
for (final Field field : fields) {

// staticの場合
if (Modifier.isStatic(field.getModifiers())) {

// 次のフィールドへ
continue;

}

// フィールドを返却一覧へ追加する
retFields.add(field);

}

// 非公開フィールド分だけ処理をする
for (final Field field : declaredFields) {

// staticの場合
if (Modifier.isStatic(field.getModifiers())) {

// 次のフィールドへ
continue;

}

// アクセス可能に設定する
field.setAccessible(true);

// フィールドを返却一覧へ追加する
retFields.add(field);

}

// 返却一覧を返す
return retFields;

} catch (final Throwable e) {

// 失敗のため例外を返す
throw new IllegalStateException(e);

}

}


/**
* 指定インスタンスの指定フィールド(公開フィールド対象)を取得する。
*
* @param instance インスタンス
* @param name 取得するフィールド名
* @return 指定インスタンスの指定フィールド
* @throws IllegalArgumentException インスタンスまたはフィールド名が null の場合
* @throws IllegalStateException フィールド取得失敗エラー時
*/
private static Field getInstancePublicField(
final Object instance,
final String name
) {

// インスタンスまたはフィールド名が null の場合は例外
if ((instance == null) || (name == null)) {

throw new IllegalArgumentException();

}

// インスタンスからクラスを取得する
final Class<?> localClass = instance.getClass();


try {

// 公開フィールドを返す
return localClass.getField(name);

} catch (final Throwable e) {

// 失敗のため例外を返す
throw new IllegalStateException(e);

}

}


/**
* 指定インスタンスの指定フィールド(publicフィールド対象)を取得する。
*
* @param clazz クラス
* @param instance インスタンス
* @param name 取得するフィールド名
* @return 指定インスタンスの指定フィールド
* @throws IllegalArgumentException インスタンスまたはフィールド名が null の場合
* @throws IllegalStateException フィールド取得失敗エラー時
*/
private static Field getInstanceDeclaredField(
final Object instance,
final String name
) {

// インスタンスまたはフィールド名が null の場合
if ((instance == null) || (name == null)) {

throw new IllegalArgumentException();

}

// インスタンスからクラスを取得する
final Class<?> localClass = instance.getClass();


try {

// 非公開フィールドを取得する
final Field retField = localClass.getDeclaredField(name);

// アクセス可能に設定する
retField.setAccessible(true);

// フィールドを返却する
return retField;

} catch (final Throwable e) {

// 失敗のため例外を返す
throw new IllegalStateException(e);

}

}


/**
* 指定されたインスタンスフィールドへ値を設定する。
*
* @param clazz 対象クラス
* @param instance 対象インスタンス
* @param name フィールド名
* @param value 設定する値
* @throws IllegalArgumentException インスタンスまたはフィールド名が null の場合
* @throws IllegalStateException メソッド取得失敗エラー時
*/
private static void setInstanceFieldValue(
final Class<?> clazz,
final Object instance,
final String name,
final Object value
) {

// 引数が不正の場合は例外
if ((instance == null) || (name == null)) {

throw new IllegalArgumentException();

}


Field field = null; // 取得したフィールド

try {

// 対象クラスがない場合
if (clazz == null) {

try {

// 指定インスタンスの公開フィールドを取得する
field = getInstancePublicField(instance, name);

} catch (final Throwable e) {

// 指定インスタンスの非公開フィールドを取得する
field = getInstanceDeclaredField(instance, name);

}

} else {

try {

// 指定クラスの公開フィールドを取得する
field = getClassPublicField(clazz, name);

} catch (final Throwable e) {

// 指定クラスの非公開フィールドを取得する
field = getClassDeclaredField(clazz, name);

}

}

// 値を設定する
field.set(instance, value);

} catch (final IllegalStateException e) {

throw e;

} catch (final Throwable e) {

throw new IllegalStateException(e);
}

}


/**
* 指定クラスの指定公開フィールドを取得する。
*
* @param clazz クラス
* @param name フィールド名
* @return 指定クラスの指定公開フィールド
* @throws IllegalArgumentException クラスまたはフィールド名が null の場合
* @throws IllegalStateException フィールド取得失敗エラー時
*/
private static Field getClassPublicField(
final Class<?> clazz,
final String name
) {

// 引数が不正の場合は例外
if ((clazz == null) || (name == null)) {

throw new IllegalArgumentException();

}


try {

// 指定フィールドを返す
return clazz.getField(name);

} catch (final Throwable e) {

// 失敗のため例外を返す
throw new IllegalStateException(e);

}

}


/**
* 指定クラスの指定非公開フィールドを取得する。
*
* @param clazz クラス
* @param name フィールド名
* @return 指定クラスの指定非公開フィールド
* @throws IllegalArgumentException クラスまたはフィールド名が null の場合
* @throws IllegalStateException フィールド取得失敗エラー時
*/
private static Field getClassDeclaredField(
final Class<?> clazz,
final String name
) {

// 引数が不正の場合は例外
if ((clazz == null) || (name == null)) {

throw new IllegalArgumentException();

}


try {

// 指定フィールドを取得する
final Field retField = clazz.getDeclaredField(name);

// アクセス可能を設定する
retField.setAccessible(true);

// 取得したフィールドを返す
return retField;

} catch (final Throwable e) {

// 失敗のため例外を返す
throw new IllegalStateException(e);

}

}


/**
* 指定されたObject型データを指定されたバンドルへ追加する。
*
* 指定された値の型を判定し、
* 適切な型であれば指定されたバンドルへ値が追加される。
* Bundleでサポートされていない型の場合は値は追加されない。
*
* @param bundle 追加先バンドル
* @param key 追加する値名称
* @param value 追加する値
* @return 値の追加に成功した場合は true
* @throws IllegalArgumentException バンドルまたは値名称が null の場合
*/
private static boolean putObjectBundle(
final Bundle bundle,
final String key,
final Object value
) {

// バンドルまたは値名称が null の場合
if ((bundle == null) || (key == null)) {

throw new IllegalArgumentException();

}


// Serializable型の場合
if (value instanceof Serializable) {

// Serializable型として追加する
bundle.putSerializable(
key,
(Serializable)value
);

// Bundle型の場合
} else if (value instanceof Bundle) {

// Bundle型として追加する
bundle.putBundle(
key,
(Bundle)value
);

// Parcelable型の場合
} else if (value instanceof Parcelable) {

// Parcelable型として追加する
bundle.putParcelable(
key,
(Parcelable)value
);

// Parcelable[]型の場合
} else if (value instanceof Parcelable[]) {

// Parcelable[]型として追加する
bundle.putParcelableArray(
key,
(Parcelable[])value
);

// その他
} else {

// 追加失敗
return false;

}


// 追加成功
return true;

}


/**
* メンバ変数の自動保存処理を行う。
*
* 本メソッドは、端末がメモリ不足などの理由でアプリをメモリ上に常駐できなくなった場合に実行される。
* 本来の Activity の仕様では各アクティビティごとにメンバ変数を保存する必要があるが、
* これは非常な手間が掛かり、かつ修正時における不具合の原因となりうる。
*
* そこで本クラスはアクティビティに定義されているメンバ変数を自動的に保存する。br>
* 自動保存できるメンバ変数は、プリミティブ型とそのラッパーと配列など
* Bundleクラスへの追加をサポートしている型変数のみとなる。
*
* @param outState 自動保存するメンバ変数の保存先 Bundle データ
*/
@Override
protected void onSaveInstanceState(
final Bundle outState
) {

try {

// 自クラスを含んだ全スーパークラス情報を取得する
final List<Class<?>> classes = getSuperClasses(
getClass(),
AbstractActivity.class,
true,
true
);

// 自クラスを含んだ全スーパークラス情報分繰り返す
for (final Class<?> nowClass : classes) {

// 全フィールドを取得する
final List<Field> fields = getClassInstanceFields(nowClass);

// 全フィールド分処理をする
for (final Field field : fields) {

// フィールドが final の場合
if (Modifier.isFinal(field.getModifiers())) {

// 次のフィールドへ
continue;

}

// パラメータを追加する
putObjectBundle(
outState,
String.format(
FIELD_KEY_FORMAT,
nowClass.getName(),
field.getName()
),
field.get(this)
);

}

}

} catch (final Throwable e) {

e.printStackTrace();

}

// スーパークラスの処理を実行する
super.onSaveInstanceState(outState);

}


/**
* メンバ変数の自動復帰処理を行う。
*
* {@link #onSaveInstanceState(Bundle)} メソッドで自動保存されたメンバ変数の
* 自動復帰処理を行う。
*
* @param savedInstanceState 自動保存されたメンバ変数を格納した Bundle データ
*/
@Override
protected void onRestoreInstanceState(
final Bundle savedInstanceState
) {

try {

final int indexClassName = 0; // クラス名インデックス
final int indexFieldName = 1; // フィールド名インデックス
final int indexCount = 2; // インデックス数

// 全キー名分処理をする
for (final String key : savedInstanceState.keySet()) {

// キー名を分割する
final String[] names = key.split(FIELD_TOKEN);

// キー名の要素がインデックス数と異なる場合
if (names.length != indexCount) {

// 次の要素へ
continue;

}

// クラス名とフィールド名を取得する
final String className = names[indexClassName];
final String fieldName = names[indexFieldName];

// 値を設定する
setInstanceFieldValue(
getClassObject(className),
this,
fieldName,
savedInstanceState.get(key)
);

}

} catch (final Throwable e) {

e.printStackTrace();

}

// スーパークラスの処理を実行する
super.onRestoreInstanceState(savedInstanceState);

}


}



ブログに載せるには少々長かったですが、このAbstractActivityクラスをアプリ内の全Activityクラスが継承して利用することで、メンバ変数の自動復帰が行えるようになります。ここに、型チェック処理などを入れて、非サポート型をメンバ変数に定義しているときに例外を出すようにすればより完璧になることかと思います。ただ、扱いには十分注意しましょう。


以上で、Activityのメンバ変数が突然クリアされる問題については終了です。static変数が突然クリアされる問題と合わせて対応することが非常に重要となります。高品質なアプリを目指すためにも、是非対応しましょう。

Androidでstatic変数が勝手にクリアされる問題と対処法

前回、Androidでは端末のメモリが不足するとstatic変数がクリアされたり、
Activityのメンバ変数がクリアされるという問題を紹介しました。

とはいえ、Object型変数であれば常に null になることを想定しなければならないとすると毎回 null チェックをしなければならないことになり、プログラムとしてもかなり可読性が悪いばかりか、不具合発生時に不具合を見逃す可能性も高くなります。

じゃあどうすればいい?ということになりますが、具体的な対処法の前に、まずはstatic変数がクリアされる理由について詳しく紹介します。


01. 端末がメモリ不足になってアプリを再アクティブにした場合、クラスロードから再度実行される

端末がメモリ不足になると、static変数やActivityのメンバ変数がクリアされると紹介しましたが、このとき、Javaとしてどのような動きをするかというと、クラスロードから再度実行されます

そもそもクラスロードとは何かということを簡単に説明しますと、「プログラムを実行する際、対象となるクラスデータをメモリ上に読み込むこと」を指します。そして、通常Javaでは一度クラスロードされたクラスはアプリケーションを再起動しない限りはメモリ上へ残り続けます。

しかし、Androidではメモリが少なくなるとこのクラスロードされたデータすらをもメモリ上から消し去ってしまうため、各クラスに保持された static 変数がクリアされてしまうというわけです。


では、実際に例を出します。
以下のクラスを見てください。


/**
* テストクラス。
*
* @author Kou
*/
public class Test {

/**
* static finalの文字列
*/
public static final String TEST_LABEL = "Test";

/**
* staticの文字列
*/
private static String testString;



/**
* テスト用文字列を設定する。
*
* @param str 設定する文字列
*
public static void setTestString(final String str) {

testString = str;

}

}


上記のクラスには、2つのstatic変数があります。
そして今、setTestStringメソッドを使って、"TEST String" という文字列を設定した状態とします。

それでは、Android端末がメモリ不足になった場合、
各々のメンバ変数はどのような値になるでしょうか?



正解は

・TEST_LABEL → "Test"
・testString → null

となります。


前述のとおり、Androidではメモリ不足になるとクラスロードが再実行されます。Javaの仕様としては、クラスロードが実行されると全てのstatic変数の初期化を行います。このとき、メンバ変数宣言箇所で直接値を代入していたら、その値が初期値となります。そのため、宣言時に代入処理をしている TEST_LABEL は "Test" という値になり、代入処理をしていない testString は null となるわけです。

通常のJavaではクラスロードは基本一度しか実行されませんが、Androidでは何度も実行される可能性があるために、このような現象が起きるというわけです。


02. static変数をAndroidで上手く利用するには、static finalを利用する

さて、Androidで static 変数が突然 null になる理屈はご理解頂けたかと思いますが、そうなると「Androidではstatic変数は使えないのか?」と思っている方もいらっしゃるかもしれませんが、そんなことはありません。

01でも説明したとおり、要はクラスロードされたときに元に戻っていれば問題ないのです。Javaでは static final で宣言した値は定数として扱われます。static変数に対して付けられた final という修飾子は、メンバ変数宣言時の代入もしくはstaticイニシャライザーでの代入を強制させるものですので、クラスロードされたときの初期値を明確に設定することが可能になります。(別に final を付けていなくても同じですが、final化することで代入漏れを防げるのと、最適化が効きやすくなるので final化することが推奨されます)



/**
* テストクラス。
*
* @author Kou
*/
public class Test {

/**
* static finalの文字列
* (static finalの値は必ず何かしらの値の代入を強制されるので、
* クラスロード時に意図した値を設定することが可能になる)
*/
public static final String TEST_LABEL = "Test";

/**
* 文字列検索マップ
*/
private static final Map<String, String> SEARCH_MAP = new HashMap<String, String>();

/**
* staticの文字列
*/
private static String testString;


/**
* staticイニシャライザー
* (ここで定義された処理はクラスロード時に実行されるので、
* キャッシュマップなどの再構築に利用出来る)
*/
static {

// 検索文字列をマップへ設定する
SEARCH_MAP.put("test", "てすと");

}



/**
* テスト用文字列を設定する。
*
* @param str 設定する文字列
*
public static void setTestString(final String str) {

testString = str;

}

}




しかし、現実的な問題として
例えばクラスのインスタンス数を static 変数に保持していたとしたら
その値もクリアされると 0 から開始されますので、メモリ不足になる前にどの値になっていたのかわからないために復元することが難しくなります。

そのため、前回も少し触れましたが、Androidにおいてはstatic変数をキャッシュ以外の目的で利用してはいけません。もう少しわかりやすくいえば、永続的なデータとして利用してはいけません。Androidではこの問題に対処するために、プリファレンスという仕組みが存在します。逆にいえば、そもそも static 変数が常に値を保持し続けられるのであれば、プリファレンスなどという仕組みは別に必要ないわけです。

今まで何気なくプリファレンスを利用していた方もいたかもしれませんが、このような背景を知っているのといないのとでは、プリファレンスとstatic変数に対する扱いも大きく変わってくるかと思います。


以上の情報をまとめると、


  1. static変数は永続的データとして(キャッシュ以外の目的で)利用してはいけない
  2. 永続データとして、アプリケーション開始〜終了まで一貫して値を保持し続けたい場合は、staticではなくプリファレンス(PreferencesやSharedPreferencesなど)を使う
  3. static final(定数化)したり、staticイニシャライザーを利用することで、クラスロード時にある程度データの再構築が可能になる


これでAndroidにおける static 変数の扱いについてはご理解頂けたかと思います。
次回は Activity におけるメンバ変数の保存・復元について詳しく説明したいと思います。

Android開発においての設計・開発の注意点

AndroidアプリケーションはJavaで記述します。
Javaは非常に柔軟なプログラミングが可能な言語で、登場した当初はガベージコレクションの際に一時停止する遅い言語というイメージでしたが、今やHotSpotを利用するとC/C++よりも速くなる場合があるという、登場当初からは想像もつかなかった言語に成長しています。

前振りはさておき、そんなJavaという言語を利用してAndroidではアプリケーションを開発しますが、AndroidのJavaは他のプラットフォームにおけるJavaとは大きく異なる点があります。


01. static変数はキャッシュ以外の目的で利用してはいけない
static変数はアプリケーションが起動してから終了するまで、
ずっと保持され続ける値というイメージがあるかと思います。
これを利用して、例えば予め重い画像処理においてカラーテーブルを作成して、
それをstatic変数に保持しておくといった手法はよく使われます。
そのため、Androidにおいてもこのような処理をついやってしまいがちな方が多いのですが、
実はこれが大きな落とし穴なのです。
Androidにおいては、端末のメモリが少なくなるとアプリで利用されているstatic変数がすべてクリアされてしまいます。

特にこれが危険なのが、Object型変数をstatic変数にしていた場合です。
Object型のstatic変数がクリアされた場合、その値は null となります。
今までのJavaの常識から考えると、一度値を設定したstatic変数の値はアプリが終了するまでずっと変わらないため、nullチェックを行わずにアクセスするようにプログラミングしてしまいがちですが、
これが元で、なんだかよくわからないけどNullPointerExceptionが発生するAndroidアプリケーションが出来上がってしまうのです。

この問題に対する対策としては、static変数をキャッシュ以外の目的で利用しないことです。
つまり、static変数はいつクリアされてもおかしくないという前提でプログラミングする必要があります。
もし従来のstatic変数と同様に、ずっと値を保持し続けておきたいのであれば、
プリファレンス(SharedPreferencesなど)を使いましょう。

また、手っ取り早くこの現象を再現したいのであれば
Task Killerアプリを使うことです。
端末によって出来たり出来なかったりしますが、以下の手順で再現できます。


  1. アプリ起動中にホームボタンを押下する
  2. Task Killerアプリを起動し、1で起動していたアプリだけをKillする
  3. ホームボタンを長押しして、起動中アプリ一覧を表示すると、1のアプリが表示されているのでこれを選択する
  4. static変数がクリアされた状態で、1のアプリが再開する


Android利用者には比較的Task Killerアプリがインストールしている方が多いため、
static変数の利用を前提とした設計で作られたアプリケーションがTask Killされてしまうと
何度もエラーで落ちてしまう可能性が高くなります。

このような現象を発生させないためにも
static変数を利用する際は、キャッシュ以外の目的で利用しないようにしましょう。


02. Activityのメンバ変数は突然クリアされる場合がある
実は端末のメモリが少なくなると、static変数の他にActivityのメンバ変数までAndroidはクリアしてしまいます。
端末のメモリが少なくなると、現在のアプリケーションの一部領域を残してすべてクリアしてしまうという話は、
もしかしたらみなさんも一度は聞いたことがあるかもしれません。
ただ、このActivityのメンバ変数が突然クリアされてしまうということを意識してプログラミングをしている人がどれほどいるのかというと、私の周りには殆どいませんでした。
これを意識してプログラミングをしないと、01でも述べたように「なんだかよくわからないけどNullPointerExceptionが発生するアプリ」を作りだしてしまうのです。

特にこの現象が発生しやすいのが、アプリ内からインテントでカメラアプリを起動して戻ってきた場合です。
カメラアプリはメモリを非常に多く使うため、カメラアプリで端末の殆どのメモリを利用してしまう傾向があります。
このとき、カメラアプリ起動元アプリへ処理が戻ったときに、ActivityにObject型メンバ変数が定義されているものなら、クリアされたメンバ変数へアクセスしてNullPointerExceptionが発生してしまうわけです。
カメラアプリを使わない場合でも、裏でどのようなアプリが動いているかは端末を利用しているユーザですらわかりませんので、いつメモリが不足してActivityのメンバ変数がクリアされてしまってもいいように作るしかないわけです。


では、どうすればこれを回避できるのかというと、
ActivityにあるonSaveInstanceStateメソッドとonRestoreInstanceStateメソッドをオーバーライドして利用することで回避できます。

端末がメモリ不足になると、Androidはメモリ内にあるアプリケーションの各アクティビティに対して
onSaveInstanceStateメソッドをコールします。
この onSaveInstanceState メソッドは Bundle 型の引数をもっており、
このBundle型の変数に保存された値は、Activityのメンバ変数やstatic変数がクリアされた場合でも
ずっとメモリに保持され続けるという特徴があります。
そして、アプリケーションが再度アクティブになったときに、onRestoreInstanceStateメソッドがコールされます。
この onRestoreInstanceState メソッドも Bundle 型の引数をもっており、
onSaveInstanceStateで保存された情報がそのまま与えられますので、
このメソッド内で、クリアされたActivityのメンバ変数情報を復元することが可能になるわけです。

以下に例を示します。



/**
* テスト用アクティビティ。
*
* @author Kou
*
*/
public class TestActivity extends Activity {


/**
* テスト日付データ
*/
private Date testDate;


@Override
protected void onActivityResult(
final int requestCode,
final int resultCode,
final Intent data
) {

long time = testDate.getTime(); // ←メモリ不足になった場合に、testDateが null になってしまう場合がある

}

}




/**
* テスト用アクティビティ。
*
* @author Kou
*
*/
public class TestActivity extends Activity {


/**
* テスト日付データ
*/
private Date testDate;



/**
* メンバ変数の保存処理を行う。
*
* @param outState 保存するメンバ変数の保存先 Bundle データ
*/
@Override
protected void onSaveInstanceState(
final Bundle outState
) {

// 日付を保存する
outState.putLong("testDate", testDate.getTime());

}


/**
* メンバ変数の復帰処理を行う。
*
* {@link #onSaveInstanceState(Bundle)} メソッドで保存されたメンバ変数の
* 復帰処理を行う。
*
* @param savedInstanceState 保存されたメンバ変数を格納した Bundle データ
*/
@Override
protected void onRestoreInstanceState(
final Bundle savedInstanceState
) {

// 日付を復元する
testDate = new Date(savedInstanceState.getLong("testDate"));

}


@Override
protected void onActivityResult(
final int requestCode,
final int resultCode,
final Intent data
) {

long time = testDate.getTime(); // ←メモリ不足になった場合でも、testDateが null にならない!

}

}



このように、Androidにおいては突然メモリがクリアされることを想定して
プログラミングをしなければならないのですが、
「Javaを知っていればなんとかなる」と前提知識を持たないまま参入する方が多かったり、先駆者たちもこの問題に対してはそれほど興味を持っていないのか各Webサイトでも取り上げなかったり、そもそもGoogle自体がこれほど通常のJavaとは異なるポイントを大々的に説明していないという点が重なったりと、結果として、不具合の多いアプリケーションがマーケットにたくさん溢れてしまう結果となっています。


とはいえ、「理屈はわかったが実際に組むとなると非常に大変」という局面も抱えています。次回は、どのような変数がクリアされ、またどのような変数がクリアされないのかについて詳しく説明していきたいと思います。
プロフィール

Author:Kou
モバイル関連の開発ばかりやってる人のブログです。たまにWebもやります。

最新記事
最新コメント
最新トラックバック
月別アーカイブ
カテゴリ
検索フォーム
RSSリンクの表示
リンク
ブロとも申請フォーム

この人とブロともになる

QRコード
QR
上記広告は1ヶ月以上更新のないブログに表示されています。新しい記事を書くことで広告を消せます。