lottieが大変よろしいものだった
仕事でかっこいいインタラクションを実装することになって、canvasでごりごり頑張るかなぁとも思ったのだけど、少し前に話題になったlottieを使ってみることにした。
結果、レベルの高いアニメーションをすこぶる簡単に1時間もかからず実装することができた。
実際に導入してみるところまで +α を説明してみる。
lottieとは
詳細は こちら から見ることができる。知らない人に三行で説明してみると
After Effectsで作ったアニメーションを
jsonに変換して
それをそのまま読み込ませてアプリに組み込める
以上。
Android, iOS, Native Reactに対応しているみたい。
例えば下記はAfter Effectsで Lottie のサンプルを動かしているところ。
これを、jsonに変換してそのまま lottieのライブラリを入れてjsonを読み込ませるだけで下記のようになる。
手順
導入から、実際にアプリに組み込むところまでの流れをざっと説明します。
After Effectsにbodymovinを導入する
https://github.com/bodymovin/bodymovin をAfter Effectsに導入する
- http://aescripts.com/learn/zxp-installer/ でZXPInstallerをダウンロード
- ZXPInstallerを開こうとして「ZXPInstallerは壊れているため開けません。"ゴミ箱"に入れる必要があります。」と言われたら、
xattr -rc ZXPInstaller
とすると開けるようになる - bodymovinのZXPファイルをダウンロード
- ZXPInstallerに先のZXPファイルをドラッグ&ドロップ
- After Effectsを起動して、メニュー>AfterEffects CC>環境設定>一般設定 の「スクリプトによるファイルへの書き込みとネットワークへのアクセスを許可」を有効
jsonの作り方
これで指定した保存先にjsonが保存されます。
アプリに導入(Android)
https://github.com/airbnb/lottie-android を見てその通りにするだけ。
build.gradleに下記を追加。
dependencies { compile 'com.airbnb.android:lottie:1.0.3' }
そして、先ほどのjsonをassetsに配置して、
animationView.setAnimation("data.json"); animationView.loop(false); animationView.playAnimation();
とするだけ。自動でアニメーションを流したままとかであれば下記でも可。
<com.airbnb.lottie.LottieAnimationView android:id="@+id/animation_view" android:layout_width="wrap_content" android:layout_height="wrap_content" app:lottie_fileName="data.json" app:lottie_loop="true" app:lottie_autoPlay="true" />
以上で下記のようなクオリティ高いアニメーションを実装できる。
bodymovin入れてしまえば、あとはもう画像をImageViewにセットするレベルで簡単に高クオリティのアニメーションを実装できる。はい最高。
使う前の自分が疑問に思ってたところ
ちゃんと透過できます?
できました。下記はLottieのサンプルを動かしている様子。
サイズ調整とかどういう感じなん?
LottieAnimationView
は実質ImageViewです。画像がセットされたImageViewと思って scaleType
やらで調整できます。
はまりどころ?
うごかない?
下記のようにコンポジションの中に別のコンポジションがネストされているとき、そのネストされたコンポジションがアニメーションされないという事象にぶつかった。
ネストを解消することでうまく動いた。bodymovinの吐き出すdemo htmlではしっかり動いていたので lottie側が対応していない or bugのよう。
セットしたアニメーションのクリアはどうする?
アニメーションのセットは下記コードでできる。
animationView.setAnimation("data.json");
描画されているものをクリアするにはどうしたらいんだろう? animationView.clearAnimation();
かな?と思ったけど、これは違う。
先にも少し触れたが LottieAnimationView
は実質ImageView。
setAnimation
時、選択されたjsonファイルはLottieDrawable
に変換され setImageDrawable(lottieDrawable)
が呼ばれる。
そのため、描画されているものをクリアするには setImageDrawable(null)
で実現出来る。
感想
Cooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooool!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
RecyclerView.ViewHolderの細かい話
受肉したノロいと化し(ノロになりました)、療養 & パンデミックを防ぐために今週はずっと家に引きこもってます、釘宮です。
おかげで今年一楽しみにしていた柗亭新年会にも行けず、ATI発揮してconnpassページ作ったりもろもろ頑張ってたkyobashi.dexにも参加できずで、なるほどこれが2017年かと噛みしめております。いっそ1月中に今年の不運全てを出しきりたい。
体調の方は、だいぶ動けるようになりちょっと買い物でもと外に出てみたら「うん、死にそう」と思うくらいには回復しております。 家でずっと座ってプログラミングする分には特に支障はありません。
ということで、動けない腹いせに今更RecyclerViewについての細かい記事を。
「 Activityでは createIntent
を」だったり、「FragmentではnewInstance
を」みたいな話に近いです。
よく見かけるコード
RecyclerView.Adapter
のonCreateViewHolder
でよく以下のコードを見かけます。
public class MyAdapter extends RecyclerView.Adapter<HogeViewHolder> { private LayoutInfalter infalater; : @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return new HogeViewHolder(inflater.inflate(R.layout.hoge, parent, false)); // ★ } : }
viewtypeが複数ある場合はこんな感じですかね。
public class MyAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { private LayoutInfalter infalater; : @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { switch (viewType) { case VIEW_TYPE_HOGE: return new HogeViewHolder(inflater.inflate(R.layout.hoge, parent, false));// ★ case VIEW_TYPE_FUGA: return new FugaViewHolder(inflater.inflate(R.layout.fuga, parent, false));// ★ : } } : }
何が良くないと思ったか
別段悪いコードじゃないと思います。
が、ただ大抵の場合において HogeViewHolder
に対応するlayoutは R.layout.hoge
と決まっています。そしてどのlayoutを使うかを知るべきなのは HogeViewHolder
のみでいいはず 。上のコードではそのことを Adapter
が知ってしまっています。
この一点においてだけちょっといただけないなと思うんです。
改善
下記のようにstatic
ファクトリメソッドからのみ作るようにして、その中でレイアウトの指定をしてあげれば、すっきりします。
public class HogeViewHolder extends RecyclerView.ViewHolder { public static HogeViewHolder create( LayoutInflater inflater, ViewGroup parent, boolean attachToRoot ) { return new HogeViewHolder(inflater.inflate(R.layout.hoge, parent, attachToRoot)); } private HogeViewHolder(View itemView) { super(itemView); } }
public class MyAdapter extends RecyclerView.Adapter<HogeViewHolder> { private LayoutInfalter infalater; : @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return HogeViewHolder.create(inflater, parent, false); // ★ } : }
viewtypeが複数ある場合。
public class MyAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { private LayoutInfalter infalater; : @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { switch (viewType) { case VIEW_TYPE_HOGE: return HogeViewHolder.create(inflater, parent, false); // ★ case VIEW_TYPE_FUGA: return FugaViewHolder.create(inflater, parent, false); // ★ : } } : }
もちろん、同じ ViewHolder
で複数のレイアウトを使うという場合や (自分はあまりそういう使い方をしたことないですが)、DataBinding
+ generics
で解決したりするような場合はこの限りじゃないです。
以上です。些細な話で申し訳ないです。上記のように変えたところでビジネス的価値がどれだけ上がるかどうかはわかりませんが、 new HogeViewHolder(inflater.inflate(R.layout.fuga, parent, false));
みたいなレイアウトの指定ミスはなくなりますね。
(ちなみに、 kyobashi.dexで話そうと思ってた内容はこれじゃないです。それはまた別の機会に...)
WakaTimeで前日のプログラミング時間の集計をGASを使ってslackに通知する
こんにちは。
年末年始に 『さくら荘のペットな彼女』を見て、主人公空太の以下の名言にいまだあてられております、kgmyshinです。
やばいよなあ、本気になるって。
帰ってきて、椎名が連載が決まったって聞いたとき、自分が否定されているような気がした。
心がくじけそうだった。本気だったから。後悔からも、悔しさからも、逃げも隠れも出来なかった。
でも、だから簡単なんだ。やるしかない!
この気持ちをぬぐうにはやるしかない! ダメでもダメでもやるしかない!
そのため、それなりにやる気はあったのですが、以下のツイートが回ってきてハッとしました。
どうやったらギター上達するの?って時々聞かれるけどその度にslipknotのMick先生のこの言葉を思い出す pic.twitter.com/MYaVca9T1f
— SeiyaIto伊藤聖也 (@tephanie_oid) 2015年3月15日
自分は一日に何時間プログラミングをして、何時間本を読んで(インプットに費やして)、何時間アウトプットに費やしているのかということが無性に気になりました。
その中の 何時間プログラミングをして に関しては、年始にWakaTimeを導入することによってうまく集計できてました。
ただ、それでも毎日WakaTimeを見に行くのはしんどい。ということで、今回botを作ってslackに通知することにしました。
WakaTimeにはslackインテグレーションが初めから用意されてますが、有料プランでのみ使用できます。ただAPIは無料ユーザーでも使えます。
Google Apps Scripts
はじめはhubotあたりで作ってHerokuやBluemixとかにあげる感じかなぁと思ってたのですが、どうやらGASで無料かつcron的なこともできるようなので下記らへんを参考に触ってみることにしました。
実装
まず WakaTime APIから前日の結果を取得して
var date = new Date(); date.setDate(date.getDate() - 1); var dateStr = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); var apiKey = PropertiesService.getScriptProperties().getProperty("WAKATIME_API_KEY"); var url = "https://wakatime.com/api/v1/users/@kgmyshin/durations?api_key=" + apiKey + "&date=" + dateStr; var urlFetchOption = { 'method': 'get', 'contentType' : 'application/json; charset=utf-8', 'muteHttpExceptions' : true }; var response = UrlFetchApp.fetch(url, urlFetchOption); var body = JSON.parse(response.getContentText()); :
前日の総プログラミング時間を算出して
: var body = JSON.parse(response.getContentText()); var totalDuration = 0; for each(var item in body.data) { totalDuration = totalDuration + item.duration; } :
Slackにポストする。
: var message = dateStr + "のプログラミングに費やした時間は " + Math.floor((totalDuration / 3600)) + "時間" + Math.floor(((totalDuration % 3600) / 60)) + "分 でした。" var token = PropertiesService.getScriptProperties().getProperty('SLACK_ACCESS_TOKEN'); var slackApp = SlackApp.create(token); slackApp.postMessage("#general", message, {username: "えま", icon_url: "http://i.imgur.com/TdUStmi.png"});
下記がコードの全容です。(25行)
function postSlackMessage() { var date = new Date(); date.setDate(date.getDate() - 1); var dateStr = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); var apiKey = PropertiesService.getScriptProperties().getProperty("WAKATIME_API_KEY"); var url = "https://wakatime.com/api/v1/users/@kgmyshin/durations?api_key=" + apiKey + "&date=" + dateStr; var urlFetchOption = { 'method': 'get', 'contentType' : 'application/json; charset=utf-8', 'muteHttpExceptions' : true }; var response = UrlFetchApp.fetch(url, urlFetchOption); var body = JSON.parse(response.getContentText()); var totalDuration = 0; for each(var item in body.data) { totalDuration = totalDuration + item.duration; } var message = dateStr + "のプログラミングに費やした時間は " + Math.floor((totalDuration / 3600)) + "時間" + Math.floor(((totalDuration % 3600) / 60)) + "分 でした。" var token = PropertiesService.getScriptProperties().getProperty('SLACK_ACCESS_TOKEN'); var slackApp = SlackApp.create(token); slackApp.postMessage("#general", message, {username: "えま", icon_url: "http://i.imgur.com/TdUStmi.png"}); }
これで下記のようにSlackに通知してくれます。
Triggerの設定
Resources -> All your triggers を選択し、下記のように設定することで、毎日深夜1時に送信してくれるようになります。
所感
楽。
テスト書いてないとエラーになるCustom Lint書いた
作り方とかは基本的には下記の記事と同じ流れです。
なのでDetectorだけ紹介。
public class MustWriteTestDetector extends Detector implements Detector.JavaScanner { : @Override public AstVisitor createJavaVisitor(@NonNull JavaContext context) { return new TestChecker(context); } private static class TestChecker extends ForwardingAstVisitor { private final JavaContext context; private TestChecker(JavaContext context) { this.context = context; } @Override public boolean visitClassDeclaration(ClassDeclaration node) { String name = node.astName().astValue(); if (node.getParent() instanceof CompilationUnit) { CompilationUnit parent = (CompilationUnit) node.getParent(); if (parent.astPackageDeclaration() != null && parent.astPackageDeclaration().getPackageName().startsWith("YOUR_PACKAGE_NAME)) { boolean isTarget = false; // テストを書きたいターゲットにかどうかのロジックをここに書く : if (isTarget) { String packageName = parent.astPackageDeclaration().getPackageName(); String testFilePath = context.getProject().getDir().getAbsolutePath() + File.separator + "src" + File.separator + "test" + File.separator + "java" + File.separator + packageName.replace(".", File.separator) + File.separator + name + "Test.java"; if (!new File(testFilePath).exists()) { context.report(ISSUE, context.getLocation(node), "not found test code at \"" + testFilePath + "\""); } } } } return super.visitClassDeclaration(node); } } }
- AstVisitorを作る
- クラス宣言時に、自分のアプリで、かつ諸々自分がテストを書くべきと思ってるがテストファイルがないものを検知
ということをやっています。
即席なので自分のアプリのパッケージ名なんかを条件に入れちゃってます。ごめんなさい。
もう少し真面目にやるなら、設定ファイルとかをプロジェクトルートにおいて、それの有無で動作するしない、テスト対象かどうかはその設定ファイルに、とするといいかもしれない。
RxJava2でonErrorをsubscribeしてないのを検知するCustom Lintを作る
深夜に眠れなかったので、男もすなるCustom Lint作りというものをやってみようかと。
RxJava2でonErrorをsubscribeしてないものを検知するというものを作りました。
きっと探せばあるんでしょうが、始める題材としては良さそうだと思ったのでここはあえて探さずに。
参考
下記を参考に作りました
関係ない話な上たまたまなんですが、上記qiitaのhotchemiさんもamyu君も今同じフロアで働いてたりします。
作り方
上記qiitaにも載ってますが、ここでも。下記手順で作ります。
- build.gradleにdependeicies追加
- Detector及びIssueを作成
- Registoryを作成
- Test作成
一つ一つ説明します。
build.gradleにdependeicies追加
build.gradleのdependeiciesに下記を追加しておきます。
compile 'com.android.tools.lint:lint-api:24.3.1' compile 'com.android.tools.lint:lint-checks:24.3.1' compile 'com.android.tools.lint:lint:24.3.1' testCompile 'junit:junit:4.11' testCompile 'com.android.tools.lint:lint-tests:24.3.1' testCompile 'com.android.tools:testutils:24.3.1'
Detector及びIssueを作成
Observable
, Flowable
, Maybe
, Single
, Completable
が subscribe
された時にsusbscribe()
もしくは subscribe(onNext)
の時はNGとします。
まずIssueというものを作っておきます。このlintの説明みたいなものです。
public static final Issue ISSUE = Issue.create( "RxJava2SubscribeOnError", "not subscribe onError", "must subscribe onError", Category.PERFORMANCE, 7, Severity.ERROR, new Implementation( RxJava2SubscribeOnErrorDetector.class, Scope.JAVA_FILE_SCOPE ) );
そしてDetectorを作っていきます。
public class RxJava2SubscribeOnErrorDetector extends Detector implements Detector.JavaScanner { : private static final String CLS_FLOWABLE = "io.reactivex.Flowable"; private static final String CLS_OBSERAVLE = "io.reactivex.Observable"; private static final String CLS_SINGLE = "io.reactivex.Single"; private static final String CLS_COMPLETABLE = "io.reactivex.Completable"; private static final String CLS_OBJECT = "java.lang.Object"; private static final String CLS_CONSUMER = "io.reactivex.functions.Consumer"; private static final String CLS_ACTION = "io.reactivex.functions.Action"; private static final String CLS_CONSUMER_SIMPLE = "Consumer"; private static final String CLS_ACTION_SIMPLE = "Action"; private static final String ANONYMOUS_NEW_CONSUMER = "new io.reactivex.functions.Consumer(){}"; private static final String ANONYMOUS_NEW_ACTION = "new io.reactivex.functions.Action(){}"; private static final String METHOD_SUBSCRIBE = "subscribe"; // ---- Implements JavaScanner ---- @Override public List<String> getApplicableConstructorTypes() { return Arrays.asList(CLS_FLOWABLE, CLS_OBSERAVLE, CLS_SINGLE, CLS_COMPLETABLE); } @Override public List<String> getApplicableMethodNames() { return Collections.singletonList(METHOD_SUBSCRIBE); } @Override public void visitMethod( @NonNull JavaContext context, @Nullable AstVisitor visitor, @NonNull MethodInvocation node ) { boolean isNG = false; StrictListAccessor<Expression, MethodInvocation> expressions = node.astArguments(); int size = expressions.size(); if (size == 0) { // Any.subscribe() -> NG isNG = true; } else if (size == 1) { // Any.subscribe(Consumer<T> onNext) -> NG Expression expression = expressions.first(); JavaParser.ResolvedClass param1Class = context.getType(expression).getTypeClass(); while (!param1Class.getName().equals(CLS_OBJECT)) { if (param1Class.getName().startsWith(CLS_CONSUMER) || param1Class.getName().startsWith(CLS_CONSUMER_SIMPLE) || param1Class.getName().startsWith(ANONYMOUS_NEW_CONSUMER) || param1Class.getName().startsWith(CLS_ACTION) || param1Class.getName().startsWith(CLS_ACTION_SIMPLE) || param1Class.getName().startsWith(ANONYMOUS_NEW_ACTION)) { isNG = true; break; } param1Class = param1Class.getSuperClass(); } } if (isNG) { context.report(ISSUE, node, context.getLocation(node), "you must subscribe onError"); } } }
getApplicableConstructorTypes
で上記の5クラスに絞るgetApplicableMethodNames
でsubscribe
メソッドに絞るvisitMethod
にて、先の条件に当てはまるものをNGとしてcontext.report
する
ということをやっています。
Registoryを作成
こういうものを作って
public class LintIssueRegistry extends IssueRegistry { public LintIssueRegistry() { } @Override public List<Issue> getIssues() { return Collections.singletonList(RxJava2SubscribeOnErrorDetector.ISSUE); } }
build.gradleに下記を追記しておきます。
jar { manifest { attributes("Lint-Registry": "com.kgmshin.lint.LintIssueRegistry") } }
Test作成
LintDetectorTest
を継承してテストを作成します。
public class RxJava2SubscribeOnErrorDetectorTest extends LintDetectorTest { : @Override protected Detector getDetector() { return new RxJava2SubscribeOnErrorDetector(); } @Override protected List<Issue> getIssues() { return ImmutableList.of(RxJava2SubscribeOnErrorDetector.ISSUE); } @Test public void testSingleSubscribeNoParam() throws Exception { @Language("JAVA") String foo = "" + PACKAGE + IMPORT + "public class Foo {\n" + "public void test() {\n" + "Single.just(\"test\").subscribe()" + "}\n" + "}"; String result = lintProject(java(SOURCE_PATH + "Foo.java", foo)); assertEquals( "src/com/kgmyshin/lint/Foo.java:16: Error: you must subscribe onError [RxJava2SubscribeOnError]\n" + "Single.just(\"test\").subscribe()}\n" + "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n" + "1 errors, 0 warnings\n", result ); } : }
getDetector()
とgetIssues()
で先ほどのDetectorとIssueを返すようにしてあとはゴリゴリテストを書いていくだけです。
gist
途中で何回も貼ってますが、gistにソースを載せています。
追記 2017/1/5 17:36
Testだと動いていたのですが、実際のプロジェクトで使ってみると一部動かなかったので修正しました。上記に紹介しているコードは最新です。
Single.just("test").subscribe(new Consumer<String>() { @Override public void accept(String s) throws Exception { : } });
みたいなコードがある時に、
@Override public void visitMethod( @NonNull JavaContext context, @Nullable AstVisitor visitor, @NonNull MethodInvocation node ) { : Expression expression = expressions.first(); JavaParser.ResolvedClass param1Class = context.getType(expression).getTypeClass(); // ★ : }
の★にたいして getSuperClass()
した挙動がTest時と実際の時で違うみたいです。
うろ覚えですがTest時はgetName()
とgetSuperClass()
を繰り返した時
new io.reactivex.functions.Consumer(){}
Consumer
java.lang.Object
みたいになってたんですが、実際は
new io.reactivex.functions.Consumer(){}
java.lang.Object
ってなってたみたいです。
と、時間がなかったのでなんでそうなるかまでは今回は調べてないですが、以上です。
ちなみに
作ったlintのjarファイルは
~/.android/lint/
に格納されるのですが、module名を lint
としてしまうと lint.jar
が作られてしまいます。
何が言いたいかというと、結構どこのプロジェクトもmodule名をlint
としているっぽくて、同名のjarファイルができてしまうのでコマンドなどで上書きされないように名前を変えてコピペするようにするか、もしくは最初からモジュール名を変えておくと良いと思いました。