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

Flutterアプリのスクショを極力自動で撮る(Riverpod使用・メソッドの濫用あり)

// TODO: 仕様追加。
// データベースに依存するRepositoryパターン。
// 別のProviderに依存するProviderがあり、UIはその両方に依存する。

以前こんな記事を書きました。

Flutterアプリのスクショを(極力)自動で撮る on AndroidStudio

これでスクショ撮影はだいぶ簡単になった!!と思っていたのですが、

今回新たにゲーム要素のあるアプリを作成しまして、スクショ用の画面を作るためにそのゲームを攻略する必要が生じてしまいました。

ストア申請のために言語や端末を切り替えながら毎回ゲームを攻略するなんてとてもやってられません。

アプリコードに手を入れて数値を指定すればどうとでもなりますが、スクショを撮るためだけにあまりアプリを改造したくありません。

ということで、アプリコードを極力改造せず、必要な画面を自動的に生成しながら次々にスクショを撮る方法を確立しました。

アプリコード・スクショ撮るコードどちらも、break pointを打ってデバッグすることができます。

基本的な考え方

アプリの画面はアプリの状態の関数です。状態が決まれば画面が決まる。

ui-equals-function-of-state-54b01b000694caf9da439bd3f774ef22b00e92a62d3b2ade4f2e95c8555b8ca7.png

画像は公式サイトのこちらから引用してます。

https://flutter.dev/docs/development/data-and-backend/state-mgmt/declarative

ということは、状態(state)さえ指定してやれば、欲しい画面は作れるということです。

今回の記事では、StateNotifierのstateと、Navigatorスタックの状態とTextEditingControllerの状態を直接指定することで、欲しい画面を生成します。

StateNotifierのstateの代わりに、ChangeNotifierのプロパティを指定することでも同様のことができると思いますが、試していません。アプリ内で使うChangeNotifierをimplementsして専用クラスを作れば良さそうな気がします。

メソッドの濫用は自己責任で

今回の提案手法ではこちらのメソッドを、本来の使用用途を超えた使い方で使います。

https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/requestData.html

「濫用する部分があります」というレベルではなく、ほぼ話の中心です。

スクショを撮るだけの目的なら恐らく問題ないとは思いますが、何か問題が生じても筆者は責任を負いかねますので自己責任でお願いします。

バージョン

この記事は2020年9月6日に書いており、下記のバージョンを使用しています。
NavigatorとRiverpodは使い方が大きく変更される可能性がそこそこあるので気をつけてください。

  • flutter 1.20.2 (stable)
  • hooks_riverpod 0.9.1
  • flutter_hooks 0.13.2
  • state_notifier 0.6.0

アプリの仕様

  • Riverpod + StateNotifier + freezed で状態管理
  • 日英中の3言語対応
  • ページが複数あり、Navigatorを使って遷移する
  • テキスト入力欄がある

Riverpodの機能を積極的に使います。

StateNotifier+freezedを使わず、ChangeNotifierを使ったパターンでも同様のことはできると思います。

一方、Riverpodなしで同様のことができるかどうかはわかりません。

前回記事の手法との違い

前回記事を読んでくださった方向けに述べますと…

改善点

  • 言語切替も自動でできるようになった(かなり嬉しい!)
  • 画面の状態を直接指定するのでアプリを操作する必要がなくなった
  • Terminalからプロセスキルする必要が何故かなくなった。
    • AndroidStudioの右上のStop Allを押せばOK
    • 何故?AndroidStudioのバージョンアップ???

改悪点

  • メソッドを濫用している
  • Riverpodに依存している(なしでできるかどうかは不明)

アプリのソースコード

こんな感じにします。

main.dart

普通にアプリを起動する場合のエントリーポイントです。

RiverpodのProviderScopeで囲んだMyApprunAppに渡すだけです。

lib/main.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'my_app.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

my_app.dart

MaterialAppに必要な引数を渡してるだけです。

debugShowCheckedModeBannerlocaleは、スクショ撮影時にいじれるように、Providerを使って渡すように改造しています。

また、外からNavigatorを操作できるように、コンストラクタで受け取ったGlobalKeyMaterialAppnavigatorKeyに渡しています。Navigatorを使わないアプリならこれは不要です。

lib/my_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'page1.dart';

// MaterialAppの引数の変更に応答して画面を変化させるため、HookWidgetにしておきます。
class MyApp extends HookWidget {
  // 外からNavigatorを操作するためのGlobalKeyをコンストラクタで受け取れるようにします。
  const MyApp({this.navigatorKey});
  final GlobalKey<NavigatorState> navigatorKey;
  @override
  Widget build(BuildContext context) {
    final materialAppArgs = useProvider(materialAppArgsProvider);

    return MaterialApp(
      navigatorKey: navigatorKey,
      debugShowCheckedModeBanner: materialAppArgs.showDebugBanner,
      locale: materialAppArgs.locale,
      supportedLocales: const [Locale('en'), Locale('ja'), Locale('zh')],
      localizationsDelegates: const [
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      home: const Page1(),
    );
  }
}

// MaterialAppに与える引数を書き換えられるようにprovideしておきます。
final materialAppArgsProvider = Provider((ref) => const MaterialAppArgs());

class MaterialAppArgs {
  // showDebugBannerはnullだとエラー。普段はtrueにします。スクショを撮る時はfalse。
  // localeはnullでもいい。その場合デバイスの設定言語が使われます。
  const MaterialAppArgs({bool showDebugBanner, this.locale})
      : showDebugBanner = showDebugBanner ?? true;
  final bool showDebugBanner;
  final Locale locale;
}

page1.dart

アプリ画面本体の1つ目です。

画面の表示内容はデバイスの設定言語と、StateNotifierextendsしたSomethingControllerクラス(のstate)に依存しています。

なお、記事に載せるソースコードを短くするため、別のページへの遷移は実装していませんのでアプリとしては機能しません!(それでも全ページのスクショが撮れるのが今回の手法です)

lib/page1.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'something/something_controller.dart';

class Page1 extends HookWidget {
  const Page1();
  @override
  Widget build(BuildContext context) {
    final a = useProvider(somethingProvider.state).a;
    final languageCode = Localizations.localeOf(context).languageCode;
    return Scaffold(
      appBar: AppBar(
        title: const Text('Page 1'),
      ),
      body: Column(
        children: [
          // MyControllerのstateに依存
          Text('a: $a'),
          // とってつけた多言語対応要素
          Text(languageCode == 'zh'
              ? '你好谢谢'
              : languageCode == 'ja' ? 'こんにちはありがとう' : 'Hello. Thank you.'),
        ],
      ),
    );
  }
}

final somethingProvider = StateNotifierProvider((ref) => SomethingController());

page2.dart

アプリ本体のページ2つ目です。StatelessWidgetです。

TextFieldがあり、そこに渡すTextEditingControllerSomethingControllerが管理しています。

lib/page2.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:stepbystep/page1.dart';

class Page2 extends StatelessWidget {
  const Page2();
  @override
  Widget build(BuildContext context) {
    final languageCode = Localizations.localeOf(context).languageCode;
    final somethingController = context.read(somethingProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Page 2'),
      ),
      // とってつけた多言語対応要素
      body: Column(
        children: [
          Text(languageCode == 'zh'
              ? '真的吗?'
              : languageCode == 'ja' ? 'マジ?' : 'Really?'),
          TextField(controller: somethingController.textController),
        ],
      ),
    );
  }
}

something_controller.dart

このアプリ唯一のStateNotifierです。でも別に今回の手法はStateNotifierがいくつあっても同様に適用できます。

Page1の表示内容と、Page2TextFieldに渡すTextEditingControllerを管理しています。

スクショ撮影のため、TextEditingControllertextの初期値を外から渡せるように改造してあります。

lib/something/something_controller.dart
import 'package:flutter/material.dart';
import 'package:state_notifier/state_notifier.dart';

import 'something_state.dart';

class SomethingController extends StateNotifier<SomethingState> {
  // TextEditingControllerのtextの初期値を外から受け取れるように改造します。
  SomethingController({SomethingState state, String textValue})
      : textController = TextEditingController(text: textValue),
        super(state ?? SomethingState());
  final TextEditingController textController;
}

something_state.dart

freezed用の元ファイルです。プロパティはaだけ。

lib/something/something_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'something_state.freezed.dart';

@freezed
abstract class SomethingState with _$SomethingState {
  factory SomethingState({
    @Default(0) int a,
  }) = _SomethingState;
}

スクショ撮影用ソースコード

ここからは、スクショ撮影専用のソースコードを紹介します。

ここがこの記事のメインです。

以下の2ファイルはlibではなくtake_ssというディレクトリを作ってそこに置いてます。

main_for_ss.dart

スクショ撮影用にアプリを起動する際のエントリーポイントです。

これを使って、スクショ撮影専用の設定でアプリを起動します。

enableFlutterDriverExtensionrequestData

FlutterDriverパッケージにあるenableFlutterDriverExtensionhandler関数を渡しておきます。

https://api.flutter.dev/flutter/flutter_driver_extension/enableFlutterDriverExtension.html

このhandlerは後にFlutterDriverrequestDataによってアクセスでき、引数に与えられた文字列に応答して文字列を返します。

https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/requestData.html

本来これはrequestDataという名の通り、FlutterDriverを使ったintegration testの最中にアプリからデータを取得してテストするためのものなのですが、

今回はこの仕組みを悪用して、handlerに副作用を持たせてアプリの状態をいじりまくります。

handler関数に渡された文字列に対し、アプリ画面の状態を定義します。

画面状態を表す文字列

今回は1-2-jaというハイフン区切りの文字列で「Page1の2つ目の画像で、言語は日本語」という画面を表すことにします。

ProviderContainerUncontrolledProviderScope

Riverpodでは、すべてのProviderProviderContainerという所で管理されます。

通常はWidgetツリー全体をProviderScopeの傘下に入れることで、ProviderContainerが自動的に用意されるので意識することはありません。

ですが今回はProviderを直接いじりたいので、外から触れるProviderContainerが必要です。

ProviderScopeの代わりにUncontrolledProviderScopeを使うと、外部で用意したProviderContainerを渡すことができるのでこれを利用します。

後からoverrideする予定のProviderは、ProviderScopeの生成時点で一度overrideしておく必要があります。後にupdateOverridesでそれを上書きします。

https://pub.dev/documentation/flutter_riverpod/latest/flutter_riverpod/UncontrolledProviderScope-class.html

Navigator

Navigatorに外部から直接アクセスするためのGlobalKey<NavigatorState>もここで用意しています。

外部からNavigatorpushAndRemoveUntilに新しいページを渡すことで強制的にページ遷移します。

ソースコード

take_ss/main_for_ss.dart
import 'dart:io';

import 'package:device_info/device_info.dart';
import 'package:flutter/material.dart';
import 'package:flutter_driver/driver_extension.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:stepbystep/my_app.dart';
import 'package:stepbystep/something/something_controller.dart';
import 'package:stepbystep/something/something_state.dart';
import 'package:stepbystep/page1.dart';
import 'package:stepbystep/page2.dart';

void main() {
  // 後にoverrideし直す予定のProviderははじめからoverrideしておく必要がある。
  final providerContainer = ProviderContainer(overrides: [
    somethingProvider
        .overrideWithValue(SomethingController(state: SomethingState(a: 50))),
    materialAppArgsProvider.overrideWithValue(
        const MaterialAppArgs(showDebugBanner: false, locale: Locale('en'))),
  ]);

  // Navigatorにアクセスするためのグローバルキー
  final navigatorKey = GlobalKey<NavigatorState>();

  enableFlutterDriverExtension(handler: (action) async {
    // このget_device_infoの場合だけ、この機能本来の使い方
    if (action == 'get_device_info') {
      final _deviceInfo = DeviceInfoPlugin();
      if (Platform.isAndroid) {
        return (await _deviceInfo.androidInfo).device;
      } else {
        return (await _deviceInfo.iosInfo).name;
      }

      // ここから、スクショ用の画面を定義していきます
      // actionに渡ってきた文字列によって画面を場合分け。
    } else if (action.startsWith('1-')) {
      Navigator.of(navigatorKey.currentContext).pushAndRemoveUntil<void>(
          MaterialPageRoute(builder: (_) => const Page1()), (route) => false);
      if (action.startsWith('1-1-')) {
        providerContainer.updateOverrides([
          somethingProvider.overrideWithValue(
              SomethingController(state: SomethingState(a: 100))),
        ]);
      } else if (action.startsWith('1-2-')) {
        providerContainer.updateOverrides([
          somethingProvider.overrideWithValue(
              SomethingController(state: SomethingState(a: 3141592))),
        ]);
      }
    } else if (action.startsWith('2-')) {
      Navigator.of(navigatorKey.currentContext).pushAndRemoveUntil<void>(
          MaterialPageRoute(builder: (_) => const Page2()), (route) => false);
      // TextFieldの初期値を指定して、入力中の文字列をセットします。
      providerContainer.updateOverrides([
        somethingProvider
            .overrideWithValue(SomethingController(textValue: 'OH! YEAH!!')),
      ]);
    }

    // 言語を切り替えます
    final lang = action.substring(action.length - 2);
    providerContainer.updateOverrides([
      materialAppArgsProvider.overrideWithValue(
          MaterialAppArgs(showDebugBanner: false, locale: Locale(lang))),
    ]);
    return '';
  });

  // UncontrolledProviderScopeには、自分で用意したProviderContainerを渡すことが出来ます。
  // そのProviderScopeを使って外から各種Providerにアクセスします。
  runApp(UncontrolledProviderScope(
      container: providerContainer,
      child: MyApp(
        // NavigatorにアクセスするためのGlobalKey
        navigatorKey: navigatorKey,
      )));
}

take_ss.dart

最後に、このDartプログラムを実行することでスクショを撮影します。

FlutterDriverrequestDataに、欲しい画面を表す文字列を渡していくことで次々に必要な画面を作り出してスクショを撮影していきます。

take_ss/take_ss.dart
import 'dart:async';
import 'dart:io';

import 'package:flutter_driver/flutter_driver.dart';

Future<void> main() async {
  FlutterDriver driver;

  driver = await FlutterDriver.connect();
  // ファイル名に含めるためのデバイス名を取得しておきます
  final _deviceName = (await driver.requestData('get_device_info'))
      .replaceAll(RegExp(r'\s'), '');

  for (final lang in ['ja', 'zh', 'en']) {
    // アプリ状態を操作するメソッドを呼び出す
    await driver.requestData('1-1-$lang');
    await takeScreenshot(
        driver, './screenshots/${lang}_${_deviceName}_1_1.png');
    // もし画面遷移がアニメーションを伴う場合はこれで待ちます。多くの場合不要です。
    await driver.waitForCondition(const NoPendingFrame());
  }

  for (final lang in ['ja', 'zh', 'en']) {
    await driver.requestData('1-2-$lang');
    await takeScreenshot(
        driver, './screenshots/${lang}_${_deviceName}_1_2.png');
    await driver.waitForCondition(const NoPendingFrame());
  }

  for (final lang in ['ja', 'zh', 'en']) {
    await driver.requestData('2-1-$lang');
    await takeScreenshot(
        driver, './screenshots/${lang}_${_deviceName}_2_1.png');
    await driver.waitForCondition(const NoPendingFrame());
  }

  // このcloseは必要です。ないとプログラムの実行が終わらない。
  await driver.close();
}

// スクショを撮る関数
Future<void> takeScreenshot(FlutterDriver driver, String path) async {
  final pixels = await driver.screenshot();
  final file = File(path);
  await file.writeAsBytes(pixels);
  print('took a screenshot $file');
}

エディタ側の設定

上で紹介した「前回記事」とまったく同じなので、リンクを貼って割愛します。AndroidStudioの場合の説明しかありませんので、VSCodeやその他の方はうまく応用してください?

main_for_ss.dartの起動設定
https://qiita.com/agajo/items/2d2d57561b5880618966#androidstudio%E5%81%B4%E3%81%AE%E8%A8%AD%E5%AE%9A

take_ss.dartの起動設定
https://qiita.com/agajo/items/2d2d57561b5880618966#androidstudio%E5%81%B4%E3%81%AE%E8%A8%AD%E5%AE%9A-1

さぁ、撮ってみよう!

保存先としてscreenshotsというディレクトリを作成しておきます。

iPhone2つとiPad2つの計4デバイスで、3つの場面を、3言語分撮ってみましょう。

4*3*3=36毎の画像になります。

あらかじめシミュレーターを4つ起動しておき、各シミュレーター上で

take_ss/main_for_ss.dartを起動 → take_ss/take_ss.dartでスクショを撮る

という動作をします。

screenshotsディレクトリの中身がこうなったら終了。ストア申請時に扱いやすいように、名前順に並び替えると言語別→デバイス別に並びます。

スクリーンショット 2020-09-06 23.03.37.png

前回記事よりかなり早くスクショを撮り終えることが出来ました。やったね!!

もっと複雑なケース

この記事では手法の肝だけを説明するため、StateNotifierextendsしたクラスは作らず、クラスは同じでStateだけを変えたインスタンスを使ってProviderのoverrideを行っていますが、実際には下記のような対処が必要になると思います。

下記のサンプルコードは、ここまで使ったサンプルアプリとは関係ないです。

StateNotifierに初期化処理があり、stateが書き換わるケース

せっかく普段と違うstateを持ったStateNotifierを作ったのに、初期化処理が走って内容を変えられてしまっては困ります。

StateNotifierであるSomethingControllerextendsしたFakeSomethingControllerを作って次のようにしましょう。

UIから、初期化メソッドを呼んでいる時

そのメソッドをoverrideして初期化処理が走らないようにしましょう。

// NG!! こう書いてはだめ!!!!
@override
void initThisController();

こう書くとoverrideされていないので気をつけましょう。

正しくはこう

@override
void initThisController(){}

コンストラクタ内で初期化している時

それはサブクラスからは止められないので、その後サブクラス側でまたstateを上書きすればよいでしょう。

参考
【Dart】クイズ!extendsしたクラスのコンストラクタ実行順序!

Repositoryパターンのケース

Repositoryの初期化処理が走ってデータベースへのアクセスを確保しようとし、失敗してエラーが出ることがあります。

RepositoryをimplementsしたFakeなんとかRepositoryを作って、データベースへのアクセスが発生しないようにしましょう。

RepositoryをStateNotifierにコンストラクタDIしているなら、StateNotifierをoverrideする際にFakeのRepositoryを渡せばOKです。

そもそもそのStateNotifierもFakeにしている場合は、そちらには始めからFakeのRepositoryを持たせればいいですね。

class FakeRankingController extends RankingController {
  FakeRankingController({RankingState state})
      : super(
            state: state,
            // はじめからFakeRepositoryを持たせる
            rankingRepository: FakeRankingRepository());
  // 初期化処理で何もしないようにする
  @override
  Future<void> initThisController() {}
}

class FakeRankingRepository implements RankingRepository {
  // データベースへのアクセスをしないようにする
  @override
  Future<List<Duration>> getData() {}
}

このFakeRankingControllerを使ってProviderのoverrideを行えばOKです。

別のProviderに依存するProviderがあり、その両方をoverrideしたいケース

gameControllerProvidertimerProviderに依存している場合、このように両方まとめてoverrideするとエラーが出ます。

providerContainer.updateOverrides([
  timerProvider.overrideWithValue(StateController(Duration.zero)),
  gameControllerProvider
    .overrideWithValue(FakeGameController(state: GameState())),
]);
Error in Flutter application: Uncaught extension error while executing request_data:
 'package:riverpod/src/framework/container.dart': Failed assertion: line 247 pos 9:
 'unusedOverrides.isEmpty': Updated the list of overrides with providers that were not overriden before

「一度もoverrideしていないproviderのoverrideをupdateしようとしています。」というエラーが出ていますが、これは正しくないです。というのも、ProviderContainerを宣言した時点で、該当providerは全て一度overrideしています。

エラーメッセージが食い違っていますので、Riverpodのバグなのかもしれません。(2020年9月8日時点)

このように一つずつoverrideすると回避できます。

providerContainer
  ..updateOverrides([
    timerProvider.overrideWithValue(StateController(Duration.zero)),
])
  ..updateOverrides([
    gameControllerProvider
      .overrideWithValue(FakeGameController(state: GameState())),
]);  

原因の仮説

timerProviderをoverrideすると、それに依存しているgameControllerProviderも自動的にoverrideされます。その瞬間、「override可能なproviderのリスト」的な所からgameControllerProviderが除去されてしまい、その後改めてgameControllerProviderをoverrideする際にエラーになるものと思われます。

これは順番を入れ替えても解決しません。

どちらのproviderも指定した値でoverrideしたいので、これでは困ってしまいますね。上記のように2回に分ければ解決できます。

まだ残る課題

  • 端末切り替えのたびにXcodeビルドが走るが、それを止めて、前と同じバイナリを使いたい
  • 端末の切り替えも自動化出来ないかしら…
  • そもそも論、訴求力の高いストア用スクショを作るためにやるべきことはもっと別かも??

皆さん結局どうやってストア用スクショ用意してるんでしょう。

ということで、今回も終わります!

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

GitHub Actionsを使ってAndroidアプリをFirebase App Distributionへ配信する際のベストプラクティス

はじめに

どうも、GitHub Actions大好きな@tarumzuです。
今回はGitHub ActionsでAndroidアプリを配信する際のベストプラクティスな記事がなかなか無いなと思ったので、無いなら作ろう!という試みです。
例としてdev環境とstg環境の2つをApp Distributionで配信するという流れをGitHub Actionsで作ります。

コード

まずは早速コードの全文です。今回はタスクごとにJobを分けて対応しています。こちらのコードを順を追って説明していきます。

.github/workflows/publish.yml
name: publish
on:
  push:
    branches: # 今回はdevelopブランチをdev環境、release/〇〇〇ブランチをstg環境への配信としてます
      - 'develop'
      - 'release/**'
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          submodules: true
      - name: cache gradle
        uses: actions/cache@v2
        id: cache_gradle
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
          restore-keys: |
            ${{ runner.os }}-gradle-
        continue-on-error: true
      - name: set up JDK 1.8
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: restore debug.keystore
        run: |
          echo ${{ secrets.DEBUG_KEYSTORE }} | base64 --decode > ~/signingConfigs/debug.keystore
        shell: bash
      - name: Build with Gradle [DEV]
        if: github.ref == 'refs/heads/develop'
        run: ./gradlew :app:assembleDevDebug
      - name: Build with Gradle [STG]
        if: github.ref != 'refs/heads/develop'
        run: ./gradlew :app:assembleStgDebug
      - name: Upload apk
        uses: actions/upload-artifact@v2
        with:
          name: apk
          path: app/build/outputs/apk
  publish:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          submodules: true
      - name: Download apk
        uses: actions/download-artifact@v2
        with:
          name: apk
      - name: Deploy Firebase App Distribution [DEV]
        if: github.ref == 'refs/heads/develop'
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{secrets.FIREBASE_DEV_APP_ID}}
          token: ${{secrets.FIREBASE_TOKEN}}
          file: dev/debug/app-dev-debug.apk
      - name: Deploy Firebase App Distribution [STG]
        if: github.ref != 'refs/heads/develop'
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{secrets.FIREBASE_STG_APP_ID}}
          token: ${{secrets.FIREBASE_TOKEN}}
          groups: testers
          file: stg/debug/app-stg-debug.apk
  notice:
    if: always()
    needs: publish
    runs-on: ubuntu-latest
    steps:
      - uses: technote-space/workflow-conclusion-action@v1
      - uses: 8398a7/action-slack@v3
        with:
          status: custom
          fields: workflow,job,commit,repo,ref,author,took
          custom_payload: |
            {
              username: 'action-slack',
              icon_emoji: ':octocat:',
              attachments: [{
                color: '${{ env.WORKFLOW_CONCLUSION }}' === 'success' ? 'good' : '${{ job.status }}' === 'failure' ? 'danger' : 'warning',
                text: `${process.env.AS_WORKFLOW}\n${process.env.AS_JOB} (${process.env.AS_COMMIT}) of ${process.env.AS_REPO}@${process.env.AS_REF} by ${process.env.AS_AUTHOR} succeeded`,
              }]
            }
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # optional
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required

buildジョブ

caches

まずはコードのビルドを担当するbuildジョブです。順を追って説明します。まず、最初のstepで大事なのがactions/cache@v2 1 です。今回はビルドキャッシュである ~/.gradle/caches~/.gradle/wrapper の2つをキャッシュしてCI実行時間の削減をしてます。この2つで数百MBあるので結構大事。

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2 # GitHubのcheckout
      - name: cache gradle
        uses: actions/cache@v2
        id: cache_gradle
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
          restore-keys: |
            ${{ runner.os }}-gradle-
        continue-on-error: true

keystoreのリストア

続いて大事なのがkeystoreファイルのリストアです。GitHub ActionsではファイルをSecrets 2 としては指定できないためbase64でstringにして保存します。

keystoreをbase64エンコードする方法
base64 ~/signingConfigs/debug.keystore

base64エンコードされた文字列をデコードする場合は下記のようにしてください。

      - name: set up JDK 1.8
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: restore debug.keystore
        run: |
          echo ${{ secrets.DEBUG_KEYSTORE }} | base64 --decode > ~/signingConfigs/debug.keystore
        shell: bash

build

ここまで来たらようやくbuildです。
if文を使ってdev環境とstg環境でビルドコマンドを分けています。

      - name: Build with Gradle [DEV]
        if: github.ref == 'refs/heads/develop'
        run: ./gradlew :app:assembleDevDebug
      - name: Build with Gradle [STG]
        if: github.ref != 'refs/heads/develop'
        run: ./gradlew :app:assembleStgDebug

ジョブ間でファイルを受け渡しするためのアップロード

ビルドが完了したらジョブ間でファイルを受け渡しするための設定を行いましょう。これをやらないとせっかく作成したAPKが台無しになってしまいます。
ここでは app/build/outputs/apk 以下のファイルを他のジョブに渡したいのでこのpathをアップロードします。

      - name: Upload apk
        uses: actions/upload-artifact@v2
        with:
          name: apk
          path: app/build/outputs/apk

publishジョブ

needs

needsはジョブの依存関係が定義できます。下記の例だとbuildジョブのあとに実行するという意味になります。

  publish:
    needs: build

別のジョブでアップロードされたでファイルをダウンロード

ここで先程のbuildジョブでアップロードしたapkをダウンロードします。ファイル名はapkと定義しているのでここでは apk を指定するだけです。

    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          submodules: true
      - name: Download apk
        uses: actions/download-artifact@v2
        with:
          name: apk

続いて今回のゴールであるApp Distributionでの配信です。ここもdev環境、stg環境でsecrets変数を分けたかったのでif文を用いてます。今回は wzieba/Firebase-Distribution-Github-Action@v1というアクションを利用させてもらってます。

      - name: Deploy Firebase App Distribution [DEV]
        if: github.ref == 'refs/heads/develop'
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{secrets.FIREBASE_DEV_APP_ID}}
          token: ${{secrets.FIREBASE_TOKEN}}
          file: dev/debug/app-dev-debug.apk
      - name: Deploy Firebase App Distribution [STG]
        if: github.ref != 'refs/heads/develop'
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{secrets.FIREBASE_STG_APP_ID}}
          token: ${{secrets.FIREBASE_TOKEN}}
          groups: testers
          file: stg/debug/app-stg-debug.apk

noticeジョブ

配信できたら通知がほしい!ということで最後にslack通知です。通知には 8398a7/action-slack@v3を利用しています。また、ジョブを複数に分けると各ジョブの結果を受け取るのが難しいのですが、そこは technote-space/workflow-conclusion-action@v1アクションを使うことですべてのジョブ実行結果のサマリーがenv.WORKFLOW_CONCLUSIONに保存されるようになるのでこちらを利用することで解決してます。

  notice:
    if: always() # 他のジョブが成功しても失敗しても実行させる
    needs: publish # publishジョブのあとに実行
    runs-on: ubuntu-latest
    steps:
      - uses: technote-space/workflow-conclusion-action@v1 # 他のジョブの成功、失敗を検知するために必要!
      - uses: 8398a7/action-slack@v3
        with:
          status: custom
          fields: workflow,job,commit,repo,ref,author,took
          custom_payload: |
            {
              username: 'action-slack',
              icon_emoji: ':octocat:',
              attachments: [{
                color: '${{ env.WORKFLOW_CONCLUSION }}' === 'success' ? 'good' : '${{ job.status }}' === 'failure' ? 'danger' : 'warning',
                text: `${process.env.AS_WORKFLOW}\n${process.env.AS_JOB} (${process.env.AS_COMMIT}) of ${process.env.AS_REPO}@${process.env.AS_REF} by ${process.env.AS_AUTHOR} succeeded`,
              }]
            }
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # optional
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required

最後に

最近よくGitHub Actionsを利用しているのですが、まだまだ利用している人が少ないようで、ネット上での情報が不足していて対応に難儀することが多々あります。
この記事を機に利用してくれる人が増えてくれると嬉しいです!

参考

https://qiita.com/yokawasa/items/e8d2552b4dffebdc3328


  1. actions/cache@v2はキャッシュがあればキャッシュを復元します。また、ジョブの最後でここのpathで指定しているファイルをキャッシュしてくれます。 

  2. GitHubリポジトリ -> Settings -> Secretsで文字列を変数として扱うことができます。 

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

GitHub Actionsを使ってAndroidアプリをFirebase App Distributionへ配信するまでのベストプラクティス

はじめに

どうも、GitHub Actions大好きな@tarumzuです。
今回はGitHub ActionsでAndroidアプリを配信する際のベストプラクティスな記事がなかなか無いなと思ったので、無いなら作ろう!という試みです。
例としてdev環境とstg環境の2つをApp Distributionで配信するという流れをGitHub Actionsで作ります。

コード

早速ですがコードの全文です。今回はタスク毎にJobを分けて対応しています。こちらのコードを順を追って説明していきます。

.github/workflows/publish.yml
name: publish
on:
  push:
    branches: # 今回はdevelopブランチをdev環境、release/〇〇〇ブランチをstg環境への配信としてます
      - 'develop'
      - 'release/**'
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: cache gradle
        uses: actions/cache@v2
        id: cache_gradle
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
          restore-keys: |
            ${{ runner.os }}-gradle-
        continue-on-error: true
      - name: set up JDK 1.8
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: restore debug.keystore
        run: |
          echo ${{ secrets.DEBUG_KEYSTORE }} | base64 --decode > ~/signingConfigs/debug.keystore
        shell: bash
      - name: Build with Gradle [DEV]
        if: github.ref == 'refs/heads/develop'
        run: ./gradlew :app:assembleDevDebug
      - name: Build with Gradle [STG]
        if: github.ref != 'refs/heads/develop'
        run: ./gradlew :app:assembleStgDebug
      - name: Upload apk
        uses: actions/upload-artifact@v2
        with:
          name: apk
          path: app/build/outputs/apk
  publish:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Download apk
        uses: actions/download-artifact@v2
        with:
          name: apk
      - name: Publish Firebase App Distribution [DEV]
        if: github.ref == 'refs/heads/develop'
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{secrets.FIREBASE_DEV_APP_ID}}
          token: ${{secrets.FIREBASE_TOKEN}}
          file: dev/debug/app-dev-debug.apk
      - name: Publish Firebase App Distribution [STG]
        if: github.ref != 'refs/heads/develop'
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{secrets.FIREBASE_STG_APP_ID}}
          token: ${{secrets.FIREBASE_TOKEN}}
          groups: testers
          file: stg/debug/app-stg-debug.apk
  notice:
    if: always()
    needs: publish
    runs-on: ubuntu-latest
    steps:
      - uses: technote-space/workflow-conclusion-action@v1
      - uses: 8398a7/action-slack@v3
        with:
          status: custom
          fields: workflow,job,commit,repo,ref,author,took
          custom_payload: |
            {
              username: 'action-slack',
              icon_emoji: ':octocat:',
              attachments: [{
                color: '${{ env.WORKFLOW_CONCLUSION }}' === 'success' ? 'good' : '${{ job.status }}' === 'failure' ? 'danger' : 'warning',
                text: `${process.env.AS_WORKFLOW}\n${process.env.AS_JOB} (${process.env.AS_COMMIT}) of ${process.env.AS_REPO}@${process.env.AS_REF} by ${process.env.AS_AUTHOR} succeeded`,
              }]
            }
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # optional
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required

buildジョブ

caches

まずはコードのビルドを担当するbuildジョブです。
最初のstepで大事なのがactions/cache@v2 1 です。今回はビルドキャッシュである ~/.gradle/caches~/.gradle/wrapper の2つをキャッシュしてCI実行時間の削減をしてます。この2つで数百MBあるので結構大事。

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2 # GitHubのcheckout
      - name: cache gradle
        uses: actions/cache@v2
        id: cache_gradle
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
          restore-keys: |
            ${{ runner.os }}-gradle-
        continue-on-error: true

keystoreのリストア

続いて大事なのがkeystoreファイルのリストアです。GitHub ActionsではファイルをSecrets 2 として指定できないためbase64でstringにして保存します。

keystoreをbase64エンコードする方法
base64 ~/signingConfigs/debug.keystore

base64エンコードされた文字列をデコードする場合は下記のようにしてください。

      - name: set up JDK 1.8
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: restore debug.keystore
        run: |
          echo ${{ secrets.DEBUG_KEYSTORE }} | base64 --decode > ~/signingConfigs/debug.keystore
        shell: bash

build

ここまで来たらようやくbuildです。
if文を使ってdev環境とstg環境でビルドコマンドを分けています。コマンドは皆様の環境のBuild Variantsによって異なるのでよしなに置き換えてください。

      - name: Build with Gradle [DEV]
        if: github.ref == 'refs/heads/develop'
        run: ./gradlew :app:assembleDevDebug
      - name: Build with Gradle [STG]
        if: github.ref != 'refs/heads/develop'
        run: ./gradlew :app:assembleStgDebug

ジョブ間でapkファイルを受け渡しするためにアップロードする

ビルドが完了したらジョブ間でファイルを受け渡しするための設定を行いましょう。これをやらないとせっかく作成したAPKが台無しになってしまいます。
ここでは app/build/outputs/apk 以下のファイルを他のジョブに渡したいのでこのpathにあるディレクトリをアップロードします。

      - name: Upload apk
        uses: actions/upload-artifact@v2
        with:
          name: apk
          path: app/build/outputs/apk

publishジョブ

needs

needsはジョブの依存関係が定義できます。下記の例だとbuildジョブのあとに実行するという意味になります。

  publish:
    needs: build

別のジョブでアップロードされたapkファイルをダウンロードする

ここで先程のbuildジョブでアップロードしたapkをダウンロードします。ファイル名はapkと定義しているのでここでは apk を指定するだけです。

    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Download apk
        uses: actions/download-artifact@v2
        with:
          name: apk

続いて今回のゴールであるApp Distributionでの配信です。ここもdev環境、stg環境でsecrets変数を分けたかったのでif文を用いてます。今回は wzieba/Firebase-Distribution-Github-Action@v1というアクションを利用させてもらってます。

      - name: Publish Firebase App Distribution [DEV]
        if: github.ref == 'refs/heads/develop'
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{secrets.FIREBASE_DEV_APP_ID}}
          token: ${{secrets.FIREBASE_TOKEN}}
          file: dev/debug/app-dev-debug.apk
      - name: Publish Firebase App Distribution [STG]
        if: github.ref != 'refs/heads/develop'
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{secrets.FIREBASE_STG_APP_ID}}
          token: ${{secrets.FIREBASE_TOKEN}}
          groups: testers
          file: stg/debug/app-stg-debug.apk

noticeジョブ

配信できたら通知がほしい!ということで最後にslack通知です。通知には 8398a7/action-slack@v3を利用しています。また、ジョブを複数に分けると各ジョブの結果を受け取るのが難しいのですが、そこは technote-space/workflow-conclusion-action@v1アクションを使うことですべてのジョブ実行結果のサマリーがenv.WORKFLOW_CONCLUSIONに保存されるようになるのでこちらを利用することで解決してます。

  notice:
    if: always() # 他のジョブが成功しても失敗しても実行させる
    needs: publish # publishジョブのあとに実行
    runs-on: ubuntu-latest
    steps:
      - uses: technote-space/workflow-conclusion-action@v1 # 他のジョブの成功、失敗を検知するために必要!
      - uses: 8398a7/action-slack@v3
        with:
          status: custom
          fields: workflow,job,commit,repo,ref,author,took
          custom_payload: |
            {
              username: 'action-slack',
              icon_emoji: ':octocat:',
              attachments: [{
                color: '${{ env.WORKFLOW_CONCLUSION }}' === 'success' ? 'good' : '${{ job.status }}' === 'failure' ? 'danger' : 'warning',
                text: `${process.env.AS_WORKFLOW}\n${process.env.AS_JOB} (${process.env.AS_COMMIT}) of ${process.env.AS_REPO}@${process.env.AS_REF} by ${process.env.AS_AUTHOR} succeeded`,
              }]
            }
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # optional
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required

最後に

最近よくGitHub Actionsを利用しているのですが、まだまだ利用しているAndroidエンジニアが少ないようで、ネット上での情報が不足していて対応に難儀することが多々あります。
この記事を機に利用してくれる人が増えてくれると嬉しいです!

参考

https://qiita.com/yokawasa/items/e8d2552b4dffebdc3328


  1. actions/cache@v2はキャッシュがあればキャッシュを復元します。また、ジョブの最後でここのpathで指定しているファイルをキャッシュしてくれます。 

  2. GitHubリポジトリ -> Settings -> Secretsで文字列を変数として扱うことができます。 

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

Androidアプリのtargetsdkversionを変更する

背景

過去の記事で、古いAndroidアプリをKotlinに変更したりした。最近またGoogleからAndroidアプリのtargetsdkversion変更指示が来た。
もう新規機能などは何もしていないプロジェクトだが、継続はしておきたい。
自分の為の備忘録で、手順を記載しておく(その頃には手順が変わるのではないかという可能性も高いが、一応)。

ソースの設定変更

※以下変更箇所で、json形式の設定情報の表記には、hoge.fuga.foo 形式で階層を表現しています(全体図記入すると他の情報が煩わしいので)。直接的にその表現ではない事をご注意ください。

targetSdkVersion を変える

  1. %プロジェクト%/app/build.gradle を開く
  2. android.defaultConfig.targetSdkVersion を変える(今回は29)
  3. android.compileSdkVersion を変える(今回は29)

graleのバージョンを変える

コンソールに出たエラー
Minimum supported Gradle version is 6.1.1. Current version is 5.4.1.

Please fix the project's Gradle settings.
Fix Gradle wrapper and re-import project
Open Gradle wrapper properties
Gradle settings

ご親切に最後の3行にはリンクが張ってあり、クリックすると処理が始まる。Fix Gradle wrapper and re-import project をクリックしてしばし待つ。

3つのステップがあるのではなく、3種類の対応の中で1つ選ぶらしい。エラーが無くなった。

その他

上の設定ファイルを開くと色々更新サジェストされたので、それも更新しておく(リンククリックで自動でやってくれる)。

実行

メニューから、Run -> Run 'app' でエミュレータでの実行確認。

ビルド設定

  1. %プロジェクト%/app/src/main/AndroidManifest.xml を開く
  2. manifest タグ内、android:versionCode、android:versionName を一つ上に上げる。

ビルド実行

  1. Build -> Generate single bundle / APK
  2. キーストア関係のファイルやパスワード入力
  3. 出力先に %プロジェクト%/app を選択
  4. Build vaiants に release を選択
  5. %プロジェクト%/app/release 配下に app-release.apk が出来てる事確認

登録

GooglePlay コンソールでの対応

  1. アプリ選択
  2. 「リリース管理」
  3. 「アプリのリリース」
  4. 製品版トラック → 「管理」
  5. 「リリースを作成」
  6. 先に作成した app-release.apk をアップ
  7. 「このリリースの新機能」に「Android OS のセキュリティアップデートに伴う更新」とか書く
  8. 「保存」、「確認」
  9. 「製品版としてリリース」

あとがき

ほぼ未来の自分向けの記事ですが、私同様に、昔作ったAndroidアプリを継続したい人の役に立てば幸いです。
しかし、変更の度に色々AndroidStudioにサジェストされますね。従っておくのが得そうです。

全然話は変わって、せっかくKotlinでの開発環境整ったのだから、新規に何かAndroidアプリを開発したいと思う今日この頃でした。

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

Android Studioをアップデートした際に「some conflicts were found in the installation area」がでた時の対処法

表題の通り3.3.1=>4.0.1にアップデートした際に「some conflicts were found in the installation area」とエラーが出てアップデートが完了しないことがありました。

対処法

Android Studioを完全にアンインストールし、最新バーションをインストールすることによって、エラーは対処できた。

MacでAndroidStudioを完全アンインストール - Qiita

補足

アンインストールの他にもSDKManagerからエラーが発生しているComponentをアンインストールし、再度インストールすると直る方法もあるようです。
今回は事前にホームディレクトリ名などを変更し時間がかかりそうだったので、完全にアンインストールする方法を選びました。

参考

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

AndroidにおけるHMSのAPI Key導入の問題点とその解決策

HMSのAPIキー

ファーウェイ(Huawei)HMS(Huawei Mobile Services)プッシュキット(Push Kit)マップキット(Map Kit)など、複数のキッドより構成されています。特定なキッドを利用するのに、APIキーが必要です。

APIキーの場所

APIキーはAppGallery Connectの[Project Setting] -> [General Information]にあります。
AppGallery Connect - Project Setting.png

APIキーの導入とその問題点

通常、APIキーは次のように導入します。

MainActivity.kt
// たとえば、private val API_KEY = "CgB6e3x9iW/qiE9l9wAUPK0e/bJQe5uIgTlYUD4bPc8gzjriSVxDDzX2fAVjCVdUHkP+tan0Xi0sf4tj7t11TJJe"
private val API_KEY = "Your API key"

// マップキットを利用するケース
MapsInitializer.setApiKey(API_KEY)

または、次のようにように導入します

strings.xml
<!-- たとえば、<string name="api_key">CgB6e3x9iW/qiE9l9wAUPK0e/bJQe5uIgTlYUD4bPc8gzjriSVxDDzX2fAVjCVdUHkP+tan0Xi0sf4tj7t11TJJe</string> -->
<string name="api_key">Your API key</string>
MainActivity.kt
// マップキットを利用するケース
MapsInitializer.setApiKey(getString(R.string.api_key))

しかし、上記の導入方法ですと、APIキーを明示的にソースコードに記載しなければなりません。

HMSを利用するのに、agconnect-services.jsonをAppGallery Connectからダウンロードし、プロジェクトのappフォルダに置く必要があります。
Android Studio - Project.png
agconnect-services.jsonを開くと、client -> api_keyにAPIキーが載っています。
agconnect-services.png

これですと、APIキーが二重記載になってしまい、ソースコードとしてはあまりよろしくありません。

解決策

次のように書けば、APIキーの二重記載の問題が解決されるだけでなく、agconnect-services.jsonに載っているAPIキーをそのまま利用できるので、APIキーの記載間違いも起きません。

MainActivity.kt
val api_key = AGConnectServicesConfig.fromContext(applicationContext).getString("client/api_key")

// マップキットを利用するケース
MapsInitializer.setApiKey(api_key)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AndroidにおけるHMSのAPI Keyの二重定義とその解決策

HMSのAPIキー

ファーウェイ(Huawei)HMS(Huawei Mobile Services)プッシュキット(Push Kit)マップキット(Map Kit)など、複数のキッドより構成されています。特定なキッドを利用するのに、APIキーが必要です。

APIキーの場所

APIキーはAppGallery Connectの[Project Setting] -> [General Information]にあります。
AppGallery Connect - Project Setting.png

APIキーの導入とその問題点

通常、APIキーは次のように導入します。

MainActivity.kt
// たとえば、private val API_KEY = "CgB6e3x9iW/qiE9l9wAUPK0e/bJQe5uIgTlYUD4bPc8gzjriSVxDDzX2fAVjCVdUHkP+tan0Xi0sf4tj7t11TJJe"
private val API_KEY = "Your API key"

// マップキットを利用するケース
MapsInitializer.setApiKey(API_KEY)

または、次のようにように導入します

strings.xml
<!-- たとえば、<string name="api_key">CgB6e3x9iW/qiE9l9wAUPK0e/bJQe5uIgTlYUD4bPc8gzjriSVxDDzX2fAVjCVdUHkP+tan0Xi0sf4tj7t11TJJe</string> -->
<string name="api_key">Your API key</string>
MainActivity.kt
// マップキットを利用するケース
MapsInitializer.setApiKey(getString(R.string.api_key))

しかし、上記の導入方法ですと、APIキーを明示的にソースコードに記載しなければなりません。

HMSを利用するのに、agconnect-services.jsonをAppGallery Connectからダウンロードし、プロジェクトのappフォルダに置く必要があります。
Android Studio - Project.png
agconnect-services.jsonを開くと、client -> api_keyにAPIキーが載っています。
agconnect-services.png

これですと、APIキーが二重定義になってしまい、ソースコードとしてはあまりよろしくありません。

解決策

次のように書けば、APIキーの二重定義の問題が解決されるだけでなく、agconnect-services.jsonに載っているAPIキーをそのまま利用できるので、APIキーの記載間違いも起きません。

MainActivity.kt
val api_key = AGConnectServicesConfig.fromContext(applicationContext).getString("client/api_key")

// マップキットを利用するケース
MapsInitializer.setApiKey(api_key)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AndroidにおけるHMSのAPI Keyの二重定義の問題とその解決策

HMSのAPIキー

ファーウェイ(Huawei)HMS(Huawei Mobile Services)プッシュキット(Push Kit)マップキット(Map Kit)など、複数のキッドより構成されています。特定なキッドを利用するのに、APIキーが必要です。

APIキーの場所

APIキーはAppGallery Connectの[Project Setting] -> [General Information]にあります。
AppGallery Connect - Project Setting.png

APIキーの導入とその問題点

通常、APIキーは次のように導入します。

MainActivity.kt
// たとえば、private val API_KEY = "CgB6e3x9iW/qiE9l9wAUPK0e/bJQe5uIgTlYUD4bPc8gzjriSVxDDzX2fAVjCVdUHkP+tan0Xi0sf4tj7t11TJJe"
private val API_KEY = "Your API key"

// マップキットを利用するケース
MapsInitializer.setApiKey(API_KEY)

または、次のようにように導入します

strings.xml
<!-- たとえば、<string name="api_key">CgB6e3x9iW/qiE9l9wAUPK0e/bJQe5uIgTlYUD4bPc8gzjriSVxDDzX2fAVjCVdUHkP+tan0Xi0sf4tj7t11TJJe</string> -->
<string name="api_key">Your API key</string>
MainActivity.kt
// マップキットを利用するケース
MapsInitializer.setApiKey(getString(R.string.api_key))

しかし、上記の導入方法ですと、APIキーを明示的にソースコードに記載しなければなりません。

HMSを利用するのに、agconnect-services.jsonをAppGallery Connectからダウンロードし、プロジェクトのappフォルダに置く必要があります。
Android Studio - Project.png
agconnect-services.jsonを開くと、client -> api_keyにAPIキーが載っています。
agconnect-services.png

これですと、APIキーが二重定義になってしまい、ソースコードとしてはあまりよろしくありません。

解決策

次のように書けば、APIキーの二重定義の問題が解決されるだけでなく、agconnect-services.jsonに載っているAPIキーをそのまま利用するので、APIキーの記載間違いも起きません。

MainActivity.kt
val api_key = AGConnectServicesConfig.fromContext(applicationContext).getString("client/api_key")

// マップキットを利用するケース
MapsInitializer.setApiKey(api_key)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Android課金(定期購入)をはじめて実装するときに読む記事まとめ

知識ゼロの状態からAndroidの課金(定期購入)を実装することがあり、色々な記事を読み漁った時期がありました。
その中で特に参考になった記事をまとめておきます。
(細かい実装に関するものより、概要や全体像を把握するのに役立った記事が多めです)

公式ドキュメント

言わずもがな、必読の記事です:thumbsup:

Google Play 請求サービスの概要

  • Android課金の基礎用語集
  • 課金タイプは2つある
    • 1回限りのアイテム
    • 定期購入
  • サーバーとの連携部分については、REST APIのGoogle Play Developer APIや、状態の変化をモニタリングできるサーバープッシュ通知のリアルタイム デベロッパー通知などの理解も必要になってくる
  • 購入トークンオーダーIDSKUなどは実装中にもたびたび出てくる用語なので押さえておく

Google Play Billing Library の使用

  • 1 回限りのアイテムと定期購入の両方に共通する実装方法を説明している
    • 依存関係の追加からGoogle Playへの接続、購入の承認や確認など
  • 基本的にはこちらの記事を参考に実装しつつ、わからない箇所については他の記事を頼りながら実装していくのが良いかと

定期購入固有の機能の追加

  • 定期購入には固有の設定オプションがあり、そのそれぞれについて説明している

全体像の把握に役立つ記事

はじめに全体像を掴んでおけると良さそう:ok_hand:

簡単なGoogle Play Billing Libraryの課金の流れ - Qiita

  • わかりやすい図があるので、課金処理全体の流れを把握するのにおすすめ

基本的にはBillingClientに対して以下をするだけです。
- 接続する。
- 課金アイテムの情報を取得する。
- アイテムの情報を渡して課金するだけ。

詳解定期購入 - Speaker Deck

  • 実装以外の部分についても書かれているので、広い意味でAndroid課金について理解することができる
    • 課金導入の経緯や、導入によって得られた効果について
    • 検証方法について
    • etc...
  • テスト購入のトラブルシューティング(p93)に書かれている内容はハマりやすいので注意
    • 「アカウント切り替えても最初にPlayStoreに登録したアカウントでしか購入できない」

Google Play Billing Library について知るのに役立つ記事

毎年新しいメジャーバージョンが発表され、そのたびに様々な変更が入るので、Google Play Billing Library の動向については知っておく必要があります:rolling_eyes:

DroidKaigi 2020 Re:ゼロから始める Play Billing Library - Speaker Deck

  • AIDL -> Google Play Billing Library へ
  • Google I/O 2019で発表された Google Play Billing Library バージョン2.0では「購入の承認」と「保留中のトランザクションのサポート」が必須となったが、それらについての説明がされている
  • 発表概要がすでにわかりやすい

またPlay Billing Libraryは今後、毎年開催されるGoogle I/Oにて新しいメジャーバージョンがリリースされます。
(中略)
現在Google Playの課金機能をつかってアプリ内課金を提供しているアプリは今後、2年に1回は必ずPlay Billing Libraryのアップデートを行う必要があります。

【2020年版】Play Commerceアップデート対応 | Medium

  • Google Play Billing Library バージョン3.0 のアップデート内容に関する説明がされている
    • 「ライブラリ自体には大きな新機能などのアップデートはなく、Google Playの定期購入に関連するいくつかの発表があった」
      • Account Hold
      • Restore
    • 2020年11月までに対応する必要がある

サーバー側の処理について知るのに役立つ記事

課金処理の一部をサーバー側にまかせている場合に読む記事です。
サーバー側でやっていることを理解しておくと、サーバーサイドエンジニアとのやりとりがスムーズになったり、アプリ側の実装がしやすくなるというメリットがあります:v:

自動購読課金について【Android編】 | サイバーエージェント 公式エンジニアブログ

  • サイバーエージェント社の課金まわりの設計がわかりやすい図にまとまっている
    • アプリ ー GooglePlay ー サーバ ー GooglePlayAPI 間の流れがよくわかる
    • サーバー側では主に、「署名の検証」「レシートの検証」を行っている

iOS/Androidアプリ内課金の不正なレシートによる有料会員登録を防ぐ - pixiv inside archive

  • pixiv社の課金まわりの設計がわかりやすい図にまとまっている
  • 不正なレシートに対してどう対応するかにも言及している
    • Androidアプリ単体で防ぐのは難しく、サーバー側で対策を講じる必要がある
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

『こんにちはPython』のスカッシュゲーム(壁打ちテニス)を JavaScript で写経してみた(後編)

『こんにちはPython』のスカッシュゲーム(壁打ちテニス)を JavaScript で写経してみた(後編)

みなさま、こんにちは。ハーツテクノロジーの山崎です。(この記事は、業務とは直接無関係な記事ですが、業務で得られた知見が間接的に随所に織り込まれていると思われます。)

この記事は、前編と後編の2部構成になっていて、こちらは後編です。

前編には、小学生でもわかるように、「ゲームとして遊べるまで」を解説しています。
後編では、小学生に解説するには少々無理がある部分を、こちらにまとめるようにしました。ですので、小学生の方は前編だけ読んでいただき、後編は、学校を卒業してから読むようにすると理解がすすむ、、、かもしれません。

おとなの方は、後編からでもよいですが、前編を軽く流し読みしていただいてからのほうが、この後編の内容がより理解しやすい思います。よろしくお願いします。

こんにちはPython

元ネタはこちら。

ゲームセンターあらしと学ぶプログラミング入門
まんが版『こんにちはPython』
https://www.m-sugaya.jp/manga_python/

よい本です。なんと言っても、まんがであることが最高です。しかも、「ゲームセンターあらし」ですよ!(炎のコマは出ないけど)
当時、小学生~中学生だったわたしは、勉強が嫌いで、よく友達とゲームセンターに行って遊んでいました。
もちろん「ゲームセンターあらし」も読んでました。で、ゲームがめっちゃ好きになって「プログラマになりたい!」って思ったのが最初の人生の分岐点でした。

プログラマになれば、ゲームが作れるようになります。これは事実です。ただ、1人でできる規模は限られちゃいますけど。

さて、後編は、前編では書けなかったおとな向けの説明になります。

主に、
1、オブジェクト指向
2、スマホ対応
3、音対応
の3つについて解説していきます。

1、オブジェクト指向

いきなりですが、class を導入します。いわゆるオブジェクト指向です。戦略でいう「分割統治」ってやつです。

前編で作成したコードは、長くは無いコードではあるのですが、サクッと理解できる行数を超えてきたので、手を加える前に整理します。

プログラムコードは放っておくとすぐに肥大化します。肥大化したコードは散らかった部屋と一緒で、どこになにがあるのかさっぱりわからなくなります。難解なコードは、理解するための時間を奪います。

オブジェクト指向とは、(Qiitaを俳諧しているおとなの人には耳タコだと思いますが、)わたしの解釈を短めに書くと「データ構造を整理して密接に関連するアルゴリズムを近くに置くことでコードを部品化し見通しよく整理すること」です。
複雑で難解なコードをオブジェクト指向の恩恵に預かって、読みやすく、理解しやすくしましょう。というわけです。

(この Twitter の画像は「イメージ」です。プログラミングとは無関係ですが「整理されたコードの価値」を画像で的確に表現できている、と思っています。)

プログラミングに慣れたひとは、class & object にしたほうが見やすい(読みやすい)と思います。やはり、変数や定数は、グローバルなエリアには置かず、必要とされている(関係の深い)機能に近い場所に置いて、影響範囲や思考の範囲を狭めることで、思考がシンプルになり、理解しやすくなるからだど思っています。

具体的には、表示エリア、ボール、ラケットをそれぞれ class にして、作成した class から new を使って object を作成します。
ちなみに、class が本領を発揮するのは、複数の object を生成するようになってからです。このコードでは 1つの class に対して 1つの object しか生成していないので、構造体+関数で書くのと大きな違いは無いと言える、、、かもしれません。

あと、ついでに、メッセージをランダムに選んでいるところ↓

    const mes = Math.floor( Math.random() * 5 )
    let message = ''
    if ( mes == 0 )
        message = 'うまい!'
    if ( mes == 1 )
        message = 'グッド!'
    if ( mes == 2 )
        message = 'ナイス!'
    if ( mes == 3 )
        message = 'よしッ!'
    if ( mes == 4 )
        message = 'すてき!'

これは小学生向けの書き方なので、おとななプログラマの方は、この処理をサクッと関数にしましょう。

function get_random_message( ary )
{
    return ary[ Math.floor( Math.random() * ary.length ) ]
}

const message = get_random_message( [
    'うまい!', 'グッド!', 'ナイス!', 'よしッ!', 'すてき!'
] ) 

はい。短く、シンプルになりました。

その昔、JavaScript には class は無く、 function を使って class っぽいコードを書いていましたが、それはもう昔の話です。今(2020年)は、JavaScript で class が使えます。

いったんまとめると以下のようなコードになります。長くなってきたので閉じています。ここ↓「全コード」をクリックすると見られます。

全コード
<body></body><script>
// 「こんにちは Python」のスカッシュゲーム(壁打ちテニス)を JavaScript で写経(音無し版)
// オブジェクト指向対応版

// メッセージをランダムに選択
function get_random_message( ary )
{
    return ary[ Math.floor( Math.random() * ary.length ) ]
}

// canvas表示エリア
class CanvasArea
{
    constructor()
    {
        this.cv = document.createElement( 'canvas' )
        this.ctx = this.cv.getContext( '2d' )

        this.cv_w = 640
        this.cv_h = 400
        this.cv.setAttribute( 'width',  this.cv_w )
        this.cv.setAttribute( 'height', this.cv_h )
        document.body.appendChild( this.cv )

        this.draw()
    }

    // 画面クリア
    draw()
    {
        this.ctx.fillStyle = 'silver' // 'white'
        this.ctx.fillRect( 0, 0, this.cv_w, this.cv_h )
    }
}

// ボール
class Ball
{
    constructor()
    {
        this.init( 0, 250, 15, -15 )
        this.r_size = 10
    }

    init( _x, _y, _sx, _sy )
    {
        this.x = _x
        this.y = _y
        this.speed_x = _sx
        this.speed_y = _sy
    }

    // ボールを描く
    draw()
    {
        area.ctx.beginPath()
        area.ctx.arc( this.x, this.y, this.r_size, 0, 2 * Math.PI )
        area.ctx.fillStyle = 'red'
        area.ctx.fill()
    }

    // ボールの移動
    move()
    {
        if ( is_gameover ) return

        // 左右の壁に当たったのかの判定
        if ( this.x+this.speed_x < 0   ) {
            this.speed_x *= -1
        }
        if ( this.x+this.speed_x > area.cv_w ) {
            this.speed_x *= -1
        }

        // 天井の壁に当たったのかの判定
        if ( this.y+this.speed_y < 0 ) {
            this.speed_y *= -1
        }

        // ラケットに当たったのかの判定
        if ( this.y+this.speed_y > area.cv_h-10 ) {
            if ( racket.x < this.x+this.speed_x ) {
                if ( this.x+this.speed_x < racket.x+racket.w_size ) {
                    this.speed_y *= -1
                    if ( Math.random() < 0.5 )
                        this.speed_x *= -1

                    const message = get_random_message( [
                        'うまい!', 'グッド!', 'ナイス!', 'よしッ!', 'すてき!'
                    ] )
                    point += 10
                    div.innerText = message + ' 得点:' + point
                }
            }
        }

        // ミスした時の判定
        if ( this.y+this.speed_y > area.cv_h ) {
            const message = get_random_message( [
                'へたくそ!','ミスしたね!', 'あーあ、見てられないね!'
            ] )
            div.innerText = message + ' 得点:' + point

            is_gameover = true
            return
        }

        // ボールを移動
        this.x += this.speed_x
        this.y += this.speed_y
    }
}

// ラケット
class Racket
{
    constructor()
    {
        this.x = 0
        this.w_size = 100

        // マウスの動きの処理
        area.cv.onmousemove = ev => {
            this.x = ev.offsetX - 50
        }
    }

    draw()
    {
        area.ctx.fillStyle = 'yellow'
        area.ctx.fillRect( this.x, area.cv_h-10, this.w_size, 8 )
    }
}

// メッセージ表示エリア div の確保
const div = document.createElement( 'div' )
document.body.appendChild( div )
div.innerText = "スカッシュゲーム(音無し版):マウスクリックでスタート!"

const area   = new CanvasArea()
const ball   = new Ball()
const racket = new Racket()

let is_gameover = true
let point = 0

// クリックで再スタート
area.cv.onclick = ev =>
{
    if ( ! is_gameover ) return

    // ゲームの初期化
    is_gameover = false
    point = 0
    ball.init( 0, 250, 15, -15 )
    div.innerText = "スカッシュゲーム:スタート!"
}

// ゲームの繰り返し処理
function game_loop()
{
    area.draw()     // 画面クリア
    ball.draw()     // ボールを描く
    racket.draw()   // ラケットを描く
    ball.move()     // ボールの移動
}

// ゲームのメイン処理開始
setInterval( game_loop, 50 ) // 20fps

</script></html>

ちなみに、現状は、こういう感じ?で動いています。

CodePen1

コードを読んで、「is_gameover とかも class にまとめられるじゃん」と思った方もおられるでしょう。いい質問ですね。それもありだとは思います。ですが、ここは、さじ加減といいますか、やりすぎないところで止めるのが、いい塩梅だと思っています(個人の見解です)。

では、このコードを足がかりにして、スマホ対応と、音を出す対応をしていきます。

2、スマホ対応

スマホ対応では大きく2つの対応をします。

(1) 見た目を整える→リサイズレスポンシブ)に対応
(2) タッチイベントに対応

1つ目は、見た目を整えます。
現状は、canvas エリアが 640x400 と固定になっています。PCのブラウザであれば、この大きさで固定に表示しても大きな問題にはなりにくいです。
しかし、スマホのブラウザでは、ちょっと遊べません。スマホの画面サイズもいろいろありますし、画面を小さくしてもたのしくありません。
ここは、リサイズレスポンシブ)に対応し、表示しているブラウザのサイズに合わせて、canvas エリアの大きさを変更するコードを書きます。

もう1つは、タッチイベントの対応です。
意外に思われるかもしれませんが、ブラウザでは、マウスでクリックしたイベントと、画面にタッチしたイベントは別のイベントとしてあつかわれています。
ですので、スマホのブラウザでは、マウスを動かしたイベントが発生できず、ラケットを動かすことができません。
ここも、タッチイベントのコードを入れていきます。

(ちなみに、ここで指している「スマホ」とは、Android端末と、iPhone の大きく2種類がありますが、動作を確認しているのは、iPhone の Chrome ブラウザになります。Androidや、Chrome以外のブラウザでは確認していないですが、おそらく、問題なく動くと思っています。動かない環境などありましたら、コメントいただけますとたすかります。)

(1) リサイズレスポンシブ)に対応

まずは、リサイズレスポンシブ)対応から始めます。

いきなりコードを変更するのではなく、試験的に以下のような、HTMLを用意して、希望する動作をするかを確認します。特に、レスポンシブ対応の場合、頭で考えているとおりの動きになるかどうか、「実際に動かしてみないとわからない」ということが多いのです。

html タグ、 body タグを縦方向に 100% とし(横方向は自動的に広がるので、特に指定しなくてもオッケー?)、body タグには margin が効いているので、これを OFF にする意味で、 0 を指定します。

レスポンシブとしては、ヘッダ部分にある「メッセージ」は 30px、フッター部分にある「タップエリア」は 100px 固定の高さにして。中央の「キャンバス表示エリア」はレスポンシブに(ブラウザの大きさにあわせて)最大の広さになるように display: grid;grid-template-rows: 30px 1fr 100px; を指定しています。

<html style="height: 100%;"><meta charset="utf-8" />
<body style="height: 100%; margin: 0;">
<div style="height: 100%; display: grid; grid-template-rows: 30px 1fr 100px;" >
    <div style="background-color: white;"   >メッセージ</div>
    <div style="background-color: silver;"  >キャンバス表示エリア</div>
    <div style="background-color: skyblue;" >タップエリア</div>
</div>
</body></html>

CodePen2

まずは、HTML だけをブラウザで表示して、希望通りレスポンシブに表示されるか、ブラウザの表示エリアを変えて確認します。短いコードで、いったん動作を確認してから、メインのコードに組み込んでいきます。

この HTML の中央 div タグ「キャンバス表示エリア」に canvas タグをハメ込むようにコードを書き換えます。
主に、class CanvasAreaにまとめておいたコードに、リサイズレスポンシブ)の変更を加えます。

リサイズ対応前のコード
// canvas表示エリア
class CanvasArea
{
    constructor()
    {
        this.cv_w = 640
        this.cv_h = 480
        this.cv = document.createElement( 'canvas' )
        this.cv.setAttribute( 'width',  this.cv_w )
        this.cv.setAttribute( 'height', this.cv_h )
        document.body.appendChild( this.cv )
        this.ctx = this.cv.getContext( '2d' )
    }

    // 画面クリア
    draw()
    {
        this.ctx.fillStyle = 'silver' // 'white'
        this.ctx.fillRect( 0, 0, this.cv_w, this.cv_h )
    }
}

リサイズの処理
window.onresize = ()=>{
    this.resize()
}

リサイズ対応後のコード
// canvas表示エリア
class CanvasArea
{
    constructor()
    {
        this.div_area = document.querySelector( 'div#area' )
        this.cv = document.createElement( 'canvas' )
        this.cv.setAttribute( 'style', 'position: absolute;' ) // 自動で反映されないので、あえて子供から浮かせる。
        this.div_area.appendChild( this.cv )
        this.ctx = this.cv.getContext( '2d' )
        window.onresize = ()=>{
            this.resize()
        }

        this.resize()
    }

    resize()
    {   // canvas タグは div タグのように、縦横サイズの自動調整が効かないので、親 div と同じサイズにあわせる
        this.cv_w = this.div_area.clientWidth       // 親の div の縦横サイズを取得
        this.cv_h = this.div_area.clientHeight
        this.cv.setAttribute( 'width',  this.cv_w ) // 親 div の縦横サイズにあわせる
        this.cv.setAttribute( 'height', this.cv_h )

        this.draw()
    }

    // 画面クリア
    draw()
    {
        this.ctx.fillStyle = 'silver' // 'white'
        this.ctx.fillRect( 0, 0, this.cv_w, this.cv_h )
    }
}

コードを修正する範囲は、class CanvasArea に収まっています。オブジェクト指向すげー!

ってか、本当は、canvas タグも自動でサイズ調整が効けば、こんなコードを書かなくてもすむのですがねー(きっとなにかしらの理由があるのでしょう。)

(2) タッチイベントの対応

次は、タッチイベント(タップ)に対応します。

ラケットを動かす class Racket 内にある、 onmousemove イベント処理と同じ場所に、↑の HTML で用意した、水色のフッター部分に相当する、div#touch タグに、タッチイベントを追加するだけです。

    // タッチイベントの処理
    const div_touch = document.querySelector( 'div#touch' )
    div_touch.ontouchstart =
    div_touch.ontouchmove = ev => {
        this.x = ev.pageX - 50
        return false // イベントを伝搬しない(親に返すと、フリックやスクロールに反応していろいろ面倒なので)
    }

ontouchstartontouchmove に同じ関数を設定しているので、ちょっと変な書き方をしていますが、想定内(許容範囲内)ですよね。

タッチイベントの実装に関しては、手前味噌ですが、このあたり↓の記事も参考にしてください。

JavaScript タッチイベントの取得(マルチ対応) サンプルコード
https://qiita.com/yamazaki3104/items/1f550c589b13febade82

さて、スマホ対応の全コードはこのように↓なります。ここ↓「全コード」をクリックで開きます。

全コード
<html style="height: 100%;"><meta charset="utf-8" />
<body style="height: 100%; margin: 0;">
<div style="height: 100%; display: grid; grid-template-rows: 30px 1fr 100px;" >
<div id="mess" ></div>
<div id="area" ></div>
<div id="touch" style="background-color: skyblue;" ></div>
</div>
</body><script>
// 「こんにちは Python」のスカッシュゲーム(壁打ちテニス)を JavaScript で写経(音無し版)
// レスポンシブ対応版

// メッセージをランダムに選択
function get_random_message( ary )
{
    return ary[ Math.floor( Math.random() * ary.length ) ]
}

// canvas表示エリア
class CanvasArea
{
    constructor()
    {
        this.div_area = document.querySelector( 'div#area' )
        this.cv = document.createElement( 'canvas' )
        this.cv.setAttribute( 'style', 'position: absolute;' ) // 自動で反映されないので、あえて子供から浮かせる。
        this.div_area.appendChild( this.cv )
        this.ctx = this.cv.getContext( '2d' )
        window.onresize = ()=>{
            this.resize()
        }

        this.resize()
    }

    resize()
    {   // canvas タグは div タグのように、縦横サイズの自動調整が効かないので、親 div と同じサイズにあわせる
        this.cv_w = this.div_area.clientWidth       // 親の div の縦横サイズを取得
        this.cv_h = this.div_area.clientHeight
        this.cv.setAttribute( 'width',  this.cv_w ) // 親 div の縦横サイズにあわせる
        this.cv.setAttribute( 'height', this.cv_h )

        this.draw()
    }

    // 画面クリア
    draw()
    {
        this.ctx.fillStyle = 'silver' // 'white'
        this.ctx.fillRect( 0, 0, this.cv_w, this.cv_h )
    }
}

// ボール
class Ball
{
    constructor()
    {
        this.init( 0, 250, 15, -15 )
        this.r_size = 10
    }

    init( _x, _y, _sx, _sy )
    {
        this.x = _x
        this.y = _y
        this.speed_x = _sx
        this.speed_y = _sy
    }

    // ボールを描く
    draw()
    {
        area.ctx.beginPath()
        area.ctx.arc( this.x, this.y, this.r_size, 0, 2 * Math.PI )
        area.ctx.fillStyle = 'red'
        area.ctx.fill()
    }

    // ボールの移動
    move()
    {
        if ( is_gameover ) return

        // 左右の壁に当たったのかの判定
        if ( this.x+this.speed_x < 0   ) {
            this.speed_x *= -1
        }
        if ( this.x+this.speed_x > area.cv_w ) {
            this.speed_x *= -1
        }

        // 天井の壁に当たったのかの判定
        if ( this.y+this.speed_y < 0 ) {
            this.speed_y *= -1
        }

        // ラケットに当たったのかの判定
        if ( this.y+this.speed_y > area.cv_h-10 ) {
            if ( racket.x < this.x+this.speed_x ) {
                if ( this.x+this.speed_x < racket.x+racket.w_size ) {
                    this.speed_y *= -1
                    if ( Math.random() < 0.5 )
                        this.speed_x *= -1

                    const message = get_random_message( [
                        'うまい!', 'グッド!', 'ナイス!', 'よしッ!', 'すてき!'
                    ] )
                    point += 10
                    div.innerText = message + ' 得点:' + point
                }
            }
        }

        // ミスした時の判定
        if ( this.y+this.speed_y > area.cv_h ) {
            const message = get_random_message( [
                'へたくそ!','ミスしたね!', 'あーあ、見てられないね!'
            ] )
            div.innerText = message + ' 得点:' + point

            is_gameover = true
            return
        }

        // ボールを移動
        this.x += this.speed_x
        this.y += this.speed_y
    }
}

// ラケット
class Racket
{
    constructor()
    {
        this.x = 0
        this.w_size = 100

        // マウスの動きの処理
        area.cv.onmousemove = ev => {
            this.x = ev.offsetX - 50
        }

        // タッチイベントの処理
        const div_touch = document.querySelector( 'div#touch' )
        div_touch.ontouchstart =
        div_touch.ontouchmove = ev => {
            this.x = ev.pageX - 50
            return false // イベントを伝搬しない(親に返すと、フリックやスクロールに反応していろいろ面倒なので)
        }
    }

    draw()
    {
        area.ctx.fillStyle = 'yellow'
        area.ctx.fillRect( this.x, area.cv_h-10, this.w_size, 8 )
    }
}

// メッセージ表示エリア div の確保
const div = document.querySelector( 'div#mess' )
div.innerText = "スカッシュゲーム(音無し版):マウスクリックでスタート!"

const area   = new CanvasArea()
const ball   = new Ball()
const racket = new Racket()

let is_gameover = true
let point = 0

// クリックで再スタート
area.cv.onclick = ev =>
{
    if ( ! is_gameover ) return

    // ゲームの初期化
    is_gameover = false
    point = 0
    ball.init( 0, 250, 15, -15 )
    div.innerText = "スカッシュゲーム:スタート!"
}

// ゲームの繰り返し処理
function game_loop()
{
    area.draw()     // 画面クリア
    ball.draw()     // ボールを描く
    racket.draw()   // ラケットを描く
    ball.move()     // ボールの移動
}

// ゲームのメイン処理開始
setInterval( game_loop, 50 ) // 20fps

</script></html>

これでブラウザの大きさを変えても、canvas のエリアが追従するはず、こんな感じ↓。
スマホのブラウザで開いたときに、いい感じの配置になる予定です。

CodePen3

スマホで確認してみたいかたは、こちら↓の QR コードから開いてみてください。

qr.png

動いてますか?

3、音を出す

今回の写経で一番苦労したのが、ここです。ブラウザで音を出すって、意外に難しかったです。

難しい理由は、大きく2つあって、1つ目は
「ブラウザで音を出す」をググると「メディア要素(<audio>タグ)で mp3 を鳴らす」
という記事ばかりで、ブラウザではmp3 以外に音を鳴らすことができないのではないか?と錯覚してしまうほどです。

しばらく探すと、Web Audio API というAPI が用意されていることがわかります。ただ、まだ新しい、実験中の機能のようで、使っている人も少なく、すなわち参考文献がとても少ないです。

さて2つ目の問題は、ブラウザ特有の文化的背景の制限があります。
理解できるものの、なんとなくモヤッとする理由(メディア要素(<video>, <audio>)の自動再生におけるポリシーの変更)から、ブラウザを開いた瞬間に音は出せないようになっています。詳しくは「ユーザーのクリックアクションがキーになって音を出し始めないといけない」と決めたようです。
このヘンテコな仕様からプログラマは泣かされます。コードの書き方によって音が出たり出なかったり、ブラウザによって、動きが異なったり。どう書くのが正解なの??!!と。

で、結局は、試行錯誤をして、動いているコードが正義!とばかりに、「なぜかよくわからないけど、こう書くと動く」というコードが氾濫しています。混乱に混乱が重なります。

これらの問題を乗り越えて、ようやくcreateOscillator()というAPI があることにたどり着けます。
このAPIを呼べば、mp3 という、基本的に巨大なファイルを用意しなくても、ピコピコ音が出せるのです。とは言え、まだドラフト(草案)レベルなので、たまたま鳴っているだけなのですがね。

<body>
音がでます。でるはずです。たぶん。<br />
<button onclick="sine.beep()"> sine!</button>
<button onclick="square.beep()">square!</button>
<button onclick="sawtooth.beep()">sawtooth!</button>
<button onclick="triangle.beep()">triangle!</button>
</body><script>
// 音源の確保
class Beep
{
    constructor( _frq=2000, _typ='square' )
    {
        this.acx       = null   // 最初の一回だけフラグ
        this.type      = _typ
        this.beep_frq  = _frq   // 音程(周波数)
        this.beep_time = 0      // 長さ
    }

    beep( _t=5 )
    {
        this.beep_time = _t
        if ( this.acx != null ) return // 最初の一回だけ

        this.acx = new (window.AudioContext || window.webkitAudioContext)()
        this.osc = this.acx.createOscillator()
        this.vol = this.acx.createGain()
        this.osc.connect(this.vol).connect(this.acx.destination)
        this.vol.gain.value = 0     // 音量
        this.osc.type = this.type   // https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode/type
        this.osc.frequency.value = this.beep_frq // 音程(周波数)
        this.osc.start()

        // 音の処理
        setInterval( ()=>
        {
            if ( this.beep_time <= 0 ) {
                this.vol.gain.value = 0 // 音量 OFF
                return
            }
            this.vol.gain.value = 0.2  // 音量
            this.beep_time -= 1        // 長さ
        }, 10 )
    }
}

const sine     = new Beep( 2000, 'sine' )
const square   = new Beep( 2000, 'square' )
const sawtooth = new Beep( 2000, 'sawtooth' )
const triangle = new Beep( 2000, 'triangle' )

</script>

CodePen4

今回の写経で、いちばん苦労したのがこの音の実装でした。無事に音が鳴って、よかったです。

JavaScript全コード

というわけで、壁やラケット、そして床に落ちてゲームオーバーになった箇所に音を出すコードを埋めていきます。

全コードは以下です。これで最後なので、閉じずに載せます。

<html style="height: 100%;"><meta charset="utf-8" />
<body style="height: 100%; margin: 0;">
<div style="height: 100%; display: grid; grid-template-rows: 30px 1fr 100px;" >
<div id="mess" ></div>
<div id="area" ></div>
<div id="touch" style="background-color: skyblue;" ></div>
</div>
</body><script>
// 「こんにちは Python」のスカッシュゲーム(壁打ちテニス)を JavaScript で写経(音あり版)

// メッセージをランダムに選択
function get_random_message( ary )
{
    return ary[ Math.floor( Math.random() * ary.length ) ]
}

// canvas表示エリア
class CanvasArea
{
    constructor()
    {
        this.div_area = document.querySelector( 'div#area' )
        this.cv = document.createElement( 'canvas' )
        this.cv.setAttribute( 'style', 'position: absolute;' ) // 自動で反映されないので、あえて子供から浮かせる。
        this.div_area.appendChild( this.cv )
        this.ctx = this.cv.getContext( '2d' )
        window.onresize = ()=>{
            this.resize()
        }

        this.resize()
    }

    resize()
    {   // canvas タグは div タグのように、縦横サイズの自動調整が効かないので、親 div と同じサイズにあわせる
        this.cv_w = this.div_area.clientWidth       // 親の div の縦横サイズを取得
        this.cv_h = this.div_area.clientHeight
        this.cv.setAttribute( 'width',  this.cv_w ) // 親 div の縦横サイズにあわせる
        this.cv.setAttribute( 'height', this.cv_h )

        this.draw()
    }

    // 画面クリア
    draw()
    {
        this.ctx.fillStyle = 'silver' // 'white'
        this.ctx.fillRect( 0, 0, this.cv_w, this.cv_h )
    }
}

// ボール
class Ball
{
    constructor()
    {
        this.init( 0, 250, 15, -15 )
        this.r_size = 10
    }

    init( _x, _y, _sx, _sy )
    {
        this.x = _x
        this.y = _y
        this.speed_x = _sx
        this.speed_y = _sy
    }

    // ボールを描く
    draw()
    {
        area.ctx.beginPath()
        area.ctx.arc( this.x, this.y, this.r_size, 0, 2 * Math.PI )
        area.ctx.fillStyle = 'red'
        area.ctx.fill()
    }

    // ボールの移動
    move()
    {
        if ( is_gameover ) return

        // 左右の壁に当たったのかの判定
        if ( this.x+this.speed_x < 0   ) {
            this.speed_x *= -1
            po1320.beep() // ♪
        }
        if ( this.x+this.speed_x > area.cv_w ) {
            this.speed_x *= -1
            po1320.beep() // ♪
        }

        // 天井の壁に当たったのかの判定
        if ( this.y+this.speed_y < 0 ) {
            this.speed_y *= -1
            pi2000.beep() // ♪
        }

        // ラケットに当たったのかの判定
        if ( this.y+this.speed_y > area.cv_h-10 ) {
            if ( racket.x < this.x+this.speed_x ) {
                if ( this.x+this.speed_x < racket.x+racket.w_size ) {
                    this.speed_y *= -1
                    if ( Math.random() < 0.5 )
                        this.speed_x *= -1

                    pi2000.beep() // ♪
                    const message = get_random_message( [
                        'うまい!', 'グッド!', 'ナイス!', 'よしッ!', 'すてき!'
                    ] )
                    point += 10
                    div.innerText = message + ' 得点:' + point
                }
            }
        }

        // ミスした時の判定
        if ( this.y+this.speed_y > area.cv_h ) {
            const message = get_random_message( [
                'へたくそ!','ミスしたね!', 'あーあ、見てられないね!'
            ] )
            div.innerText = message + ' 得点:' + point
            boo200.beep( 80 ) // ♪

            is_gameover = true
            return
        }

        // ボールを移動
        this.x += this.speed_x
        this.y += this.speed_y
    }
}

// ラケット
class Racket
{
    constructor()
    {
        this.x = 0
        this.w_size = 100

        // マウスの動きの処理
        area.cv.onmousemove = ev => {
            this.x = ev.offsetX - 50
        }

        // タッチイベントの処理
        const div_touch = document.querySelector( 'div#touch' )
        div_touch.ontouchstart =
        div_touch.ontouchmove = ev => {
            this.x = ev.pageX - 50
            return false // イベントを伝搬しない(親に返すと、フリックやスクロールに反応していろいろ面倒なので)
        }
    }

    draw()
    {
        area.ctx.fillStyle = 'yellow'
        area.ctx.fillRect( this.x, area.cv_h-10, this.w_size, 8 )
    }
}

// 音源の確保
class Beep
{
    constructor( _frq=2000, _typ='square' )
    {
        this.acx       = null   // 最初の一回だけフラグ
        this.type      = _typ
        this.beep_frq  = _frq   // 音程(周波数)
        this.beep_time = 0      // 長さ
    }

    beep( _t=5 )
    {
        this.beep_time = _t
        if ( this.acx != null ) return // 最初の一回だけ

        this.acx = new (window.AudioContext || window.webkitAudioContext)()
        this.osc = this.acx.createOscillator()
        this.vol = this.acx.createGain()
        this.osc.connect(this.vol).connect(this.acx.destination)
        this.vol.gain.value = 0     // 音量
        this.osc.type = this.type   // https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode/type
        this.osc.frequency.value = this.beep_frq // 音程(周波数)
        this.osc.start()

        // 音の処理
        setInterval( ()=>
        {
            if ( this.beep_time <= 0 ) {
                this.vol.gain.value = 0 // 音量 OFF
                return
            }
            this.vol.gain.value = 0.2  // 音量
            this.beep_time -= 1        // 長さ
        }, 10 )
    }
}

// メッセージ表示エリア div の確保
const div = document.querySelector( 'div#mess' )
div.innerText = "スカッシュゲーム(音あり版):マウスクリックでスタート!"

const area   = new CanvasArea()
const ball   = new Ball()
const racket = new Racket()
const pi2000 = new Beep( 2000 )
const po1320 = new Beep( 1320 )
const boo200 = new Beep(  200 )

let is_gameover = true
let point = 0

// クリックで再スタート
area.cv.onclick = ev =>
{
    if ( ! is_gameover ) return

    // ゲームの初期化
    is_gameover = false
    point = 0
    ball.init( 0, 250, 15, -15 )
    div.innerText = "スカッシュゲーム:スタート!"

    // 音の初期化(最初のクリック時に鳴らす必要がある)
    pi2000.beep( 0 )    // 長さ 0 なので音は出さない
    po1320.beep( 0 )    // 長さ 0 なので音は出さない
    boo200.beep( 0 )    // 長さ 0 なので音は出さない
}

// ゲームの繰り返し処理
function game_loop()
{
    area.draw()     // 画面クリア
    ball.draw()     // ボールを描く
    racket.draw()   // ラケットを描く
    ball.move()     // ボールの移動
}

// ゲームのメイン処理開始
setInterval( game_loop, 50 ) // 20fps

</script></html>

CodePen5

最終的には、このようになります。

さて、動いたでしょうか。例によって、スマホで確認してみたいかたのために、QRコードを置いておきます。

qr.png

おまけ リフレッシュレートを超える 20fps -> 150fps

このゲームは、もとの Python のコードに合わせて 20fps で動いています。20fps とは1秒間に20回、描きなおしているということです。
20fps の速度で更新できれば、おおむねアニメーションしているように見えると言われています。
ただ、最近の PC やブラウザの描画能力はスゴイことになっていて、このゲームのような簡単な描画処理であれば、ディスプレイの表示能力(リフレッシュレート)を超える速度で描画できると思われます。

ディスプレイのリフレッシュレートとは、1秒間に何回表示を更新できるか、というディスプレイの処理性能を示しています。最近のディスプレイはおおむね 60Hz か、それを超えるもの増えています。60Hz というのは、すなわち ゲームプログラムが 60fps で描画してもその変化を漏らさず表示されるということになります(その変化を人がすべてを感じ取れるかどうかは別の話ですが、)。
しかしながら、ゲームプログラムが 120fps で描画して表示を更新できたとしても、ディスプレイが 60Hz では、その更新の半分しかディスプレイに反映されない=見えていないことになります。ですので、表示の更新は、ディスプレイのリフレッシュレートに合わせるのが賢いコードになります。

ブラウザには、requestAnimationFrame というAPIが用意されていて、このAPIは「ディスプレイのリフレッシュのタイミングで関数を呼んでくれる」という大変便利な API なのです。この API を使って描画する関数を登録し、呼ばれたときに表示を更新すると、ちょうどよい動きになる仕組みになっています。

というわけで、「ゲームの繰り返し処理」に requestAnimationFrame を組み込んだコードがこちらです。

// ゲームの繰り返し処理
let request_animation_frame = null
function game_loop()
{
    ball.move()     // ボールの移動

    if ( request_animation_frame != null ) return

    request_animation_frame = window.requestAnimationFrame( ev =>
    {
        area.draw()     // 画面クリア
        ball.draw()     // ボールを描く
        racket.draw()   // ラケットを描く

        request_animation_frame = null // クリア(次の描画イベントの登録を受け付ける)
    } )
}

ループ内で、移動処理をしたあとに描画イベントの登録をします。
ただ、描画処理が終わるくらいループ速度がゆっくりであれば問題ないのですが、描画処理よりもループ速度が早い場合は、イベントを登録しないように null を確認してスキップしています。こうすることで、ディスプレイのリフレッシュレートを気にすること無く、ループ速度を上げることができます。

ということで、最近、手に入るようになってきたゲーミング用ディスプレイのリフレッシュレート 144Hz を超えるフレームレート(ここでは 150fps )に改造して、終わりたいと思います。

全コード
<html style="height: 100%;"><meta charset="utf-8" />
<body style="height: 100%; margin: 0;">
<div style="height: 100%; display: grid; grid-template-rows: 30px 1fr 100px;" >
<div id="mess" ></div>
<div id="area" ></div>
<div id="touch" style="background-color: skyblue;" ></div>
</div>
</body><script>
// 「こんにちは Python」のスカッシュゲーム(壁打ちテニス)を JavaScript で写経(音あり版)

// メッセージをランダムに選択
function get_random_message( ary )
{
    return ary[ Math.floor( Math.random() * ary.length ) ]
}

// canvas表示エリア
class CanvasArea
{
    constructor()
    {
        this.div_area = document.querySelector( 'div#area' )
        this.cv = document.createElement( 'canvas' )
        this.cv.setAttribute( 'style', 'position: absolute;' ) // 自動で反映されないので、あえて子供から浮かせる。
        this.div_area.appendChild( this.cv )
        this.ctx = this.cv.getContext( '2d' )
        window.onresize = ()=>{
            this.resize()
        }

        this.resize()
    }

    resize()
    {   // canvas タグは div タグのように、縦横サイズの自動調整が効かないので、親 div と同じサイズにあわせる
        this.cv_w = this.div_area.clientWidth       // 親の div の縦横サイズを取得
        this.cv_h = this.div_area.clientHeight
        this.cv.setAttribute( 'width',  this.cv_w ) // 親 div の縦横サイズにあわせる
        this.cv.setAttribute( 'height', this.cv_h )

        this.draw()
    }

    // 画面クリア
    draw()
    {
        this.ctx.fillStyle = 'silver' // 'white'
        this.ctx.fillRect( 0, 0, this.cv_w, this.cv_h )
    }
}

// ボール
class Ball
{
    constructor()
    {
        this.init( 0, 250, 15, -15 )
        this.r_size = 10
    }

    init( _x, _y, _sx, _sy )
    {
        this.x = _x
        this.y = _y
        this.speed_x = _sx
        this.speed_y = _sy
    }

    // ボールを描く
    draw()
    {
        area.ctx.beginPath()
        area.ctx.arc( this.x, this.y, this.r_size, 0, 2 * Math.PI )
        area.ctx.fillStyle = 'red'
        area.ctx.fill()
    }

    // ボールの移動
    move()
    {
        if ( is_gameover ) return

        // 左右の壁に当たったのかの判定
        if ( this.x+this.speed_x < 0   ) {
            this.speed_x *= -1
            po1320.beep() // ♪
        }
        if ( this.x+this.speed_x > area.cv_w ) {
            this.speed_x *= -1
            po1320.beep() // ♪
        }

        // 天井の壁に当たったのかの判定
        if ( this.y+this.speed_y < 0 ) {
            this.speed_y *= -1
            pi2000.beep() // ♪
        }

        // ラケットに当たったのかの判定
        if ( this.y+this.speed_y > area.cv_h-10 ) {
            if ( racket.x < this.x+this.speed_x ) {
                if ( this.x+this.speed_x < racket.x+racket.w_size ) {
                    this.speed_y *= -1
                    if ( Math.random() < 0.5 )
                        this.speed_x *= -1

                    pi2000.beep() // ♪
                    const message = get_random_message( [
                        'うまい!', 'グッド!', 'ナイス!', 'よしッ!', 'すてき!'
                    ] )
                    point += 10
                    div.innerText = message + ' 得点:' + point
                }
            }
        }

        // ミスした時の判定
        if ( this.y+this.speed_y > area.cv_h ) {
            const message = get_random_message( [
                'へたくそ!','ミスしたね!', 'あーあ、見てられないね!'
            ] )
            div.innerText = message + ' 得点:' + point
            boo200.beep( 80 ) // ♪

            is_gameover = true
            return
        }

        // ボールを移動
        this.x += this.speed_x
        this.y += this.speed_y
    }
}

// ラケット
class Racket
{
    constructor()
    {
        this.x = 0
        this.w_size = 100

        // マウスの動きの処理
        area.cv.onmousemove = ev => {
            this.x = ev.offsetX - 50
        }

        // タッチイベントの処理
        const div_touch = document.querySelector( 'div#touch' )
        div_touch.ontouchstart =
        div_touch.ontouchmove = ev => {
            this.x = ev.pageX - 50
            return false // イベントを伝搬しない(親に返すと、フリックやスクロールに反応していろいろ面倒なので)
        }
    }

    draw()
    {
        area.ctx.fillStyle = 'yellow'
        area.ctx.fillRect( this.x, area.cv_h-10, this.w_size, 8 )
    }
}

// 音源の確保
class Beep
{
    constructor( _frq=2000, _typ='square' )
    {
        this.acx       = null   // 最初の一回だけフラグ
        this.type      = _typ
        this.beep_frq  = _frq   // 音程(周波数)
        this.beep_time = 0      // 長さ
    }

    beep( _t=5 )
    {
        this.beep_time = _t
        if ( this.acx != null ) return // 最初の一回だけ

        this.acx = new (window.AudioContext || window.webkitAudioContext)()
        this.osc = this.acx.createOscillator()
        this.vol = this.acx.createGain()
        this.osc.connect(this.vol).connect(this.acx.destination)
        this.vol.gain.value = 0     // 音量
        this.osc.type = this.type   // https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode/type
        this.osc.frequency.value = this.beep_frq // 音程(周波数)
        this.osc.start()

        // 音の処理
        setInterval( ()=>
        {
            if ( this.beep_time <= 0 ) {
                this.vol.gain.value = 0 // 音量 OFF
                return
            }
            this.vol.gain.value = 0.2  // 音量
            this.beep_time -= 1        // 長さ
        }, 10 )
    }
}

// メッセージ表示エリア div の確保
const div = document.querySelector( 'div#mess' )
div.innerText = "スカッシュゲーム(音あり版):マウスクリックでスタート!"

const area   = new CanvasArea()
const ball   = new Ball()
const racket = new Racket()
const pi2000 = new Beep( 2000 )
const po1320 = new Beep( 1320 )
const boo200 = new Beep(  200 )

let is_gameover = true
let point = 0

// クリックで再スタート
area.cv.onclick = ev =>
{
    if ( ! is_gameover ) return

    // ゲームの初期化
    is_gameover = false
    point = 0
    ball.init( 0, 250, 2, -2 ) // 20fps -> 150fps 対応 15px -> 2px
    div.innerText = "スカッシュゲーム:スタート!"

    // 音の初期化(最初のクリック時に鳴らす必要がある)
    pi2000.beep( 0 )    // 長さ 0 なので音は出さない
    po1320.beep( 0 )    // 長さ 0 なので音は出さない
    boo200.beep( 0 )    // 長さ 0 なので音は出さない
}

// ゲームの繰り返し処理
let request_animation_frame = null
function game_loop()
{
    ball.move()     // ボールの移動

    if ( request_animation_frame != null ) return

    request_animation_frame = window.requestAnimationFrame( ev =>
    {
        area.draw()     // 画面クリア
        ball.draw()     // ボールを描く
        racket.draw()   // ラケットを描く

        request_animation_frame = null // クリア(次の描画イベントの登録を受け付ける)
    } )
}

// ゲームのメイン処理開始
setInterval( game_loop, 1000/150 ) // 150fps

</script></html>

まとめ

お疲れ様でしたー。

前編から書き始めて、どのくらいの月日がたったでしょうか、、、すでに、覚えていないです。

すこしでも、JavaScriptプログラミングの楽しさがお伝えできれば幸いです。

最後に、ゲームを作る楽しさを思い出させていただいた、すがやみつる先生に感謝とお礼をしつつ終わりたいと思います。どうもありがとうございました!

それでは、みなさまの快適なプログラミングライフを願いつつ終わります。

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

IntelliJ/Android Studioのプロジェクトにプロジェクトアイコンを設定しよう

DroidKaigiアプリがプロジェクトアイコンを設定しているので、知っている人も多いと思います。
私もそれで知りました。
しかし、あまりドキュメントとか、設定されているプロジェクトを見かけない気がしたので(私が知らないだけだと思うけど)やり方を書きます。

設定するとWelcome画面やToolboxのプロジェクトリストにアイコンが表示されるようになります。

Toolboxの場合は何もしなければIDEのアイコンが表示されますが、プロジェクトのアイコンが表示されるようになります。

どうでしょう? カッコいいですよね! もう、設定する以外の選択肢は存在しないでしょう!

特にAndroidアプリの場合はアプリアイコンがあるはずなので、それを設定するだけです。

プロジェクトアイコンの設定方法

.ideaディレクトリの中に、icon.png(デフォルトアイコン)、icon_dark.png(ダークテーマ用アイコン)を置くだけです。icon_dark.pngは無くてもいいです。
大きさも特にこれでないといけないみたいなものはなさそうなので、私はmdpi用のアプリアイコン(48x48)を置いています。もっと大きいアイコンでもいいと思います。
プルリクだとこうなります

.gitignore.ideaディレクトリを指定している場合は除外設定が必要なので、もうちょっとDiffが出てきますね。

!.idea/icon.png
!.idea/icon_dark.png

ね、簡単でしょう?

仕様のようなものは見つけられていないのですが(あるぞって場合は教えてください)
読み出してる処理はこの辺でしょうか。

https://github.com/JetBrains/intellij-community/blob/ba678ca53c292bd4c6ce877bbc569f73902bb1c6/platform/platform-impl/src/com/intellij/ide/RecentProjectIconHelper.kt#L124-L159

以上
プロジェクトアイコンが設定されたプロジェクトが増えることを祈っています。

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

[Jetpack Compose] 今の時点でCompose利用する方法。

対象

  • Composeを早く使いたい方

Jetpack

Jetpackのライブラリーが含めているもの。
image.png

中にComposeがみえますね。

Compose

composeは形状とデータの依存関係を記述するコンポーズ可能な関数を使用して、UI をプログラムで定義します。
先週ComposeはAlphaバージョンになりました。

Download

今はAndroid StudioのStableバージョンでは利用できません。
Canaryバージョンの設置が必要です。

image.png

  • Bin image.png

image.png

  • Studio64.exe

image.png

image.png

Composeが公式パージョンになると、後はStableチャンネルでも利用できると思います。

基本サンプルを作り方。

  • File -> New -> New Project

image.png

  • Empty Compose Activity

image.png

  • Nextをクリック。
  • あとはFinishをクリックします。

image.png

boilerplate Code

package com.dreamwalker.myapplication

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.Text
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.setContent
import androidx.ui.tooling.preview.Preview
import com.dreamwalker.myapplication.ui.MyApplicationTheme

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApplicationTheme {
                // A surface container using the 'background' color from the theme
                Surface(color = MaterialTheme.colors.background) {
                    Greeting("Android")
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MyApplicationTheme {
        Greeting("Android")
    }
}

Preview機能。

image.png

image.png

image.png

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

スマホアプリにおける署名について

Code Signing

iOS/Androidいずれの場合もアプリが改ざんされることを確認できる安全性を確保するために、ビルドしたアプリケーションに電子署名します。これがCode Signingとよばれます。
概念的には同じですが、ストアとの関係やツールの操作方法などがそれぞれ異なるため、混乱しそうです。
(これにビルドサイト(CircleCI/Bitrise等)を利用するとさらにサインするための情報を設定する必要があり、さらに複雑になります)

電子署名のざっくりした説明

電子署名する

電子署名は改ざんを防止や否認防止などで利用され、PKI(公開鍵インフラストラクチャ)の技術です。あるバイナリ(テキストデータもバイナリ化したうえで)を符号化した情報に対して秘密鍵で暗号化します。その値(A)と符号化、暗号化アルゴリズムを署名のメタデータとして含めます。

署名の検証

検証する場合は同様にバイナリを署名のメタデータである符号化アルゴリズムで符号化します。また含まれている暗号化された値(A)を公開鍵で複号化して一致しているかを確認します。
ここで元のバイナリが改ざんされると符号化された情報も変わるため一致しなくなり、改ざんされていることが検知されます。
公開鍵が改ざんされていると当然、この検証が無効になってしまうため、公開鍵が改ざんされていないかを確認します。そのため、公開鍵は証明書の形で受け渡され、その証明書は他の機関によって電子署名されているというチェーンによってその公開鍵の改ざんを検知します。
このあたりの保証・管理の仕方がPKIとして実現されています。

実際は、バイナリを分割してハッシュを取得したり、メタデータの格納方式などがAndroid、iOSで決まっています。

概要

上記のPKIの技術を使って、バイナリを保護することになりますが、Android/iOS共通して
* 証明書の取り扱い(証明書と紐付いた秘密鍵の取り扱い(鍵管理)
* アプリケーションID(バンドルID)
* 開発者
をどう結びつけるかという観点では同じものになります。

これらの取り扱いがそれぞれ違うことが難しさを呼んでるといえます。

Android

アプリへの署名の仕方

Android Studioのメニュー(Build>Generatote Signed Bundle or APK)から署名することができます。
この時にKey storeのパス(以下の鍵管理参照)、パスワード、鍵名称(エイリアス)、鍵パスワードが求められます。
この証明書の期限25年にしておくのが良いです(短くしたらGoogle Playでリリースできなかった)

鍵管理

AndroidのアプリでCode Signで必要な鍵を管理しているのは、JavaのKeystoreを利用しています。
但し、keytoolコマンドは利用せず、上記の通りAndroid Studioを使って利用することができます。

Keystoreは鍵管理ファイルとそのパスワード、各鍵の名称(鍵)とパスワードで構成されています。
この仕組からKeyStoreでは自己署名証明書と呼ばれる証明書にて管理されることになります。
IssuerとSubjectが同じ内容になり、iOSがAppleのCAによって署名された証明書を利用するのとは異なります。

コマンドによる署名

Android Studioでも内部ではGradleを利用して署名しているので、CIなどのビルドプロセスで実行する場合はGradleでビルドをすることができます。以下にビルド方法を記載します。ソースコード(build.gradle)にパスワードを平文で記載するのはセキュリティ上、好ましくないのですが説明上、そうしていますので実際にはプロパティファイルに設定して、プロパティファイルを構成管理対象としないなどの工夫が必要となります。

build.gradle
signingConfigs {
    release {
        keyAlias 鍵エイリアス
        keyPassword 鍵パスワード
        storeFile Key storeファイル名
        storePassword storeパスワード
    }
}

buildTypes {
    release {
        signingConfig signingConfigs.release
    }
}

アプリケーションID

AndroidアプリのIDはAndroid Studioでプロジェクトを作るときに指定します。このIDは、appフォルダのbuild.gradleに設定されます。

app/build.gradle
    defaultConfig {
        applicationId "xxx.xxx.xxx.xxx"

    }

このIDはGoogle Playで公開する際にURLの一部(play.google.com/store/apps/details?id= このidの後ろにアプリIDをつける)となるため、重複しないようにJavaのパッケージと同様にドメインを逆に記載したものをもとに作成するのが良いと思います。

Google Play 設定(開発者との紐付け)

リリースするにはGoogle Playにリリース登録する必要があります。
Google Play はGoogleの開発者登録をして、ログインできるようになるPlay Consoleからアプリを登録することができます。Consoleから作成した、アプリバンドルをアップロードしてリリース対象のファイルとします。(内部テストなどテストをへて、公開します)

また、Google PlayにはGoogle Play アプリ署名(アップロードする鍵と配布する鍵を変える)という機能があり、鍵管理の観点からはこちらを利用するのがいいと思います。

iOS

iOSでもCode Signingが必要です。Google Playとは異なり、Apple Storeでは鍵を変えて署名してくれることはないので、ipaを作成するときに毎回署名します。

鍵管理

MacOSのアプリケーションであるKeyChainのアプリケーションにて、秘密鍵を管理します。正確に表現するとCSR(証明書要求)を作ったときに秘密鍵も作られることになります。
(AndroidではKey storeで管理しているのと同様です)

証明書の種類

署名で利用される秘密鍵に対応する署名書は二種類あります。

  • Developer証明書(開発用)
  • Distribution証明書(配布用)Adhoc配布とApple Store配布用です

証明書をCAにて署名する

オレオレ証明書ではなく、上記の証明書はそれぞれApple DeveloperサイトにてAppleの決まったCAによって電子署名して利用されます。(登録した証明書のみが利用できる)

そのため、上記で記載したCSRをもとにCAによって電子署名する方法で実現します。

Apple Developerサイトによる署名

PKIの一般用語では証明書をCAにて電子署名を依頼し、実施することでオレオレ証明書ではなく証明書チェーンに従う証明書にします。これをAppleのiOS開発においては「Certificates」にて登録、証明書を作成します。作成した証明書をダウンロードして、ダブルクリックすることでキーチェーンアプリにて秘密鍵と関連付けで保存されます。
この証明書はDER形式で保存されているので、openssslコマンドなどで中身を確認できます。

openssl x509 -inform der -in download.cer -text

などとすると中身がわかります。IssuerのOUは「Apple Worldwide Developer Relations」でした。
SubjectのCNが「Apple Distribution: XXX」になっていて、ここが配布用であることを表しています。

アプリケーションID(Bundle ID)

Androidは内部の記載をもとにAPKファイルをアップロードする際に登録されていましたが、iOSの場合はApple Developerサイトで登録する必要があります。

Identifiersメニューから追加します。いろいろな種類がありますが、「App IDs」がスマホアプリのIDとなります。
Wildcard/ExplictのバンドルIDの種類を選んで設定します。

XCodeにここで登録したBundleIDとして設定をします。

Provisioning File(開発者との紐付け)

AndroidはアップロードするAPKに含まれている署名が正になっており、PlayConsoleにログインしてアップロードすることでそのアップロード用の署名とユーザが紐付けられますが、iOSではアプリケーションID、証明書及び端末(Adhoc配布の場合など)をあわせてProfileを作成します。

XCodeでの設定

このプロファイルを利用してアプリ署名を実施します。
コード署名を自動的にする設定をするとXCodeが自動的にプロファイルを作ってくれます。アプリケーションIDがなければ作成してくれます。証明書はAppleの署名が必要なので登録が必要ですが、アプリケーションID、プロファイルは作らなくて利用可能です。

まとめ

PKIの知識をもっていれば、順をおって確認すればわかることですが、構成要素が多いのと使いやすくするためにツール化されているので少しわかり行くいと思いました。次は、iOSのコマンドでの手順を調べてCIできるサイトでの設定の理解(bitrise等)につなげたいと思います。

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