前言
现在几乎每个人都有移动手机,包括老年人和小孩,移动应用已经成了家喻户晓的东西。但是随着Android系统的升级,和手机厂商的不断优化,他们逐渐把重心移到了应用的耗电量和后台唤醒等影响手机性能的方面的研究了。导致之前版本一直支持的alarm闹钟能力,现在也逐渐不好使了。目前我们了解到的国内的一些主要手机厂商对除了白名单内的应用无法离线唤醒,这直接导致了alarm闹钟能力无法施展。
目前应用中常用的离线提醒使用的是推送通知的方式,那有没有一种简单轻量又不受系统限制的离线提醒的能力呢?答案是有的,那就是日历。
我们接下来围绕以下几个点了解并在实际项目中介绍实践:
- 什么是日历
- 业务中为什么使用日历
- 开发一个日历提醒需要具备什么能力
- 设计并实现一个日历
- 遇到的兼容性问题
1、什么是日历
日历Android系统自带的日历程序。同时它也暴露了日历API,这个API是它对外部应用程序提供的“增删改查”的接口。详细见官方介绍-链接。
2、业务中为什么使用日历
那么业务中为什么要使用日历呢?我总结相对于推送能力,它在以下两个场景会有特别的优势:
- 依赖少的场景:适用于一个简单的下载器类型的应用(不包含实际逻辑,实际逻辑要等更新完成后才能执行)
- 业务简单的应用:对于刚起步,需要快速投入市场验证的场景,此时还不具备较好的推送能力
看看各位正在使用日历的大牛们是否能对号入座~
3、开发一个日历提醒需要具备什么能力
那么开发一个日历提醒需要具备什么能力呢?我想不管对于资深的老手还是刚入门的新手都会思考的一个问题,因为这是设计一个模块需要思考的问题。
当我们要在一个程序中添加一个日历功能,我们想到的是它属于基础业务模块,那么此时我们会定一个目标,它应该具备什么样的能力:
- 日历操作:增删改查
- 日历的内容协议格式定义
- 日历提醒设置(提醒时间、方式等)
- 权限申请
以上是我们期望的日历模块应该具备的能力,也就是目标。接下来我们可以带着这些目标调研Google的日历API,梳理出我们需要解决的问题和难点。
4、设计并实现一个日历
Android的日历官方文档还是介绍的非常详细的,大家可以从官方文档获得基本所有的资料(官方链接)。不过我想大家看完后也会遇到一些疑惑,我整理出来一些点,方便大家查阅:
- Android的日历是从属于账号下面的
官方的描述如下:
A special account type for calendars not associated with any account. Normally calendars that do not match an account on the device will be removed. Setting the account_type on a calendar to this will prevent it from being wiped if it does not match an existing account.
我们应用程序无法操作手机中的用户账号(例如:Google,facebook等),但是系统提供了LOCAL 本地类型账号使用,它具备以下特点:
ACCOUNT_TYPE_LOCAL
是一种特殊的帐户类型,用于未关联设备帐户的日历。这种类型的日历不与服务器进行同步
- 什么是同步适配器
同步适配器拥有写入权限的列更多。例如,应用只能修改日历的少数几种特性,例如其名称、显示名称、可见性设置以及是否同步日历。相比之下,同步适配器不仅可访问这些列,还能访问许多其他列,例如日历颜色、时区、访问级别、地点等。不过,同步适配器受限于其指定的
ACCOUNT_NAME
和ACCOUNT_TYPE
。
那么我们该如何选择我们操作时是否应该用同步适配器还是普通的方式呢?从官方的解释来看同步适配器的权限更高,我们可以用在创建一个本地日历的场景。
- content_provider操作建议异步处理
官方推荐的方式是 AsyncQueryHandler,
4.1、日历功能的流程
通过阅读Android的官方文档,和一些疑问的解惑(所谓工欲善其事必先利其器)后我们可以开始着手设计了。
它的流程应该是这样的:
以上流程描述了日历的“增删改查”操作和对应的权限关系,其中考虑了Android提供的两种操作日历的方式:content_provider 方式和 intent 方式。这两个方式的区别是intent方式不需要任何权限,保证了日历功能的成功率。
4.2、日历的内容和提醒功能
从官方文档我们了解到,日历的内容包含:
- title:事件标题
- description: 事件描述
- event_location: 事件发生的位置
- start_time_mills: 事件发生的开始时间
- end_time_mills: 事件结束时间
详细见这里描述
提醒功能包含以下2个内容:
- minutes: 事件发生前多少分钟触发提醒
- method: 提醒方式,本地日历主要使用alert
详细见这里描述
4.3、关键实现
4.3.1、自定义AsyncQueryHandler
继承并实现一个方便自己内部调用的AsyncQueryHandler:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
private static class CalAsyncQueryHandler extends AsyncQueryHandler { private int MAX_TOKEN_VALUE = 999999; private int mToken = 0; private final SparseArray<AsyncCallback<Cursor>> mQueryCallbacks = new SparseArray<>(); private final SparseArray<AsyncCallback<Integer>> mUpdateCallbacks = new SparseArray<>(); private final SparseArray<AsyncCallback<Uri>> mInsertCallbacks = new SparseArray<>(); private final SparseArray<AsyncCallback<Integer>> mDeleteCallbacks = new SparseArray<>(); private int getToken() { int token = mToken + 1; if (token >= MAX_TOKEN_VALUE) { token = 0; } mToken = token; return mToken; } public CalAsyncQueryHandler(ContentResolver contentResolver) { super(contentResolver); } public void query(Uri uri, String[] projection, String selection, String[] selectionArgs, String orderBy, AsyncCallback<Cursor> callback) { int token = getToken(); synchronized (mQueryCallbacks) { mQueryCallbacks.put(token, callback); } super.startQuery(token, null, uri, projection, selection, selectionArgs, orderBy); } public final void insert(Uri uri, ContentValues initialValues, AsyncCallback<Uri> callback) { int token = getToken(); synchronized (mInsertCallbacks) { mInsertCallbacks.put(token, callback); } super.startInsert(token, null, uri, initialValues); } public final void update(Uri uri, ContentValues values, String selection, String[] selectionArgs, AsyncCallback<Integer> callback) { int token = getToken(); synchronized (mUpdateCallbacks) { mUpdateCallbacks.put(token, callback); } super.startUpdate(token, null, uri, values, selection, selectionArgs); } public final void delete(Uri uri, String selection, String[] selectionArgs, AsyncCallback<Integer> callback) { int token = getToken(); synchronized (mDeleteCallbacks) { mDeleteCallbacks.put(token, callback); } super.startDelete(token, null, uri, selection, selectionArgs); } @Override protected void onQueryComplete(int token, Object cookie, Cursor cursor) { AsyncCallback<Cursor> callback; synchronized (mQueryCallbacks) { callback = mQueryCallbacks.get(token); if (callback == null) { LogUtil.w(TAG, "onQueryComplete with no callback, just return:" + token); return; } mQueryCallbacks.remove(token); } int userCount = cursor != null ? cursor.getCount() : 0; if (userCount == 0) { LogUtil.w(TAG, "queryCalendars cursor result null"); if (cursor != null) { cursor.close(); } callback.onFail(EjoyConst.CalendarErrorCodes.ERR_QUERY_EMPTY, "query result empty"); } else { callback.onSuccess(cursor); } } @Override protected void onInsertComplete(int token, Object cookie, Uri uri) { AsyncCallback<Uri> callback; synchronized (mInsertCallbacks) { callback = mInsertCallbacks.get(token); if (callback == null) { LogUtil.w(TAG, "onInsertComplete with no callback, just return:" + token); return; } mInsertCallbacks.remove(token); } if (uri == null) { callback.onFail(EjoyConst.CalendarErrorCodes.ERR_ADD_EVENT_FAILED, "insert result uri empty"); } else { callback.onSuccess(uri); } } @Override protected void onUpdateComplete(int token, Object cookie, int rowCount) { AsyncCallback<Integer> callback; synchronized (mUpdateCallbacks) { callback = mUpdateCallbacks.get(token); if (callback == null) { LogUtil.w(TAG, "onUpdateComplete with no callback, just return:" + token); return; } mUpdateCallbacks.remove(token); } if (rowCount > 0) { callback.onSuccess(rowCount); } else { callback.onFail(EjoyConst.CalendarErrorCodes.ERR_EVENT_UPDATE_FAILED, "update failed"); } } @Override protected void onDeleteComplete(int token, Object cookie, int result) { AsyncCallback<Integer> callback; synchronized (mDeleteCallbacks) { callback = mDeleteCallbacks.get(token); if (callback == null) { LogUtil.w(TAG, "onDeleteComplete with no callback, just return:" + token); return; } mDeleteCallbacks.remove(token); } callback.onSuccess(result); } } |
4.3.2、创建日历
遍历calendar表,匹配account_name和name获取calendar_id,如果没有则创建一个日历:
这里提供几个github的实现做参考:
- https://github.com/xiesiwen/Funny/blob/94a82722e2eebae408a3650d744e4128a75754be/Common/project_offline/src/main/java/com/qingqing/project/offline/calendarevent/CalendarEventUtils.java
- https://github.com/ShilinaAle/first_common_project/blob/cab1e6e75734d27102b9950ab794f62d81acaad0/SRC/Project_X/app/src/main/java/com/shilina/project_x/CalendarHandler.java
- https://github.com/vankatwijk/Bee-Android/blob/caed537e518351f58b7bac3ddecf852be60d02f9/android%20WS/BeeHappy/src/com/example/beeproject/calendar/BeeHappyCalendarResolver.java
注意:创建日历时我们需要使用同步适配器和account_type为local。
示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
TimeZone timeZone = TimeZone.getDefault(); String accountName = getAccountName(); String calendarName = getCalendarName(); ContentValues value = new ContentValues(); value.put(Calendars.NAME, calendarName); value.put(Calendars.ACCOUNT_NAME, accountName); value.put(Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL); value.put(Calendars.CALENDAR_DISPLAY_NAME, calendarName); value.put(Calendars.VISIBLE, 1); value.put(Calendars.CALENDAR_COLOR, Color.BLUE); value.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER); value.put(Calendars.SYNC_EVENTS, 1); value.put(Calendars.CALENDAR_TIME_ZONE, timeZone.getID()); value.put(Calendars.OWNER_ACCOUNT, accountName); value.put(Calendars.CAN_ORGANIZER_RESPOND, 0); Uri calendarUri = CalendarContract.Calendars.CONTENT_URI.buildUpon() .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") .appendQueryParameter(Calendars.ACCOUNT_NAME, accountName) .appendQueryParameter(Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) .build(); getQueryHandler().insert(calendarUri, value, new AsyncCallback<Uri>() { @Override public void onSuccess(Uri uri) { Long calId = Long.parseLong(uri.getLastPathSegment()); if (calId != 0) { mCalendarId = String.valueOf(calId); LogUtil.d(TAG, "create calendar suc id " + mCalendarId); callback.onSuccess(mCalendarId); } else { LogUtil.w(TAG, "create calendar failed"); callback.onFail(EjoyConst.CalendarErrorCodes.ERR_ADD_CALENDAR_FAILED, "insert uri invalid"); } } @Override public void onFail(int errCode, String errMsg) { callback.onFail(errCode, errMsg); } }); |
4.3.3、日历事件增删改查
详细的操作在4.3.2的创建日历的几个github中有介绍。这里提一个主要的点是我们如何匹配是否已经存在相同的事件?
我们知道主要是需要找到事件ID,有了这个事件ID我们就能找到它对应的Reminder(提醒)对应内容。
那么如果根据已有的事件信息找到对应的事件ID呢?我们可以通过title和起始结束时间来结合判断。如果应用只传了title,那么就匹配title。如果同时带了起始结束时间,那么也匹配这个时间值。
示例代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
if (!TextUtils.isEmpty(eventInfo.eventId)) { LogUtil.d(TAG, "getEventQuerySelectionInfo, hasEventId:" + eventInfo.eventId); String selection = "(" + CalendarContract.Events._ID + " = ?)"; String[] selectionArgs = new String[]{ eventInfo.eventId }; callback.onSuccess(new Pair<String, String[]>(selection, selectionArgs)); } else if (!TextUtils.isEmpty(eventInfo.title)) { LogUtil.d(TAG, "getEventQuerySelectionInfo, has title:" + eventInfo.title); // String accountName = getAccountName(); String titleQuery = "(" + CalendarContract.Events.TITLE + " = ? AND " + Calendars.DELETED + " != 1 )"; String selection; String[] selectionArgs; if (eventInfo.startTimeInMillis > 0 && eventInfo.endTimeInMillis > 0) { LogUtil.d(TAG, "queryCalendarEvent with title, startTime, endTime not empty"); selection = "(" + titleQuery + " AND (" + CalendarContract.Events.DTSTART + " = ?) AND (" + CalendarContract.Events.DTEND + " = ?))"; selectionArgs = new String[]{eventInfo.title, Long.toString(eventInfo.startTimeInMillis), Long.toString(eventInfo.endTimeInMillis)}; } else { LogUtil.d(TAG, "queryCalendarEvent with title not empty"); selection = titleQuery; selectionArgs = new String[]{eventInfo.title}; } callback.onSuccess(new Pair<String, String[]>(selection, selectionArgs)); } else { LogUtil.w(TAG, "doDeleteCalendarEvent failed, title and id is empty"); callback.onFail(EjoyConst.CalendarErrorCodes.ERR_EVENT_PARAM_INVALID, "title is empty"); } |
5、遇到的兼容问题
1、 oppo r15手机上开始和结束时间为1970年
原因:
intent方式的start_time和 end_time要通过下面方式设置:
1 2 |
.putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, calendarInfo.eventInfo.startTimeInMillis) .putExtra(CalendarContract.EXTRA_EVENT_END_TIME, calendarInfo.eventInfo.endTimeInMillis) |
2、oppo r15 插入日历失败,返回id为0
不使用Uri.parse,改为系统提供的Uri常量。(不知道具体原因,打印Uri的内容其实是一致的)
1 2 3 |
- Uri calendarUri = Uri.parse(CALENDAR_URI); - calendarUri = calendarUri.buildUpon() + Uri calendarUri = CalendarContract.Calendars.CONTENT_URI.buildUpon() |
3、删除事件失败
查询事件时需要过滤deleted 不为 1 ( 1 代表已删除)的数据进行删除。
您可通过以下方式删除事件:将事件 _ID 作为 URI 的追加 ID,或使用标准选定范围。如果您使用追加 ID,则无法同时使用选定范围。共有两个版本的删除:应用删除和同步适配器删除。应用删除会将 deleted 列置为 1。此标记告知同步适配器该行已删除,并且应将此删除传播至服务器。同步适配器删除会从数据库中移除事件及其所有关联数据。