AndroidでMVPするときのPresenterの責務
Androidのクラス設計でMVPやMVVMが流行っています。
自分もMVPのクラス設計が導入しやすく好きなのですが、実際にやろうとすると何をPresenterに書き何をView(ActivityやFragment)に書くべきなのか指針がなくて
なんとなくMVPになることが多いです。そこで個人的にどういう風にMVPに分けるべきかメモしておきます
個人的に注意するポイントは以下の三点です
- PresenterにViewの具象クラスを引き数として与えない
- PresenterにAndroid特有のクラスを引き数として与えない
- Presenterはテスタブルな状態にする
PresenterにViewの具象クラスを引き数として与えない
個人的にNGなコードは以下のようなPresenterが出てきたときです
class MainViewPresenter(val mainView: MainActivity) { fun doSomething() { } }
PresenterのコンストラクタでActivityやFragmentをそのまま受け取るようなパターンです。
PresenterがガッツリViewと依存してしまいますし、AcitivityやFragmentを受け取ることでテストコードでmockにしにくいし、これをやってしまうとView側でできることとPresenter側で出来ることの境目がなくなってしまいます
FragmentからfindViewByIdすればレイアウトは操作できてしまうし、Contextを取り出せばやりたいほうだいです。すべてのロジックをPresenterに書けてしまいまうためチームメンバーによって何を書くかバラバラになってしまいます。
ViewとPresenterはInterfaceを通して余計なことができないように縛りましょう
interface MainViewable{ fun bindUserData(user:User) }
//MainViewableのInterfaceを渡すことで余計なことをさせない class MainViewPresenter(val mainView:MainViewable) { fun doSomething() { } }
class MainActivity : AppCompatActivity(),MainViewable{ val presenter:MainViewPresenter by lazy { MainViewPresenter(this) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) presenter.doSomething() } override fun bindUserData(user:User) { //Viewにデータを反映させる } }
PresenterにAndroid特有のクラスを引き数として与えない
例えばこういうPresenterです。
class MainViewPresenter(val context: Context,val pref:SharedPreferences) { fun doSomething() { } }
ContextやSharedPreferencesなどAndroid特有のクラスを渡してしまうものです。
これをやってしまうとActivityやFragmentを引き数として渡すのと同じでUnitテスト時にmockを差し込みにくくなります。
またContextを渡してしまうと上記で書いた通りPresenter側でほぼなんでも出来るようになってしまうのでViewとPresenterの責務が曖昧になりがちです。
SharedPreferencesなどはそれをラップしたクラスを作ってそれを渡すといいでしょう。Contextはそもそも渡さないようにしましょう。
Contextが必要なクラスが多くあると思いますが、それは上記のSharedPreferenceServiceのようにContextを扱えるView側でクラスを生成しそれをPresenterへ引き数として渡すようにしましょう。
class SharedPreferencesService(private val context: Context) { val pref: SharedPreferences by lazy { context.getSharedPreferences("sample", Context.MODE_PRIVATE) } fun apply(key: String, value: String) { pref.edit().putString(key, value).apply() } }
//ラップしたクラスにすることで簡単にmockにかえJUnitを回しやすくなる class MainViewPresenter(val context: Context,val pref:SharedPreferencesService) { fun doSomething() { } }
Presenterはテスタブルな状態にする
基本的にPresenterにAndroid特有の関心事を持ち込まなければJUnitによるテストが実行可能になっているはずです。
Presenterは高速にテスト回せる状態にし、ActivityやFragmentはAndroidUnitTestによる少し重めのテストという風に住み分けします。
PresenterがJUnitによるテストコードを回せなくなったとき、一度立ち止まって本当にPresenterでやるべき処理か立ち止まるいいきっかけになります。
ソースレビューするときもレビュー観点としておくことで、メンバー間によるプログラムの差異を減らせると思います。
最後に
そもそもMVPってAndroidのコードがFragmentやActivityにビジネスロジックとViewの操作が集中してマッチョになってしまうのでビジネスロジック部分を分けたいという欲求から生まれたものなはずなのに
そのビジネスロジック部分にFragmentやContextが出てきてマッチョになっていくのは何か違う気がすると思います。なんのためのPresenterなのか?それを一度考えましょう
ちなみにadapterの操作をどこで行うか?というのが悩みどころですが、あれもAndroid特有のクラスなので自分は「View側で操作する」でいいと思います。
sharedPreference同様にapdaterをラップしたクラスを用意しそこで操作するようにすればPresenterに渡してもいいかもしれません。
ものすごく雑にまとめたので実際のプロダクトでやるともしかしたらうまくいかない部分が出てくるかもしれませんが、基本方針としてPresenterをJUnitでテスタブルな状態にするということを守っていれば
クラス間の責務は適切にわけられているのではないでしょうか?
その他にMVPの責務の分け方の考えなど意見がある方は是非教えてください。自分もこれが最適とは思っていないので……
Androidのクラス設計ってなんでこんなに難しいんでしょうかね。
capistranoでcomposer installしてからrsync
capistranoでphpのプロジェクトをdeployしようとして
通常のdeploy方式だとdeploy先のサーバーでgit pullしてからcomposer installになってしまうがそうではなくて
deployサーバーでgit pullしてcomposer installしてそれをdeploy先のサーバーにrsyncでファイルを送りたかった
ググったらすぐに見つかるかなぁと思っていたが思いの外ピンポイントな情報がなかったのでメモ
capistrano-bundle_rsyncを使います GemFileの内容としてはだいたいこんな感じ
source "https://rubygems.org" gem 'capistrano', '3.8.1' gem 'capistrano-rbenv' gem 'capistrano-bundle_rsync'
bundle install –path vendor_bundle でbundle installしましょう。pathを指定しないとデフォルトでvendorになるので、phpのcomposerのvendorと被って余計なものがデプロイされてしまいます。なのでbundleではインストールパスを指定して後でそのパスはrsyncから外すようにします。 後はcapistrano-bundle_rsyncのREADMEに従ってCapfileやconfigファイルを設定します
コピペですがCapfileは
# Load DSL and Setup Up Stages require 'capistrano/setup' # Includes default deployment tasks require 'capistrano/deploy' # capistrano-3.3.3 - 3.6.1 require 'capistrano/bundle_rsync' # capistrano-3.7+ require 'capistrano/bundle_rsync/plugin' install_plugin Capistrano::BundleRsync::Plugin # Loads custom tasks from `lib/capistrano/tasks' if you have any defined. Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r }
config/deployにvagrantにdeployする想定のlocal.rbを作ります
server "vagrant" set :stage, :local role :web, "vagrant"
問題のdeploy.rbです
# config valid only for current version of Capistrano lock "3.8.1" set :branch, ENV['BRANCH'] || 'master' set :keep_releases, 2 set :scm, :bundle_rsync set :application, "sample" set :repo_url, "gitrepo" set :rsync_options, '-az --delete --delete-excluded --exclude=vendor_bundle' set :deploy_to, "/var/www/html/example" # rsync前にcomposer installする task :pre_rsync_action do config = Capistrano::BundleRsync::Config run_locally do execute ("cd #{config.local_release_path} && php composer.phar install --no-dev") end end before "bundle_rsync:rsync_release", "pre_rsync_action"
rsync_optionでbundle installしたディレクトリはrsyncから外しています。
capistrano-bundle_rsyncはいろいろなタイミングでフックポイントがあるのでrsyncが実行される前にcomposer installを実行するようにしています。
rsyncされるディレクトリーはcapistranoがカレントディレクトリの下に「.local_repo」というディレクトリを作り、さらにその下にできる「releases/{datetime}」ディレクトリがrsyncされるディレクトリです。
そのrsyncされるパスを取得するのがdeploy.rbの「config.local_release_path」の部分です
ここまでできたら後はgitにGemfile.lockをPushして「bundle exec cap local deploy」を実行するだけです。
今回はcomposer installしただけですが、その他にも設定ファイルなどをこのタイミングでそのstageに合わせてコピーするなど色々できます。
ごちゃごちゃ書きましたが結果としてはcapistrano-bundle_rsyncのREADME通りに書いてdeploy.rbだけ少し改良するだけで良いという話です
kotlinのjavascirptで非同期で取得したデータをDOMにバインドするサンプル
kotlinがAndroidに公式サポートされて盛り上がっておりますね。
一応Androidエンジニアやっている?いた?のでkotlinは少し追っていたのですが、今ではWebエンジニアとしての方が強くなってきたので
あえてkotlinでjavascriptやってみよう思い立ってDelegate.Observableを使って、非同期で取得したデータをhtmlのDOMにバインドできないかなぁと思ってやってみたら楽しくなってきたのでメモ
kotlinでjavascriptの準備
チュートリアルにそって準備しました kotlinlang.org
適当にhtmlを用意
<!DOCTYPE html> <html lang="en"> <head> <script src="../kjs/out/production/kjs/lib/kotlin.js"></script> <script src="../kjs/out/production/kjs/kjs.js"></script> <meta charset="UTF-8"> <title>Title</title> </head> <body> <div> <input id="sampleText" type="text" value="text" title="sampleText"> <input id="sampleCheckBox" type="checkbox" title="checkBox"> <button id="sampleButton">sampleButton</button> </div> <script> new kjs.SampleRequest().request(); </script> </body> </html>
divタグの中のDOMのvalueやらcheckboxのdisableの切り替えをAPIを叩いて帰ってきたJSONレスポンスをdata classに変換してバインドする
みたいなものを想定しています。無邪気にid指定しているのは許してください。nameでもclassでも取得できるのでそこは後でお好みで変換してください。
scriptタグ内のkjs.SampleRequest().request()で非同期でGETリクエストを投げてJSONを受けってデータバインドします
バインドするdata class
data class SampleEntity(val inputText: String, val disabled: Boolean, val buttonText: String)
inputTextをDOMのtextのvalueに
buttonTextをDOMのButtonのtextContentに
disableをcheckBoxのdisableに
バインドする想定のdata class
バインドするDOMのテンプレートクラス
object SampleTemplate { var entity by Delegates.observable<SampleEntity?>(null) { _, _, new -> new?.let { inputText.value = new.inputText checkBox.disabled = new.disabled button.textContent = new.buttonText } } val inputText: HTMLInputElement = document.getElementById("sampleText") as HTMLInputElement val checkBox: HTMLInputElement = document.getElementById("sampleCheckBox") as HTMLInputElement val button: HTMLButtonElement = document.getElementById("sampleButton") as HTMLButtonElement }
それぞれのDOMの情報をメンバ変数に持って
Delegate.observableを利用してSampleEntityがsetされたらDOMに値を入れるようにしています
GETのRequestを投げるクラス
class SampleRequest { fun request(){ val req = XMLHttpRequest() req.onloadend = fun(event:Event){ SampleTemplate.entity = JSON.parse<SampleEntity>(req.responseText) } req.open("GET","http://localhost:63342/kjs/json_text.txt",true) req.send() } }
XMLHttpRequestで非同期でGETリクエストを投げて、取得したJSONをSampleEntityに変換してSampleTemplateのentityにセットしています。
適当なJSONのファイル用意します
{"inputText":"テキストはいる","disabled":true,"buttonText":"hoge"}
buildしてブラウザでチェックします
ディレクトリ構成はこんな感じですごく適当においてます
IntelliJからindex.htmlをopenすると「http://localhost:63342/kjs/index.html」みたいなURLで開きます
すると
から
にtextの内容やcheckboxがdisable状態になったりと、データが反映されます。
今回は恐らく最小の構成?でバーっと書いてみましたが、案外使えそうな気がしています。
おまけ
チュートリアルにあるようにdebugの設定をすると、kotlinのソース上でちゃんとブレイクポイント貼れるのでかなりデバッグが助かります。 vue.jsやほかのjsのライブラリとうまく組み合わせたパターンなど色々と試してみたいですね。
phpのSlim3をコマンドラインから実行して使う
月1ペースを目標に更新したかったけど、全然更新してなかった ということでphpの軽量フレームワークのSlim3を最近使うことが多く、以前コマンドラインから実行するバッチ専用のシステムとして作ったのでその時の情報をメモです。
Slim3でプロジェクトを作る
公式ドキュメントを元に空のプロジェクトを作ります。
今回は「batch-sample」というプロジェクト名で作成します。
index.phpファイルを修正する
batch-sample/public/index.php
<?php require __DIR__ . '/../vendor/autoload.php'; $settings = require __DIR__ . '/../src/settings.php'; $commandName = $GLOBALS['argv'][1]; $settings['environment'] = \Slim\Http\Environment::mock( [ 'REQUEST_URI' => '/' . $commandName, ] ); $app = new \Slim\App($settings); require __DIR__ . '/../src/dependencies.php'; require __DIR__ . '/../src/middleware.php'; require __DIR__ . '/../src/routes.php'; $app->run();
ここが恐らく一番のポイントです。 \Slim\Http\Environment::mockでコマンドラインから受け取ったコマンド名をURIに設定して、それに対応するルーティングの処理を呼び出すようにします。 Enviroment:mockはUnitTestのときに、Httpリクエストのmockとして使われます。それをそのまま流用してコマンドラインから実行したときにコマンド名からmockのリクエストを作って実行するという流れです。
バッチの主処理を記述するクラスを作る
batch-sample/src/Action/SampleAction
<?php use Psr\Container\ContainerInterface; use Slim\Http\Request; use Slim\Http\Response; class SampleAction { private $container; public function __construct(ContainerInterface $container) { $this->container = $container; } public function __invoke(Request $request, Response $response, $args) { $response->write("hello world"); return $response; } }
ルーティングでこのActionをバインドする
batch-sample/src/route.php
<?php require_once __DIR__.'/Action/SampleAction.php'; $app->get("/sample_command", SampleAction::class);
とりあえず実行する
ここまで作ったらとりあえず実行してみます。
実行方法はbatch-sampleディレクトリで「php public/index sample_command」です。
実行してみるとコンソール上に「hello world」が出力されます。
非常に簡単ですね。
もう少し改良する
例えばWebからもフックできるようにindex.phpをいじります。 WebApplicationとCliApplicationクラスのようなものを作って、PHP_SAPIを見てどちらのApplicationクラスを実行するか分岐させます
batch-sample/src/Application/CliApplication.php
<?php //コマンドラインから起動されたとき甩のクラス class CliApplication { public function run() { $settings = require __DIR__ . '/../../src/settings.php'; array_shift($GLOBALS['argv']); $commandName = $GLOBALS['argv'][0]; $settings['environment'] = \Slim\Http\Environment::mock( [ 'REQUEST_URI' => '/' . $commandName, ] ); $app = new \Slim\App($settings); require __DIR__ . '/../../src/dependencies.php'; require __DIR__ . '/../../src/middleware.php'; require __DIR__ . '/../../src/routes.php'; $app->run(); } }
batch-sample/src/Application/WebApplication.php
//Webからフックされたとき用のクラス <?php class WebApplication { public function run() { if (PHP_SAPI == 'cli-server') { // To help the built-in PHP dev server, check if the request was actually for // something which should probably be served as a static file $url = parse_url($_SERVER['REQUEST_URI']); $file = __DIR__ . $url['path']; if (is_file($file)) { return false; } } $settings = require __DIR__ . '/../../src/settings.php'; $app = new \Slim\App($settings); require __DIR__ . '/../../src/dependencies.php'; require __DIR__ . '/../../src/middleware.php'; require __DIR__ . '/../../src/routes.php'; $app->run(); } }
batch-sample/public/index.php
<?php date_default_timezone_set('Asia/Tokyo'); require __DIR__ . '/../vendor/autoload.php'; use Batch\Sample\Application\CliApplication; use Batch\Sample\Application\WebApplication; if(PHP_SAPI == "cli"){ (new CliApplication())->run(); }else{ (new WebApplication())->run(); }
これでビルトインサーバーを起動して「http://localhost:8090/sample_command」にアクセスすれば同じバッチ処理が起動します。
さいごに
さらっとした説明はここまでにして、その他にも多重起動防止や実際にDBを操作する処理も含めたものはgithubにあげておきます。 サブシステムとして特定のバッチ処理をしたいというときにもしかしたら使えるかもしれませんね。
githubに上げてる方はクラス設計がここのサンプルとはだいぶ変わっています。といってもActionクラスがServiceクラスを決定し、Serviceクラスはバッチ処理を行うための複数のModelを使用する…ってだけですが、適度に分割されてると思いますしテストコードも問題なく書けるはずです。
Slim3は軽量なフレームワークなだけあってクラス設計がフルスタックよりも楽しいです。
APIサーバー用のフレームワークとしてもかなりアリだと思うので今作ってるアプリのサーバー側はSlim3にしようかと思っているところ。
Slim3は最近すごくお気に入りなので、APIサーバーを作ってまた何か知見を得られれば何か書きたいです。
phpのsession_decodeはarray_mergeされる
あるときphpでscript書いてるときにハマった。 session_decode()したら$_SESSIONに配列がまるっと置き換えられると思ってたらどうも違ったらしい
<?php session_start(); $_SESSION = ['a' => 'b']; $data1 = session_encode(); $_SESSION = ['c' => 'd']; $data2 = session_encode(); $_SESSION = []; session_decode($data1); session_decode($data2); print_r($_SESSION);
session_decodeをした結果
( [c] => d )
こうなると思ってたら実際は
( [a] => b [c] => d )
こうなった。
<?php session_start(); $_SESSION = ['a' => 'b']; $data1 = session_encode(); $_SESSION = ['a' => 'd']; $data2 = session_encode(); $_SESSION = []; session_decode($data1); session_decode($data2); print_r($_SESSION);
同じキーを入れると
( [a] => d )
こうなって見事にキーの中身がbからdに上書きされた。
これは騙される。完全にまるっと置き換えられると思ってたけど、そうじゃなかった。
今年一年のエンジニアライフを振り返ってみる
歳を取るたびに1年が短くなっていくというのどうやら本当のようで、振り返れば1年があっという間にすぎさってしまっています。
そんな短い1年で自分が一体エンジニアとしてどういうことができたのかちょっと振り返ってみたくなったので何をやったのかバーっと思い出してみます。
初のiOSアプリのリリース
今年一番まともなアウトプットってこれだけかもしれないです……
まだこれをやっていたころはswift2なのでswift3で何かiOSアプリを改めて作りたい。
またクラス構造も最近流行りのMVVMの構造とかは無関係にViewControllerにすべてのロジックを詰め込んでいるのでもはや何書いてるのかわからないレベル。
kotlinでAndroidアプリ作る
retrofitとRxJavaを使いたかったから無理やり作った。
tokenのリフレッシュ周りがクソ実装のまま放置してしまった。時間があったら直すは結局直さない。仕事でも同じで確実に技術的負債を積んでいく。ただこれは負債を返すより早くリリースすることを目標にしてたので正しい判断だったのだ……。
kotlinでAndroid開発するのは個人的にアリだとは思うが、Instant Runやkapt周りが怪しいので変にハマったときに解決する、もしくは便利なライブラリを諦める覚悟がない場合は素直にJavaでやるほうがいいかな。
個人の開発であってもクラス設計などしっかり考えて技術的負債を残さず開発するクセつけないと、実務でついえいやで実装してしまうので注意する。
kotlinでDIライブラリ書く
github.com
phpのDIライブラリのpimpleの模倣版。AndroidのApplicationクラスで必要なサービスクラスの生成をコンテナにつめて使ってみる妄想をしたが、まだ実際に使ってない。Dagger2あるし…
ただkotlinで何か書いてみたかった欲を発散させたなにかになった。
Webエンジニアしてphpフレームワークさわる
具体的なアウトプットはないが、仕事上ネイティブアプリとしての仕事がまわってこなくなりWebの開発がメインになったので真面目にWebの基礎からやろうと勉強した。
もともとあるソースにつきたしするのは出来たが一からフレームワークの動きを追うっていう機会があまりなかったのでLaravelとSlim3とdietcakeをVagrant上に構築して軽く動かすことだけした。
Slim3は実務でも使うことがあり、軽量でなかなか使いやすい。
そもそもnginxの設定やmysqlやRedisのインストールも怪しかったのでAnsibleを使ってそれらもろもろ一発で入るようにした
deploy方法にcapistrano入れて実際にさくらサーバーにdeployしてみたりと、Webアプリケーションエンジニアとしての基礎を改めて学んだ。
これらの基礎を改めて学んで何か実務でも障害が起こったときに問題の切り分けなど判断がスムーズにできるようになった気がする。
ネイティブアプリのAPIを作るときもさくっとサンプル作ったりできるようになったので本当に色々と立ち回りやすくなった。
品質や設計、テストについて考えるようになった
このあたりについてはまた別の機会に書いてみようかな。
今まで品質がいいとはバグが少ないことではない。テストコードはただ書けばいいというものではないと改めて考えされた。
テストで何を保証したいのかなどテストを設計しないと,、意味もなくただカバレッジ100%にするってだけではただ開発の足かせにしかならないなと学んだ。
最近はソフトウェアの品質やテスト計画など、ソフトウェアの品質はいかにして高めるのかという本ばかり読んでる。
このあたりはまだ自分も勉強中なのでしっかり身につけて、個人の開発というよりチームの開発につなげていきたい。
まとめ
広く浅く色んな技術に触れた1年だった。 ひっそりと個人でネイティブアプリをやりつつ、仕事ではWebアプリをやるという少し前に流行ったフルスタックエンジニア(笑)になりつつある。自分の軸となる技術を確立されせて、あの技術はこの人ってなれるぐらい成長したい。 とりあえずswift3で何かリリースするぞ!Vue.jsもさわりたいし!やりたいことが多すぎるっ!
後仕事でもネイティブアプリやりたいなぁ……
来年の抱負
これら頑張っていきたい。エンジニアとして周りの人に負けないように研鑽を積んでいく一年にする。
phpでRedisのlRangeがPhakeで差し替えられない罠
ある日phpのRedisをPhakeでmockにしてテストしていたときにlRangeがなぜか差し替えられない状況に陥った。
ソースコードは以下のような感じ
<?php class HogeTest extends \PHPUnit_Framework_TestCase{ /** @var \Redis */ private $target; public function setUp(){ $this->target = \Phake::mock(\Redis::class); } public function testHoge(){ \Phake::when($this->target)->get("key")->thenReturn("value"); $this->assertEquals("value", $this->target->get("key")); //OK } public function testLGet(){ \Phake::when($this->target)->lRange("key", 0, -1)->thenReturn(["a","b"]); $this->assertEquals(["a","b"],$this->target->lRange("key", 0, -1) ); // NG } }
functionのgetは期待通り差し替えられているのにlRangeだけはどうしてもnullが帰ってきてしまう。
phpredisを調べてみると、
このソースを見るとlRangeはlGetRangeのエイリアスとして存在しているらしい。
つまりlRangeは本来存在しないfunction…
ということでlGetRangeに置き換えてみると無事にfunctionを差し替えることが出来ました
<?php class HogeTest extends \PHPUnit_Framework_TestCase{ /** @var \Redis */ private $target; public function setUp(){ $this->target = \Phake::mock(\Redis::class); } public function testLGetRange(){ \Phake::when($this->target)->lGetRange("key", 0, -1)->thenReturn(["a","b"]); $this->assertEquals(["a","b"],$this->target->lGetRange("key", 0, -1) ); //OK } }
phpのエクステンションでエイリアスとして存在するfunctionは置き換えられないということに気をつけて、次もしPhakeで差し替わらないfunctionあったときはエイリアスを疑おう