1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

2billion cats, AndroidN EasterEgg, ねこをいっぱい作る ~HyperUltraCatGenerator~

Last updated at Posted at 2018-10-16

HyperUltraCatGenerator!

AndroidNのイースターエッグ、Neko Atsumeの猫をいっぱい作るアプリです。

ezgif-3-747df78d5897.gif

generate cat

左のNumberPickerを回すか右で指定することで任意のAndroidCatを生成できます。
タップで共有されます。

save 1000 cats

from ~ toで指定した範囲の名前のAndroidCatを生成できます。
生成したAndroidCatはPictures/Catsに保存されます。

リポジトリ

コードの解説

コミットひとつひとつセルフコードレビューしながらこのクソアプリ作成の軌跡を辿ります。
人がどんなこと考えながら開発しているのか、を残しておくと楽しいかなと思って。
付き合ってくださる暇な方はリポジトリ眺めながら読んでください。

initial commit

自分がアイコンに使っている画像はAndroidNのEasterEgg、Neko Atsumeから生成したものです。
ネットでも自分が集めた画像を晒し合ったり、わりと人気がある印象。
playStoreにもクローンゲームがいくつか上がっています。
Android Nougat Easter Egg
Neko Easter Egg
本家やクローンゲームをやってて思ったんですが、AndroidCatが量産できないんですよね。
餌置いて待ったりタップして逐次生成したり……。
わたしは思いました。いっぱいほしい。

import cats

まずActivity作ります。自動生成だとコンパイル通らないことがまれによくあるので自分はいつもEmptyActivityです。
https://android.googlesource.com/platform/frameworks/base/+/nougat-mr2.3-release/packages/EasterEgg
それっぽいリポジトリから必要そうなファイルを引っこ抜いてコンパイル通らないところは適当に書き換えます。
Cat.javaです。
なにはともあれコードを読むとCatは以下の箇所で生成されているようです。
基本的にはCat.create()でランダムに生成し、任意のAndroidCatが欲しい場合はintのseedをCatのコンストラクタに渡す……というところまで読んで終わり。
染色のアルゴリズムはなんかよくわかんないし別にいいかなって……。

Cat.java

// ランダム生成はこれ
public static Cat create(Context context) {
    return new Cat(context, Math.abs(ThreadLocalRandom.current().nextInt()));
}

// 生成ロジックはコンストラクタ
public Cat(Context context, long seed) {
    D = new CatParts(context);
    mSeed = seed;
    setName(context.getString(R.string.default_cat_name,
            String.valueOf(mSeed % 1000)));
    final Random nsr = notSoRandom(seed);
    // body color
    mBodyColor = chooseP(nsr, P_BODY_COLORS);
    if (mBodyColor == 0) mBodyColor = Color.HSVToColor(new float[] {
            nsr.nextFloat()*360f, frandrange(nsr,0.5f,1f), frandrange(nsr,0.5f, 1f)});
    tint(mBodyColor, D.body, D.head, D.leg1, D.leg2, D.leg3, D.leg4, D.tail,
            D.leftEar, D.rightEar, D.foot1, D.foot2, D.foot3, D.foot4, D.tailCap);
    tint(0x20000000, D.leg2Shadow, D.tailShadow);
    if (isDark(mBodyColor)) {
        tint(0xFFFFFFFF, D.leftEye, D.rightEye, D.mouth, D.nose);
    }
    tint(isDark(mBodyColor) ? 0xFFEF9A9A : 0x20D50000, D.leftEarInside, D.rightEarInside);
    tint(chooseP(nsr, P_BELLY_COLORS), D.belly);
    tint(chooseP(nsr, P_BELLY_COLORS), D.back);
    final int faceColor = chooseP(nsr, P_BELLY_COLORS);
    tint(faceColor, D.faceSpot);
    if (!isDark(faceColor)) {
        tint(0xFF000000, D.mouth, D.nose);
    }
    mFootType = 0;
    if (nsr.nextFloat() < 0.25f) {
        mFootType = 4;
        tint(0xFFFFFFFF, D.foot1, D.foot2, D.foot3, D.foot4);
    } else {
        if (nsr.nextFloat() < 0.25f) {
            mFootType = 2;
            tint(0xFFFFFFFF, D.foot1, D.foot3);
        } else if (nsr.nextFloat() < 0.25f) {
            mFootType = 3; // maybe -2 would be better? meh.
            tint(0xFFFFFFFF, D.foot2, D.foot4);
        } else if (nsr.nextFloat() < 0.1f) {
            mFootType = 1;
            tint(0xFFFFFFFF, (Drawable) choose(nsr, D.foot1, D.foot2, D.foot3, D.foot4));
        }
    }
    tint(nsr.nextFloat() < 0.333f ? 0xFFFFFFFF : mBodyColor, D.tailCap);
    final int capColor = chooseP(nsr, isDark(mBodyColor) ? P_LIGHT_SPOT_COLORS : P_DARK_SPOT_COLORS);
    tint(capColor, D.cap);
    //tint(chooseP(nsr, isDark(bodyColor) ? P_LIGHT_SPOT_COLORS : P_DARK_SPOT_COLORS), D.nose);
    final int collarColor = chooseP(nsr, P_COLLAR_COLORS);
    tint(collarColor, D.collar);
    mBowTie = nsr.nextFloat() < 0.1f;
    tint(mBowTie ? collarColor : 0, D.bowtie);
}

cat is a seekbar

シークバーの使い方 / GETTING STARTED
とりあえず使ったことないし……と思ってシークバーを使ってみる。シークバーのつまみに動的にAndroidCatを設定したら見た目が面白かったので採用。
Android SeekBar のトラックとつまみの位置を合わせる
なぜかAndroidCatの位置が固定されてしまうのですが、まだ最初だし後で直せばいいかなと思っていったん放置。
シークバーの範囲は0~999。
そう、このときの私はまだ思っていたのです。
「AndroidCatの名前から推測するに、総数は# 0 〜 #999で1000だろう」って……。

cat is a picker

NUMBERPICKERで数値を入力する/GETTING STARTED
もうひとつ、前から使ってみたかったNumberPicker。ぐるぐる回しているとAndroidCatがぐるぐる生成されてそれだけで楽しい。
最大値をInteger.MAX_VALUEにセットして、その先に回すと0になるはずがオーバーフローして次の数字が表示されなくなるのを確認。マイナスになってるっぽいです。バグな気はするけど、そんな無茶な使い方も想定されていないからしょうがないかな。

欲しい数字までぐるぐる回すのも大変なのでジャンプ機能を追加。
がしかし。mPicker.setValue(jump);とやってもonProgressChangedが来ない。とりあえず検索してみたところ同じ疑問を抱いた人がいた。

https://stackoverrun.com/ja/q/10095347
そうじゃねんだよなあ……。

仕方ないのでNumberPicker.javaを読む。LiearLayoutを継承しててびっくりだけど納得。

NumberPicker.java
    /*
     * @param value The current value.
     * @see #setWrapSelectorWheel(boolean)
     * @see #setMinValue(int)
     * @see #setMaxValue(int)
     */
    public void setValue(int value) {
        setValueInternal(value, false);
    }

これが外部から蹴れるやつ。でこれが何を蹴ってるかっていうと以下。

NumberPicker.java
   /**
     * Sets the current value of this NumberPicker.
     *
     * @param current The new value of the NumberPicker.
     * @param notifyChange Whether to notify if the current value changed.
     */
    private void setValueInternal(int current, boolean notifyChange) {
        if (mValue == current) {
            return;
        }
        // Wrap around the values if we go past the start or end
        if (mWrapSelectorWheel) {
            current = getWrappedSelectorIndex(current);
        } else {
            current = Math.max(current, mMinValue);
            current = Math.min(current, mMaxValue);
        }
        int previous = mValue;
        mValue = current;
        // If we're flinging, we'll update the text view at the end when it becomes visible
        if (mScrollState != OnScrollListener.SCROLL_STATE_FLING) {
            updateInputTextView();
        }
        if (notifyChange) {
            notifyChange(previous, current);
        }
        initializeSelectorWheelIndices();
        invalidate();
    }

君なんでprivateなの?
外部から値設定してるんだから値変更のコールバックも期待しているに決まってるじゃんというか、変化通知のコールバックに通知するか否かも選ばせてくれていいじゃんみたいな……。
しょうがないので値がセットされたときにコールバックも実行されたっぽくしてあげる。

cat is cardview, cat is extracter, cat is start

大した変更がないのでまとめます。なんか適当にコミットしちゃったみたいでcat is cardviewはコンパイル通らないです。
なにはともあれ白背景だとAndroidCatが見づらかったのでCardView導入。本来ならConstraintLayoutやCoordinatorLayoutでレイアウトを作るべきなんですが、まだぜんぜん勉強できてないので、ついつい使い慣れたRelativeLayout。レイアウトエディタではLegacyに分類されております。
エラー表示がうっとうしいので文字をstring.xmlに外出し。電球をクリックするかalt+Enter叩くと後はAndroidStudioさんがよしなに片付けてくれます。
後はページ初期表示のときに適当に生成した初期値つっこんで、生成したAndroidCatを共有できるようにしておけばgenerate cat部分は終了。

cat is provider

が、しかし。cat is a pickerでうっかりコミットしていたshareCatを実行してみたところ見事にクラッシュ。
なんでクラッシュするんだよ! コピペなのに!

FATAL EXCEPTION: main
    Process: plan.militarize.stray.cat.hyperultracatgenerator, PID: 5979
    android.os.FileUriExposedException: file:///storage/emulated/0/Pictures/Cats/Cat_180.png exposed beyond app through ClipData.Item.getUri()

[Android]android.os.FileUriExposedException
Android 7.0 Nougat(ヌガー)でFileUriExposedExceptionが起きる問題
Android N での file:// スキーマによるファイル共有の挙動

エラーメッセージで検索。WRITE_EXTERNAL_STORAGE付与してなかったのもそうですが、AndroidNからはUriの取り扱いが厳しくなったらしい。ファイルを外出しするならもっともっと明示的に、ということか。
ContetProvider系はあまりやったことがなかったので1時間くらいドハマリ。いまいち理解しきれていないですがとりあえず動くところまでは行きました。
コピペ元のNekoLand.javaでクラッシュしないのは謎ですが、たぶんEasterEggはsystem領域に格納されてる特権アプリだから?

cat is snack

Toastとかだっせーよな!
ということで最近の流行りと聞くスナックバーを導入。
Displaying the Snackbar
引数にしたviewの下に出る……と聞いていたが画面下に出る。別にそれでもいいかなって思って放置。AndroidCatが保存されているであろうPicturesへのジャンプボタンをつけてみる。飛んでなにするかとかは特に考えてない
ついでにシークバーのつまみにしていたAndroidCatに専用の表示場所を作ってあげる。
gradleでSupportLibraryのバージョンを定数にする。いつも思うんですがバージョンとか別ファイルに分けるのめんどくさい……めんどくさくない? gradleがカオスになりやすいのは確かですが。

cat is permission

ここでRuntimePermission対応。なぜかJava8も導入。Lambdaいいよね!

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    for (int grantResult : grantResults) {
        if (grantResult != PackageManager.PERMISSION_GRANTED) {
            Snackbar.make(mCardSave, R.string.request_permission, Snackbar.LENGTH_SHORT).show();
            return;
        }
    }
    switch (requestCode) {
        case R.id.current_cat:
            shareCat(new Cat(getApplicationContext(), mPicker.getValue()));
            break;
        case R.id.save_cats:
            // TODO save tha cats
            break;
        default:
            return;
    }
}

RuntimePermissionのコールバック管理はどの機能がどのPermissionをリクエストしてきたのか覚えてなくちゃいけなくてめんどくさいですね。requestCodeもviewIdもintなのでそのままブチ込んでます。requestCodeを自前で定義すればいいだけの話なんですが、だったらそのままでいいじゃん、みたいな……。
READ_EXTERNAL_STORAGEをなぜつけたのか。たぶんいらないと思います。

cat is foreground

save 1000 cats部分の実装を始めます。
AndroidCatの名前から推測するに、総数は# 0 〜 #999で1000、つまり1000匹作れればAndroidCatがコンプできる!
そんなふうに考えていた時期が私にもありました。でも思ったんです。なんで先人のクローンゲームにはAndroidCatコンプリートモードがないんだろうって。

Neko Easter Egg

This can't really be compared to the easter egg, as this just ruins all of the fun and gives you over 4 billion different cat possibilities...but perhaps you'll find the fun in looking at them one at a time. ;)

4 billion different cat possibilities...
よんじゅうおく……?

Cat.java
public static Cat create(Context context) {
    return new Cat(context, Math.abs(ThreadLocalRandom.current().nextInt()));
}

AndroidCatを決めているのはこのThreadLocalRandom.current().nextInt()です。
ThreadLocalRandom#nextInt
書いてないですがおそらくすべてのintの値を返しうるのでしょう。intの範囲は-2147483648~2147483647、Math.abs()で絶対値にしてるので2147483647匹のAndroidCatがいるはずです。0含めると2147483648かもしれませんがめんどくさいんで計算しません。にじゅうおく……。

呆然としながらIntentServiceのforegroundServiceを実装します。
もう定型文だと思うので適当に。permissionのFOREGROUND_SERVICEはたまに忘れてつらい気持ちになります。

cat is progress

Android Progress Notification with Examples
Notificationのprogressもやったことなかったので作成。

たまにprogressが更新されないな……と思ったら、Notificationの更新頻度には制限があるらしいです。
Android Nougat and rate limiting of notification updates
こんなクソアプリならともかく、ここまで頻繁に更新する普通のアプリってあるんでしょうか。進捗をそんな細かく知りたいユーザなんていないでしょうし。

とりあえずAndroidCatの生成ロジックは以下の通りです。

CatServeService.java
Set<Integer> set = new HashSet<>(MAX);
List<Future<?>> list = new ArrayList<Future<?>>(MAX);
executorService = Executors.newWorkStealingPool();

while (set.size() < MAX) {
    int seed = Math.abs(ThreadLocalRandom.current().nextInt());
    int name = seed % 1000;
    if (name < from || to < name) {
        continue;
    }
    if (set.contains(name)) {
        continue;
    }
    set.add(name);
    list.add(executorService.submit(new CatSaver(seed)));
}
executorService.shutdown();

我ながら鉄アレイで殴り続けると死ぬ並みのマッチョなロジックだと自負しています。
乱数生成に偏りがあると永遠にwhileループから抜けられないですが、エミュレータで試す限りではいつもすぐ抜けるので大丈夫でしょう。多分。
CatSavershareCatの中身をコピペしただけのしょうもないRunnableです。ExecutorServiceに渡して待ちます。

CatServeService.java
for (int count = 0; count < list.size(); count++) {
    try {
        list.get(count).get();
        builder.setProgress(MAX, count, false);
        manager.notify(NOTIFICATION_ID_SAVE_CAT_PROGRESS, builder.build());
    } catch (ExecutionException | InterruptedException e) {
    }
}

本来ならExecutorServiceに渡された順ではなく各自完了次第progressを更新するとか、失敗したタスクのリトライとか入れるべきなんでしょうがめんどくさいのでやめました。マルチスレッドこわい。

cat is name

名前出しといた。

cat is crash

ここまではPのエミュレータで動作確認していました。ここでNの実機に入れてAndroidCatを大量生成したところクラッシュ。Nのエミュレータ作って実行したらクラッシュ。特にlogcatが赤く染まることもなく……。
やたら重くてあんまり好きじゃないのですが、Profilerを起動したところメモリが大量に増加してクラッシュしています。
どうせ画像だからメモリリークだろ!
ということで対策。

  • インナークラスをstaticに
  • Bitmap.recycle()
  • 使い終わったBitmap, Drawableにはnullを入れる

それでもクラッシュしました。しょうがないのでマルチスレッドでAndroidCatを生成していたところをシングルスレッドに変更したらクラッシュしなくなりました。

CatServeService.java
// before マルチスレッド
executorService = Executors.newWorkStealingPool();

// after シングルスレッド
executorService = Executors.newSingleThreadExecutor();

簡単に切り替えられるExecutorServiceは神。
マルチスレッドでIOするのもやばそうですが、このクラッシュはNのBitmapやDrawableのメモリ解放処理がなにかしら駄目なのかなーという気がします。OやPだとGCが走りまくってクラッシュしないので。

cat is icon

Adaptive Iconにとりあえず対応する方法
Android StudioでAdaptive Iconを作る
せっかくなのでAdaptive Iconも作ってみる。対応しているホームアプリが2018/10/16現在少ないらしく、その威力は確認できませんでした。
「あっこのアプリAdaptive Icon対応してねー! だっせー!」とか言って煽るくらいしか使いみちがない気がします。フレームワークの新しい仕様にキャッチアップしてないとユーザにクソダサアイコンが見られてしまうのはけっこう残酷な選別。
ちなみにこのクソダサアイコンはgimpで20分もかけて作ったんですが、今思えば横じゃなくて縦にAndroidCatを刻んだほうがかっこよかったかも。

cat is font

Kielo Typeface
フォントも追加。ついでにtoolBarも追加。
普通のフォントは端末標準のフォントにお任せするのがいいと思いますが、こういうアクセントとして装飾フォント使うと楽しいかも。
最初はこれ使おうかと思ったんですが猫は2種類もいらないのでオサレな感じに。

cat is large icon

O以降でNotificationにLargeIconが表示されない。しっかり検証してないですが、Adaptive IconはLarge Iconに設定できないみたいです。Bitmapが生成できてないのかも。Vectorだったら行けたりする……?
めんどくさいのでmipmapフォルダから自動生成されたアイコン画像をコピーしてdrawableフォルダに突っ込むことで対応。力こそパワー!

cat is modifications

いろいろ調整。
progressが更新されるたびに通知音が鳴り続けることに気づいたのでNotificationManager.IMPORTANCE_DEFAULTからNotificationManager.IMPORTANCE_LOWに変更。
.setCategory(NotificationCompat.CATEGORY_PROGRESS)してるじゃん! 鳴らすなや! と思いますが、まあ、しょうがない……。

foregroudServiceもクラッシュする可能性があったのでfix。ContextCompat.startForegroundServiceで始めたServiceはIntentに不備があってなにもせず終了する場合でもstartForeground()しとかないとクラッシュします。あまり考えづらいエラーケースなので後から発見して大変なことになったり。

後はMediaScannerConnection。生成した画像を即座に他のアプリにも見てほしい場合にこれを行うんですが、1枚ごとに絶え間なく要求しているのがなんとなく気に食わなくて、一括でやってもらうようにしました。これは本当になんとなくでやったので、知見がある方間違ってたら教えてください。

おわりに

これであなたも自由自在にAndroidCatを大量生成できるようになりました。
この記事を書いている今、この公開する直前、この地球上で私が最も多くのAndroidCatを生成した人間だと思います。
みなさんもHyperUltraCatGeneratorでAndroidCatを大量生成してアイコンとかなんかに使ってください!

おまけ

めっっっちゃ解析してる方がいました。はっきり言ってこの記事読む時間でこっち読んだほうがAndroidぢからが上がります。
アプリ作ってから見つけました。かなしい。

おまけのおまけ

AndroidCatの画像サイズは感覚で平均すると14kbです。
14 * 2147483647 = 30064771058

f2668204-9e05-4e18-b941-82f629d57bd0.png

すっごーい。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?