【Android】通知准时送达之日历事件

索引

  • 案例
  • 写在前面
  • 为什么要添加日历事件,有哪些好处
  • 添加日历事件方案
    • 相关数据表
    • 添加权限
    • 日历账户
    • 日历事件
  • 扩展

案例

场景:商城会在10点发起对一件商品的秒杀,要求客户端在10点之前五分钟通知到客户打开app准备参与秒杀活动

面对这一需求是否我们第一反应就是如果程序被关闭了如何通知到用户呢?是否需要设计程序保活的方案?是否无法做到这个需求?这篇文章主要按照这个需求来说一说这样的产品需求下,app应该如何优雅的应对。

写在前面

android风风雨雨一路过来,正在变得越来越完善,越来越规范。从这一路走过来的程序员相信都面临过:一方面来自自家公司产品要求准时提醒用户做也一些业务(如参加抢购活动),这就涉及到了 程序保活的问题,另一方面面临这国产手机 各种白名单以外的,后台进程的意外回收(主要手机厂商处于省电,和节省内存这方面来考虑)。可以看到网上各种程序保活的方案屡见不鲜。

其实稍懂得操作系统的程序员都知道,android的普通应用只是运行在系统单独分配的独立的 “小空间” 里,级别要比系统应用的权限低。所以说到底猴子是跳不出如来佛祖的手掌心的,如果跳出来了那就是手机系统的bug

其实我们普通的应用程序如果想运行的稳定,最好的方式还是中规中矩的按照系统要求来,同时去想一些折中的用户可以接受的一些方案。啰嗦了这么多,主要来说一下今天的主题为app添加日历事件

为什么要添加日历事件,有哪些好处

前面说了一堆,其实只有一个主题就是背着系统偷偷干的坏事,不是长久之计,增加了app的异常风险。而系统日历事件提醒这个功能,确实是在系统允许的范围之内,允许第三方应用保存通知事件在日历的程序中。这个是从2.2以上的系统就已经有的功能。

  • 好处1:可以不用在考虑那烦人不靠谱的保活的方案
  • 好处2:代码实现非常方便,而且系统应用通知会更稳定
  • 好处3:用户可以接受 ,这一点很关键

这里大概列一些常用的一些保活方案,读者有兴趣可以逐个去尝试

  • 双进程互相监控存活状态,并重启
  • 利用第三方的推送服务
  • 前台service
  • AlarmClock方案
  • 注册系统广播如开机,开关网,来电等等注册一堆然后来唤醒。
    等等…

添加日历事件方案

下面内容我就默认读者已经知道什么是日历事件了,如果不知道的请打开自己的手机日历,然后选择日期并且添加选择日期的提醒事件熟悉一下具体的流程。这里说的只是不通过程序来实现其他程序往日历程序里面添加提醒事件

相关数据表

日历事件的操作,说到底就是对日历应用做增删改查的数据库操作,这里就用到了ContentProvider,跨应用操作数据库,主要涉及以下几张数据表(找个模拟器直接导出calendar.db):
【Android】通知准时送达之日历事件_第1张图片

【Android】通知准时送达之日历事件_第2张图片

如上的几张表分别对应关系如下图示1
【Android】通知准时送达之日历事件_第3张图片
一个用户可以拥有多个 Calendar,每个 Calendar 可以与不同类型的帐号关联(Google Calendar、Exchange 等)。
CalendarContract 定义了 Calendar 和 Event 的数据模型。这些数据存放在以下数据表中。

数据表 说明
Calendars 该表存放日程的定义数据。每行表示一条日程的详细信息,如名称、颜色、同步信息等。
Events 该表存放事件的定义数据。每行表示一个事件,内容包括 — 事件标题、位置、起始时间、结束时间等等。 事件可以是一次性的,也可以重复多次触发。 参与人员、提醒闹钟及附加属性都存放在其他表中,并通过 EVENT_ID 字段与 Events 表中的 _ID 关联。
Instances 该表存放事件每次触发时的起始时间和结束时间。一次性事件只会1:1对应一条实例记录。 对于重复触发的事件而言,则会自动生成多条实例记录,对应每一次的触发。
Reminders 该表存放闹钟/通知数据。每行代表一次闹钟提醒。 一个事件可以拥有多个闹钟提醒。每个事件可拥有的最大提醒数在 MAX_REMINDERS 中定义,这是由拥有该日程的 sync adapter 设置的。 提醒定义了事件触发前的分钟数,以及提醒用户的方式。

我们下面简单的介绍一下使用calendars,events,reninders三张张表来实现事件的新增

calendars表结构

字段名称 含义 类型
account_type 账户类型 TEXT
account_name 账户名称 TEXT
calendar_displayName TEXT
calendar_color 日历显示的颜色背景 int(RGB)
visible 0不可见1可见 INTEGER
calendar_timezone 日历时区 TEXT
sync_events 0不同步1同步 TEXT
sync_events 最大提醒次数 int

events表结构

字段名称 含义 类型
calendar_id 账户id对应calendars表的id INTEGER
dtstart 开始时间戳 INTEGER
dtend 结束时间戳 INTEGER
title 事件标题 TEXT
description 事件描述 TEXT
eventLocation 事件发生的位置(如果设置的话可以直接点击可进入地图) TEXT
eventTimezone 时区,使用系统默认即可 TEXT
eventStatus 时间状态设置1为正常 INTEGER
rrule 提醒重复规则(FREQ=DAILY;UNTIL=结束时间(yyyyMMdd)+T235959Z(T开头Z结尾即可)),详细的设置我们可以在看资料 TEXT

reminders表结构

字段名称 含义 类型
eventID events表对应的id Integer
minutes 提前几分钟通知 Integer
method 通知方式(见下图) Integer

【Android】通知准时送达之日历事件_第4张图片

添加权限

  • Manifest添加对日历数据的读写权限
 <uses-permission android:name="android.permission.READ_CALENDAR" />
 <uses-permission android:name="android.permission.WRITE_CALENDAR" />
  • 动态权限申请
    6.0之后的动态权限检查这块也是不可少的,操作之前要做权限检查
 private boolean checkPromession(){
      if (ContextCompat.checkSelfPermission(this,
            Manifest.permission.WRITE_CALENDAR) != PackageManager.PERMISSION_GRANTED) {
         ActivityCompat.requestPermissions(this,
               new String[]{Manifest.permission.WRITE_CALENDAR,
                     Manifest.permission.READ_CALENDAR}, 1);
         return false;
      }
      return true;
   }

日历账户

日历账户这个必须是要有,如果没有的话日历事件就没有归属,相当于我们要创建的事件归属于哪个账户下


  	/**
     * 检查是否存在日历账户
     * @return 存在:日历账户ID  不存在:-1
     */
    private static long checkCalendarAccount(Context context) {
        try (Cursor cursor = context.getContentResolver().query(CalendarContract.Calendars.CONTENT_URI,
                null, null, null, null)) {
            // 不存在日历账户
            if (null == cursor) {
                return -1;
            }
            int count = cursor.getCount();
            // 存在日历账户,获取第一个账户的ID
            if (count > 0) {
                cursor.moveToFirst();
                return cursor.getInt(cursor.getColumnIndex(CalendarContract.Calendars._ID));
            } else {
                return -1;
            }
        }
    }
    /**
     * 创建一个新的日历账户
     *
     * @return success:ACCOUNT ID , create failed:-1 , permission deny:-2
     */
    private static long createCalendarAccount(Context context) {
        // 系统日历表
        Uri uri = CalendarContract.Calendars.CONTENT_URI;

        // 要创建的账户
        Uri accountUri;

        // 开始组装账户数据
        ContentValues account = new ContentValues();
        // 账户类型:本地
        // 在添加账户时,如果账户类型不存在系统中,则可能该新增记录会被标记为脏数据而被删除
        // 设置为ACCOUNT_TYPE_LOCAL可以保证在不存在账户类型时,该新增数据不会被删除
        account.put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL);
        // 日历在表中的名称
        account.put(CalendarContract.Calendars.NAME, CALENDAR_NAME);
        // 日历账户的名称
        account.put(CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_ACCOUNT_NAME);
        // 账户显示的名称
        account.put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, CALENDAR_DISPLAY_NAME);
        // 日历的颜色
        account.put(CalendarContract.Calendars.CALENDAR_COLOR, Color.parseColor("#515bd4"));
        // 用户对此日历的获取使用权限等级
        account.put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER);
        // 设置此日历可见
        account.put(CalendarContract.Calendars.VISIBLE, 1);
        // 日历时区
        account.put(CalendarContract.Calendars.CALENDAR_TIME_ZONE, TimeZone.getDefault().getID());
        // 可以修改日历时区
        account.put(CalendarContract.Calendars.CAN_MODIFY_TIME_ZONE, 1);
        // 同步此日历到设备上
        account.put(CalendarContract.Calendars.SYNC_EVENTS, 1);
        // 拥有者的账户
        account.put(CalendarContract.Calendars.OWNER_ACCOUNT, CALENDAR_ACCOUNT_NAME);
        // 可以响应事件
        account.put(CalendarContract.Calendars.CAN_ORGANIZER_RESPOND, 1);
        // 单个事件设置的最大的提醒数
        account.put(CalendarContract.Calendars.MAX_REMINDERS, 8);
        // 设置允许提醒的方式
        account.put(CalendarContract.Calendars.ALLOWED_REMINDERS, "0,1,2,3,4");
        // 设置日历支持的可用性类型
        account.put(CalendarContract.Calendars.ALLOWED_AVAILABILITY, "0,1,2");
        // 设置日历允许的出席者类型
        account.put(CalendarContract.Calendars.ALLOWED_ATTENDEE_TYPES, "0,1,2");

        /*
            TIP: 修改或添加ACCOUNT_NAME只能由SYNC_ADAPTER调用
            对uri设置CalendarContract.CALLER_IS_SYNCADAPTER为true,即标记当前操作为SYNC_ADAPTER操作
            在设置CalendarContract.CALLER_IS_SYNCADAPTER为true时,必须带上参数ACCOUNT_NAME和ACCOUNT_TYPE(任意)
         */
        uri = uri.buildUpon()
                .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
                .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_ACCOUNT_NAME)
                .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE,
                        CalendarContract.Calendars.CALENDAR_LOCATION)
                .build();

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            // 检查日历权限
            if (PackageManager.PERMISSION_GRANTED == context.checkSelfPermission(
                    "android.permission.WRITE_CALENDAR")) {
                accountUri = context.getContentResolver().insert(uri, account);
            } else {
                return -2;
            }
        } else {
            accountUri = context.getContentResolver().insert(uri, account);
        }

        return accountUri == null ? -1 : ContentUris.parseId(accountUri);
    }

日历事件

按照如下规则组装日历事件后插入数据库即可生效。

 	/**
     * 组装日历事件
     */
    private static void setupEvent(CalendarEvent calendarEvent, ContentValues event,long calendarId) {
   	    event.put(CalendarContract.Events.CALENDAR_ID, calendarId);
        // 事件开始时间
        event.put(CalendarContract.Events.DTSTART, calendarEvent.getStart());
        // 事件结束时间
        event.put(CalendarContract.Events.DTEND, calendarEvent.getEnd());
        // 事件标题
        event.put(CalendarContract.Events.TITLE, calendarEvent.getTitle());
        // 事件描述(对应手机系统日历备注栏)
        event.put(CalendarContract.Events.DESCRIPTION, calendarEvent.getDescription());
        // 事件地点
        event.put(CalendarContract.Events.EVENT_LOCATION, calendarEvent.getEventLocation());
        // 事件时区
        event.put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().getID());
        // 定义事件的显示,默认即可
        event.put(CalendarContract.Events.ACCESS_LEVEL, CalendarContract.Events.ACCESS_DEFAULT);
        // 事件的状态
        event.put(CalendarContract.Events.STATUS, 1);
        // 设置事件提醒警报可用
        event.put(CalendarContract.Events.HAS_ALARM, true);
        // 设置事件忙
        event.put(CalendarContract.Events.AVAILABILITY, CalendarContract.Events.AVAILABILITY_BUSY);
        String dailay = "FREQ=DAILY;UNTIL="+Util.getFinalRRuleMode(calendarEvent.getEnd());
        // 设置事件重复规则
        event.put(CalendarContract.Events.RRULE, dailay);
    }
  • 插入events表 得到eventId也就是对应在events表中自动生成的id字段

CalendarContract.Events.CONTENT_URI
Uri eventUri = context.getContentResolver().insert(uri, event);
long eventID = ContentUris.parseId(eventUri);

  • 插入reminders表

CalendarContract.Reminders.CONTENT_URI

            ContentValues reminders = new ContentValues();
            // 此提醒所对应的事件ID
            reminders.put(CalendarContract.Reminders.EVENT_ID, eventID);
            // 设置提醒提前的时间(0:准时  -1:使用系统默认)
            reminders.put(CalendarContract.Reminders.MINUTES, calendarEvent.getAdvanceTime());
            // 设置事件提醒方式为通知警报
            reminders.put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT);
            reminderUri = context.getContentResolver().insert(uri, reminders);

至此我们就完成了向日历应用插入通知事件的全部过程了。其实整个流程如果理解了这几个表的关联和字段的作用使用起来还是非常方便的。(github上有人已经写了一些demo可参考这里,里面有一些问题如查询的时候未按照插入的时候生成的eventId来查询,导致查询不出是否已有事件,大家可以参考一下知道具体整个流程自己动手实现)

扩展

通过上面的日历事件的添加我们解决了,准时通知用户参与活动的这一需求,但是是否可以更方便一些让用户找到我们的应用呢。这里说一下题外话。添加常驻通知栏的通知

通知栏:这个是android的系统的一个快捷入口,和锁屏,桌面,这些入口一样是应用必争的快捷入口之一。应用进程关闭后通知栏的通知有可能被清除具体看手机产商。

为什么我们这样的需求我们选择将打开app的入口放在通知栏里呢?有以下几点考虑

  • 日历提醒大多是通过通知栏的弹出通知来提醒的,当然我们可以设置日历闹钟,但是这个最好让用户自己去选择用闹钟去通知。
  • 当日历的通知弹出时,用户打开通知栏就能快速看到程序入口
  • 一般日历时间可能会间隔很长时间,因此把打开app的入口放在通知栏,不用影响用户的情况因为默认通知栏折叠起来的,用户是看不到的

附带上简单的通知栏常驻的代码

核心设置,防止用户滑动删除
mNotification.flags = Notification.FLAG_ONGOING_EVENT ;

 Intent notificationIntent = new Intent(Intent.ACTION_MAIN);
       notificationIntent.addCategory(Intent.CATEGORY_LAUNCHER);
       notificationIntent.setClass(mContext, LoginActivity.class);
       notificationIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK
             | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);

       NotificationSupport mNotifySupport = new NotificationSupport(mContext);
       mNotifySupport.build();
       Notification mNotification =  mNotifySupport.getNotificationBuilder()
             /**设置通知左边的大图标**/
             .setLargeIcon(BitmapFactory.decodeResource(mContext.getResources(), R.mipmap.icon))
             /**设置通知右边的小图标**/
             .setSmallIcon(R.mipmap.icon)
             /**通知首次出现在通知栏,带上升动画效果的**/
             .setTicker("程序启动")
             /**设置通知的标题**/
             .setContentTitle("xx应用")
             /**设置通知的内容**/
             .setContentText("正在运行中")
             /**通知产生的时间,会在通知信息里显示**/
             .setWhen(System.currentTimeMillis())
             /**设置该通知优先级**/
             .setPriority(Notification.PRIORITY_MAX)
             /**设置他为一个正在进行的通知。他们通常是用来表示一个后台任务,用户积极参与(如播放音乐)或以某种方式正在等待,因此占用设备(如一个文件下载,同步操作,主动网络连接)**/
             .setOngoing(false)
             /**向通知添加声音、闪灯和振动效果的最简单、最一致的方式是使用当前的用户默认设置,使用defaults属性,可以组合:**/
             .setDefaults(Notification.DEFAULT_VIBRATE | Notification.DEFAULT_SOUND)
             .setContentIntent(PendingIntent.getActivity(mContext, 1, notificationIntent, PendingIntent.FLAG_CANCEL_CURRENT))
             .build();
       mNotification.flags = Notification.FLAG_ONGOING_EVENT ;

        NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(mContext.NOTIFICATION_SERVICE);
       /**发起通知**/
       notificationManager.notify(0, mNotification);
  • android8.0需要使用NotificationChannel(如果),这里也贴出NotificationSupport类的代码
public class NotificationSupport extends ContextWrapper {

    private NotificationManager manager;
    public static final String id = "channel_1";
    public static final String name = "channel_name_1";
    public  Notification notification;

    public Notification.Builder mBuilder;
    public NotificationSupport(Context context){
        super(context);
    }

    @RequiresApi(api = Build.VERSION_CODES.O)
    public void createNotificationChannel(){
        NotificationChannel channel = new NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH);
        getManager().createNotificationChannel(channel);
    }


    @RequiresApi(api = Build.VERSION_CODES.O)
    public void clearChannelId (){
        getManager().deleteNotificationChannel(id);
    }

    private NotificationManager getManager(){
        if (manager == null){
            manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
        }
        return manager;
    }

    @RequiresApi(api = Build.VERSION_CODES.O)
    public Notification.Builder getChannelNotification(){
        return new Notification.Builder(getApplicationContext(), id)
                .setSmallIcon(R.mipmap.icon)
                .setLargeIcon( BitmapFactory.decodeResource(getResources(),R.mipmap.icon))
                .setAutoCancel(true);
    }

    public Notification.Builder getNotification_25(){
              return new Notification.Builder(this)
                    .setSmallIcon(R.mipmap.icon).setLargeIcon( BitmapFactory.decodeResource(getResources(),R.mipmap.icon));
    }


    public void build(){
        if(this.notification==null) {
            if (Build.VERSION.SDK_INT >= 26) {
                createNotificationChannel();
                this.mBuilder = getChannelNotification();
            } else {
                this.mBuilder = getNotification_25();
            }
        }
    }

    public Notification.Builder getNotificationBuilder(){
        return mBuilder;
    }

你可能感兴趣的:(#,Android开发)