[AS3] AIRでも使える! 初めてのMVC入門

2013/12/18

こんにちは。きんくまです。

この記事はAdobe AIR Advent Calendar 2013の18日目の記事です。

AIRの記事を書こうとしたのですが、AS3の話題でもギリギリセーフかなと思いMVC入門記事を書こうと思います。

一応、その前にAdvent CalendarのAIRの方も触れておきます。

AIRネタ

お仕事でモバイル(iOS/ Android)のAIRアプリを作ってます。
AIRはあんまり知らない人もいると思うので、基本的なことを書くと

デスクトップ(mac / Win) / モバイル (iOS / Android)向けに1ソースで作れます。
(実際は最適化の必要があるので、部分的な切り分けは必要です)

モバイルの場合は、デスクトップと比べてパフォーマンスを気にする必要があります。
何も気にしないと、すんごく遅く表示されて「何これ、使えね!」となります。

なので、まず表示リストをどちらでいくのか決めます

・従来のAS3 Display List(Sprite, MovieClip)
・Stage 3D

■従来のAS3 Display List

・これまでWebページでずっと使われてた
・激しい動きのないもの(ツール系など)に向いている
・レンダリングモードはGPUがいいかも
(CPU,Directモードはコンテンツによっては最適だったりするので、試してみて速かったらそちらにするという感じがいいと思います。)
・Flashのタイムラインを使った開発ができる

■Stage 3D

・GPUを使って描画を速くする
・ゲーム系に向いている
・レンダリングモードはDirect
・Flashのタイムラインを使った開発がしづらい
・レイアウトは基本プログラムで行い、テクスチャを作って絵を読み込む
・ライブラリは2D -> Starling. 3D -> Away3Dが有名

という感じです。Stage 3Dは早いのですが、その分開発に若干時間がかかります。あと、ツール系は苦手です。

で、従来の方だとツール系アプリを作るときに、UIまわりを自前で作ってもよいのですが、もしiOS, AndroidのネイティブっぽいUIを作りたいときは、ライブラリの候補としてMadComponentsがあります。
この間、何かいいのないかなーと探していたら見つけました。実際にちょっと前の端末のiPhone4(Sでなく)で動かしてみたところ、30fpsぐらいは出ていたので、1ソースでiOS/Android両対応のアプリを作りたいのだとしたら、現実的な落としどころだと思います。
正直Stage3DのUIを作るライブラリのFeathersは、あんまり好きになれなかったのですが、こちらはぱっと見の印象として頑張ってみようかなという気になりました。実際に使ってみた後、またブログに書こうと思います。

あ、そうです。iOSにはAppストアに出さずに、企業内の閉じた環境でだけアプリを配ることができるEnterpriseライセンスというものがあります。
この間、これを使って納品まで済ませましたので、そういった業務で使うアプリの開発も大丈夫です。

と、ここまでがAIRの話題でした。

MVCって何?

さてここからがMVCの話題です。

MVCというのは、クラスを切り分ける考え方です。
特にユーザーが何か入力イベントを起こす -> アプリのデータを変更 -> 結果を表示
みたいなアプリに向いています。

MVCはクラスをModel, View, Controllerの3つの役割に分けます。

mvc_l1_fig1

■ Model
データを保持します。View, Controllerの参照は持ちません。

■ View
ユーザーに画面を表示したり、ユーザーからの入力を受け付けます。
Modelのデータを描画したりします。(全てのModelのデータを描画する必要はないです)
WikiによるとModelのデータを参照して持つようです。
ただ、自分の場合ViewがModelのデータをプロパティとして保持し続けるのはイマイチかな?と思ってるので、Controllerにその役目を持たせています。なので、ViewはModel, Controllerの参照を保持し続けません。

■ Controller
ModelとViewのつなぎ役。両者をコントロールします。

と、ざっと書いたのですが、いきなりこんなの書かれても、わかんないかもしれないです。
ていうか、私は最初に見たときイマイチわかりませんでした。
なので、実例をあげて実際にコードを書いてみるとなんとなくわかってくるかもです。

あと、MVCはどこに何の役割まで持たせるのかをよく議論されるため、どれが本当に正しいMVCかなんてないと思います。
例えば言語やそのアプリの目的(ゲーム系やツール系やアート系やら)によりケースバイケースかと。
今回の場合も、私が考えてる、非常にベーシックなMVCの考え方だと思って、参考にしてもらえるとうれしいです。

具体的なコード例

今回はボタンを押すとその押した回数を表示するアプリを作ってみます。
できあがりイメージ。実際にうごきます。

mvc_l1_comp_img1

Modelを定義

今回はボタンを押した回数だけを記録するので、シンプルです。

CountData.as

package model
{
	import flash.events.Event;
	import flash.events.EventDispatcher;

	public class CountData extends EventDispatcher
	{
		private var _count:int;
		
		public function CountData(count:int = 0)
		{
			this._count = count;
		}
		
		public function countUp():void
		{
			this.count = this.count + 1;
		}
		
		public function set count(value:int):void
		{
			if(this._count == value){
				return;
			}
			this._count = value;
			dispatchEvent(new Event(Event.CHANGE)); //ココ!
		}
		
		public function get count():int
		{
			return this._count;
		}
	}
}

キモとなるのが、countの値をsetで設定したときにイベントを発行するところです。
今回はもとからあるEvent.CHANGEイベントを発行しましたが、
プロパティに

public static const MY_SUGOI_EVENT:String = "MySugoiEvent";

などと自分でイベント名を定義したり、カスタムイベントを別で作ってそれを、値変更時にdispatchすることも可能です。

ViewController

ViewControllerはControllerの一種です。Viewをコントロールします。
iOSのUIKitというフレームワークで使われていた名前で、気に入って使っています。
今回はViewというプロパティを持つだけでしたが、これにprotected、publicのメソッドを追加してテンプレート化して使うのもよいと思います。

各ViewControllerはこれを継承して使います。
継承しているのでviewという値を常に持ちます。なので、他から使いやすくなります。

package view_controllers
{
	import flash.display.Sprite;

	public class ViewController
	{
		public var view:Sprite;
		
		public function ViewController()
		{
		}
	}
}

入力用のViewとViewController

画面左上の+ボタンのViewとViewControllerです。

CountButtonView.as

 
package view_controllers
{
	import flash.display.Sprite;
	import flash.events.MouseEvent;
	
	public class CountButtonView extends Sprite
	{
		[Embed(source="/plus_button.png")]
		private static const PLUS_BUTTON_IMG:Class;
		
		public var plusButton:Sprite;
		
		public function CountButtonView()
		{
			super();
			setup();
		}
		
		private function setup():void
		{
			this.x = 10;
			this.y = 10;
			plusButton = new Sprite();
			plusButton.addChild(new PLUS_BUTTON_IMG());
			plusButton.buttonMode = true;
			this.addChild(plusButton);
			
			plusButton.addEventListener(MouseEvent.MOUSE_DOWN, plusButtonMouseDownHandler);
			plusButton.addEventListener(MouseEvent.MOUSE_UP, plusButtonMouseUpHandler);
		}
		
		protected function plusButtonMouseUpHandler(event:MouseEvent):void
		{
			plusButton.alpha = 1;
		}
		
		protected function plusButtonMouseDownHandler(event:MouseEvent):void
		{
			plusButton.alpha = 0.5;
		}
	}
}

+ボタンのViewは特にModelやControllerの参照をもっていません。おしている間だけ薄くなって表示するクラスです。

CountButtonViewController.as

package view_controllers
{
	import flash.events.MouseEvent;
	
	import model.CountData;

	public class CountButtonViewController extends ViewController
	{
		private var _countData:CountData;
		
		public function CountButtonViewController(countData:CountData)
		{
			this._countData = countData;
			init();
		}
		
		private function init():void
		{
			this.view = new CountButtonView();
			countButtonView.plusButton.addEventListener(MouseEvent.CLICK, plusButtonClickHandler);
		}
		
		private function plusButtonClickHandler(event:MouseEvent):void
		{
			_countData.countUp();
		}
		
		public function get countButtonView():CountButtonView
		{
			return this.view as CountButtonView;
		}
	}
}

コンストラクタにModelの参照を渡します。そして中で自分に関係あるViewを作成します。
ボタンが押されたらModelの値を書き換えます。
値が書き換えられたあと、どう表示されるのかはこのクラスは知りません。
ということは逆に言えば、どうやって表示されるかに変更が入ったとしても、このクラス自体に変更が入ることはないということです。
もちろん入力方法が変わった、例えばボタンを押すのではなくて、スライダーで値が変えるようになったというのであれば、このクラスを修正する必要があります。

出力(表示用)のViewとViewController

CountDisplayTextView.as

package view_controllers
{
	import flash.display.Sprite;
	import flash.text.TextField;
	import flash.text.TextFormat;
	import flash.text.TextFormatAlign;
	
	import model.CountData;
	
	public class CountDisplayTextView extends Sprite
	{
		private var _countTextField:TextField;
		
		public function CountDisplayTextView()
		{
			super();
			setup();
		}
		
		private function setup():void
		{
			_countTextField = new TextField();
			var tf:TextFormat = new TextFormat("_sans", 60, 0x333333);
			tf.align = TextFormatAlign.RIGHT;
			_countTextField.defaultTextFormat = tf;
			_countTextField.selectable = false;
			addChild(_countTextField);
		}
		
		public function update(countData:CountData):void
		{
			_countTextField.text = countData.count.toString(10);
		}
	}
}

Modelの値をTextFieldで表示します。他の参照は保持していません。
(updateのところで引数として一時的には持つ)

CountDisplayViewController.as

package view_controllers
{
	import flash.display.Sprite;
	import flash.events.Event;
	
	import model.CountData;

	public class CountDisplayViewController extends ViewController
	{
		private var _countData:CountData;
		private var _countDisplayTextView:CountDisplayTextView;
		
		public function CountDisplayViewController(countData:CountData)
		{
			this._countData = countData;
			init();
		}
		
		private function init():void
		{
			this.view = new Sprite();
			_countDisplayTextView = new CountDisplayTextView();
			_countDisplayTextView.x = 200;
			_countDisplayTextView.y = 180;
			view.addChild(_countDisplayTextView);
			
			this._countData.addEventListener(Event.CHANGE, countDataChangeHandler);
			updateView();
		}
		
		private function countDataChangeHandler(event:Event):void
		{
			updateView();
		}
		
		private function updateView():void
		{
			_countDisplayTextView.update(this._countData);
		} 
	}
}

Modelの値変更イベントを監視して、変更があったらそれをViewに伝えます。
updateでModelの参照を渡しているだけで、それがどう表示されるかはControllerは知りません。
ということは、Viewが赤い文字で表示することになっても、すんごく大きなフォントサイズになったとしても、このControllerにとってはどうでもいいことで、ModelとViewのパイプ役に徹していればよいのです。

これらをまとめるメインクラス

MVCLesson1.as

package
{
	import flash.display.Sprite;
	
	import model.CountData;
	
	import view_controllers.CountButtonViewController;
	import view_controllers.CountDisplayViewController;
	
	[SWF(width="480",height="480",frameRate="30",backgroundColor="#eeeeee")]
	public class MVCLesson1 extends Sprite
	{
		private var _countData:CountData;
		private var _countButtonViewController:CountButtonViewController;
		private var _countDisplayViewController:CountDisplayViewController;
		
		public function MVCLesson1()
		{
			init();
		}
		
		private function init():void
		{
			_countData = new CountData();
			
			_countButtonViewController = new CountButtonViewController(_countData);
			this.addChild(_countButtonViewController.view);
			
			_countDisplayViewController = new CountDisplayViewController(_countData);
			this.addChild(_countDisplayViewController.view);
		}
	}
}

各ModelとViewControllerを作ってまとめています。各ViewControllerが継承してくれているおかげで、特別そのクラスを調べなくてもviewをaddChildできています。

ここまでのまとめ

「なんだかボタン押した数を表示するだけなのに、すんごく面倒くさくなってるんですけどー!」というふうに思う場合もあるかと思います。
その意見はある意味で正しいです。実際このアプリにMVCはオーバースペックなのです。
ただ、もしこれが複雑に機能を追加していったらどうでしょうか? そのときにMVCにきりわけてあると、影響の出る範囲が非常に狭くなり、開発もやりやすくなります。そこがポイントです。

では、いまは数字で表したのですが、カウントが増えるごとに、円を描画するようにしてみます。

円を描画するようにする

完成イメージ。(実際に動作します)
+ボタン横のきりかえボタンで変わります。

mvc_l1_comp_img2

円を描画するView

CountDisplayCircleView.as

package view_controllers
{
	import flash.display.Graphics;
	import flash.display.Sprite;
	
	import model.CountData;
	
	public class CountDisplayCircleView extends Sprite
	{
		public function CountDisplayCircleView()
		{
			super();
		}
		
		public function update(countData:CountData):void
		{
			if(!stage){
				return;
			}
			var g:Graphics = this.graphics;
			var stgW:int = stage.stageWidth;
			var stgH:int = stage.stageHeight;
			var colW:int= stgW / 10;
			var rowH:int = stgH / 10;
			
			g.clear();
			for(var i:int = 0, len:int = countData.count; i < len; i++){
				g.beginFill(0x4DD2FF, 1);
				g.drawCircle(i % 10 * colW + 25, Math.floor(i / 10) * rowH + 120, 20);
				g.endFill();
			}
		}
	}
}

引数のCountDataに応じて円を描画します。

表示用のControllerを修正

ViewControllerは複数のViewをもつことが可能です。なので、さきほどの円を描画するクラスや表示切り替えボタンを中に持たせます。

CountDisplayViewController.as

package view_controllers
{
	import flash.display.Sprite;
	import flash.events.Event;
	import flash.events.MouseEvent;
	
	import model.CountData;

	public class CountDisplayViewController extends ViewController
	{
		[Embed(source="/flip_button.png")]
		private static const FLIP_BUTTON_IMG:Class;
		
		public static const DISPLAY_MODE_TEXT:String = "displayModeText";
		public static const DISPLAY_MODE_CIRCLE:String = "displayModeCircle";
		
		private var _countData:CountData;
		private var _countDisplayTextView:CountDisplayTextView;
		private var _countDisplayCircleView:CountDisplayCircleView;
		private var _flipButton:Sprite;
		
		private var _displayMode:String = DISPLAY_MODE_TEXT;
		
		public function CountDisplayViewController(countData:CountData)
		{
			this._countData = countData;
			init();
		}
		
		private function init():void
		{
			this.view = new Sprite();
			_countDisplayTextView = new CountDisplayTextView();
			_countDisplayTextView.x = 200;
			_countDisplayTextView.y = 180;
			view.addChild(_countDisplayTextView);
			
			_countDisplayCircleView = new CountDisplayCircleView();
			view.addChild(_countDisplayCircleView);
			
			_flipButton = new Sprite();
			_flipButton.addChild(new FLIP_BUTTON_IMG());
			_flipButton.x = 70;
			_flipButton.y = 10;
			_flipButton.buttonMode = true;
			view.addChild(_flipButton);
			_flipButton.addEventListener(MouseEvent.CLICK, flipButtonClickHandler);
			
			updateViewByDisplayMode();
			
			this._countData.addEventListener(Event.CHANGE, countDataChangeHandler);
			updateView();
		}
		
		protected function flipButtonClickHandler(event:MouseEvent):void
		{
			if(_displayMode == DISPLAY_MODE_TEXT){
				_displayMode = DISPLAY_MODE_CIRCLE;
			}else{
				_displayMode = DISPLAY_MODE_TEXT;
			}
			updateViewByDisplayMode();
		}
		
		private function updateViewByDisplayMode():void
		{
			if(_displayMode == DISPLAY_MODE_TEXT){
				_countDisplayTextView.visible = true;
				_countDisplayCircleView.visible = false;
				
			}else if(_displayMode == DISPLAY_MODE_CIRCLE){
				_countDisplayTextView.visible = false;
				_countDisplayCircleView.visible = true;
			}
		}
		
		private function countDataChangeHandler(event:Event):void
		{
			updateView();
		}
		
		private function updateView():void
		{
			_countDisplayTextView.update(this._countData);
			_countDisplayCircleView.update(this._countData);
		} 
	}
}

最後のまとめ

今回値に応じて円を表示するように仕様を変更しました。
その際修正をしたクラスは、表示用のControllerと新規に作成したCountDisplayCircleViewの2つだけでした。
文字で表示するCountDisplayTextViewも変更はありませんでした。
またその他のModelや入力用のViewやViewControllerにも全く影響がでませんでした。
なので、他のことを気にすることなく、開発をすることができました。

あと今回はViewとViewControllerを入力用と出力用にわざわざわけました。
が、規模に応じてひとつのViewやViewControllerの中で入出力の機能を含んでもよいと思います。ViewとControllerの機能だけはしっかり切り分けるとして。

Java言語で学ぶリファクタリング入門という本に書いてあったのですが、「クラスを分ける粒度は、分けるクラス数が多すぎては管理が大変になるし、少なすぎてはクラスの責任が重くなりすぎてソースを理解するのが大変」ということでした。なのでバランスよく分けたいです。

あとは画面遷移をともなう場合、タイトル画面、編集画面、ゲーム画面などは、それぞれにViewとViewControllerをもたせてあげるときりわけがスッキリすると思います。

次回はもう少し実践的なTodoアプリを作ってみたいと思います。

>> 今回のプロジェクト一式です。ご参考まで。


自作iPhoneアプリ 好評発売中!
フォルメモ - シンプルなフォルダつきメモ帳
ジッピー電卓 - 消費税や割引もサクサク計算!

ページトップへ戻る