kamulog

xamarin.formsのネタなど

スタートアップトレース (Startup Tracing / Profiled AOT) を使ってXamarin.Androidの起動速度を向上させる

この記事はXamarin Advent Calendar 2020の9日目の記事です。

Xamarin.Androidの起動が遅いという問題が長年ありましたが、最近はスタートアップトレースという機能である程度改善できるようになっています。 この機能は、残念ながら公式ドキュメントに記載されておらず、公式ブログに2件ほど記事がある程度で、あまり浸透してないのかもと思い、日本語の記事を書いてみることにしました。

このスタートアップトレース機能は2種類の方法があり、一つはデフォルトのプロファイルを使用する方法で、もう一つがカスタムのプロファイルを生成してそれを適用する方法です。

まずはどれくらいの効果があるのかを、私の個人開発アプリ「Fithor」で比較してみました。

ライブラリの使用状況はこんな感じです。

f:id:kamusoft:20201128171008p:plain

比較動画

youtu.be

結果

スタートアップトレース 起動時間 aabサイズ
なし 6.42s 39.4MB
デフォルト 6.90s 61.4MB
カスタム 6.00s 66.7MB

うむ、微妙ですね。 実は比較とかする以前に初めてスタートアップトレースを有効にした時はもっと劇的に早くなったと思ったんですが、 まぁプロジェクト構成もその時と比べて変わったし、そういう影響なんだろうと思います。

スタートアップトレースOFFよりスタートアップトレースONのデフォルトプロファイルの方が遅くなっちゃってますが 、これは誤差の範囲だと思います。 というより、この結果で重要なことは、スタートアップトレースはただ有効にするだけ(デフォルトプロファイルを使うだけ)では ほとんど効果が出ない場合があるってことだと思います。

カスタムプロファイルを使うことで1秒弱縮まってます。起動速度の1秒はかなり大きいのでこれは是非有効にすべきだと思います。

カスタムプロファイルの作成方法

Androidのプロジェクトのフォルダに移動し以下のコマンドを実行してビルドします。

msbuild /t:BuildAndStartAotProfiling

この時、シミュレータや接続している実機は1つだけの状態にする必要があります。 使わないシミュレータや実機は外しておきましょう。

ビルドが終わりアプリが起動すると完了です。

次に以下のコマンドを実行してプロファイルを生成します。

msbuild /t:FinishAotProfiling xxxxx.csproj  

以下のようなメッセージが表示されれば完了です。

  Summary:
    Modules:         51
    Types:        2,261
    Methods:      9,125
  Going to write the profile to 'custom.aprof'

これでプロジェクトにcustom.aprofが生成されます。

カスタムプロファイルの有効化

まずはスタートアップトレースを有効にします。以下 VS for Mac の例です。

f:id:kamusoft:20201128172347p:plain

カスタムプロファイルを使う設定はUIには存在しないのでcsprojを直接開いてリリースビルド構成のところに 以下の項目を追加します。

<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
...
    <AndroidUseDefaultAotProfile>false</AndroidUseDefaultAotProfile>
</PropertyGroup>

同じくcsprojに以下のように、カスタムプロファイルをプロジェクトに追加します。

<ItemGroup>  
    <AndroidAotProfile Include="$(MSBuildThisFileDirectory)custom.aprof" />
</ItemGroup>  

これで設定完了です。

ビルドした際にログに以下のように表示されればカスタムプロファイルは適用されています。

[aot-compiler stdout] Using profile data file '/Users/xxxxx/custom.aprof'

注意事項

カスタムプロファイルを生成するのに adb と シミュレータまたは実機が必要になるため、 この工程はCIには組み込めないと思います。 ライブラリや起動ページの構成なんかが大きく変わらない限りは毎回は生成する必要ないと思いますが、 定期的にローカルで実行して生成し直す方が良さそうです。

おわりに

Xamarin.Android (Xamarin.Forms Android) で起動時間を1秒でも縮めたい場合は是非スタートアップトレース カスタムプロファイルを使いましょう!

参考

サインインだけで使う場合の Google OAuth 同意画面 の設定について

Google OAuth 同意画面の意図せぬ審査にかなり手こずったので、ここにまとめておきます。

事の発端

復習帳というアプリで Azure AD B2C 経由で Googleのサインインで OAuth 2.0 クライアントを使っています。

結構前から Azure AD B2C で使うリダイレクトURIドメインが microsoftonline.com から b2clogin.com に変わっていて 私はそれより前に設定していた microsoftonline.com で使っていたんですが、移行期限もせまり b2clogin.com に変更しようとしました。

現行バージョンと更新バージョンそれぞれで動作するためには一時的に両方のリダイレクトURIを設定する必要があります。 その作業をtwitterfacebookなどに順番に追加設定していきました。

そして何の迷いもなく、Googleの方も追加設定したところGoogleから 「審査を開始するよ、ただしアプリアイコンを使わない場合はスキップできるよ」 的なメールがきました。

アイコンは不要なので「スキップお願いします」と返信。

すると 「アイコンは一度設定すると削除できません。あなたはアイコンを設定しているので検証の対象になりました。スキップする場合、未検証のスコープにアクセスするなら、未検証アプリ画面が表示されます。未検証アプリ画面を消すには検証が必要です。」 みたいな返信が来て未検証アプリ画面はちょっと困るなということで検証を続けてもらうことにしました。

そこからはプライバシーポリシーのダメ出しやリダイレクトのドメインは所有権のあるもの以外は消してくれだとか結構な無茶を言われて、毎回「いやサインインにしか使わなくてしかも外部サービスなのでドメインは消せない」みたいな返信をするみたいなやりとりが数日間続いてしまいました。 最終的にはRejectになって 「アイコンも必要なくサインインのスコープのみを使用しているようなので検証は必要ありません。」という結末になりました。

Rejectされても今まで通り使用できています。

どうすれば良かったか

大前提としてサインインのみのスコープを使っているだけなら検証は不要です。しかしアイコンを設定してしまうとこの条件だとしてももれなく検証が始まってしまいます。 なのでサインインのみで利用する場合は絶対にアイコンを設定してはいけません。一度設定すると絶対に削除できないです。

また検証が始まってしまった場合でもスキップしてもらえば大丈夫だと思います。 未確認アプリの画面が表示される条件は未検証のスコープにアクセスする場合で、サインインのみのスコープは検証が必要ないため、メールでいろいろ警告されますが気にせずスキップ続行で良いと思います。

私の場合は未確認アプリの画面がでてしまうことにビビってしまって続行してしまいましたが、続行してしまうと無限ループになりかけるので本当に注意が必要です。

まとめ

Google OAuth をサインインのみで利用している場合は

  • 絶対にアイコンを設定してはいけない
  • もし設定してしまったら審査をスキップする

で対応できると思います。

補足

アイコンの削除に関して、以下のかなりグレーな方法は現在は当然ながら無効でした。

stackoverflow.com

Xamarin.Forms で ダークモードにほぼ完全に対応し、「端末の設定を使う」にも対応する

Twitterアプリにある、ダークモード対応の

  • ライト
  • ダーク
  • 端末の設定を使う

の3択パターンを Xamarin.Forms で実装してみました。

サンプル

github.com

youtu.be

Forms 側の対応

Xamarin.Formsでのダークモード対応は現時点でいくつかの選択肢があります。

  1. アプリケーションのシステムテーマの変更に応答する Xamarin.FormsRespond to system theme changes in Xamarin.Forms applications - Xamarin | Microsoft Docs
  2. アプリケーションのテーマを Xamarin.Forms 適用するTheme a Xamarin.Forms Application - Xamarin | Microsoft Docs

1は、まだ試験段階ですが、「端末の設定を使う」だけで良いなら一番簡単だと思います。 ただし、端末はダークだけど、アプリはライトとかの要件だと1では対応しきれないので、今回は2のテーマ切り替えの方法を使いました。

やり方はリンク先のドキュメントに詳しく載っているので割愛しますが、ライト・ダークに対応するResourceDictionaryを作って、それを動的に切り替えるというものです。

追記

Xamarin.Forms 4.7 で現在のシステムのテーマを切り替えることができるようになったみたいなので多分もっと簡単に実装できるかも知れません。

Respond to system theme changes in Xamarin.Forms applications - Xamarin | Microsoft Docs

ただ、この記事の方法だとライト2種、ダーク2種とかのテーマを切り替えることも可能です…

Forms側だけの限界

画像の下部分はSwitchとDatePickerですが

iOSではSwitchの背景が黒くて背景に溶け込みすぎて見にくいのと、DatePickerは背景が白いままです。また画像は省略しますがPickerのドラムみたいな部品も明るいままです。 AndroidではSwitchも同じように見にくく、DatePickerは真っ黒で見えず、DatePickerの下線の色もPlatform側のStyleがそのままなので変化せず、ダイアログは完全にライトモードのままです。

f:id:kamusoft:20200618004007p:plainf:id:kamusoft:20200617213953p:plain

このように、Forms側のテーマだけを変えても対応できない部分があります。 このような部分はシステム側が持っている現在のモードを変更してあげる必要があります。

プラットフォーム実装

NETStandard

現在のシステムテーマを返すものと、システムテーマを上書きするものを定義します。

    public interface ISystemTheme
    {
        void SetTheme(Theme theme);
        Theme GetTheme();
    }

iOS

Interfaceを実装します。

[assembly: Dependency(typeof(SystemThemeImplementation))]
namespace DarkSample.iOS.Platforms
{
    public class SystemThemeImplementation:ISystemTheme
    {
        public Theme GetTheme()
        {
            return UITraitCollection.CurrentTraitCollection.UserInterfaceStyle switch
            {
                UIUserInterfaceStyle.Dark => Theme.Dark,
                _ => Theme.Light
            };
        }

        public void SetTheme(Theme theme)
        {
            UIApplication.SharedApplication.KeyWindow.OverrideUserInterfaceStyle = theme switch
            {
                Theme.Dark => UIUserInterfaceStyle.Dark,
                Theme.Light => UIUserInterfaceStyle.Light,
                _ => UITraitCollection.CurrentTraitCollection.UserInterfaceStyle
            };            
        }
    }
}

Android

iOSと同様に実装します。 ただしこちらはSet時に設定値を保存してます。 これは以下の記事にあるように、システムのテーマをForms初期化前に設定する必要があるからです。

【Android】ダークテーマを反映するアプリ起動時の適切なタイミングについて【Kotlin】 - Qiita

[assembly: Dependency(typeof(SystemThemeImplementation))]
namespace DarkSample.Droid.Platforms
{
    public class SystemThemeImplementation:ISystemTheme
    {
        public static Context Context;

        public Theme GetTheme()
        {
            var currentMode = Context.Resources.Configuration.UiMode & UiMode.NightMask;
            return currentMode switch
            {
                UiMode.NightYes => Theme.Dark,
                _ => Theme.Light
            };
        }

        public void SetTheme(Theme theme)
        {
            AppCompatDelegate.DefaultNightMode = theme switch
            {
                Theme.Dark => AppCompatDelegate.ModeNightYes,
                Theme.Light => AppCompatDelegate.ModeNightNo,
                _ => AppCompatDelegate.ModeNightFollowSystem,
            };            

            Context.GetSharedPreferences("AppSettings", FileCreationMode.Private)
                .Edit()
                .PutInt("NightKey", AppCompatDelegate.DefaultNightMode)
                .Apply();
        }
    }
}

またAndroidはダイアログや、AndroidのStyleが出てしまう部分を対応するためにStyleもダークテーマに対応させます。 以下の記事を参考にして設定しました。

Androidでダークテーマを試す - URL Memo - Medium

Xamarin.FormsのテンプレートのStyleに修正を加えたものです。 Theme.AppCompat.DayNight を使うのがポイントで、これにするとシステムのテーマに応じて色を動的に変更できます。 色の定義はダーク用の values-night/colors.xml で定義します。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="MainTheme" parent="MainTheme.Base">
    </style>
    <!-- Base theme applied no matter what API -->
    <style name="MainTheme.Base" parent="Theme.AppCompat.DayNight">
        <!--If you are using revision 22.1 please use just windowNoTitle. Without android:-->
        <item name="windowNoTitle">true</item>
        <!--We will be using the toolbar so no need to show ActionBar-->
        <item name="windowActionBar">false</item>
        <!-- Set theme colors from https://aka.ms/material-colors -->
        <!-- colorPrimary is used for the default action bar background -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <!-- colorPrimaryDark is used for the status bar -->
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <!-- colorAccent is used as the default value for colorControlActivated
         which is used to tint widgets -->
        <item name="colorAccent">@color/colorAccent</item>
        <!-- You can also set colorControlNormal, colorControlActivated
         colorControlHighlight and colorSwitchThumbNormal. -->
        <item name="windowActionModeOverlay">true</item>
        <!-- 下の3つを設定すれば大体のダイアログに対応できるはず -->
        <item name="android:datePickerDialogTheme">@style/AppCompatDialogStyle</item>
        <item name="android:alertDialogTheme">@style/AppCompatDialogStyle</item>
        <item name="android:timePickerDialogTheme">@style/AppCompatDialogStyle</item>
    </style>
    <style name="AppCompatDialogStyle" parent="Theme.AppCompat.DayNight.Dialog">
        <item name="colorAccent">@color/colorAccent</item>
        <item name="android:textColorPrimary">@color/normal_text</item>
        <item name="android:windowBackground">@color/dialog_back</item>
    </style>
</resources>

起動時に設定値を反映させます。あとステータスバーのアイコンの色が見えなくならないようにモードに対応して変更させています。 それについては中の人ブログ参照

Setting Android Status Bar Background & Icon Colors - James Montemagno

public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity
    {
        protected override void OnCreate(Bundle savedInstanceState)
        {
            var theme = GetSharedPreferences("AppSettings", FileCreationMode.Private)
                .GetInt("NightKey", AppCompatDelegate.ModeNightNo);
            AppCompatDelegate.DefaultNightMode = theme;

            TabLayoutResource = Resource.Layout.Tabbar;
            ToolbarResource = Resource.Layout.Toolbar;            

            base.OnCreate(savedInstanceState);

           ...
            if (Build.VERSION.SdkInt >= Android.OS.BuildVersionCodes.M)
            {
                // モードによってステータスバーのアイコンのモードを変える。
                var flag = (Android.Views.StatusBarVisibility)Android.Views.SystemUiFlags.LightStatusBar;
                var uimode = Resources.Configuration.UiMode & UiMode.NightMask;
                Window.DecorView.SystemUiVisibility = uimode == UiMode.NightNo ? flag : 0;
            }

変更処理実装

適当なstaticメソッドなどでテーマの変更を処理する。 似たようなメソッドが二つありますが、ApplyThemeの方は起動時用で、 起動時はAndroidiOSで微妙に処理が異なるので専用のメソッドにしました。 ChangeThemeの方は起動中に切り替える場合に使い、設定を保存しておくようにします。

「端末の設定を使う」に対応するために、システムテーマを取得して、それをFormsテーマに反映させる処理を入れています。 アプリを起動したまま、ホームに戻り、システムの変更を変更した場合に対応できるようにOnResumeのタイミングで前回テーマと変化があれば切り替えるようにしています。

(AndroidAndroid 10以降のみ機能します。)

public static class ThemeResource
{
    static Theme LastSystemTheme = Theme.Light;

    public static void ApplyTheme()
    {
        // 保存設定読み出し
        var theme = (Theme)Xamarin.Essentials.Preferences.Get("mode", (int)Theme.Light);
        var sysTheme = DependencyService.Get<ISystemTheme>();
        if (Device.RuntimePlatform == Device.iOS)
        {
            // iOSのみシステムテーマ変更
            sysTheme.SetTheme(theme);
            // AndroidはPlatform側の起動時に設定
        }

        if (theme == Theme.Auto)
        {
            theme = sysTheme.GetTheme();
            // Autoであれば端末のモード切り替えを待ち受ける
            App.OnForeground += App_OnForeground;
            LastSystemTheme = theme;
        }

        SetDictionary(theme);
    }

    public static void ChangeTheme(Theme theme)
    {
        Xamarin.Essentials.Preferences.Set("mode", (int)theme);

        var sysTheme = DependencyService.Get<ISystemTheme>();

        // PlatformのThemeを変更
        sysTheme.SetTheme(theme);

        App.OnForeground -= App_OnForeground;
        if (theme == Theme.Auto)
        {
            theme = sysTheme.GetTheme();
            // Autoであれば端末のモード切り替えを待ち受ける
            App.OnForeground += App_OnForeground;
            LastSystemTheme = theme;
        }

        SetDictionary(theme);
    }

    static void SetDictionary(Theme theme)
    {
        var mergedDict = Application.Current.Resources.MergedDictionaries;
        mergedDict.Clear();

        switch (theme)
        {
            case Theme.Light:
                mergedDict.Add(new LightTheme());
                break;
            case Theme.Dark:
                mergedDict.Add(new DarkTheme());
                break;
        }
    }

    private static void App_OnForeground(object sender, EventArgs e)
    {
        var sysTheme = DependencyService.Get<ISystemTheme>();
        var curTheme = sysTheme.GetTheme();
        // 前回とテーマが変わらなければ何もしない
        if (curTheme == LastSystemTheme)
        {
            return;
        }

        // PlatformのThemeを変更
        sysTheme.SetTheme(curTheme);

        SetDictionary(curTheme);

        LastSystemTheme = curTheme;
    }

}

起動時にテーマを適用するように。

public partial class App : Application
{
    public static event EventHandler OnForeground;

    public App()
    {
        InitializeComponent();

        MainPage = new MyNavigationPage(new MainPage());
    }

    protected override void OnStart()
    {
        // 起動時テーマ適用
        ThemeResource.ApplyTheme();
    }

    protected override void OnResume()
    {
        OnForeground?.Invoke(this, EventArgs.Empty);
    }
}

結果

完全にダークになりました。分かりにくいですがiOSのSwitchも見やすくなっています。Androidのダイアログにもダークが反映されています。

f:id:kamusoft:20200618003635p:plainf:id:kamusoft:20200618003651p:plain

終わりに

iOSの方は比較的分かりやすかったです。AndroidはStyleを多少いじらないといけなかったり少し面倒でした。 あとAndroidでActivity属性に

ConfigurationChanges = ConfigChanges.UiMode

みたいに UiModeを入れてるとモードを切り替えてもActivityの再生成が起こらず、テーマが反映されないので注意が必要です。 これでかなりハマってしまいました。これにすると OnConfigurationChanged で変更を検知できるようになりますが、Activityの処理は自分でやれよってことみたいです。 そしてよく見たらそうドキュメントにも書いてあったという…

ダークテーマ  |  Android デベロッパー  |  Android Developers

あとはCollectionViewなどのリスト系のUIは即時反映されない場合があるので、その場合は切り替えのタイミングでリロードする必要があるかも知れません。