アナログ金木犀

つれづれなるまままにつれづれする

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/bodymovinAfter Effectsに導入する

  1. http://aescripts.com/learn/zxp-installer/ でZXPInstallerをダウンロード
  2. ZXPInstallerを開こうとして「ZXPInstallerは壊れているため開けません。"ゴミ箱"に入れる必要があります。」と言われたら、 xattr -rc ZXPInstaller とすると開けるようになる
  3. bodymovinのZXPファイルをダウンロード
  4. ZXPInstallerに先のZXPファイルをドラッグ&ドロップ
  5. After Effectsを起動して、メニュー>AfterEffects CC>環境設定>一般設定 の「スクリプトによるファイルへの書き込みとネットワークへのアクセスを許可」を有効

jsonの作り方

  1. メニュー>ウィンドウ>拡張機能>Bodymovin
  2. 書き出したいコンポジションを選択(左列の円をクリック)して、選択した同じ行の右端の「…」をクリックして保存先を設定
  3. Renderをクリック

f:id:kgmyshin:20170219141622p:plain

これで指定した保存先に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 やらで調整できます。

はまりどころ?

うごかない?

下記のようにコンポジションの中に別のコンポジションがネストされているとき、そのネストされたコンポジションがアニメーションされないという事象にぶつかった。

f:id:kgmyshin:20170219142758p:plain

ネストを解消することでうまく動いた。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.AdapteronCreateViewHolderでよく以下のコードを見かけます。

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です。

やばいよなあ、本気になるって。

帰ってきて、椎名が連載が決まったって聞いたとき、自分が否定されているような気がした。

心がくじけそうだった。本気だったから。後悔からも、悔しさからも、逃げも隠れも出来なかった。

でも、だから簡単なんだ。やるしかない!

この気持ちをぬぐうにはやるしかない! ダメでもダメでもやるしかない!

そのため、それなりにやる気はあったのですが、以下のツイートが回ってきてハッとしました。

自分は一日に何時間プログラミングをして、何時間本を読んで(インプットに費やして)、何時間アウトプットに費やしているのかということが無性に気になりました。

その中の 何時間プログラミングをして に関しては、年始に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に通知してくれます。

f:id:kgmyshin:20170111153448p:plain

Triggerの設定

Resources -> All your triggers を選択し、下記のように設定することで、毎日深夜1時に送信してくれるようになります。

f:id:kgmyshin:20170111154140p:plain

所感

楽。

テスト書いてないとエラーになるCustom Lint書いた

作り方とかは基本的には下記の記事と同じ流れです。

motida-japan.hatenablog.com

なので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にも載ってますが、ここでも。下記手順で作ります。

  1. build.gradleにdependeicies追加
  2. Detector及びIssueを作成
  3. Registoryを作成
  4. 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, Completablesubscribe された時に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クラスに絞る
  • getApplicableMethodNamessubscribeメソッドに絞る
  • 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()を繰り返した時

  1. new io.reactivex.functions.Consumer(){}
  2. Consumer
  3. java.lang.Object

みたいになってたんですが、実際は

  1. new io.reactivex.functions.Consumer(){}
  2. java.lang.Object

ってなってたみたいです。

と、時間がなかったのでなんでそうなるかまでは今回は調べてないですが、以上です。

ちなみに

作ったlintのjarファイルは

~/.android/lint/

に格納されるのですが、module名を lint としてしまうと lint.jarが作られてしまいます。

何が言いたいかというと、結構どこのプロジェクトもmodule名をlintとしているっぽくて、同名のjarファイルができてしまうのでコマンドなどで上書きされないように名前を変えてコピペするようにするか、もしくは最初からモジュール名を変えておくと良いと思いました。