1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Androidで2つのアプリを画面分割起動するショートカット実装と起動競合(遅延制御)の解決策

1
Posted at

TL;DR

  • Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT と FLAG_ACTIVITY_NEW_TASK の組み合わせにより、Androidの画面分割(マルチウィンドウ)で2アプリを同時起動できる。
  • 2つのActivityを連続起動すると、WindowManagerの配置前に2つ目が届いて全画面上書きされる競合が発生する。ミリ秒単位の起動遅延(Launch Delay)制御で解決する。
  • ShortcutManagerCompat とCanvas合成により、2つのアプリアイコンを斜め分割したピン留めショートカットを動的生成できる。
  • PackageManager で対象Activityの resizeableActivity を事前走査し、非対応アプリを検知して不発を防ぐ。

環境

項目 バージョン・詳細
対象OS Android 10 (API 29) 〜 Android 16 (API 36)
開発言語 Kotlin 1.9.x / 2.0.x
主要ライブラリ androidx.core:core-ktx:1.13.x
アーキテクチャ Jetpack Compose / Standalone Local Engine

Androidにおけるマルチウィンドウ分割起動の仕組み

Android 7.0(API 24)以降、プラットフォーム標準でマルチウィンドウ機能がサポートされている。隣接領域(上下または左右)にアプリを起動・配置するには、特定のIntentフラグを付与してOSに指示を出す必要がある。

画面分割起動を要求するIntentフラグ

隣接ウィンドウへの起動を要求するコアフラグが Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT である。このフラグは新規タスクスタックを生成する Intent.FLAG_ACTIVITY_NEW_TASK と併用して発行する。

// packageA(起点アプリ)のLaunch Intentを取得して起動
val intentA = packageManager.getLaunchIntentForPackage(packageA)?.apply {
    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intentA)

// packageB(隣接配置アプリ)をLAUNCH_ADJACENTを付与して起動
val intentB = packageManager.getLaunchIntentForPackage(packageB)?.apply {
    addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intentB)

この処理において、OSのWindowManagerは1つ目のActivityを基準タスクとし、2つ目のActivityを隣接した分割スロットへ割り当てようと試みる。しかし、この単純な連続呼び出しには落とし穴が存在する。

連続起動時のレースコンディションと「起動遅延」による解決

実機環境で上記コードをそのまま実行すると、「片方のアプリしか起動しない」「2つ目のアプリで画面全体が上書きされる」という不具合が頻発する。

根本原因:Activity起動ライフサイクルとWindowManagerの競合

startActivity を呼び出してからプロセスが生成され、WindowManagerが画面レイアウトを確定するまでには一定の遅延がある。

1つ目のアプリが onCreate から onResume を経てウィンドウフォーカスを取得する前に2つ目の起動リクエストが到達すると、WindowManagerは現在の状態を「単一アプリのフォアグラウンド表示」と判定する。その結果、FLAG_ACTIVITY_LAUNCH_ADJACENT が無視され、通常のタスク切り替えとして2つ目が全画面表示されてしまう。

解決策:ミリ秒単位の起動遅延(Launch Delay)制御

この競合(レースコンディション)を回避するため、1つ目のActivityの初期化進行を待つ待機時間(ウェイト)を挟んでから2つ目のIntentを発行する。

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

/**
 * 2つのアプリを起動遅延を挟んで画面分割起動する
 *
 * @param context コンテキスト
 * @param packageA 1つ目のアプリアイコン(起点)
 * @param packageB 2つ目のアプリアイコン(隣接ウィンドウ)
 * @param delayMillis 起動間隔(ミリ秒)。端末性能やアプリ負荷に応じて0〜1000msで調整
 */
fun launchAppPair(
    context: Context,
    packageA: String,
    packageB: String,
    delayMillis: Long = 300L
) {
    val pm = context.packageManager

    val intentA = pm.getLaunchIntentForPackage(packageA)?.apply {
        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    } ?: return

    val intentB = pm.getLaunchIntentForPackage(packageB)?.apply {
        addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT or Intent.FLAG_ACTIVITY_NEW_TASK)
    } ?: return

    // 1つ目のアプリをフォアグラウンドへ起動
    context.startActivity(intentA)

    // コルーチンを用いて指定ミリ秒の遅延後に2つ目を起動
    CoroutineScope(Dispatchers.Main).launch {
        if (delayMillis > 0L) {
            delay(delayMillis)
        }
        context.startActivity(intentB)
    }
}

実測値として、軽量なユーティリティ(電卓・メモ等)同士なら 100ms 程度で安定動作する。一方、Googleマップやブラウザ等の重いアプリを絡める場合は、CPU負荷に応じて 300ms〜400ms 程度に設定することで失敗をゼロに抑えられる。

ShortcutManagerによる合成アイコン付きショートカット生成

作成したペアをホーム画面から即座に呼び出すため、Androidの ShortcutManagerCompat を利用してピン留めショートカットを生成する。何のアプリの組み合わせかを視認できるよう、2つのアイコンを動的に合成する。

2つのアプリアイコンを合成するCanvas描画処理

左上と右下にそれぞれ円形クリッピングしたアイコンを配置し、境界部分に白いストローク(境界線)を描画することで、アプリアイコン同士の混色を防ぎ視認性を確保する。

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.graphics.drawable.Drawable
import androidx.core.graphics.drawable.toBitmap

/**
 * 2つのDrawableから斜め分割の合成Bitmapアイコンを生成する
 */
fun createDuoIconBitmap(
    iconA: Drawable,
    iconB: Drawable,
    sizePx: Int = 192
): Bitmap {
    val output = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(output)

    val bmpA = iconA.toBitmap(sizePx, sizePx)
    val bmpB = iconB.toBitmap(sizePx, sizePx)

    val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    // 背景として2つ目のアイコンを全体描画
    canvas.drawBitmap(bmpB, 0f, 0f, paint)

    // 左上半分を三角形パスでクリッピングして1つ目のアイコンを描画
    val clipPath = Path().apply {
        moveTo(0f, 0f)
        lineTo(sizePx.toFloat(), 0f)
        lineTo(0f, sizePx.toFloat())
        close()
    }

    canvas.save()
    canvas.clipPath(clipPath)
    canvas.drawBitmap(bmpA, 0f, 0f, paint)
    canvas.restore()

    // 視認性向上のための白い境界線(セパレーター)を描画
    val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = 0xFFFFFFFF.toInt()
        strokeWidth = sizePx * 0.04f
        style = Paint.Style.STROKE
    }
    canvas.drawLine(sizePx.toFloat(), 0f, 0f, sizePx.toFloat(), borderPaint)

    return output
}

ショートカットの登録とピン留め要求

合成したBitmapを IconCompat.createWithBitmap でアイコン化し、ピン留めIntentを発行する。

import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat

fun createPinnedShortcut(
    context: Context,
    shortcutId: String,
    label: String,
    iconBitmap: Bitmap,
    launchIntent: Intent
) {
    if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
        return
    }

    val pinShortcutInfo = ShortcutInfoCompat.Builder(context, shortcutId)
        .setShortLabel(label)
        .setIcon(IconCompat.createWithBitmap(iconBitmap))
        .setIntent(launchIntent)
        .build()

    // システムダイアログを呼び出してホーム画面への追加を要求
    ShortcutManagerCompat.requestPinShortcut(context, pinShortcutInfo, null)
}

画面分割非対応アプリの事前フィルタリング

AndroidManifest.xml 内でマルチウィンドウが無効化されているアプリをショートカット化すると、起動時にOS側で分割が拒否される。

resizeableActivity 属性の検証

アプリが画面分割に対応しているかは、対象Activityの ActivityInfo を検証することで判別できる。

import android.content.pm.PackageManager
import android.content.pm.ActivityInfo

fun isAppSplitScreenSupported(context: Context, packageName: String): Boolean {
    val pm = context.packageManager
    return try {
        val launchIntent = pm.getLaunchIntentForPackage(packageName) ?: return false
        val component = launchIntent.component ?: return false
        val activityInfo = pm.getActivityInfo(component, PackageManager.GET_META_DATA)

        // resizeableActivity が明示的に false に設定されていないか確認
        val isResizeable = (activityInfo.resizeMode != ActivityInfo.RESIZE_MODE_UNRESIZEABLE)
        val isOrientationFixed = (activityInfo.screenOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT ||
                activityInfo.screenOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)

        !(isOrientationFixed && !isResizeable)
    } catch (e: Exception) {
        false
    }
}

Android 16(API 36)以降における仕様変更の考慮

Android 16(API 36)以降、画面の最小幅が600dp以上の大画面・フォルダブル端末において、OS側が android:resizeableActivity="false" の指定を原則無視してマルチウィンドウ表示を強制許可する仕様が導入された。ただし、ゲームカテゴリ(appCategory="game")や通常のスマートフォン画面では依然として属性値が有効に働くため、アプリ側での事前チェックは依然として必要だ。

設計思想とトレードオフ

設計項目 採用方針 メリット トレードオフ・制約
権限モデル 権限要求ゼロ(完全スタンドアロン) 不審な権限がなく安全、導入ハードルが最小 バックグラウンド自律監視やAccessibilityServiceによる強制配置は不可
起動制御 コルーチンによる遅延発行 特別なシステム権限を要さず安全にIntentを分離 端末負荷のスパイク発生時に遅延設定が足りないと稀に失敗する
アイコン生成 クライアントローカルでのCanvas合成 外部サーバー通信不要、プライバシーを保護 端末解像度に応じた描画処理がローカルで発生する

ハマりどころ・注意点

  1. ベンダー独自カスタマイズOS(MIUI / OneUI / ColorOS等)の挙動差:
    AOSP標準の FLAG_ACTIVITY_LAUNCH_ADJACENT に対する解釈が端末によって異なる場合がある。特にXiaomi(HyperOS/MIUI)等の端末では、バッテリー最適化により連続Activity起動が制限されるケースがあるため、起動遅延を長め(300ms〜500ms)に設定することが有効な対策となる。
  2. Intent URIの永続化:
    ホーム画面ショートカットに持たせるIntentには、対象パッケージだけでなく起動遅延パラメータやペア情報を含める。Intent.toUri(Intent.URI_INTENT_SCHEME) でシリアライズする際、Extrasに含まれる型の互換性に留意する。
  3. Android 8.0(API 26)未満の互換性:
    ShortcutManager による動的ピン留めはAPI 26以降の機能である。Android 10(API 29)以降をターゲットとする場合は問題ないが、古いOSを対象にする場合は旧式ショートカットBroadcastとの分岐が必要になる。

まとめ

  • Androidの画面分割起動は Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT と FLAG_ACTIVITY_NEW_TASK の組み合わせによって実現できる。
  • WindowManagerのタスク配置タイミングに起因するレースコンディションを回避するため、ミリ秒単位の起動遅延(Launch Delay)を挟むことが実機安定化の要となる。
  • ShortcutManagerCompat とCanvas描画を連携させることで、視認性の高い合成アイコンを備えたホーム画面ショートカットを完全ローカルで安全に構築できる。
  • 画面分割非対応アプリは ActivityInfo.resizeMode の事前走査により検出可能であり、起動失敗を未然に防止できる。

本アプリはGoogle Playストアよりインストール可能。
→ PairLauncher - Google Play
→ PairLauncher 公式ページ(kixking)

実際の使用感やおすすめのペア設定などをまとめたブログ記事はこちら:
→ Androidの画面分割を1タップで起動する!『PairLauncher』でアプリの神ペアを作ってみた

1
2
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?