20200126のAndroidに関する記事は13件です。

関数型プログラミングのFunctorって何が嬉しいの?

関数型プログラミング言語を勉強していると、Monadだけでなく、FunctorとかApplicativeなど、オブジェクト指向に慣れ親しんだプログラマには聞きなれない言葉が出てきます。

今回は自分の勉強も兼ねて、Functorの使い所の代表的なパターンを見てみます。

※筆者は圏論の専門家ではないので、あくまでアプリケーションを作るプログラマ視点で、Functorの概要を整理しています。

Functorのおさらい

Functorのおさらいをするにあたり、Haskellを説明に使用します。
HaskellでのFunctorのソースコードを抜粋します。
以下、https://hackage.haskell.org/package/base-4.12.0.0/docs/src/GHC.Base.html#Functorより抜粋。

class Functor f where
    fmap :: (a -> b) -> f a -> f b

Functorは「fmap」という関数を提供します。fmapは「引数として、型aを取り型bを返す関数と、f aを取り、f bを返す」関数です。

※上記の「f a」と「f b」の「f」はFunctorを表す。
※上記の型「a」および型「b」は任意の型を表す。

※あるインスタンスがFunctorであるためには、上記fmapの実装がFunctor則を満たす必要があります。詳しくは、以下のURLなどを参照。
https://hackage.haskell.org/package/base-4.12.0.0/docs/Data-Functor.html

Functorのfmap関数の使いどころ

上記のFunctorの説明に対して、見方を変えると、
「型aを取り型bを返す関数fに対してfmapを適用することで、その関数fをFunctorのコンテキストに持ち上げることができる」と読むこともできます。

文章だけだと分かりにくいので、以下に例を示します。

Prelude> let f = (+1) -- 関数 (+1)をfに束縛
Prelude> :t f -- fの型を表示
f :: Num a => a -> a -- fはNumクラスのインスタンスである型aを引数に取り、型aを返す関数
Prelude> let g = fmap f -- fに対して、fmapを適用する実装をgとして束縛
Prelude> :t g -- gの型を表示
g :: (Functor f, Num a) => f a -> f a --gは 「型(f a) を引数に取り、型(f a)を返す関数である。ここで、fはFunctorクラスのインスタンス、aはNumクラスのインスタンスである。

関数「f :: a -> a」にfmapを適用することにより、関数「g :: f a -> f a」を得ることができました。
これの何が嬉しいのか、イメージが湧きづらいと思いますので、もう少し詳しく見てみます。

fmapが適用された関数は、どのFunctorに関しても利用できる

上記の通り、関数「f :: a -> a」にfmapを適用することにより、関数「g :: f a -> f a」を得ることができました。

関数gの型をもう一度見てみます。

g :: (Functor f, Num a) => f a -> f a --ここでは、「g = fmap f」として束縛されている。

型を見てわかる通り、引数のfに対して、「Functor」であることという制約を課していますが、それ以上の制約は課していません。なので、上記fに対して、どんなFunctorでも適用できるということになります。
例を見てみましょう。

※以下のMaybe、およびEither StringはいずれもFunctor則を満たすfmapを提供するインスタンスです

関数gにMaybe Int型を適用してみる

関数gにMaybeInt型を適用した場合の型を表示
Prelude> :t g $ (Just 1 :: Maybe Int) --gにMaybe Int型の値を適用した結果の型を調べる
g $ (Just 1 :: Maybe Int) :: Maybe Int --結果は、Maybe Intとなる。fの部分にMaybe、aの部分にIntが適用された形となる。
実際に関数gにMaybeInt型の値を適用
Prelude> g $ (Just 1 :: Maybe Int) --実際に関数gにMaybe Int型である「Just 1」を適用
Just 2 --「g = fmap f = (+1)」なので、結果は「Just 2」となる。

関数gにEither String Int型を適用してみる

関数gにEitherを適用した場合の型を表示
Prelude> :t g $ (Right 1 :: Either String Int) --gにEither String Int型の値を適用した結果の型を調べる
g $ (Right 1 :: Either String Int) :: Either String Int --結果は、Either String Intとなる。fの部分にEither String、aの部分にIntが適用された形となる。
実際に関数gにEitherの値を適用
Prelude>  g $ (Right 1 :: Either String Int) --実際に関数gにEither String Int型である「Just 1」を適用
Right 2 --「g = fmap f = (+1)」なので、結果は「Right 2」となる。

上記の通り、関数fをfmapによる持ち上げにより束縛した関数gは、引数としてMaybe Int、Either String Intを取っています。

もうちょっとFunctorの何が嬉しいか考えてみる

これまでで、fmapの適用により持ち上がられた関数gは、どのFunctorインスタンスに対しても引数として取れるようになることがわかりました。ここで、視点を変えてみます。

改めて今回使用した関数fと関数gの定義を見てみましょう。

今回使用した関数fと関数gの定義
let f = (+1)
let g = fmap f

fmapにより関数gを定義したとしても、「f = (+1)」の定義はそのままです。
これは、「関数fという特定のFunctorインスタンスに依存しない関数の定義はそのままで、fmapの適用により、どのFunctorインスタンスも引数に取れる関数を新たに作ることができる」と考えることができます。
これは素晴らしい。

例えば、開発の初期に以下のような関数を作ったとします。

add1 :: Int -> Int
add1 = (+1)

当初、上記のadd1関数は引数としてFunctorに依存しない汎用的な関数として作っていたとしましょう。
ところが、開発の途中、引数としてMaybe Intをとる以下のような関数を使いたくなったとします。

maybeAdd1 :: Maybe Int -> Maybe Int
maybeAdd1 n = case n of
Just n -> Just (n + 1)
Nothing -> Nothing

maybeAdd1の計算を実現するために、当初定義していたadd1関数を作り直す必要があるのでしょうか。
また、maybeAdd1のような関数を再定義する必要があるのでしょうか。
その必要はありません。これまで見てきたように、fmapを使ってadd1関数を持ち上げてやれば解決です。

*Main>let fAdd1 = fmap add1 -- add1関数をfmapにより、Functorのコンテキストで使えるよう持ち上げ
*Main>:t fAdd1 --fmapによる持ち上げにより作られたfAdd1関数の型を表示
fAdd1 :: Functor f => f Int -> f Int --add1関数をFunctorのコンテキストで使えるようになった
*Main> fAdd1 $ Just 1 -- fAdd1関数にJust 1を与える
Just 2 --結果はJust 2となる。
*Main> fAdd1 $ Nothing --fAdd1関数にNothingを与える
Nothing --結果はNothingとなる。

特定のコンテキストに依存しない関数をfmapにより、Functorのコンテキストで使えるようにする、というのはオブジェクト指向に慣れ親しんだプログラマからすると、なかなか目から鱗ではないでしょうか。私もそうでした。

まだまだ筆者も勉強中ですが、次は時間があったら、Applicativeとか取り上げたいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Androidエンジニアが関数型プログラミングに手を出してみる〜Functor編〜

最近ではKotlin製の関数型プログラミングを手助けするライブラリである「Arrow」などにより、Kotlinで関数型プログラミング中心のプログラムが組みやすくなっています。

関数型プログラミング言語を勉強していると、Monadだけでなく、FunctorとかApplicativeなど、オブジェクト指向に慣れ親しんだプログラマには聞きなれない言葉が出てきます。

「Arrow」を使うとなっても、基本的な関数型プログラミングの知識がないと、結局使いどころが分からず、そのまま使わなくなってしまう可能性もあります。
今回は自分の勉強も兼ねて、Functorについておさらいをします。

※筆者は圏論の専門家ではないので、あくまでアプリケーションを作るプログラマ視点で、Functorの概要を整理しています。

Functorのおさらい

Functorのおさらいをするにあたり、Haskellを説明に使用します。
HaskellでのFunctorのソースコードを抜粋します。
以下、https://hackage.haskell.org/package/base-4.12.0.0/docs/src/GHC.Base.html#Functorより抜粋。

class Functor f where
    fmap :: (a -> b) -> f a -> f b

Functorは「fmap」という関数を提供します。fmapは「引数として、型aを取り型bを返す関数と、f aを取り、f bを返す」関数です。

※上記の「f a」と「f b」の「f」はFunctorを表す。
※上記の型「a」および型「b」は任意の型を表す。

※あるインスタンスがFunctorであるためには、上記fmapの実装がFunctor則を満たす必要があります。詳しくは、以下のURLなどを参照。
https://hackage.haskell.org/package/base-4.12.0.0/docs/Data-Functor.html

Functorのfmap関数の使いどころ

上記のFunctorの説明に対して、見方を変えると、
「型aを取り型bを返す関数fに対してfmapを適用することで、その関数fをFunctorのコンテキストに持ち上げることができる」と読むこともできます。

文章だけだと分かりにくいので、以下に例を示します。

Prelude> let f = (+1) -- 関数 (+1)をfに束縛
Prelude> :t f -- fの型を表示
f :: Num a => a -> a -- fはNumクラスのインスタンスである型aを引数に取り、型aを返す関数
Prelude> let g = fmap f -- fに対して、fmapを適用する実装をgとして束縛
Prelude> :t g -- gの型を表示
g :: (Functor f, Num a) => f a -> f a --gは 「型(f a) を引数に取り、型(f a)を返す関数である。ここで、fはFunctorクラスのインスタンス、aはNumクラスのインスタンスである。

関数「f :: a -> a」にfmapを適用することにより、関数「g :: f a -> f a」を得ることができました。
これの何が嬉しいのか、イメージが湧きづらいと思いますので、もう少し詳しく見てみます。

fmapが適用された関数は、どのFunctorに関しても利用できる

上記の通り、関数「f :: a -> a」にfmapを適用することにより、関数「g :: f a -> f a」を得ることができました。

関数gの型をもう一度見てみます。

g :: (Functor f, Num a) => f a -> f a --ここでは、「g = fmap f」として束縛されている。

型を見てわかる通り、引数のfに対して、「Functor」であることという制約を課していますが、それ以上の制約は課していません。なので、上記fに対して、どんなFunctorでも適用できるということになります。
例を見てみましょう。

※以下のMaybe、およびEither StringはいずれもFunctor則を満たすfmapを提供するインスタンスです

関数gにMaybe Int型を適用してみる

関数gにMaybeInt型を適用した場合の型を表示
Prelude> :t g $ (Just 1 :: Maybe Int) --gにMaybe Int型の値を適用した結果の型を調べる
g $ (Just 1 :: Maybe Int) :: Maybe Int --結果は、Maybe Intとなる。fの部分にMaybe、aの部分にIntが適用された形となる。
実際に関数gにMaybeInt型の値を適用
Prelude> g $ (Just 1 :: Maybe Int) --実際に関数gにMaybe Int型である「Just 1」を適用
Just 2 --「g = fmap f = (+1)」なので、結果は「Just 2」となる。

関数gにEither String Int型を適用してみる

関数gにEitherを適用した場合の型を表示
Prelude> :t g $ (Right 1 :: Either String Int) --gにEither String Int型の値を適用した結果の型を調べる
g $ (Right 1 :: Either String Int) :: Either String Int --結果は、Either String Intとなる。fの部分にEither String、aの部分にIntが適用された形となる。
実際に関数gにEitherの値を適用
Prelude>  g $ (Right 1 :: Either String Int) --実際に関数gにEither String Int型である「Just 1」を適用
Right 2 --「g = fmap f = (+1)」なので、結果は「Right 2」となる。

上記の通り、関数fをfmapによる持ち上げにより束縛した関数gは、引数としてMaybe Int、Either String Intを取っています。

もうちょっとFunctorの何が嬉しいか考えてみる

これまでで、fmapの適用により持ち上がられた関数gは、どのFunctorインスタンスに対しても引数として取れるようになることがわかりました。ここで、視点を変えてみます。

改めて今回使用した関数fと関数gの定義を見てみましょう。

今回使用した関数fと関数gの定義
let f = (+1)
let g = fmap f

fmapにより関数gを定義したとしても、「f = (+1)」の定義はそのままです。
これは、「関数fという特定のFunctorインスタンスに依存しない関数の定義はそのままで、fmapの適用により、どのFunctorインスタンスも引数に取れる関数を新たに作ることができる」と考えることができます。
これは素晴らしい。

例えば、開発の初期に以下のような関数を作ったとします。

add1 :: Int -> Int
add1 = (+1)

当初、上記のadd1関数は引数としてFunctorに依存しない汎用的な関数として作っていたとしましょう。
ところが、開発の途中、引数としてMaybe Intをとる以下のような関数を使いたくなったとします。

maybeAdd1 :: Maybe Int -> Maybe Int
maybeAdd1 n = case n of
Just n -> Just (n + 1)
Nothing -> Nothing

maybeAdd1の計算を実現するために、当初定義していたadd1関数を作り直す必要があるのでしょうか。
また、maybeAdd1のような関数を再定義する必要があるのでしょうか。
その必要はありません。これまで見てきたように、fmapを使ってadd1関数を持ち上げてやれば解決です。

*Main>let fAdd1 = fmap add1 -- add1関数をfmapにより、Functorのコンテキストで使えるよう持ち上げ
*Main>:t fAdd1 --fmapによる持ち上げにより作られたfAdd1関数の型を表示
fAdd1 :: Functor f => f Int -> f Int --add1関数をFunctorのコンテキストで使えるようになった
*Main> fAdd1 $ Just 1 -- fAdd1関数にJust 1を与える
Just 2 --結果はJust 2となる。
*Main> fAdd1 $ Nothing --fAdd1関数にNothingを与える
Nothing --結果はNothingとなる。

特定のコンテキストに依存しない関数をfmapにより、Functorのコンテキストで使えるようにする、というのはオブジェクト指向に慣れ親しんだプログラマからすると、なかなか目から鱗ではないでしょうか。私もそうでした。

まだまだ筆者も勉強中ですが、次は時間があったら、Applicativeとか取り上げたいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

モバイル初心者がQiitaのスマホアプリを爆速で個人開発した話【React Native】

はじめに

何についての記事?

React Nativeを使って3週間弱でQiitaの非公式スマホアプリを個人開発・公開したのでその時の所感を書き連ねようと思います.
個人開発する上での反省点や、使用したフレームワークであるReact Nativeのこと等を書くつもりです.
なお、現状Android版しか開発していないので以下はすべてAndroidの話になります.

作ったもの

「Qiita API v2」を用いてアプリ上で閲覧や記事のストック、タグのフォロー等、一通りの操作ができるものを作りました.
「Qiita in Mobile App」ということで、アプリ名は「QiiMa!」です.

eyecatch.png

Google Play Storeで見る

「某ニュースアプリ的な感覚でQiitaの記事が読めたらいいな」
っていう自分の欲望を解決するアプリになってます.

機能としては,

  • 記事の閲覧、いいね、ストック
  • ストックした記事の一覧表示
  • ユーザー情報の閲覧、フォロー
  • タグ情報の閲覧、フォロー
  • フォローしているタグがついた記事の閲覧
  • 記事検索
  • アプリ上で読んだ記事の履歴閲覧
  • アプリトップページに表示する記事一覧の並び替え

等必要十分な機能を搭載したつもりです.
個人的には、

  • フォローしているタグのトレンド、新着記事が見れる.
  • さらにその表示するタブの順序をカスタマイズできる.

点が素晴らしいと思ってます(自画自賛)

使用技術

このアプリはReact Nativeというクロスプラットフォームのモバイル開発フレームワークで作ってます.

開発について

期間

githubのログを見る限り、開発着手から公開まで19日間です.
完成物を見ると、「19日も掛けてこんな完成度かい」って気はしますが、

  • モバイル開発全然したことない(数年前に電卓アプリ作ってみた程度)
  • React Nativeに初めて触れた
  • そもそもJavaScript歴すら1ヶ月位

って状態だからなのでそのへんは温かい目で見てください.

手法

ぶっちゃけノリと勢いでゴリゴリコーディングするスタイルでやってました.
反省点としては、

  • ロードマップが不明瞭すぎた
  • 行き当りばったりなので、午前中に書いたコードを午後に全部消して書き直したりしてた
  • 何を実装して何を見送るのかの基準があいまいだった

などなど、枚挙に暇がないくらい思いつきますが、全ては 計画性のNASA に帰着します.
というのも、もともと
「ちょっとReact Native勉強すっか」
くらいの気持ちで始めたので、コーディング以外の部分を疎かにしていました.
仕様書とか、ロードマップとかを最初に定義しておくことの大切さを身を以て実感しました.
そのへんの手順をしっかり踏んでいれば結果的にもっと短い期間で完成できたと思います.

ただ、あくまで今回の本質的な目標は「React Nativeの勉強」なので上記の反省点は、「アプリリリース」の観点から見た結果論ですけども・・・

React Nativeについて

今回始めてReact Nativeに触れたので、良かった点や辛かった点について書いてみようと思います.

良かった点

  • そのへんに便利なライブラリが転がってる
  • ホットリロードが便利
  • 直感的に書ける(個人的感想)
  • component思想が書きやすい(個人的感想)

パッと思いつくのはこのぐらいです.

そのへんに便利なライブラリが転がってる

React Nativeにはサードパーティ製の便利なライブラリがたくさんあって、簡単にインストールして使うことが出来ます.
例えば、アプリ内でスクロールできるタブバーがありますが、

scrollable.gif

これはreact-native-scrollable-tab-viewというライブラリを使用しています.

これを自力実装するとなると結構しんどいと思うのですが、うまくこういうライブラリを利用すれば、効率的に開発をすすめることが出来ます.
さらに、「流石にこんなニッチなライブラリないやろ」っていうものも案外公開されてたりもします.
ただ割とこれは諸刃の剣のような気はしています(後述)

ホットリロードが便利

コード上での変更点が、リビルドとか無しでもすぐにアプリ側に反映されます.
細かいデザインの修正などのときに重宝しました.

直感的に書ける(個人的感想)

他の言語やフレームワークを用いたモバイル開発の経験がないのでかなり主観的ですが、React nativeは直感的にコーディングできて書きやすいと感じました.

例えば、何かしらのボタンが押されたときに処理を走らせるような実装にするとき、
Javaとかだと、別ファイルに記述したボタンのid取ってきイベントリスナ設定して...
みたいな感じだと思います(違ったらごめんなさい).
これがReact Nativeだと、ボタンの描画を設定する際にそのままonPressプロパティとして関数を渡せます.
このあたりは、設計思想とか色々あるとは思うのですが、とりあえず初心者の自分にとっては直感的で書きやすかったという感想です.

component思想が書きやすい(個人的感想)

これも初心者の自分としての感想ですが、アプリ全体を細かいcomponentの寄せ集めみたいなイメージで構築できるので書きやすかったです.
どのcomponentがどのデータを持っていて、どういうふうに描画されるのかを考えてcomponent を作り、最後にデータの受け渡しなどを考えながらアプリ全体を構築していくといった感じです.

辛かった点

  • 使うライブラリを間違えると地獄
  • 情報がそこまで多くない
  • 先行きがちょっと不安

使うライブラリを間違えると地獄

良い点で便利なライブラリが多いということを書きましたが、反面それは辛い点にもなり得ました.

まず、React Native自体まだvarsion 1.0に達していないので、結構破壊的アップデートが多いようです(自分はまだ3週間目くらいなので伝聞ですが).
そのため、良さげなライブラリを見つけても、ちょっと更新されてなかったりすると簡単にエラー祭り開催が決定してしまいます.
また、エラーは出ないが挙動は正しくないみたいなケースもあります.

なので、便利なライブラリを見つけても、githubのissueやstar数などから、使用に耐えるかどうかを判断する力が大事になってくると思います.
自分が開発してる中でも、「便利なライブラリがあったけど、結局自分で実装したほうが確実で安定している」というケースのも多々ありました.

情報がそこまで多くない

そこまでメジャーなフレームワークではないということで、まだまだ情報は多くはなかったです.
せっかくヒットした情報も、バージョン違いで現バージョンには対応していないとかいうケースも多々あります.
なのでしょうもないことが原因で時間が溶けていくこともありました.
ただ、OSSなのでそのへんのライブラリ含めソースコードがたくさん落ちているので、それらを見れば意外となんとかなったりはします.

先行きがちょっと不安

辛い点1個めの話にも繋がるのですが、今動いているコードが次のバージョンでは動かないということは有り得るので、そこが不安ではあります.
枯れてない技術は全部そうでしょって言われたらそうなのですが...

React Native自体は今後もっと発展していくと信じているのですが、周辺ライブラリはちょっとどうなるか分からないものもあるので、効率と安定のいいバランスを保ちながらライブラリを選定する力が大事になりそうですね(2回目).

アプリの改善点

このアプリはノリと勢いで開発して、ノリと勢いで公開したので、結構改善点があります...
一番大きいものとしては

  • 記事の表示がたまに正しくない

という改善点があります.

記事は、APIで得られたMarkdownをライブラリを用いてレンダリングしているのですが、表記の揺れなどの影響でうまくレンダリングされずにそのままの文字列として表示されてしまうことがあります.
気づいた範囲で対処しているのですが、なかなか全部には対応しきれていないです.
また、数式のレンダリングは、ブラウザであればMathjaxで表示できるのですが、そこにも対応しきれておらず...
h1タグやリンクが正しく表示されない問題は、APIでhtmlを取得してレンダリングすれば解決できるのですが、そうなると今度はインラインブロックやブロックコードのシンタックスハイライトの描画が難しくなったり...
しかも、結局数式をレンダリングしようと思ったらhtmlでも無理なので、Web Viewを用いることになるのですが、それはそれでブラウザでもQiitaにログインしなければならなかったりで...
現状はとりあえず潰せる表記の揺れは潰して、それでも読みづらい記事は「ブラウザで開くボタン」からお好きなブラウザで読めるようにしています.
アプリのコンセプトとしては、トレンドやストックした記事へのアクセスを簡単にしたいというところがメインなので、正しいレンダリングは後回しになってしまっています...

iOS対応について

現状iOSには対応する予定はありません.
なぜならば、developer登録年間1万円は厳しいので....

その他

今回このアプリを開発・公開するにあたって、QiitaのAPI等を利用しているので、ちゃんとQiitaのサポートの方に問い合わせを行ったのですが、
どこぞのよく分からん学生にも関わらず懇切丁寧に対応していただいてびっくりしました.
軽くあしらわれても仕方ないなくらいの気持ちで問い合わせを行ったのですが、なんでも言ってみるもんですねぇ.

最後に

React Nativeの勉強が目的だったのですが、せっかく作ったので興味があれば使って改善点などフィードバックしていただけると泣いて喜びます.

また、自分と同じくReact Native初心者のためになりそうな知見なども得れたので、
需要があれば そのうち記事化しようと思います!

最後にもう一回リンクを張っておきます!
Google Play Storeで見る

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

モバイル初心者がQiitaのスマホアプリを個人開発した話【React Native】

はじめに

何についての記事?

React Nativeを使って3週間弱でQiitaの非公式スマホアプリを個人開発・公開したのでその時の所感を書き連ねようと思います.
個人開発する上での反省点や、使用したフレームワークであるReact Nativeのこと等を書くつもりです.
なお、現状Android版しか開発していないので以下はすべてAndroidの話になります.

作ったもの

「Qiita API v2」を用いてアプリ上で閲覧や記事のストック、タグのフォロー等、一通りの操作ができるものを作りました.
「Qiita in Mobile App」ということで、アプリ名は「QiiMa!」です.

eyecatch.png

Google Play Storeで見る

「某ニュースアプリ的な感覚でQiitaの記事が読めたらいいな」
っていう自分の欲望を解決するアプリになってます.

機能としては,

  • 記事の閲覧、いいね、ストック
  • ストックした記事の一覧表示
  • ユーザー情報の閲覧、フォロー
  • タグ情報の閲覧、フォロー
  • フォローしているタグがついた記事の閲覧
  • 記事検索
  • アプリ上で読んだ記事の履歴閲覧
  • アプリトップページに表示する記事一覧の並び替え

等必要十分な機能を搭載したつもりです.
個人的には、

  • フォローしているタグのトレンド、新着記事が見れる.
  • さらにその表示するタブの順序をカスタマイズできる.

点が素晴らしいと思ってます(自画自賛)

使用技術

このアプリはReact Nativeというクロスプラットフォームのモバイル開発フレームワークで作ってます.

開発について

期間

githubのログを見る限り、開発着手から公開まで19日間です.
完成物を見ると、「19日も掛けてこんな完成度かい」って気はしますが、

  • モバイル開発全然したことない(数年前に電卓アプリ作ってみた程度)
  • React Nativeに初めて触れた
  • そもそもJavaScript歴すら1ヶ月位

って状態だからなのでそのへんは温かい目で見てください.

手法

ぶっちゃけノリと勢いでゴリゴリコーディングするスタイルでやってました.
反省点としては、

  • ロードマップが不明瞭すぎた
  • 行き当りばったりなので、午前中に書いたコードを午後に全部消して書き直したりしてた
  • 何を実装して何を見送るのかの基準があいまいだった

などなど、枚挙に暇がないくらい思いつきますが、全ては 計画性のNASA に帰着します.
というのも、もともと
「ちょっとReact Native勉強すっか」
くらいの気持ちで始めたので、コーディング以外の部分を疎かにしていました.
仕様書とか、ロードマップとかを最初に定義しておくことの大切さを身を以て実感しました.
そのへんの手順をしっかり踏んでいれば結果的にもっと短い期間で完成できたと思います.

ただ、あくまで今回の本質的な目標は「React Nativeの勉強」なので上記の反省点は、「アプリリリース」の観点から見た結果論ですけども・・・

React Nativeについて

今回始めてReact Nativeに触れたので、良かった点や辛かった点について書いてみようと思います.

良かった点

  • そのへんに便利なライブラリが転がってる
  • ホットリロードが便利
  • 直感的に書ける(個人的感想)
  • component思想が書きやすい(個人的感想)

パッと思いつくのはこのぐらいです.

そのへんに便利なライブラリが転がってる

React Nativeにはサードパーティ製の便利なライブラリがたくさんあって、簡単にインストールして使うことが出来ます.
例えば、アプリ内でスクロールできるタブバーがありますが、

scrollable.gif

これはreact-native-scrollable-tab-viewというライブラリを使用しています.

これを自力実装するとなると結構しんどいと思うのですが、うまくこういうライブラリを利用すれば、効率的に開発をすすめることが出来ます.
さらに、「流石にこんなニッチなライブラリないやろ」っていうものも案外公開されてたりもします.
ただ割とこれは諸刃の剣のような気はしています(後述)

ホットリロードが便利

コード上での変更点が、リビルドとか無しでもすぐにアプリ側に反映されます.
細かいデザインの修正などのときに重宝しました.

直感的に書ける(個人的感想)

他の言語やフレームワークを用いたモバイル開発の経験がないのでかなり主観的ですが、React nativeは直感的にコーディングできて書きやすいと感じました.

例えば、何かしらのボタンが押されたときに処理を走らせるような実装にするとき、
Javaとかだと、別ファイルに記述したボタンのid取ってきイベントリスナ設定して...
みたいな感じだと思います(違ったらごめんなさい).
これがReact Nativeだと、ボタンの描画を設定する際にそのままonPressプロパティとして関数を渡せます.
このあたりは、設計思想とか色々あるとは思うのですが、とりあえず初心者の自分にとっては直感的で書きやすかったという感想です.

component思想が書きやすい(個人的感想)

これも初心者の自分としての感想ですが、アプリ全体を細かいcomponentの寄せ集めみたいなイメージで構築できるので書きやすかったです.
どのcomponentがどのデータを持っていて、どういうふうに描画されるのかを考えてcomponent を作り、最後にデータの受け渡しなどを考えながらアプリ全体を構築していくといった感じです.

辛かった点

  • 使うライブラリを間違えると地獄
  • 情報がそこまで多くない
  • 先行きがちょっと不安

使うライブラリを間違えると地獄

良い点で便利なライブラリが多いということを書きましたが、反面それは辛い点にもなり得ました.

まず、React Native自体まだvarsion 1.0に達していないので、結構破壊的アップデートが多いようです(自分はまだ3週間目くらいなので伝聞ですが).
そのため、良さげなライブラリを見つけても、ちょっと更新されてなかったりすると簡単にエラー祭り開催が決定してしまいます.
また、エラーは出ないが挙動は正しくないみたいなケースもあります.

なので、便利なライブラリを見つけても、githubのissueやstar数などから、使用に耐えるかどうかを判断する力が大事になってくると思います.
自分が開発してる中でも、「便利なライブラリがあったけど、結局自分で実装したほうが確実で安定している」というケースのも多々ありました.

情報がそこまで多くない

そこまでメジャーなフレームワークではないということで、まだまだ情報は多くはなかったです.
せっかくヒットした情報も、バージョン違いで現バージョンには対応していないとかいうケースも多々あります.
なのでしょうもないことが原因で時間が溶けていくこともありました.
ただ、OSSなのでそのへんのライブラリ含めソースコードがたくさん落ちているので、それらを見れば意外となんとかなったりはします.

先行きがちょっと不安

辛い点1個めの話にも繋がるのですが、今動いているコードが次のバージョンでは動かないということは有り得るので、そこが不安ではあります.
枯れてない技術は全部そうでしょって言われたらそうなのですが...

React Native自体は今後もっと発展していくと信じているのですが、周辺ライブラリはちょっとどうなるか分からないものもあるので、効率と安定のいいバランスを保ちながらライブラリを選定する力が大事になりそうですね(2回目).

アプリの改善点

このアプリはノリと勢いで開発して、ノリと勢いで公開したので、結構改善点があります...
一番大きいものとしては

  • 記事の表示がたまに正しくない

という改善点があります.

記事は、APIで得られたMarkdownをライブラリを用いてレンダリングしているのですが、表記の揺れなどの影響でうまくレンダリングされずにそのままの文字列として表示されてしまうことがあります.
気づいた範囲で対処しているのですが、なかなか全部には対応しきれていないです.
また、数式のレンダリングは、ブラウザであればMathjaxで表示できるのですが、そこにも対応しきれておらず...
h1タグやリンクが正しく表示されない問題は、APIでhtmlを取得してレンダリングすれば解決できるのですが、そうなると今度はインラインブロックやブロックコードのシンタックスハイライトの描画が難しくなったり...
しかも、結局数式をレンダリングしようと思ったらhtmlでも無理なので、Web Viewを用いることになるのですが、それはそれでブラウザでもQiitaにログインしなければならなかったりで...
現状はとりあえず潰せる表記の揺れは潰して、それでも読みづらい記事は「ブラウザで開くボタン」からお好きなブラウザで読めるようにしています.
アプリのコンセプトとしては、トレンドやストックした記事へのアクセスを簡単にしたいというところがメインなので、正しいレンダリングは後回しになってしまっています...

iOS対応について

現状iOSには対応する予定はありません.
なぜならば、developer登録年間1万円は厳しいので....

その他

今回このアプリを開発・公開するにあたって、QiitaのAPI等を利用しているので、ちゃんとQiitaのサポートの方に問い合わせを行ったのですが、
どこぞのよく分からん学生にも関わらず懇切丁寧に対応していただいてびっくりしました.
軽くあしらわれても仕方ないなくらいの気持ちで問い合わせを行ったのですが、なんでも言ってみるもんですねぇ.

最後に

React Nativeの勉強が目的だったのですが、せっかく作ったので興味があれば使って改善点などフィードバックしていただけると泣いて喜びます.

また、自分と同じくReact Native初心者のためになりそうな知見なども得れたので、
需要があれば そのうち記事化しようと思います!

最後にもう一回リンクを張っておきます!
Google Play Storeで見る

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【React Native】Qiitaのスマホアプリを3週間弱で個人開発した話

はじめに

何についての記事?

React Nativeを使って3週間弱でQiitaのスマホアプリを個人開発・公開したのでその時の所感を書き連ねようと思います.
個人開発する上での反省点や、使用したフレームワークであるReact Nativeのこと等を書くつもりです.
なお、現状Android版しか開発していないので以下はすべてAndroidの話になります.

作ったもの

「Qiita API v2」を用いてアプリ上で閲覧や記事のストック、タグのフォロー等、一通りの操作ができるものを作りました.
「Qiita in Mobile App」ということで、アプリ名は「QiiMa!」です.

eyecatch.png

Google Play Storeで見る

「某ニュースアプリ的な感覚でQiitaの記事が読めたらいいな」
っていう自分の欲望を解決するアプリになってます.

機能としては,

  • 記事の閲覧、いいね、ストック
  • ストックした記事の一覧表示
  • ユーザー情報の閲覧、フォロー
  • タグ情報の閲覧、フォロー
  • フォローしているタグがついた記事の閲覧
  • 記事検索
  • アプリ上で読んだ記事の履歴閲覧
  • アプリトップページに表示する記事一覧の並び替え

等必要十分な機能を搭載したつもりです.
個人的には、

  • フォローしているタグのトレンド、新着記事が見れる.
  • さらにその表示するタブの順序をカスタマイズできる.

点が素晴らしいと思ってます(自画自賛)

使用技術

このアプリはReact Nativeというクロスプラットフォームのモバイル開発フレームワークで作ってます.

開発について

期間

githubのログを見る限り、開発着手から公開まで19日間です.
完成物を見ると、「19日も掛けてこんな完成度かい」って気はしますが、

  • モバイル開発全然したことない(数年前に電卓アプリ作ってみた程度)
  • React Nativeに初めて触れた
  • そもそもJavaScript歴すら1ヶ月位

って状態だからなのでそのへんは温かい目で見てください.

手法

ぶっちゃけノリと勢いでゴリゴリコーディングするスタイルでやってました.
反省点としては、

  • ロードマップが不明瞭すぎた
  • 行き当りばったりなので、午前中に書いたコードを午後に全部消して書き直したりしてた
  • 何を実装して何を見送るのかの基準があいまいだった

などなど、枚挙に暇がないくらい思いつきますが、全ては 計画性のNASA に帰着します.
というのも、もともと
「ちょっとReact Native勉強すっか」
くらいの気持ちで始めたので、コーディング以外の部分を疎かにしていました.
仕様書とか、ロードマップとかを最初に定義しておくことの大切さを身を以て実感しました.
そのへんの手順をしっかり踏んでいれば結果的にもっと短い期間で完成できたと思います.

ただ、あくまで今回の本質的な目標は「React Nativeの勉強」なので上記の反省点は、「アプリリリース」の観点から見た結果論ですけども・・・

React Nativeについて

今回始めてReact Nativeに触れたので、良かった点や辛かった点について書いてみようと思います.

良かった点

  • そのへんに便利なライブラリが転がってる
  • ホットリロードが便利
  • 直感的に書ける(個人的感想)
  • component思想が書きやすい(個人的感想)

パッと思いつくのはこのぐらいです.

そのへんに便利なライブラリが転がってる

React Nativeにはサードパーティ製の便利なライブラリがたくさんあって、簡単にインストールして使うことが出来ます.
例えば、アプリ内でスクロールできるタブバーがありますが、

scrollable.gif

これはreact-native-scrollable-tab-viewというライブラリを使用しています.

これを自力実装するとなると結構しんどいと思うのですが、うまくこういうライブラリを利用すれば、効率的に開発をすすめることが出来ます.
さらに、「流石にこんなニッチなライブラリないやろ」っていうものも案外公開されてたりもします.
ただ割とこれは諸刃の剣のような気はしています(後述)

ホットリロードが便利

コード上での変更点が、リビルドとか無しでもすぐにアプリ側に反映されます.
細かいデザインの修正などのときに重宝しました.

直感的に書ける(個人的感想)

他の言語やフレームワークを用いたモバイル開発の経験がないのでかなり主観的ですが、React nativeは直感的にコーディングできて書きやすいと感じました.

例えば、何かしらのボタンが押されたときに処理を走らせるような実装にするとき、
Javaとかだと、別ファイルに記述したボタンのid取ってきイベントリスナ設定して...
みたいな感じだと思います(違ったらごめんなさい).
これがReact Nativeだと、ボタンの描画を設定する際にそのままonPressプロパティとして関数を渡せます.
このあたりは、設計思想とか色々あるとは思うのですが、とりあえず初心者の自分にとっては直感的で書きやすかったという感想です.

component思想が書きやすい(個人的感想)

これも初心者の自分としての感想ですが、アプリ全体を細かいcomponentの寄せ集めみたいなイメージで構築できるので書きやすかったです.
どのcomponentがどのデータを持っていて、どういうふうに描画されるのかを考えてcomponent を作り、最後にデータの受け渡しなどを考えながらアプリ全体を構築していくといった感じです.

辛かった点

  • 使うライブラリを間違えると地獄
  • 情報がそこまで多くない
  • 先行きがちょっと不安

使うライブラリを間違えると地獄

良い点で便利なライブラリが多いということを書きましたが、反面それは辛い点にもなり得ました.

まず、React Native自体まだvarsion 1.0に達していないので、結構破壊的アップデートが多いようです(自分はまだ3週間目くらいなので伝聞ですが).
そのため、良さげなライブラリを見つけても、ちょっと更新されてなかったりすると簡単にエラー祭り開催が決定してしまいます.
また、エラーは出ないが挙動は正しくないみたいなケースもあります.

なので、便利なライブラリを見つけても、githubのissueやstar数などから、使用に耐えるかどうかを判断する力が大事になってくると思います.
自分が開発してる中でも、「便利なライブラリがあったけど、結局自分で実装したほうが確実で安定している」というケースのも多々ありました.

情報がそこまで多くない

そこまでメジャーなフレームワークではないということで、まだまだ情報は多くはなかったです.
せっかくヒットした情報も、バージョン違いで現バージョンには対応していないとかいうケースも多々あります.
なのでしょうもないことが原因で時間が溶けていくこともありました.
ただ、OSSなのでそのへんのライブラリ含めソースコードがたくさん落ちているので、それらを見れば意外となんとかなったりはします.

先行きがちょっと不安

辛い点1個めの話にも繋がるのですが、今動いているコードが次のバージョンでは動かないということは有り得るので、そこが不安ではあります.
枯れてない技術は全部そうでしょって言われたらそうなのですが...

React Native自体は今後もっと発展していくと信じているのですが、周辺ライブラリはちょっとどうなるか分からないものもあるので、効率と安定のいいバランスを保ちながらライブラリを選定する力が大事になりそうですね(2回目).

アプリの改善点

このアプリはノリと勢いで開発して、ノリと勢いで公開したので、結構改善点があります...
一番大きいものとしては

  • 記事の表示がたまに正しくない

という改善点があります.

記事は、APIで得られたMarkdownをライブラリを用いてレンダリングしているのですが、表記の揺れなどの影響でうまくレンダリングされずにそのままの文字列として表示されてしまうことがあります.
気づいた範囲で対処しているのですが、なかなか全部には対応しきれていないです.
また、数式のレンダリングは、ブラウザであればMathjaxで表示できるのですが、そこにも対応しきれておらず...
h1タグやリンクが正しく表示されない問題は、APIでhtmlを取得してレンダリングすれば解決できるのですが、そうなると今度はインラインブロックやブロックコードのシンタックスハイライトの描画が難しくなったり...
しかも、結局数式をレンダリングしようと思ったらhtmlでも無理なので、Web Viewを用いることになるのですが、それはそれでブラウザでもQiitaにログインしなければならなかったりで...
現状はとりあえず潰せる表記の揺れは潰して、それでも読みづらい記事は「ブラウザで開くボタン」からお好きなブラウザで読めるようにしています.
アプリのコンセプトとしては、トレンドやストックした記事へのアクセスを簡単にしたいというところがメインなので、正しいレンダリングは後回しになってしまっています...

iOS対応について

現状iOSには対応する予定はありません.
なぜならば、developer登録年間1万円は厳しいので....

その他

今回このアプリを開発・公開するにあたって、QiitaのAPI等を利用しているので、ちゃんとQiitaのサポートの方に問い合わせを行ったのですが、
どこぞのよく分からん学生にも関わらず懇切丁寧に対応していただいてびっくりしました.
軽くあしらわれても仕方ないなくらいの気持ちで問い合わせを行ったのですが、なんでも言ってみるもんですねぇ.

最後に

React Nativeの勉強が目的だったのですが、せっかく作ったので興味があれば使って改善点などフィードバックしていただけると泣いて喜びます.

また、自分と同じくReact Native初心者のためになりそうな知見なども得れたので、
需要があれば そのうち記事化しようと思います!

最後にもう一回リンクを張っておきます!
Google Play Storeで見る

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Androidの非同期処理の方法と実装について

非同期処理関連の勉強と実装

ひとまずではありますが、非同期処理について
2020/01/26現在の学習結果をまとめましたのでここに書かせていただきます。

目次

  1. 非同期処理の種類について
    1. Thread での処理について
    2. AsyncTask での処理について
    3. AsyncTaskLoader について

Thread での処理

実装のながれは、下記のような概要ですね。
1. handlerオブジェクトを宣言する。
2. Threadでのメソッド宣言を書く。(無名クラスを作成)
3. handlerオブジェクトのpostメソッドクラスのメソッド宣言を作成(無名クラスを作成)
4. postメソッド内のクラスで処理したいことを記載(主に通信・UI変更など)

AsyncTask での処理

こちらも実装は下記の流れのようにできるようです。(ただし、今回の実装時は、パターン2で行きました)

  1. パターン1

    1. AsyncTask を親クラスとする非同期処理のクラスを作成する。
    2. doInBackGroundの処理を作成する。
    3. Activity クラスで1で作成したクラスのオブジェクトを作成し、executeonExecuter()メソッドを呼び出す。
  2. パターン2

    1. AsyncTask を無名クラスとして宣言
    2. doInBackGround を実装する。
    3. onPostexecute() にて、レイアウト変更の処理を記入

パターン1をとればactivity外に処理を分離できるかと思いましたが、Activityを継承していないクラス内で画面部品の操作を行う手段が分からなかったので、パターン2の方針を採用しました(多分ですが、UI操作を伴わない非同期処理であれば、Activityクラス内部でなく外だしが可能なのではないかと考えます。レイアウト変更を伴わない通信処理とか・・・?)

AsyncTaskLoder について

API28(Android 9ぐらいでしょうか)から非推奨になっているようです。
公式より代替えの案として、下記の2つのものがあるようです。

・ viewModel での実装
・ liveData での実装

このあたりの学習は次の記事にて、記載したいと思います。

実装

シンプルに下記の実装にして、ThreadとAsyncTaskの動作を実装して学習しました。

MainActivity.kt
package jp.ne.mks.threadsample

import android.os.AsyncTask
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.Handler
import android.view.Gravity
import java.lang.Thread.sleep
import kotlinx.android.synthetic.main.activity_main.*
import android.view.View
import android.widget.Toast


class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // AsyncTaskの確認用のメソッドは、テキストのタップ時に設定しています。
        val asyncTextChangeClickListener = object : View.OnClickListener {
            override fun onClick(v: View) {

                val task = object : AsyncTask<Void, Void, String>() {
                    override fun doInBackground(vararg voids: Void): String {
                        try {
                            return getString(R.string.async_change_text)
                        } catch (exception: InterruptedException) {
                            return "失敗していますね"
                        }
                    }

                    override fun onPostExecute(result: String) {
                        // UIスレッド
                        changeText.text = result
                        val toast = Toast.makeText(applicationContext, "おや、変更されましたよ・・・", Toast.LENGTH_SHORT)
                        toast.setGravity(Gravity.BOTTOM or Gravity.CENTER, 0, 0)
                        toast.show()

                    }
                }
                task.execute() // 実行
            }
        }
        changeText.setOnClickListener(asyncTextChangeClickListener)

    }

    // onresume()で非同期通信を行うと、Androidの初期の描画が完了したのちに非同期処理を終えて
    // 描画事項が変化する様が見やすいと考えてこの実装を行っております。

    override fun onResume() {
        super.onResume()

        // Thread を使ったパターン
        // この処理だとメインスレッドでUIを操作してしまっているので、ViewRootImpl$CalledFromWrongThreadException で落ちます。。。(コメントアウトを外したら確認ができます)
        /*Thread{
            sleep(5000)
            // 非同期通信でテキストを変更するタスク
            setContentView(R.layout.activity_main)
            changeText.text = "これはThreadによる変更ですね"
        }.start()*/

        // Handlerオブジェクトを呼び出し、 別スレッドからUIスレッドを呼び出すことになる。
        val handler = Handler()
        Thread(Runnable {
            sleep(10000)
            // Handlerを使用してメイン(UI)スレッドに処理を依頼する
            handler.post { changeText.text = getString(R.string.thread_change_text)}
        }).start()

        // AsyncTask を使ったパターン
     // ダメだったもの
        // 別クラスにて処理を作成したものの、作成したクラス内でUI部品を変更する手段が思い浮かびませんでした。
        // SampleAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, changeText)

        // できたもの
        // onCreate() でClicklistnerに設定してます。
    }
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/changeText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

※ 今回ライブラリやviewModelなどを含めていないので、次回にそのあたりを調査してサンプルをつくれたらなと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

sealed classでのParcelizeの使い方と動く仕組み

以下のようにすることで実装してくことができます。
ポイントはsealed class自体にはParcelizeをつけないで、サブクラスで実装させることです。

    sealed class Sealed(open val sealedClassProperty: String) : Parcelable {
        @Parcelize
        data class SubDataClass(val subClassProperty: String, override val sealedClassProperty: String) : Sealed(sealedClassProperty)

        @Parcelize
        class SubClass(val subClassProperty: String, override val sealedClassProperty: String) : Sealed(sealedClassProperty)

        @Parcelize
        object SubObject : Sealed("test") {
            val objStr: String = "test"
        }
    }
}

確認コード

        val p = Parcel.obtain()
        val bundle = Bundle()
        val subDataClass = Sealed.SubDataClass("a", "b")
        bundle.putParcelable("SubDataClass", subDataClass)
        val subClass = Sealed.SubClass("a", "b")
        bundle.putParcelable("SubClass", subClass)
        val subObject = Sealed.SubObject
        bundle.putParcelable("SubObject", subObject)
        p.writeBundle(bundle)

        p.setDataPosition(0)
        val restoredBundle = p.readBundle(Sealed::class.java.classLoader)
        println(restoredBundle!!.getParcelable<Sealed>("SubDataClass"))
        println(restoredBundle.getParcelable<Sealed>("SubClass"))
        println(restoredBundle.getParcelable<Sealed>("SubObject"))

        p.recycle()
    }

実行結果

I/System.out: SubDataClass(subClassProperty=a, sealedClassProperty=b) 
I/System.out: com.github.takahirom.sealedparcelable.MainActivity$Sealed$SubClass@3a66f1d 
I/System.out: com.github.takahirom.sealedparcelable.MainActivity$Sealed$SubObject@1265592

CREATORを指定しなくてなぜうまく動くのか?

デコンパイルしてみて見るとParcelizeはCREATORなどを自動生成してくれるようです。
image.png

しかし以下のコードでは親クラスでgetParcelableを呼ぶだけで、サブクラスのCREATORを指定していません。

println(restoredBundle!!.getParcelable<Sealed>("SubDataClass"))

なぜCREATORを指定しなくてなぜうまく動くのでしょうか?

Parcelは以下のようなバイト列を持っています。

image.png

以下のようにすることで見ることができます。

        p.setDataPosition(0)
        File(dataDir, "parcelable").outputStream().write(p.createByteArray())

まずここから4というのを取り出してこれがParcelableだということを取得し、

またその後にクラス名を取得し、

そこからCREATORクラスを取得します。

このようにParcelの中に保存するときにクラス名が入るため、取得するときにシールドクラスの親クラスを指定していても、うまくCREATORを使ってくれて動くようです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Androidアプリの新規プロジェクトで作られるアイコンをシンプルにしたい

Android Studioで新規プロジェクトを作成すると、以下のようなアプリアイコンが作られます。

ファイル構成は以下のようになっていると思います。

VectorDrawableに対応していないOSでも問題なく表示され、Round Iconも用意されていて、API 26以上ではAdaptive Iconになる、Adaptive Iconのforeground/backgroundがVectorDrawableになっていて、それぞれ一定の複雑さを持っている。
Androidアプリのアイコンはどうやって作ればよいのかというサンプルになっていて、デフォルトで用意されるアイコンとしては適切なものだと思います。

ただ、ちゃんとしたアプリを作る場合は適切なアイコンを作って置き換えるのでどうでもいいのですが、何かの説明に使うサンプルアプリなどの場合、わざわざアイコンを作ったりしません。
だからといってデフォルトそのままだと、無駄にファイルが多いしシンプルにしたい。
でもただの丸とかのアイコンはそれはそれでやだなと、私は思いました。

すみません、なんの役にも立たないこだわりです。
というわけで、このデフォルトで作られるアイコンをベースにしたシンプルなアイコンデータを作ります。

minSdkVersionが21以上ならVectorDrawableに

まず、minSdkVersionはもう21以上になっている場合が多いでしょう。サンプルだし。
ということでpngではなくVectorDrawableにしてしまって、解像度ごとに用意する必要をなくしちゃいましょう。

VectorDrawableはmipmap以下にはつくれないので注意しましょう。drawableに作る必要があります。

デフォルトアイコン

ただの丸とかにしたくないので、とりあえずドロイド君を組み込んだアイコンにしましょう。
デフォルトで用意されている、ic_launcher_foreground.xmlの以下のドロイド君を利用します。

ただ、このアイコンのドロップシャドウの部分は、API 24以上でないと使えないので、minSdkVersionがこれより下の場合はそのまま使えません。

というわけで、デフォルトのアイコンとして、ドロップシャドウ部分を除外して以下のようにします。

res/drawable/ic_launcher.xml
<vector
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="48dp"
    android:height="48dp"
    android:viewportWidth="72"
    android:viewportHeight="72"
    >
    <path
        android:fillColor="#008577"
        android:pathData="M36,36m-36,0a36,36 0,1 1,72 0a36,36 0,1 1,-72 0"
        />
    <path
        android:fillColor="#FFFFFF"
        android:pathData="M48.9,28L48.9,28c5.5,4 9.1,10.6 9.1,18H14c0,-7.4 3.6,-13.9 9,-17.9l-4.8,-4.9c-0.7,-0.7 -0.7,-1.9 0,-2.6c0.7,-0.8 1.9,-0.8 2.6,0l5.5,5.5c2.9,-1.5 6.2,-2.3 9.8,-2.3c3.5,0 6.8,0.9 9.7,2.3l5.4,-5.5c0.7,-0.8 1.9,-0.8 2.6,0c0.7,0.7 0.7,1.9 0,2.6L48.9,28zM44.9,38.9c1.1,0 2.1,-0.9 2.1,-2c0,-1.1 -0.9,-2 -2.1,-2c-1.1,0 -2.1,0.9 -2.1,2C42.9,38 43.8,38.9 44.9,38.9zM27.1,38.9c1.1,0 2.1,-0.9 2.1,-2c0,-1.1 -0.9,-2 -2.1,-2c-1.1,0 -2.1,0.9 -2.1,2C25,38 25.9,38.9 27.1,38.9z"
        />
</vector>

絵的にはこうなります。

以下の部分が背景の丸の部分ですので、android:fillColorの色を変えることで背景色の違うアイコンが作れます。

<path
    android:fillColor="#008577"
    android:pathData="M36,36m-36,0a36,36 0,1 1,72 0a36,36 0,1 1,-72 0"
    />

サンプルアプリのアイコンとしては十分。

リソースの場所としては res/drawable/ic_launcher.xml になります。mipmapではないので、AndroidManifestのアイコン情報も書き換えましょう。同じアイコンなのでandroid:roundIconは削除しちゃってもいいかも。

AndroidManifest.xml
<application
    ...
    android:icon="@drawable/ic_launcher"
    android:roundIcon="@drawable/ic_launcher"
    ...
    >

これ一つで十分かもしれない。

API 24以上でドロップシャドウをつける

API 24以上ではVectorDrawableでグラデーションが使えるようになっているので、ドロップシャドウのところを組み込みましょう

res/drawable-anydpi-v24/ic_launcher.xml
<vector
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:aapt="http://schemas.android.com/aapt"
    android:width="48dp"
    android:height="48dp"
    android:viewportWidth="72"
    android:viewportHeight="72"
    >
    <path
        android:fillColor="#008577"
        android:pathData="M36,36m-36,0a36,36 0,1 1,72 0a36,36 0,1 1,-72 0"
        />
    <path
        android:fillType="evenOdd"
        android:pathData="M14,46C14,46 20.39,34.99 26.13,32.95C33.37,30.37 52.14,31.57 52.14,31.57L70,48L40,72L14,46Z"
        android:strokeWidth="1"
        android:strokeColor="#00000000"
        >
        <aapt:attr name="android:fillColor">
            <gradient
                android:endX="50.5885"
                android:endY="61.9159"
                android:startX="25.7653"
                android:startY="38.0927"
                android:type="linear"
                >
                <item
                    android:color="#40000000"
                    android:offset="0.0"
                    />
                <item
                    android:color="#00000000"
                    android:offset="1.0"
                    />
            </gradient>
        </aapt:attr>
    </path>
    <path
        android:fillColor="#FFFFFF"
        android:pathData="M48.9,28L48.9,28c5.5,4 9.1,10.6 9.1,18H14c0,-7.4 3.6,-13.9 9,-17.9l-4.8,-4.9c-0.7,-0.7 -0.7,-1.9 0,-2.6c0.7,-0.8 1.9,-0.8 2.6,0l5.5,5.5c2.9,-1.5 6.2,-2.3 9.8,-2.3c3.5,0 6.8,0.9 9.7,2.3l5.4,-5.5c0.7,-0.8 1.9,-0.8 2.6,0c0.7,0.7 0.7,1.9 0,2.6L48.9,28zM44.9,38.9c1.1,0 2.1,-0.9 2.1,-2c0,-1.1 -0.9,-2 -2.1,-2c-1.1,0 -2.1,0.9 -2.1,2C42.9,38 43.8,38.9 44.9,38.9zM27.1,38.9c1.1,0 2.1,-0.9 2.1,-2c0,-1.1 -0.9,-2 -2.1,-2c-1.1,0 -2.1,0.9 -2.1,2C25,38 25.9,38.9 27.1,38.9z"
        />
</vector>

見た目はこうなります。

前述と同様に、背景部分の色指定を変更することでバリエーションが作れます。

API 24以上でしか使えないので、minSdkVersionがそれ以下の場合は res/drawable-anydpi-v24/ic_launcher.xml において、API 24以上で適用されるようにしておきます。

API 26以上でAdaptive Iconにする

ここまで来たので(?)API 26ではAdaptive Iconにしましょう。これはデフォルトで用意されるものほぼそのままです。
ただ、前述のアイコンを作るためにiconの指定がdrawableになっているので、サンプルで作られるres/mipmap-anydpi-v26res/drawable-anydpi-v26 に移動させる必要があります。

デフォルトで用意されているアイコンの背景は格子模様がありますが、無地でつくってきたのでこいつも無地にしちゃいます。また、ic_launcher_background.xmlres/drawableic_launcher_foreground.xmlres/drawable-anydpi-v24 以下にありますが、使われるのはAPI 26以上のときのみなので、これらも移動させちゃいましょう。

res/drawable-anydpi-v26/ic_launcher.xml
<adaptive-icon
    xmlns:android="http://schemas.android.com/apk/res/android">
    <background android:drawable="@drawable/ic_launcher_background"/>
    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
res/drawable-anydpi-v26/ic_launcher_background.xml
<vector
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="108dp"
    android:height="108dp"
    android:viewportWidth="108"
    android:viewportHeight="108"
    >
    <path
        android:fillColor="#008577"
        android:pathData="M0,0h108v108h-108z"
        />
</vector>
res/drawable-anydpi-v26/ic_launcher_foreground.xml
<vector
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:aapt="http://schemas.android.com/aapt"
    android:width="108dp"
    android:height="108dp"
    android:viewportWidth="108"
    android:viewportHeight="108"
    >
    <path
        android:fillType="evenOdd"
        android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
        android:strokeWidth="1"
        android:strokeColor="#00000000"
        >
        <aapt:attr name="android:fillColor">
            <gradient
                android:endX="78.5885"
                android:endY="90.9159"
                android:startX="48.7653"
                android:startY="61.0927"
                android:type="linear"
                >
                <item
                    android:color="#44000000"
                    android:offset="0.0"
                    />
                <item
                    android:color="#00000000"
                    android:offset="1.0"
                    />
            </gradient>
        </aapt:attr>
    </path>
    <path
        android:fillColor="#FFFFFF"
        android:fillType="nonZero"
        android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
        android:strokeWidth="1"
        android:strokeColor="#00000000"
        />
</vector>

この構成でも ic_launcher_background.xml の色を変更すればその他のアイコンと同様に背景色の変化でバリエーションが作れますね。

まとめ

ここまでやるとこうなります。

全部VectorDrawableなので、画像編集ソフトなどなしに、テキスト編集で背景色をイジればバリエーションも作れます。
サンプルアプリとして手抜き感がなくファイル構成もシンプル。と個人的には満足しています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

アンドロイドで画面に書いた数字を判別する画像認識アプリを作る(PyTorch Mobile)[アンドロイド実装編]

今回作成するアプリ

画面に書いた数字を認識する画像認識アプリをPytorch Mobileとkotlinで作る。
画像認識用のモデルとアンドロイドの機能を1から全部作る。
モデル作成編(Python)アンドロイド実装編(kotlin)の全2回に分けます。

今回のandroid studio のプロジェクト Github : https://github.com/SY-BETA/NumberRecognitionApp/tree/master

まだpythonでモデルを作ってない方はアンドロイドで画面に書いた数字を判別する画像認識アプリを作る(PyTorch Mobile)[ネットワーク作成編]で作ってください。
もしくはpythonの環境がないアンドロイドエンジニアの方やモデル作るのがめんどいという方は学習済みモデルを挙げているので、
Github: https://github.com/SY-BETA/CNN_PyTorch/blob/master/CNNModel.pt から学習済みモデルをダウンロードしてください。

今回作るもの、これ↓

作成の流れ

1.MNISTをダウンロードする (※チャネル数を3チャネルに直す必要あり)
2. 簡単なCNNモデルをpython(PyTorch)で作成
3. モデルを学習させる
4. モデルを保存
5. アンドロイドで絵を描ける機能を実装
6. アンドロイドにモデルを実装してforwardプロパゲーションする

この回でやること

5と6をやる
モデルの作成が完了したので、それをpytorch mobileを使ってアンドロイドで推論できるようにする、また画面に数字を書く機能を実装する。

依存関係

gradleに以下を追加(2020年1月25日時点)

dependencies {
    implementation 'org.pytorch:pytorch_android:1.4.0'
    implementation 'org.pytorch:pytorch_android_torchvision:1.4.0'
}

レイアウトを作成

文字を書くためのsurfaceViewをセットする
キャプチcvxbxャ.PNG

xmlファイル↓

activity_main.xml
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <FrameLayout
        android:id="@+id/frameLayout"
        android:layout_width="230dp"
        android:layout_height="230dp"
        android:layout_marginStart="24dp"
        android:layout_marginTop="24dp"
        android:layout_marginEnd="24dp"
        android:layout_marginBottom="24dp"
        android:background="@android:color/darker_gray"
        app:layout_constraintBottom_toTopOf="@+id/sampleImg"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/text1">

        <SurfaceView
            android:id="@+id/surfaceView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </FrameLayout>

    <Button
        android:id="@+id/resetBtn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="リセット"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/inferBtn"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent" />

    <Button
        android:id="@+id/inferBtn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="推論"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/resetBtn" />

    <TextView
        android:id="@+id/text1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="24dp"
        android:text="書かれた数字は"
        android:textSize="40sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/resultNum"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:text="?"
        android:textAppearance="@style/TextAppearance.AppCompat.Body2"
        android:textColor="@color/colorAccent"
        android:textSize="55sp"
        app:layout_constraintBottom_toBottomOf="@+id/text1"
        app:layout_constraintStart_toEndOf="@+id/text1"
        app:layout_constraintTop_toTopOf="@+id/text1" />

    <ImageView
        android:id="@+id/sampleImg"
        android:layout_width="100dp"
        android:layout_height="100dp"
        app:layout_constraintBottom_toTopOf="@+id/resetBtn"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:srcCompat="@mipmap/ic_launcher_round" />

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="28×28リサイズ後↓"
        app:layout_constraintBottom_toTopOf="@+id/sampleImg"
        app:layout_constraintEnd_toEndOf="@+id/sampleImg"
        app:layout_constraintStart_toStartOf="@+id/sampleImg" />
</androidx.constraintlayout.widget.ConstraintLayout>

CustomSurfaceViewを作る

描画用にsurfaceViewを使う。そのためにSurfaceView, SurfaceHolder.Callbackを継承してsurfaceViewを制御するクラスを作成。
モデルの学習したデータであるMNISTは黒背景に白線だったので、その色で描けるようにする。

コンストラクタ

各種状態を保持する変数。適当にコピペでok

DrawSurfaceView.kt
class DrawSurfaceView : SurfaceView, SurfaceHolder.Callback {

    private var surfaceHolder: SurfaceHolder? = null
    private var paint: Paint? = null
    private var path: Path? = null
    var color: Int? = null
    var prevBitmap: Bitmap? = null  /** 書いた画像を保持するビットマップ **/
    private var prevCanvas: Canvas? = null
    private var canvas: Canvas? = null

    var width: Int? = null
    var height: Int? = null

    constructor(context: Context, surfaceView: SurfaceView, surfaceWidth: Int, surfaceHeight: Int) : super(context) {
        // surfaceHolder
        surfaceHolder = surfaceView.holder

        /// surfaceViewのサイズ
        width = surfaceWidth
        height = surfaceHeight

        /// コールバック
        surfaceHolder!!.addCallback(this)

        /// ペイントの設定
        paint = Paint()
        color = Color.WHITE  // 白の線で書く
        paint!!.color = color as Int
        paint!!.style = Paint.Style.STROKE
        paint!!.strokeCap = Paint.Cap.ROUND
        paint!!.isAntiAlias = false
        paint!!.strokeWidth = 50F
    }
}

MainActivityでこのインスタンスを作成するときにレイアウトファイルのsurfaceViewの横と高さを入れるようにする。

データクラス

描画する際のpathと色を保存するデータクラスを作る。

DrawSurfaceView.kt
    //// pathクラスの情報とそのpathの色情報を保存する
    data class pathInfo(
        var path: Path,
        var color: Int
    )

インターフェースの実装と初期化メソッド

implementと、canvasとbitmapを初期化するメソッドを作る

DrawSurfaceView.kt
override fun surfaceCreated(holder: SurfaceHolder?) {
        /// bitmap,canvas初期化
        initializeBitmap()
    }

    override fun surfaceChanged(holder: SurfaceHolder?, format: Int, width: Int, height: Int) {
    }

    override fun surfaceDestroyed(holder: SurfaceHolder?) {
        /// bitmapをリサイクル(メモリーリーク防止)
        prevBitmap!!.recycle()
    }

    /// bitmapとcanvasの初期化
    private fun initializeBitmap() {
        if (prevBitmap == null) {
            prevBitmap = Bitmap.createBitmap(width!!, height!!, Bitmap.Config.ARGB_8888)
        }

        if (prevCanvas == null) {
            prevCanvas = Canvas(prevBitmap!!)
        }
        //背景黒に
        prevCanvas!!.drawColor(Color.BLACK)
    }

今回BitmapはsurfaceViewがdestroyされたときにリサイクルする。bitmapはそのままにしておくとメモリーリークが発生する危険があるので使わなくなったらリサイクルしておく。

描画メソッド

キャンパスに描画する関数を作成

DrawSurfaceView.kt
 ///// 描画する関数
    private fun draw(pathInfo: pathInfo) {
        /// ロックしてキャンバスを取得
        canvas = Canvas()
        canvas = surfaceHolder!!.lockCanvas()

        //// キャンバスのクリア
        canvas!!.drawColor(0, PorterDuff.Mode.CLEAR)

        /// 前回のビットマップをキャンバスに描画
        canvas!!.drawBitmap(prevBitmap!!, 0F, 0F, null)

        //// pathを描画
        paint!!.color = pathInfo.color
        canvas!!.drawPath(pathInfo.path, paint!!)

        /// ロックを解除
        surfaceHolder!!.unlockCanvasAndPost(canvas)
    }

    /// 画面をタッチしたときにアクションごとに関数を呼び出す
    fun onTouch(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> touchDown(event.x, event.y)
            MotionEvent.ACTION_MOVE -> touchMove(event.x, event.y)
            MotionEvent.ACTION_UP -> touchUp(event.x, event.y)
        }
        return true
    }

    ///// path クラスで描画するポイントを保持
    ///    ACTION_DOWN 時の処理
    private fun touchDown(x: Float, y: Float) {
        path = Path()
        path!!.moveTo(x, y)
    }

    ///    ACTION_MOVE 時の処理
    private fun touchMove(x: Float, y: Float) {
        path!!.lineTo(x, y)
        draw(pathInfo(path!!, color!!))
    }

    ///    ACTION_UP 時の処理
    private fun touchUp(x: Float, y: Float) {
        path!!.lineTo(x, y)
        draw(pathInfo(path!!, color!!))
        prevCanvas!!.drawPath(path!!, paint!!)
    }

キャンバスリセット機能

描画されたビットマップを初期化するメソッド

DrawSurfaceView.kt
    /// resetメソッド
    fun reset() {
        ///初期化とキャンバスクリア
        initializeBitmap()
        canvas = surfaceHolder!!.lockCanvas()
        canvas?.drawColor(0, PorterDuff.Mode.CLEAR)
        surfaceHolder!!.unlockCanvasAndPost(canvas)
    }

これでDrawSurfaceView完成。これをMainActivity.ktで実装すれば絵を描ける機能を実装できる。

作ったDrawSurfaceView.ktを実装

レイアウトのdrawSurfaceViewのサイズを取得し、DrawSurfaceViewのインスタンスを作成し、実装する。
また、リセットボタンのメソッドも呼び出せるようにする。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    var surfaceViewWidth: Int? = null
    var surfaceViewHeight: Int? = null
    var drawSurfaceView:DrawSurfaceView? = null

    /// 拡張関数
    // ViewTreeObserverを使ってViewが作成されてからsurfaceViewのサイズ取得
    private inline fun <T : View> T.afterMeasure(crossinline f: T.() -> Unit) {
        viewTreeObserver.addOnGlobalLayoutListener(object :
            ViewTreeObserver.OnGlobalLayoutListener {
            override fun onGlobalLayout() {
                if (width > 0 && height > 0) {
                    viewTreeObserver.removeOnGlobalLayoutListener(this)
                    f()
                }
            }
        })
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)


        /// ViewTreeObserberを使用
        /// surfaceViewが生成し終わってからsurfaceViewのサイズを取得
        surfaceView.afterMeasure {
            surfaceViewWidth = surfaceView.width
            surfaceViewHeight = surfaceView.height
            //// DrawrSurfaceViewのセットとインスタンス生成
            drawSurfaceView = DrawSurfaceView(
                applicationContext,
                surfaceView,
                surfaceViewWidth!!,
                surfaceViewHeight!!
            )
            /// リスナーのセット
            surfaceView.setOnTouchListener { v, event -> drawSurfaceView!!.onTouch(event) }
        }

        /// リセットボタン
        resetBtn.setOnClickListener {
            drawSurfaceView!!.reset()   /// bitmap初期化メソッドを呼び出す
            sampleImg.setImageResource(R.color.colorPrimaryDark)
            resultNum.text = "?"
        }
    }
}

ここまでうまくできたら画面に絵を描けるようになってるはず。

なんか、うまくいかねぇって方はもうGithubから全部コピペしてみてください。Github: https://github.com/SY-BETA/NumberRecognitionApp/tree/master

次からやっと PyTorch Mobile を使っていきます。

PyTorch Mobileで画像認識を実装

学習済みモデルをロードする

プロジェクトにassetsフォルダを作成する。(「UI左のapp右クリック-> 新規 -> フォルダ -> assetsフォルダ」 でできる)
その中にアンドロイドで画面に書いた数字を判別する画像認識アプリを作る(PyTorch Mobile)[ネットワーク作成編]で作った、もしくは冒頭でダウンロードした学習済みモデルを放り込む。

そのassetフォルダからパスを取得できるようにする。
MainActivity.ktonCreateに以下を追加する。

MainActivity.kt
//// assetファイルからパスを取得する関数
        fun assetFilePath(context: Context, assetName: String): String {
            val file = File(context.filesDir, assetName)
            if (file.exists() && file.length() > 0) {
                return file.absolutePath
            }
            context.assets.open(assetName).use { inputStream ->
                FileOutputStream(file).use { outputStream ->
                    val buffer = ByteArray(4 * 1024)
                    var read: Int
                    while (inputStream.read(buffer).also { read = it } != -1) {
                        outputStream.write(buffer, 0, read)
                    }
                    outputStream.flush()
                }
                return file.absolutePath
            }
        }

        /// 学習済みモデルをロード
        val module = Module.load(assetFilePath(this, "CNNModel.pt"))

assetsフォルダから画像やモデルをロードするのは結構面倒な書き方をするので注意

推論

ロードした学習済みモデルで推論ボタン押下時にフォワードプロパゲーションを行う。
またその結果を取得して表示する。
MainActivity.ktonCreateに以下を追加する。

MainActivity.kt
         // 推論ボタンクリック
        inferBtn.setOnClickListener {
            //描いた画像(bitmapを取得)
            val bitmap = drawSurfaceView!!.prevBitmap!!
            // 作成した学習済みモデルの入力サイズにリサイズ
            val bitmapResized    = Bitmap.createScaledBitmap(bitmap,28, 28, true)

            /// テンソル変換と標準化
            val inputTensor = TensorImageUtils.bitmapToFloat32Tensor(
                bitmapResized,
                TensorImageUtils.TORCHVISION_NORM_MEAN_RGB, TensorImageUtils.TORCHVISION_NORM_STD_RGB
            )

            /// 推論とその結果
            /// フォワードプロパゲーション
            val outputTensor = module.forward(IValue.from(inputTensor)).toTensor()
            val scores = outputTensor.dataAsFloatArray

            // リサイズした画像を表示
            sampleImg.setImageBitmap(bitmapResized)

            /// scoreを格納する変数
            // スコアMAXのインデックス = 画像認識で予測した数字 (モデルの作り方から)
            var maxScore: Float = 0F
            var maxScoreIdx = -1
            for (i in scores.indices) {
                Log.d("scores", scores[i].toString()) // スコア一覧をログに出力(どの数字に近いか見てみると面白い)
                if (scores[i] > maxScore) {
                    maxScore = scores[i]
                    maxScoreIdx = i
                }
            }

            // 推論結果を表示
            resultNum.text = "$maxScoreIdx"
        }

inputTensorのサイズは(1, 3, 28, 28) このサイズが入力となるようにモデルを作成する必要がある。

ここまでできたら冒頭のアプリができているはず!! 
数字を書いて予測し、遊んでみてね

おわり

全体的にみてネットワーク作成でのチャネル数の変更とかネットワークの入力サイズを合わせるのに苦労した。アンドロイドでの実装はフォワードプロパゲーションするだけなのでネットワークの作成ができるかどうかでいろいろ変わってくるなと思った。
あと、PyTorch Mobileは出たばかりだが、2週間くらいでバージョンアップしてておどろいた。

画面に書いた数字を認識できるのはやってて楽しい。今回はMNISTで手書き数字だったけど、なんか他のも転移学習とかさせたら面白そう。

今回のコードはGithubに挙げてます。
Github: https://github.com/SY-BETA/NumberRecognitionApp/tree/master

学習済みCNN モデル 
Github: https://github.com/SY-BETA/CNN_PyTorch/blob/master/CNNModel.pt

アンドロイドで画面に書いた数字を判別する画像認識アプリを作る(PyTorch Mobile)[ネットワーク作成編]

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

アンドロイドで画面に数字を書いて画像認識するアプリを作る(PyTorch Mobile)[CNNネットワーク作成編]

今回作成するアプリ

画面に書いた数字を認識する画像認識アプリをPytorch Mobileとkotlinで作る。
画像認識用のモデルとアンドロイドの機能を1から全部作る。
CNNネットワーク作成編(Python)アンドロイド実装編(kotlin)の全2回に分けます。

Python環境がないアンドロイドエンジニアの方やモデル作成がめんどいって方はアンドロイドで画面に書いた数字を判別する画像認識アプリを作る(PyTorch Mobile)[アンドロイド実装編]へ行って実装編で学習済みモデルをダウンロードして進めてください。

Githubに今回のpythonコード挙げてます
Github: https://github.com/SY-BETA/CNN_PyTorch

これ↓

作成の流れ

1.MNISTをダウンロードする (※チャネル数を3チャネルに直す必要あり)
2. 簡単なCNNモデルをpython(PyTorch)で作成
3. モデルを学習させる
4. モデルを保存
5. アンドロイドで絵を描ける機能を実装
6. アンドロイドにモデルを実装してforwardプロパゲーションする

この回でやること

1~4までやる。
python使ってモデルの保存までやる。今回使用するライブラリはPyTorch 実行環境はjupyter notebook
MNISTのデータセットをダウンロードしシンプルなCNNモデルを作成し学習させる。

MNISTダウンロード

みんな大好き手書き数字データセットMNISTをtorchvisionを使ってダウンロードする

import torch
import torchvision
import torchvision.transforms as transforms

transform = transforms.Compose([
        transforms.ToTensor()])
train = torchvision.datasets.MNIST(
    root="data/train", train=True, transform=transform, target_transform=None, download=True)
test = torchvision.datasets.MNIST(
    root="data/test", train=False, transform=transform, target_transform=None, download=True)

MNISTを見てみる

どんなデータセットか見てみる

from matplotlib import pyplot as plt
import numpy as np

print(train.data.size())
print(test.data.size())
img = train.data[0].numpy()
plt.imshow(img, cmap='gray')
print('Label:', train.targets[0])

実行結果
キャプチaaaaャ.PNG

グレースケールからRGBに変更する

MNISTのカラーチャネル数を1から3にする。

なんでそんな計算量増える無駄ことをわざわざするのか? -> アンドロイドで画像を扱うときにbitmap形式で扱う、それをpytorch mobileでテンソルに変換するときにチャネル数3のテンソルにしか変換できない。(今後グレースケール変換が追加されるのかそれともそういう仕様なのか...)なのでモデルを学習させるデータをRGBにして学習させる。

今回に限らずPyTorch Mobileで使うモデルはカラーチャネル数3のモデルにする必要がある。

train_data_resized = train.data.numpy()  #torchテンソルからnumpyに
test_data_resized = test.data.numpy()

train_data_resized = torch.FloatTensor(np.stack((train_data_resized,)*3, axis=1))  #RGBに変換
test_data_resized =  torch.FloatTensor(np.stack((test_data_resized,)*3, axis=1))
print(train_data_resized.size())

これでデータセットのサイズがtorch.Size([60000, 28, 28])からtorch.Size([60000, 3, 28, 28])になった。

データセットを自作する

カスタムデータセットクラスを作る

今回はチャネル数の関係でMNISTのデータセットはそのまま使用できないので、pytorchのDatasetを継承してカスタムデータセットを作る。
また、画像の前処理である標準化するクラスもここで作る。

import torch.utils.data as data

mean = (0.485, 0.456, 0.406)
std = (0.229, 0.224, 0.225)

#画像の前処理
class ImgTransform():
    def __init__(self):
        self.transform = transforms.Compose([
            transforms.ToTensor(),  # テンソル変換
            transforms.Normalize(mean, std)  # 標準化
        ])

    def __call__(self, img):
        return self.transform(img)

#Datasetクラスを継承
class _3ChannelMnistDataset(data.Dataset):
    def __init__(self, img_data, target, transform):
        #[データ数,高さ,横,チャネル数]に
        self.data = img_data.numpy().transpose((0, 2, 3, 1)) /255
        self.target = target
        self.img_transform = transform #画像前処理クラスのインスタンス

    def __len__(self):
        #画像の枚数を返す
        return len(self.data)

    def __getitem__(self, index):
        #画像の前処理(標準化)したデータを返す
        img_transformed = self.img_transform(self.data[index])
        return img_transformed, self.target[index]

なおmeanstdはVGG16とかでも標準化によく使ういつもの値。アンドロイドでテンソルに変換するときに必ず標準化する、その時の値がこれ。
値がわからなかったら android studio でpytroch mobileのImageUtilsを確認してもよい。
aaaaキャプチャ.PNG

上記で作成したクラスを使ってデータセット作成

train_dataset = _3ChannelMnistDataset(train_data_resized, train.targets, transform=ImgTransform())
test_dataset = _3ChannelMnistDataset(test_data_resized, test.targets, transform=ImgTransform())

# データセットをテストしてみる
index = 0
print(train_dataset.__getitem__(index)[0].size())
print(train_dataset.__getitem__(index)[1])
print(train_dataset.__getitem__(index)[0][1]) #ちゃんと標準化されていることがわかる

データローダー作成

作ったデータセットでカスタムデータローダーを作る。バッチサイズは適当に100

train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=100, shuffle=True)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=100, shuffle=False)

CNNネットワークを作成

畳み込み1層、全結合3層のシンプルなネットワークを適当に作成。(学習に時間かかるのも嫌だし)

from torch import nn
class Model(nn.Module):
    def __init__(self):
        super(Model, self).__init__()
        self.relu = nn.ReLU()
        self.pool = nn.MaxPool2d(3)
        self.conv = nn.Conv2d(3, 10, kernel_size=4)
        self.fc1 = nn.Linear(640, 300)
        self.fc2 = nn.Linear(300, 100)
        self.fc3 = nn.Linear(100, 10)

    def forward(self, x):
        x = self.conv(x)
        x = self.relu(x)
        x = self.pool(x)
        x = x.view(x.size()[0], -1) #行列を線形処理できるようにベクトルに(view(高さ、横))
        x = self.fc1(x)
        x = self.relu(x)
        x = self.fc2(x)
        x = self.relu(x)
        x = self.fc3(x)
        return x

model = Model()
print(model)

こんなネットワーク
キャプfadsfcvaチャ.PNG

ネットワークを学習させる

訓練modeと推論modeの関数を作る

import tqdm
from torch import optim

# 推論モード
def eval_net(net, data_loader, device="cpu"): #GPUある人はgpuに
    #推論モードに
    net.eval()
    ypreds = [] #予測したラベル格納変数
    for x, y in (data_loader):
        # toメソッドでデバイスに転送
        x = x.to(device)
        y = [y.to(device)]
        # 確率が最大のクラスを予測
        # forwardプロパゲーション
        with torch.no_grad():
            _, y_pred = net(x).max(1)
            ypreds.append(y_pred)
            # ミニバッチごとの予測を一つのテンソルに
            y = torch.cat(y)
            ypreds = torch.cat(ypreds)
            # 予測値を計算(正解=予測の要素の和)
            acc = (y == ypreds).float().sum()/len(y)
            return acc.item()


# 訓練モード
def train_net(net, train_loader, test_loader,optimizer_cls=optim.Adam, 
              loss_fn=nn.CrossEntropyLoss(),n_iter=3, device="cpu"):
    train_losses = []
    train_acc = []
    eval_acc = []
    optimizer = optimizer_cls(net.parameters())
    for epoch in range(n_iter):  #4回回す
        runnig_loss = 0.0
        # 訓練モードに
        net.train()
        n = 0
        n_acc = 0

        for i, (xx, yy) in tqdm.tqdm(enumerate(train_loader),
                                     total=len(train_loader)):
            xx = xx.to(device)
            yy = yy.to(device)
            output = net(xx)

            loss = loss_fn(output, yy)
            optimizer.zero_grad()   #optimizerの初期化
            loss.backward()   #損失関数(クロスエントロピー誤差)からバックプロパゲーション
            optimizer.step()

            runnig_loss += loss.item()
            n += len(xx)
            _, y_pred = output.max(1)
            n_acc += (yy == y_pred).float().sum().item()

        train_losses.append(runnig_loss/i)
        # 訓練データの予測精度
        train_acc.append(n_acc / n)
        # 検証データの予測精度
        eval_acc.append(eval_net(net, test_loader, device))

        # このepochでの結果を表示
        print("epoch:",epoch, "train_loss:",train_losses[-1], "train_acc:",train_acc[-1],
              "eval_acc:",eval_acc[-1], flush=True)

まずは学習なしで推論してみる

eval_net(model, test_loader)

ネットワークのランダムパラメータのseed値を固定していないので再現性はなくランダムに変わるが、自分の環境では学習前のスコアは0.0799999982って感じになった。

学習させる

先ほど作成した関数を使って学習

train_net(model, train_loader, test_loader)

最終的に予測精度が0.98000001907くらいになった。えっ、精度高すぎね。精度良すぎてあってるか不安になる...

実際に1つ推論してみる

学習させたモデルにデータを1つ入れてラベルを予測してみる。

data = train_dataset.__getitem__(0)[0].reshape(1, 3, 28, 28) #リサイズ(データローダーのサイズに注意)
print("ラベル",train_dataset.__getitem__(0)[1].data)
model.eval()
output = model(data)
print(output.size())
output

実行結果
キafdfafdaャプチャ.PNG
しっかりインデックスが5のスコアが一番高くなっていて予測できていることがわかる。

やっと、モデルの作成と学習が終了!!

モデルを保存する

アンドロイドで使うためにモデルを保存する

# モデルの保存
model.eval()
#サンプル入力サイズ
example = torch.rand(1, 3, 28, 28)
traced_script_module = torch.jit.trace(model, example)
traced_script_module.save("./CNNModel.pt")
print(model)

おわり

とりあえずこれで[ネットワーク作成編] 終了!! 次は作ったモデルをアンドロイドに実装していく。
PyTorch Mobileでテンソルに変換するときにRGBのテンソルになり、グレースケールにできなかったので、MNISTをわざわざRGBに変換したりして、結構面倒な処理が多くなった。
その影響でMNISTのデータセットがそのまま使えず自作のデータセット、データローダーを使わなきゃいけなくなった。まあ、グレースケールとか商用レベルではほとんど使えないんだろうけど。
あと、適当に作ったCNNネットワークだったが意外と精度高くなっておどろいた、さすがはCNN
一応Githubあげてます。

今回のコード Github: https://github.com/SY-BETA/CNN_PyTorch

今回の作成した学習済みモデル(.py) : https://github.com/SY-BETA/CNN_PyTorch/blob/master/CNNModel.pt

それではアンドロイド実装編へレッツゴー
アンドロイドで画面に書いた数字を判別する画像認識アプリを作る(PyTorch Mobile)[アンドロイド実装編]

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

アンドロイドで画面に書いた数字を判別する画像認識アプリを作る(PyTorch Mobile)[CNNネットワーク作成編]

今回作成するアプリ

画面に書いた数字を認識する画像認識アプリをPytorch Mobileとkotlinで作る。
CNNネットワーク作成編(Python)アンドロイド実装編(kotlin)の全2回に分けます。

Python環境がないアンドロイドエンジニアの方やモデル作成がめんどいって方はアンドロイドで画面に書いた数字を判別する画像認識アプリを作る(PyTorch Mobile)[アンドロイド実装編]へ行って実装編で学習済みモデルをダウンロードして進めてください。

Githubに今回のpythonコード挙げてます
Github: https://github.com/SY-BETA/CNN_PyTorch

これ↓

作成の流れ

1.MNISTをダウンロードする (※チャネル数を3チャネルに直す必要あり)
2. 簡単なCNNモデルをpython(PyTorch)で作成
3. モデルを学習させる
4. モデルを保存
5. アンドロイドで絵を描ける機能を実装
6. アンドロイドにモデルを実装してforwardプロパゲーションする

この回でやること

1~4までやる。
python使ってモデルの保存までやる。今回使用するライブラリはPyTorch 実行環境はjupyter notebook
MNISTのデータセットをダウンロードしシンプルなCNNモデルを作成し学習させる。

MNISTダウンロード

みんな大好き手書き数字データセットMNISTをtorchvisionを使ってダウンロードする

import torch
import torchvision
import torchvision.transforms as transforms

transform = transforms.Compose([
        transforms.ToTensor()])
train = torchvision.datasets.MNIST(
    root="data/train", train=True, transform=transform, target_transform=None, download=True)
test = torchvision.datasets.MNIST(
    root="data/test", train=False, transform=transform, target_transform=None, download=True)

MNISTを見てみる

どんなデータセットか見てみる

from matplotlib import pyplot as plt
import numpy as np

print(train.data.size())
print(test.data.size())
img = train.data[0].numpy()
plt.imshow(img, cmap='gray')
print('Label:', train.targets[0])

実行結果
キャプチaaaaャ.PNG

グレースケールからRGBに変更する

MNISTのカラーチャネル数を1から3にする。

なんでそんな計算量増える無駄ことをわざわざするのか? -> アンドロイドで画像を扱うときにbitmap形式で扱う、それをpytorch mobileでテンソルに変換するときにチャネル数3のテンソルにしか変換できない。(今後グレースケール変換が追加されるのかそれともそういう仕様なのか...)なのでモデルを学習させるデータをRGBにして学習させる。

train_data_resized = train.data.numpy()  #torchテンソルからnumpyに
test_data_resized = test.data.numpy()

train_data_resized = torch.FloatTensor(np.stack((train_data_resized,)*3, axis=1))  #RGBに変換
test_data_resized =  torch.FloatTensor(np.stack((test_data_resized,)*3, axis=1))
print(train_data_resized.size())

これでデータセットのサイズがtorch.Size([60000, 28, 28])からtorch.Size([60000, 3, 28, 28])になった。

データセットを自作する

カスタムデータセットクラスを作る

今回はチャネル数の関係でMNISTのデータセットはそのまま使用できないので、pytorchのDatasetを継承してカスタムデータセットを作る。
また、画像の前処理である標準化するクラスもここで作る。

import torch.utils.data as data

mean = (0.485, 0.456, 0.406)
std = (0.229, 0.224, 0.225)

#画像の前処理
class ImgTransform():
    def __init__(self):
        self.transform = transforms.Compose([
            transforms.ToTensor(),  # テンソル変換
            transforms.Normalize(mean, std)  # 標準化
        ])

    def __call__(self, img):
        return self.transform(img)

#Datasetクラスを継承
class _3ChannelMnistDataset(data.Dataset):
    def __init__(self, img_data, target, transform):
        #[データ数,高さ,横,チャネル数]に
        self.data = img_data.numpy().transpose((0, 2, 3, 1)) /255
        self.target = target
        self.img_transform = transform #画像前処理クラスのインスタンス

    def __len__(self):
        #画像の枚数を返す
        return len(self.data)

    def __getitem__(self, index):
        #画像の前処理(標準化)したデータを返す
        img_transformed = self.img_transform(self.data[index])
        return img_transformed, self.target[index]

なおmeanstdはVGG16とかでも標準化によく使ういつもの値。アンドロイドでテンソルに変換するときに必ず標準化する、その時の値がこれ。
値がわからなかったら android studio でpytroch mobileのImageUtilsを確認してもよい。
aaaaキャプチャ.PNG

上記で作成したクラスを使ってデータセット作成

train_dataset = _3ChannelMnistDataset(train_data_resized, train.targets, transform=ImgTransform())
test_dataset = _3ChannelMnistDataset(test_data_resized, test.targets, transform=ImgTransform())

# データセットをテストしてみる
index = 0
print(train_dataset.__getitem__(index)[0].size())
print(train_dataset.__getitem__(index)[1])
print(train_dataset.__getitem__(index)[0][1]) #ちゃんと標準化されていることがわかる

データローダー作成

作ったデータセットでカスタムデータローダーを作る。バッチサイズは適当に100

train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=100, shuffle=True)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=100, shuffle=False)

CNNネットワークを作成

畳み込み1層、全結合3層のシンプルなネットワークを適当に作成。(学習に時間かかるのも嫌だし)

from torch import nn
class Model(nn.Module):
    def __init__(self):
        super(Model, self).__init__()
        self.relu = nn.ReLU()
        self.pool = nn.MaxPool2d(3)
        self.conv = nn.Conv2d(3, 10, kernel_size=4)
        self.fc1 = nn.Linear(640, 300)
        self.fc2 = nn.Linear(300, 100)
        self.fc3 = nn.Linear(100, 10)

    def forward(self, x):
        x = self.conv(x)
        x = self.relu(x)
        x = self.pool(x)
        x = x.view(x.size()[0], -1) #行列を線形処理できるようにベクトルに(view(高さ、横))
        x = self.fc1(x)
        x = self.relu(x)
        x = self.fc2(x)
        x = self.relu(x)
        x = self.fc3(x)
        return x

model = Model()
print(model)

こんなネットワーク
キャプfadsfcvaチャ.PNG

ネットワークを学習させる

訓練modeと推論modeの関数を作る

import tqdm
from torch import optim

# 推論モード
def eval_net(net, data_loader, device="cpu"): #GPUある人はgpuに
    #推論モードに
    net.eval()
    ypreds = [] #予測したラベル格納変数
    for x, y in (data_loader):
        # toメソッドでデバイスに転送
        x = x.to(device)
        y = [y.to(device)]
        # 確率が最大のクラスを予測
        # forwardプロパゲーション
        with torch.no_grad():
            _, y_pred = net(x).max(1)
            ypreds.append(y_pred)
            # ミニバッチごとの予測を一つのテンソルに
            y = torch.cat(y)
            ypreds = torch.cat(ypreds)
            # 予測値を計算(正解=予測の要素の和)
            acc = (y == ypreds).float().sum()/len(y)
            return acc.item()


# 訓練モード
def train_net(net, train_loader, test_loader,optimizer_cls=optim.Adam, 
              loss_fn=nn.CrossEntropyLoss(),n_iter=3, device="cpu"):
    train_losses = []
    train_acc = []
    eval_acc = []
    optimizer = optimizer_cls(net.parameters())
    for epoch in range(n_iter):  #4回回す
        runnig_loss = 0.0
        # 訓練モードに
        net.train()
        n = 0
        n_acc = 0

        for i, (xx, yy) in tqdm.tqdm(enumerate(train_loader),
                                     total=len(train_loader)):
            xx = xx.to(device)
            yy = yy.to(device)
            output = net(xx)

            loss = loss_fn(output, yy)
            optimizer.zero_grad()   #optimizerの初期化
            loss.backward()   #損失関数(クロスエントロピー誤差)からバックプロパゲーション
            optimizer.step()

            runnig_loss += loss.item()
            n += len(xx)
            _, y_pred = output.max(1)
            n_acc += (yy == y_pred).float().sum().item()

        train_losses.append(runnig_loss/i)
        # 訓練データの予測精度
        train_acc.append(n_acc / n)
        # 検証データの予測精度
        eval_acc.append(eval_net(net, test_loader, device))

        # このepochでの結果を表示
        print("epoch:",epoch, "train_loss:",train_losses[-1], "train_acc:",train_acc[-1],
              "eval_acc:",eval_acc[-1], flush=True)

まずは学習なしで推論してみる

eval_net(model, test_loader)

ネットワークのランダムパラメータのseed値を固定していないので再現性はなくランダムに変わるが、自分の環境では学習前のスコアは0.0799999982って感じになった。

学習させる

先ほど作成した関数を使って学習

train_net(model, train_loader, test_loader)

最終的に予測精度が0.98000001907くらいになった。えっ、精度高すぎね。精度良すぎてあってるか不安になる...

実際に1つ推論してみる

学習させたモデルにデータを1つ入れてラベルを予測してみる。

data = train_dataset.__getitem__(0)[0].reshape(1, 3, 28, 28) #リサイズ(データローダーのサイズに注意)
print("ラベル",train_dataset.__getitem__(0)[1].data)
model.eval()
output = model(data)
print(output.size())
output

実行結果
キafdfafdaャプチャ.PNG
しっかりインデックスが5のスコアが一番高くなっていて予測できていることがわかる。

やっと、モデルの作成と学習が終了!!

モデルを保存する

アンドロイドで使うためにモデルを保存する

# モデルの保存
model.eval()
#サンプル入力サイズ
example = torch.rand(1, 3, 28, 28)
traced_script_module = torch.jit.trace(model, example)
traced_script_module.save("./CNNModel.pt")
print(model)

おわり

とりあえずこれで[ネットワーク作成編] 終了!! 次は作ったモデルをアンドロイドに実装していく。
PyTorch Mobileでテンソルに変換するときにRGBのテンソルになり、グレースケールにできなかったので、MNISTをわざわざRGBに変換したりして、結構面倒な処理が多くなった。
その影響でMNISTのデータセットがそのまま使えず自作のデータセット、データローダーを使わなきゃいけなくなった。まあ、グレースケールとか商用レベルではほとんど使えないんだろうけど。
あと、適当に作ったCNNネットワークだったが意外と精度高くなっておどろいた、さすがはCNN
一応Githubあげてます。

今回のコード Github: https://github.com/SY-BETA/CNN_PyTorch

今回の作成した学習済みモデル(.py) : https://github.com/SY-BETA/CNN_PyTorch/blob/master/CNNModel.pt

それではアンドロイド実装編へレッツゴー
アンドロイドで画面に書いた数字を判別する画像認識アプリを作る(PyTorch Mobile)[アンドロイド実装編]

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AppiumでFlutterアプリのテストを自動化する 実践編(JavaScript)

はじめに

AppiumでFlutterアプリのテストを自動化する 環境構築編 - Qiita
の続きになります。
実際にテストコードを書いて、それを実行し、レポートを出力するところまでやります。
今回は「JavaScript」を使います。

前提条件

AppiumでFlutterアプリのテストを自動化する 環境構築編 - Qiita
で、Appiumの環境構築が完了していること

なぜAppiumで自動化するのか

Flutterには、「Integration Test」という仕組みが存在します。

これは結合テストを行うための仕組みですが、この仕組みを利用することで、UIテストを自動化することも可能になります。また、各OSごとにUIテストを用意せずとも、ワンソースで実装することができます。
ですが、テストしたいWidgetに対して逐一Keyを設定していかなければならないのと、全て手動で実装しないといけないのが難点になります。
Appiumはワンソースで実装することが容易ではありませんが、OSごとのデバイス設定やWidgetの取得方法などをうまく共通化することができればワンソースでも実現可能ですし、何よりレコード機能があるため、Appiumで自動化する選択肢もありだと考えています。
ただ何を優先するかはプロジェクトによりけりではあるので、どちらが正解というわけでもないと思います。

プロジェクトの準備

appium/sample-code/javascript-wd at master · appium/appium · GitHub
をベースにプロジェクトを準備します。
なぜこのプロジェクトを流用したかというと、レコード機能で取得した内容をコピペするだけで実装が容易にできるのと、Mochaというテスティングフレームワークがなかなか良さげ(レポート機能ついてたりとか)だったので採用しました。
基本的にはほぼ流用している形になります。

package.json
{
  "name": "appium_test_js_wd",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "test": "mocha test/**/*.test.js",
    "clean": "rm -rf node_modules && rm -f package-lock.json && npm install"
  },
  "author": "",
  "license": "ISC",
  "engines": {
    "node": ">=6",
    "npm": ">=6"
  },
  "devDependencies": {
    "@babel/register": "^7.0.0",
    "@babel/core": "^7.0.0",
    "@babel/preset-env": "^7.0.0",
    "chai": "^4.1.2",
    "mocha": "^6.0.0",
    "wd": "^1.5.0"
  }
}
.babelrc
{
    "presets": [
      [
        "@babel/preset-env",
        {
          "targets": {
            "node": "6"
          }
        }
      ]
    ]
  }

mocha.optsでオプションを設定することができます。
--reporterオプションでレポートの出力形式を設定することができます。

test/mocha.opts
--require @babel/register
--timeout 1800000
--reporter spec
test/.eslintrc
{
    "rules": {
        "func-names": 0
    }
}

Appium Desktopでテストコードを記録

テストコードの記録方法については以下に記載していますので参照してください。
AppiumでFlutterアプリのテストを自動化する 実践編(Python) - Qiita
使用するアプリは上記ページと同様です。
記録する言語は、「JS(wd)」を選択します。

記録したテストコードを実行できるようにする

例えば以下のように記載します。

test/top/top.test.js
import wd from 'wd';
import chai from 'chai';

const {assert} = chai;

describe('カウントアップアプリ', function () {
  let driver;

  before(async function () {
    driver = await wd.promiseChainRemote("http://127.0.0.1:4723/wd/hub");

    const caps = {
      "platformName": "Android",
      "automationName": "Appium",
      "deviceName": "Android Emulator",
      "app": "/Users/Hitoshi/AndroidStudioProjects/flutter_app_for_appium/build/app/outputs/apk/release/app-release.apk"
    };

    await driver.init(caps);
    // ここで待たないと要素の取得に失敗してしまうので待つ
    await driver.setImplicitWaitTimeout(5000);
  });

  after(async function () {
    await driver.quit();
  });

  it('初期状態', async function () {
    let el1 = await driver.elementByXPath("/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.view.View/android.view.View/android.view.View/android.view.View/android.view.View[3]");
    const countText = await el1.text();
    assert.equal(countText, '0');
  });

  it('カウントアップされるか', async function () {
    let el1 = await driver.elementByXPath("/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.view.View/android.view.View/android.view.View/android.view.View/android.widget.Button");
    await el1.click();
    let el2 = await driver.elementByXPath("/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.view.View/android.view.View/android.view.View/android.view.View/android.view.View[3]");
    const countText = await el2.text();
    assert.equal(countText, '1');
  });
});

テストコードの実行

コマンドでテストコードを実行します。

npm test

すると、以下のように表示されるはずです。
端末側もアプリが起動し、ボタンが押されてカウントアップされるはずです。

> appium_test_js_wd@1.0.0 test /Users/Hitoshi/src/appium-test/appium_test_js_wd
> mocha test/**/*.test.js



  カウントアップアプリ
    ✓ 初期状態 (2195ms)
    ✓ カウントアップされるか (2199ms)


  2 passing (42s)

ソースコード

以下にアップしましたので参考にしてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS, Androidアプリの強制アップデート(サーバーレス)

はじめに

文字列→塩基配列の相互変換ツールをつくってみた(アプリ版)でつくったアプリ

これに強制アップデート機能(半強制で抜け道あり)をつけてみました。

特に必要なわけではないですがこの記事([iOS]アプリに強制アップデート機能を導入すべき理由と、簡単に実装する方法)をみてやりたいと思い実装してみました。

が!!サーバーを用意するのはめんどくさいと思いサーバーなしで強制アップデート機能のようなものをつけてみました。

iOS&Mac

方法

iTunes Search APIというのがあるらしくこれを使うとアプリの情報が取れるそうです。

下記のURLのアプリIDに指定のアプリを設定するとそのアプリ情報が取得できます。

https://itunes.apple.com/lookup?id=[アプリID]

取得した情報からバージョンをみてBundleのバージョンと比較してアプリストアに遷移させるようにすれば強制アップデートのようなことができます。

ソース

iOS&Macアプリソース

AppStoreModel.swift
struct AppStoreModel {
    private let version = Version(version: Bundle.main.version!)
    private var appId: String {
        #if targetEnvironment(macCatalyst)
        return "1494127578"
        #else
        return "1493994947"
        #endif
    }
    private var url: URL {
        return URL(string: "https://itunes.apple.com/lookup?id=\(appId)")!
    }
    var appStoreURL: URL {
        return URL(string: "itms-apps://itunes.apple.com/app/id\(appId)")!
    }

    func checkVersion(completion: @escaping ((Result<AppVersionState, AppVersionError>) -> ())) {
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            if let _ = error {
                completion(.failure(.network))
                return
            }
            guard let data = data else {
                completion(.failure(.invalidData))
                return
            }
            do {
                let appVersion = try JSONDecoder().decode(AppVersion.self, from: data)
                if let version = appVersion.version,
                    Version(version: version) > self.version {
                    completion(.success(.shouldUpdate))
                } else {
                    completion(.success(.noUpdate))
                }

            } catch {
                completion(.failure(.invalidJSON))
            }
        }
        task.resume()
    }
}

struct AppVersion: Decodable {

    struct Result: Codable {
        let version: String
        let trackName: String
    }

    let name: String?
    let version: String?
    private enum CodingKeys: String, CodingKey {
        case results = "results"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let results = try container.decodeIfPresent([Result].self, forKey: .results)
        name = results?.first?.trackName
        version = results?.first?.version
    }
}

struct Version {
    let major: Int
    let minor: Int
    let revision: Int
}

extension Version {
    init(version: String) {
        let versions = version.components(separatedBy: ".")
        self.major = versions[safe: 0].flatMap { Int($0) } ?? 0
        self.minor = versions[safe: 1].flatMap { Int($0) } ?? 0
        self.revision = versions[safe: 2].flatMap { Int($0) } ?? 0
    }

    static func > (lhs: Version, rhs: Version) -> Bool {
        if lhs.major > rhs.major {
            return true
        }
        if lhs.major < rhs.major {
            return false
        }
        // lhs.major == rhs.major
        if lhs.minor > rhs.minor {
            return true
        }
        if lhs.minor < rhs.minor {
            return false
        }
        // lhs.major == rhs.major && lhs.minor == rhs.minor
        if lhs.revision > rhs.revision {
            return true
        }
        return false
    }
}

enum AppVersionError: Error {
    case network
    case invalidData
    case invalidJSON
}

enum AppVersionState {
    case shouldUpdate
    case noUpdate
}

extension Bundle {
    var version: String? {
        return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
    }
}

SceneDelegatefunc scene(_ scene: UIScene, willConnectTo... に下記を記載

SceneDelegate.swift
guard let _ = (scene as? UIWindowScene) else { return }
let appStoreModel = AppStoreModel()
        appStoreModel.checkVersion { [weak self] result in
            DispatchQueue.main.async {
                if case .success(.shouldUpdate) = result {
                    let alertController = UIAlertController(title: "アップデート", message: "", preferredStyle: .alert)
                    let action = UIAlertAction(title: "OK", style: .default,
                                               handler:
                        { _ in
                            UIApplication.shared.open(appStoreModel.appStoreURL)
                    })
                    alertController.addAction(action)
                    self?.window?.rootViewController?.present(alertController, animated: true)
                }
            }
        }

なんかめっちゃ長くなった...

最初は Version(version: version) > self.version このバージョン比較を version != self.version にしてたのですがこれだと審査のときに常にアラートが表示されリジェクトされました:sob:

この方法はストアに遷移したあとにもう一回アプリを表示したら普通に使えるので強制アップデートとまではいえませんが、アップデートを促すことはできるのでまあいいかな。

Android

方法

in-app Updates APIというのがあるのでこれを使えばいい感じにやってくれるみたいです。

使えるのはAndroid 5.0 (API level 21) 以上です。

下記の参考サイトに丁寧に書いてくれています:tada:

ソース

Androidアプリソース

ソースも参考サイトにあるのですが少しつまずきました...

manager.appUpdateInfo.addOnCompleteListener { task ->
            val info = task.result
            when (info.updateAvailability()) {
                UpdateAvailability.UPDATE_AVAILABLE -> {
                    manager.startUpdateFlowForResult(info, AppUpdateType.FLEXIBLE, activity, REQUEST_CODE)
                }
                else -> {
                }
            }
        }

上記のような実装をしているとエミュレータで実行すると下記のようなエラーが発生しました。

com.google.android.play.core.tasks.RuntimeExecutionException: com.google.android.play.core.internal.aa: Failed to bind to the service.
        at com.google.android.play.core.tasks.l.getResult(Unknown Source:18)
        at am10.dnaconverter.models.AppUpdateModel$checkAppVersion$1.onComplete(AppUpdateModel.kt:19)
        at com.google.android.play.core.tasks.a.run(Unknown Source:23)
        at android.os.Handler.handleCallback(Handler.java:883)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:214)
        at android.app.ActivityThread.main(ActivityThread.java:7356)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)
     Caused by: com.google.android.play.core.internal.aa: Failed to bind to the service.
        at com.google.android.play.core.internal.t.b(Unknown Source:82)
        at com.google.android.play.core.internal.t.a(Unknown Source:0)
        at com.google.android.play.core.internal.v.a(Unknown Source:4)
        at com.google.android.play.core.internal.r.run(Unknown Source:0)
        at android.os.Handler.handleCallback(Handler.java:883)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:214)
        at android.os.HandlerThread.run(HandlerThread.java:67)

これは一部の実機でも起こるようで addOnCompleteListener を使う場合は task が成功したかを下記のようにしっかりチェックしないといけないようです:see_no_evil:

manager.appUpdateInfo.addOnCompleteListener { task ->
            if (task.isSuccessful) {
                val info = task.result
                when (info.updateAvailability()) {
                    UpdateAvailability.UPDATE_AVAILABLE -> {
                        manager.startUpdateFlowForResult(info, AppUpdateType.FLEXIBLE, activity, REQUEST_CODE)
                    }
                    else -> {
                    }
                }
            } else {
                task.exception.printStackTrace()
            }
        }

参考サイトは addOnSuccessListener を使ってました:hear_no_evil:

全体としては下記のような実装になりました。

gradleに下記を追加

implementation 'com.google.android.play:core:1.6.4'
implementation 'com.google.android.material:material:1.0.0' // Snackbar用
AppUpdateModel.kt
class AppUpdateModel(context: Context) {
    val manager = AppUpdateManagerFactory.create(context)
    var listener: InstallStateUpdatedListener? = null
    val REQUEST_CODE = 100
    fun checkAppVersion(activity: Activity, callback: (() -> (Unit))?) {
        listener = makeListener(callback)
        manager.registerListener(listener)
        manager.appUpdateInfo.addOnSuccessListener { info ->
            when (info.updateAvailability()) {
                UpdateAvailability.UPDATE_AVAILABLE -> {
                    manager.startUpdateFlowForResult(info, AppUpdateType.FLEXIBLE, activity, REQUEST_CODE)
                }
                else -> {
                }
            }
        }
    }

    // ミス(20200126修正ここもCompleteじゃなくてSuccessじゃないと落ちる)
    fun addOnSuccessListener(callback: (() -> (Unit))?) {
        manager.appUpdateInfo.addOnSuccessListener { info ->
            if (info.installStatus() == InstallStatus.DOWNLOADED) {
                callback?.invoke()
            }
        }
    }

    fun completeUpdate() {
        manager.completeUpdate()
    }

    private fun makeListener(callback: (() -> (Unit))?) : InstallStateUpdatedListener {
        return InstallStateUpdatedListener {
            if (it.installStatus() == InstallStatus.DOWNLOADED) {
                callback?.invoke()
                manager.unregisterListener(listener)
            }
        }
    }
}
MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        appUpdateModel = AppUpdateModel(this)
        appUpdateModel.checkAppVersion(this) {
            popupSnackbarForCompleteUpdate()
        }
     }

    override fun onResume() {
        super.onResume()
        appUpdateModel.addOnSuccessListener {
            popupSnackbarForCompleteUpdate()
        }
    }

    private fun popupSnackbarForCompleteUpdate() {
        Snackbar.make(findViewById(R.id.root_layout),
            "ダウンロード完了", Snackbar.LENGTH_INDEFINITE)
            .setAction("更新") {
                appUpdateModel.completeUpdate()
            }
            .show()
    }

Google Playストアのアプリが更新判定を行うらしいのでどの時点で更新情報が受け取れるのかはわかりません。

これも戻るボタンとかで回避できるらしいので強制アップデートとまではいえないかもしれません。(そもそもGoogle Playストアのアプリがないと取れない?)

おまけ

サーバー用意してバージョン情報のJSONファイル置くのめんどくさいと思って今回の方法で実装しましたが、GitHubにJSONファイル置けばいいんじゃね?ふと思いました。(iOSアプリをリリースしてればもしかしたらプライバシーポリシー用にGitHub使ってるかもしれないですし)

試しにJSONファイル置いてるリポジトリでやってみたらJSON取れました:tada:

https://raw.githubusercontent.com/adventam10/TestApplicationArchitecture/master/TestWeatherApplication/TestWeatherApplication/Resource/CityData.json

github.com のところを raw.githubusercontent.com するといけそうです!!

さいごに

特にこのアプリに強制アップデート必要ないですが、試しに実装してみましたmm

関係ないですがAndroidのアプリを最適化しようと思って下記をgradleに追加したらサイズがめっちゃ小さくなりました。

release {
    minifyEnabled true
    shrinkResources true
    proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
before after
before after

アプリサイズ 50%OFF !!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む