场景:商城会在10点发起对一件商品的秒杀,要求客户端在10点之前五分钟通知到客户打开app准备参与秒杀活动
面对这一需求是否我们第一反应就是如果程序被关闭了如何通知到用户呢?是否需要设计程序保活的方案?是否无法做到这个需求?这篇文章主要按照这个需求来说一说这样的产品需求下,app应该如何优雅的应对。
android风风雨雨一路过来,正在变得越来越完善,越来越规范。从这一路走过来的程序员相信都面临过:一方面来自自家公司产品要求准时提醒用户做也一些业务(如参加抢购活动),这就涉及到了 程序保活
的问题,另一方面面临这国产手机
各种白名单
以外的,后台进程的意外回收(主要手机厂商处于省电,和节省内存这方面来考虑)。可以看到网上各种程序保活的方案屡见不鲜。
其实稍懂得操作系统的程序员都知道,android的普通应用只是运行在系统单独分配的独立的 “小空间” 里,级别要比系统应用的权限低。所以说到底猴子是跳不出如来佛祖的手掌心的,如果跳出来了那就是手机系统的bug
其实我们普通的应用程序如果想运行的稳定,最好的方式还是中规中矩的按照系统要求来,同时去想一些折中的用户可以接受的一些方案。啰嗦了这么多,主要来说一下今天的主题为app添加日历事件
前面说了一堆,其实只有一个主题就是背着系统偷偷干的坏事,不是长久之计,增加了app的异常风险。而系统日历事件提醒这个功能,确实是在系统允许的范围之内,允许第三方应用保存通知事件在日历的程序中。这个是从2.2以上的系统就已经有的功能。
这里大概列一些常用的一些保活方案,读者有兴趣可以逐个去尝试
下面内容我就默认读者已经知道什么是日历事件了,如果不知道的请打开自己的手机日历,然后选择日期并且添加选择日期的提醒事件熟悉一下具体的流程。这里说的只是不通过程序来实现其他程序往日历程序里面添加提醒事件
日历事件的操作,说到底就是对日历应用做增删改查的数据库操作,这里就用到了ContentProvider
,跨应用操作数据库,主要涉及以下几张数据表(找个模拟器直接导出calendar.db):
如上的几张表分别对应关系如下图示1
:
一个用户可以拥有多个 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 |
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
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);
}
CalendarContract.Events.CONTENT_URI
Uri eventUri = context.getContentResolver().insert(uri, event);
long eventID = ContentUris.parseId(eventUri);
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的入口放在通知栏里呢?有以下几点考虑
附带上简单的通知栏常驻的代码
核心设置,防止用户滑动删除
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);
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;
}