UnityでネイティブプラグインをいじらずにAndroid/iOSローカルPUSHを実装した話

スマホゲームを作っていると、↓みたいなときってありますよね。

  • 定期イベントのお知らせをしたい(内部でスケジューリングされているゲリライベント的なやつ)
  • しばらくゲームを起動していないユーザに起動をうながしたい

そんなとき、役に立つのがローカルPUSHです。

ググってみるとローカルPUSHの情報は色々と出てくるんですが、大体ネイティブプラグインを作ってPUSHを送るものばかり。
ネイティブプラグインは面倒くさいので、既存のAssetとC#だけを使ってなんとかしてみました。

Assetのチョイス

Area730様が無料でローカル&リモートPUSH用のAssetを公開されています。
https://www.assetstore.unity3d.com/jp/#!/content/61019

執筆時点のVer1.0だと、iOS11で動かそうとしてもウンともスンとも言わなかったため、Androidのみで使用。
iOS向けのローカルPUSH処理はUnityEngine.iOS.NotificationServicesに実装されているので、コレを使いました。
Unity公式なので安心ですねー(Unity自体は結構バグるけど)

C#側でラップしてAndroid/iOS気にせず使えるようにする

使うときにOSを気にしたコードを書くのは面倒なので、Android/iOSの両OSで気兼ねなく使いたいところ。
ってことで、こんな感じにラップしました。

PushNotificationCenter.cs
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
#if UNITY_ANDROID
using Area730.Notifications;
#elif UNITY_IOS
using NotificationServices = UnityEngine.iOS.NotificationServices;
using NotificationType = UnityEngine.iOS.NotificationType;
#endif

public class PushNotificationCenter : SavableSingletonBase<PushNotificationCenter>
{

    [SerializeField] List<LocalNotificationGroup> reservedLocalNotifications = new List<LocalNotificationGroup>();

    int NextId
    {
        get
        {
            return reservedLocalNotifications.Count > 0
                                             ? (reservedLocalNotifications.Max(x => x.informations.Max(y => y.Id)) + 1)
                                             : 1;
        }
    }

    /// <summary>
    /// イニシャライズ
    /// </summary>
    public void Initialize()
    {
#if UNITY_IOS
        NotificationServices.RegisterForNotifications(
            NotificationType.Alert |
            NotificationType.Badge |
            NotificationType.Sound);
#endif
    }

    /// <summary>
    /// ローカルPUSHを登録します
    /// </summary>
    /// <returns>The registrate.</returns>
    /// <param name="key">Key.</param>
    /// <param name="title">Title.</param>
    /// <param name="body">Body.</param>
    /// <param name="sendAt">Send at.</param>
    /// <param name="repeatCount">Repeat count.</param>
    /// <param name="repeatInterval">Repeat interval.</param>
    public void Registrate(string key, string title, string body, DateTime sendAt, int repeatCount = 1, TimeSpan? repeatInterval = null)
    {
        var registrator = new LocalNotificationRegistrator(key, title, body, sendAt);

        if (1 < repeatCount && null != repeatInterval)
        {
            registrator.SetRepeat(repeatCount, repeatInterval.Value);
        }

        lock (registrator)
        {
            var reserved = reservedLocalNotifications.Cast<LocalNotificationGroup?>().FirstOrDefault(x => x.Value.Key == key);
            reservedLocalNotifications.Add(registrator.Registrate(NextId, reserved));
            Save();
        }
    }

    /// <summary>
    /// 指定したキーのローカルPUSH群をキャンセルします
    /// </summary>
    /// <returns>The cancel.</returns>
    /// <param name="key">Key.</param>
    public void Cancel(string key)
    {
        var reserved = reservedLocalNotifications.Cast<LocalNotificationGroup?>().FirstOrDefault(x => x.Value.Key == key);
        if (null == reserved)
        {
            return;
        }

        foreach (var id in reserved.Value.informations.Select(x => x.Id))
        {
#if UNITY_ANDROID
                AndroidNotifications.cancelNotification(id);
#elif UNITY_IOS
            var notification = NotificationServices.scheduledLocalNotifications
                                                   .FirstOrDefault(x => null != x.userInfo && x.userInfo.Contains("id") && Convert.ToInt32(x.userInfo["id"]) == id);
            if (null != notification)
            {
                NotificationServices.CancelLocalNotification(notification);
            }
#endif
        }

        reservedLocalNotifications.RemoveAll(x => x.Key == reserved.Value.Key);
        Save();
    }

    /// <summary>
    /// 予約中のローカルPUSHをすべてキャンセルします
    /// </summary>
    public void CancelAll()
    {
#if UNITY_ANDROID
            AndroidNotifications.cancelAll();
#elif UNITY_IOS
        NotificationServices.CancelAllLocalNotifications();
#endif
        reservedLocalNotifications.Clear();
        Save();
    }

    /// <summary>
    /// 受信済みのPUSH通知をクリアします
    /// </summary>
    public void ClearAll()
    {
#if UNITY_ANDROID
            AndroidNotifications.clearAll();
#elif UNITY_IOS
        NotificationServices.ClearLocalNotifications();
#endif
    }
}
LocalNotificationRegistrator.cs
using UnityEngine;
using System;
using System.Collections.Generic;
#if UNITY_ANDROID
using Area730.Notifications;
#elif UNITY_IOS
using NotificationServices = UnityEngine.iOS.NotificationServices;
using LocalNotification = UnityEngine.iOS.LocalNotification;
#endif

/// <summary>
/// ローカルPUSH通知登録クラス
/// </summary>
public class LocalNotificationRegistrator
{
    string key;
    string title;
    string body;
    DateTime date;
    int repeatCount = 1;
    TimeSpan repeatInterval = TimeSpan.Zero;

    public LocalNotificationRegistrator(string key, string title, string body, DateTime date)
    {
        this.key = key;
        this.title = title;
        this.body = body;
        this.date = date;
    }

    /// <summary>
    /// 繰り返し設定を行います
    /// </summary>
    /// <returns>The repeat.</returns>
    /// <param name="count">Count.</param>
    /// <param name="interval">Interval.</param>
    public LocalNotificationRegistrator SetRepeat(int count, TimeSpan interval)
    {
        repeatCount = count;
        repeatInterval = interval;
        return this;
    }

    /// <summary>
    /// PUSH通知の登録を実行します
    /// </summary>
    /// <returns>The registrate.</returns>
    /// <param name="nextId">Next identifier.</param>
    /// <param name="reserved">Reserved.</param>
    public LocalNotificationGroup Registrate(int nextId, LocalNotificationGroup? reserved)
    {
        if (null == reserved)
        {
            reserved = new LocalNotificationGroup(key, new List<LocalNotificationGroup.Information>());
        }

        for (var i = 0; i < repeatCount; i++)
        {
            var sendAt = date.Add(TimeSpan.FromTicks(repeatInterval.Ticks * i));
            if (reserved.Value.Has(sendAt) || sendAt < DateTime.Now)
            {
                // 同時刻に既にPUSH通知が登録されていた場合&過去日時の場合はスルー
                Debug.Log(key + " ローカルPUSHの登録をスキップしました: " + sendAt);
                continue;
            }

            var id = nextId + i;
#if UNITY_ANDROID
                var builder = new NotificationBuilder(id, title, body);
                builder.setDelay(sendAt - DateTime.Now);
                AndroidNotifications.scheduleNotification(builder.build());
#elif UNITY_IOS
            var notification = new LocalNotification();
            notification.alertAction = title;
            notification.alertBody = body;
            notification.fireDate = sendAt;
            notification.userInfo = new Dictionary<string, int> { { "id", id } };
            NotificationServices.ScheduleLocalNotification(notification);
#endif

            reserved.Value.informations.Add(new LocalNotificationGroup.Information(id, sendAt));
            Debug.Log(key + " ローカルPUSHを登録しました: " + sendAt);
        }
        return reserved.Value;
    }
}
LocalNotificationGroup.cs
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

[Serializable]
public struct LocalNotificationGroup
{
    [SerializeField] string key;
    public List<Information> informations;

    public string Key { get { return key; } }

    public LocalNotificationGroup(string key, List<Information> informations)
    {
        this.key = key;
        this.informations = informations;
    }

    public bool Has(DateTime date)
    {
        return informations.Any(x => x.Date == date);
    }

    [Serializable]
    public struct Information
    {
        [SerializeField] int id;
        [SerializeField] double oaDate;

        DateTime? dateCache;

        public int Id { get { return id; } }
        public DateTime Date { get { return dateCache.HasValue ? dateCache.Value : (dateCache = DateTime.FromOADate(oaDate)).Value; } }

        public Information(int id, DateTime date)
        {
            this.id = id;
            oaDate = date.ToOADate();
            dateCache = date;
        }
    }
}

PushNotificationCenterで継承しているSavableSingletonBaseは、データをカンタンにセーブするためのクラスです。
ひとまず、コチラのSavableSingletonクラスをSavableSingletonBaseにリネームして使ってくださいまし。
※12/11のqnoteアドベントカレンダーで、上記LitJson版よりイイものをご紹介する予定です!

こちらの記事にまとめていますので、ご利用ください!

オマケな機能

ついでに、ちょいと便利機能を乗せています。

iOSの定期PUSHが使いづらいのでひと工夫

iOSは定期PUSHが時間/日などの大きな単位でしか設定できないため、疑似定期PUSHをカンタンに登録できるようにしています。(設定に応じて、PUSH予約を複数放り込んでおくだけデス)

PUSHのグルーピング

任意のキー値を指定してPUSHを登録し、そのキーに対応したPUSHを一気にキャンセルすることを可能にしています。
特定の種類のローカルPUSHをON/OFFしたいことがあると思うので、そのための機能です。

つかいかた

ゲーム起動時にイニシャライズ。
PushNotificationCenter.Instance.Initialize();

そのあと、任意のタイミングで
PushNotificationCenter.Instance.Registrate(
"キー",
"たいとる",
"ほんぶん",
DateTime.Now.AddHour(1), // 通知日時
10, // 繰り返し回数,
new TimeSpan(3, 0, 0) // 繰り返し間隔
);

でOKです。

任意のPUSH予約をキャンセルしたい時はコチラ。
PushNotificationCenter.Instance.Cancel("キー");

受信したローカルPUSHは蓄積されるので、任意のタイミングで
PushNotificationCenter.Instance.ClearAll();
を呼んで空っぽにしてあげてください。

ハマりどころ

android-support-v4が含まれているので、他のAndroid用Assetを導入している場合はライブラリが重複していないか気をつけましょう。
重複していると、ビルド時にエラーが出ます。

まとめ

意外と面倒だったローカルPUSHくん。

Unityさん、UnityEngine.iOS.NotificationServicesだけじゃなくUnityEngine.Android.NotificationServicesも作ってくださいまし・・・!

あとはリモートPUSHも対応しないとですね〜