前言
Android Notification 几乎每个版本都有改动,因此有很多兼容性问题,摆在开发者面前的难题是每个版本的展示效果不同,再加app保活能力的日渐式微和google大力推进WorkManager、JobScheduler、前台进程的行为,即便AlarmManager#setAlarmClock这种可以解除Dozen模式的超级工具,也无法对抗进程死亡的问题,通知到达率和及时性的效果已经大幅减弱。
自定义通知是否仍有必要?
实际上,目前大多推送通知都被系统厂商代理展示了,导致实现效果雷同且没有新意。众多一样的效果,站在用户角度也产生了很多厌恶情绪,对用户的吸引点也是逐渐减弱,这其实和自定义通知的初衷是相背离的,因为自定义通知首要解决的是特色功能的展示,而通用通知却很难做到这一点。因此,在一些app中,自定义通知仍然是有必要的,但必要性没有那么强了。
当前的使用场景:
- 前台进程常驻类型app,比如直播、音乐类等
- 类似QQ的app浮动弹窗提醒 (这类不算通知,但是可以使用统一的方法适配)
- 系统白名单中的app
现状
通知首要解决的是功能问题,其次是主题问题。当前,大部分app已经习惯使用系统通知栏而不使用自定义的通知,主要原因是适配难度问题。
对于自定义通知的适配,目前有两条路线:
-
统一样式:
是定义一套深色模式和浅色模式都能通用的色彩搭配,一些音视频app也是这么做的,巧妙的避免了因系统主题不一致造成的现实效果不同的问题,但仍然在部分手机上展示的比较突兀。
-
读取系统通知颜色进行适配:
遗憾的是,在Android 7.0之后,正常的通知是拿不到notification.contentView,但似乎没有看到相关的文章来解决这个问题。
两种方案可以搭配使用,但方案二目前存在无法提取颜色的问题,关键是怎么解决contentView拿不到的问题呢?接下来我们重点解决方案二的这个问题。
问题点
我们在无论使用NotificationBuilder或者NotificationCompatBuilder,其内部的build方法存在targetSdkVersion的判断,而在大于Android 7.0 的版本中,不会立即创建ContentView
java
protected Notification buildInternal() {
if (Build.VERSION.SDK_INT >= 26) {
return mBuilder.build();
} else if (Build.VERSION.SDK_INT >= 24) {
Notification notification = mBuilder.build();
if (mGroupAlertBehavior != GROUP_ALERT_ALL) {
// if is summary and only children should alert
if (notification.getGroup() != null
&& (notification.flags & FLAG_GROUP_SUMMARY) != 0
&& mGroupAlertBehavior == GROUP_ALERT_CHILDREN) {
removeSoundAndVibration(notification);
}
// if is group child and only summary should alert
if (notification.getGroup() != null
&& (notification.flags & FLAG_GROUP_SUMMARY) == 0
&& mGroupAlertBehavior == GROUP_ALERT_SUMMARY) {
removeSoundAndVibration(notification);
}
}
return notification;
} else if (Build.VERSION.SDK_INT >= 21) {
mBuilder.setExtras(mExtras);
Notification notification = mBuilder.build();
if (mContentView != null) {
notification.contentView = mContentView;
}
if (mBigContentView != null) {
notification.bigContentView = mBigContentView;
}
if (mHeadsUpContentView != null) {
notification.headsUpContentView = mHeadsUpContentView;
}
if (mGroupAlertBehavior != GROUP_ALERT_ALL) {
// if is summary and only children should alert
if (notification.getGroup() != null
&& (notification.flags & FLAG_GROUP_SUMMARY) != 0
&& mGroupAlertBehavior == GROUP_ALERT_CHILDREN) {
removeSoundAndVibration(notification);
}
// if is group child and only summary should alert
if (notification.getGroup() != null
&& (notification.flags & FLAG_GROUP_SUMMARY) == 0
&& mGroupAlertBehavior == GROUP_ALERT_SUMMARY) {
removeSoundAndVibration(notification);
}
}
return notification;
} else if (Build.VERSION.SDK_INT >= 20) {
mBuilder.setExtras(mExtras);
Notification notification = mBuilder.build();
if (mContentView != null) {
notification.contentView = mContentView;
}
if (mBigContentView != null) {
notification.bigContentView = mBigContentView;
}
if (mGroupAlertBehavior != GROUP_ALERT_ALL) {
// if is summary and only children should alert
if (notification.getGroup() != null
&& (notification.flags & FLAG_GROUP_SUMMARY) != 0
&& mGroupAlertBehavior == GROUP_ALERT_CHILDREN) {
removeSoundAndVibration(notification);
}
// if is group child and only summary should alert
if (notification.getGroup() != null
&& (notification.flags & FLAG_GROUP_SUMMARY) == 0
&& mGroupAlertBehavior == GROUP_ALERT_SUMMARY) {
removeSoundAndVibration(notification);
}
}
return notification;
} else if (Build.VERSION.SDK_INT >= 19) {
SparseArray<Bundle> actionExtrasMap =
NotificationCompatJellybean.buildActionExtrasMap(mActionExtrasList);
if (actionExtrasMap != null) {
// Add the action extras sparse array if any action was added with extras.
mExtras.putSparseParcelableArray(
NotificationCompatExtras.EXTRA_ACTION_EXTRAS, actionExtrasMap);
}
mBuilder.setExtras(mExtras);
Notification notification = mBuilder.build();
if (mContentView != null) {
notification.contentView = mContentView;
}
if (mBigContentView != null) {
notification.bigContentView = mBigContentView;
}
return notification;
} else if (Build.VERSION.SDK_INT >= 16) {
Notification notification = mBuilder.build();
// Merge in developer provided extras, but let the values already set
// for keys take precedence.
Bundle extras = NotificationCompat.getExtras(notification);
Bundle mergeBundle = new Bundle(mExtras);
for (String key : mExtras.keySet()) {
if (extras.containsKey(key)) {
mergeBundle.remove(key);
}
}
extras.putAll(mergeBundle);
SparseArray<Bundle> actionExtrasMap =
NotificationCompatJellybean.buildActionExtrasMap(mActionExtrasList);
if (actionExtrasMap != null) {
// Add the action extras sparse array if any action was added with extras.
NotificationCompat.getExtras(notification).putSparseParcelableArray(
NotificationCompatExtras.EXTRA_ACTION_EXTRAS, actionExtrasMap);
}
if (mContentView != null) {
notification.contentView = mContentView;
}
if (mBigContentView != null) {
notification.bigContentView = mBigContentView;
}
return notification;
} else {
return mBuilder.getNotification();
}
}
那么我们怎么解决这个问题呢?
Context Wrapper
在App 开发中,Context Wrapper是常见的事情,比如用在预加载Layout、模拟Service运行、插件加载等方面有大量使用。
本文思路是要hack targetSdkVersion,但targetSdkVersion是保存在ApplicationInfo中的,不过没关系,它是通过Context获取的,因此我们在它获取前将其修改为android 5.0的不就行了?
为什么可以修改ApplicationInfo,因为其事Parcelable的子类,看到Parcleable的子类你就能明白,该类的修改是不会触发系统服务的调度,但会影响部分功能,安全起见,我们可以拷贝一下。
java
public class NotificationContext extends ContextWrapper {
private Context mContextBase;
private ApplicationInfo mApplicationInfo;
private NotificationContext(Context base) {
super(base);
this.mContextBase = base;
}
@Override
public ApplicationInfo getApplicationInfo() {
if(mApplicationInfo!=null) return mApplicationInfo;
ApplicationInfo applicationInfo = super.getApplicationInfo();
mApplicationInfo = new ApplicationInfo(applicationInfo);
return mApplicationInfo;
}
public static NotificationContext from(Context context) {
return new NotificationContext(context);
}
}
下一步,修改targetSdkVersion 为android 5.0版本
java
NotificationContext notificationContext = NotificationContext.from(context);
ApplicationInfo applicationInfo = notificationContext.getApplicationInfo();
int targetSdkVersion = applicationInfo.targetSdkVersion;
applicationInfo.targetSdkVersion = Math.min(21, targetSdkVersion);
完整的代码
要获取的属性
ini
class NotificationResourceInfo {
String titleResourceName;
int titleColor;
float titleTextSize;
ViewGroup.LayoutParams titleLayoutParams;
String descResourceName;
int descColor;
float descTextSize;
ViewGroup.LayoutParams descLayoutParams;
long updateTime;
}
获取颜色,用于判断是不是深色模式,这里其实利用的是标记查找方法,先给标题和内容设置Text,然后查找具备此Text的TextView
java
private static String TITLE_TEXT = "APP_TITLE_TEXT";
private static String CONTENT_TEXT = "APP_CONTENT_TEXT";
下面是核心查找逻辑
java
//遍历布局找到字体最大的两个textView,视其为主副标题
private <T extends View> T findView(ViewGroup viewGroupSource, CharSequence locatorTextId) {
Queue<ViewGroup> queue = new ArrayDeque<>();
queue.add(viewGroupSource);
while (!queue.isEmpty()) {
ViewGroup parentGroup = queue.poll();
if (parentGroup == null) {
continue;
}
int childViewCount = parentGroup.getChildCount();
for (int num = 0; num < childViewCount; ++num) {
View childView = parentGroup.getChildAt(num);
String resourceIdName = getResourceIdName(childView.getContext(), childView.getId());
Log.d("NotificationManager", "--" + resourceIdName);
if (TextUtils.equals(resourceIdName, locatorTextId)) {
Log.d("NotificationManager", "findView");
return (T) childView;
}
if (childView instanceof ViewGroup) {
queue.add((ViewGroup) childView);
}
}
}
return null;
}
完成的代码
java
public class NotificationThemeHelper {
private static String TITLE_TEXT = "APP_TITLE_TEXT";
private static String CONTENT_TEXT = "APP_CONTENT_TEXT";
final static String TAG = "NotificationThemeHelper";
static SoftReference<NotificationResourceInfo> notificationInfoReference = null;
private static final String CHANNEL_NOTIFICATION_ID = "CHANNEL_NOTIFICATION_ID";
public NotificationResourceInfo parseNotificationInfo(Context context) {
String channelId = createNotificationChannel(context, CHANNEL_NOTIFICATION_ID, CHANNEL_NOTIFICATION_ID);
NotificationResourceInfo notificationInfo = null;
NotificationContext notificationContext = NotificationContext.from(context);
ApplicationInfo applicationInfo = notificationContext.getApplicationInfo();
int targetSdkVersion = applicationInfo.targetSdkVersion;
try {
applicationInfo.targetSdkVersion = Math.min(21, targetSdkVersion);
//更改版本号,这样可以让builder自行创建contentview
NotificationCompat.Builder builder = new NotificationCompat.Builder(notificationContext, channelId);
builder.setContentTitle(TITLE_TEXT);
builder.setContentText(CONTENT_TEXT);
int icon = context.getApplicationInfo().icon;
builder.setSmallIcon(icon);
Notification notification = builder.build();
if (notification.contentView == null) {
return null;
}
int layoutId = notification.contentView.getLayoutId();
ViewGroup root = (ViewGroup) LayoutInflater.from(context).inflate(layoutId, null);
notificationInfo = getNotificationInfo(notificationContext, root);
} catch (Exception e) {
Log.d(TAG, "更新失败");
} finally {
applicationInfo.targetSdkVersion = targetSdkVersion;
}
return notificationInfo;
}
private NotificationResourceInfo getNotificationInfo(Context Context, ViewGroup root) {
NotificationResourceInfo resourceInfo = new NotificationResourceInfo();
root.measure(0,0);
root.layout(0,0,root.getMeasuredWidth(),root.getMeasuredHeight());
Log.i(TAG,"bitmap ok");
TextView titleTextView = (TextView) root.findViewById(android.R.id.title);
if (titleTextView == null) {
titleTextView = findView(root, "android:id/title");
}
if (titleTextView != null) {
resourceInfo.titleColor = titleTextView.getCurrentTextColor();
resourceInfo.titleResourceName = getResourceIdName(Context, titleTextView.getId());
resourceInfo.titleTextSize = titleTextView.getTextSize();
resourceInfo.titleLayoutParams = titleTextView.getLayoutParams();
}
TextView contentTextView = findView(root, "android:id/text");
if (contentTextView != null) {
resourceInfo.descColor = contentTextView.getCurrentTextColor();
resourceInfo.descResourceName = getResourceIdName(Context, contentTextView.getId());
resourceInfo.descTextSize = contentTextView.getTextSize();
resourceInfo.descLayoutParams = contentTextView.getLayoutParams();
}
return resourceInfo;
}
//遍历布局找到字体最大的两个textView,视其为主副标题
private <T extends View> T findView(ViewGroup viewGroupSource, CharSequence locatorTextId) {
Queue<ViewGroup> queue = new ArrayDeque<>();
queue.add(viewGroupSource);
while (!queue.isEmpty()) {
ViewGroup parentGroup = queue.poll();
if (parentGroup == null) {
continue;
}
int childViewCount = parentGroup.getChildCount();
for (int num = 0; num < childViewCount; ++num) {
View childView = parentGroup.getChildAt(num);
String resourceIdName = getResourceIdName(childView.getContext(), childView.getId());
Log.d("NotificationManager", "--" + resourceIdName);
if (TextUtils.equals(resourceIdName, locatorTextId)) {
Log.d("NotificationManager", "findView");
return (T) childView;
}
if (childView instanceof ViewGroup) {
queue.add((ViewGroup) childView);
}
}
}
return null;
}
public boolean isDarkNotificationTheme(Context context) {
NotificationResourceInfo notificationInfo = getNotificationInfoFromReference();
if (notificationInfo == null) {
notificationInfo = parseNotificationInfo(context);
saveNotificationInfoToReference(notificationInfo);
}
if (notificationInfo == null) {
return isLightColor(Color.TRANSPARENT);
}
return !isLightColor(notificationInfo.titleColor);
}
private void saveNotificationInfoToReference(NotificationResourceInfo notificationInfo) {
if (notificationInfoReference != null) {
notificationInfoReference.clear();
}
if (notificationInfo == null) return;
notificationInfo.updateTime = SystemClock.elapsedRealtime();
notificationInfoReference = new SoftReference<NotificationResourceInfo>(notificationInfo);
}
private boolean isLightColor(int color) {
int simpleColor = color | 0xff000000;
int baseRed = Color.red(simpleColor);
int baseGreen = Color.green(simpleColor);
int baseBlue = Color.blue(simpleColor);
double value = (baseRed * 0.299 + baseGreen * 0.587 + baseBlue * 0.114);
if (value < 192.0) {
Log.d("ColorInfo", "亮色");
return true;
}
Log.d("ColorInfo", "深色");
return false;
}
public NotificationResourceInfo getNotificationInfoFromReference() {
if (notificationInfoReference == null) {
return null;
}
NotificationResourceInfo resourceInfo = notificationInfoReference.get();
if (resourceInfo == null) {
return null;
}
long dx = SystemClock.elapsedRealtime() - resourceInfo.updateTime;
if (dx > 10 * 1000) {
return null;
}
return resourceInfo;
}
public static String getResourceIdName(Context context, int id) {
Resources r = context.getResources();
StringBuilder out = new StringBuilder();
if (id > 0 && resourceHasPackage(id) && r != null) {
try {
String pkgName;
switch (id & 0xff000000) {
case 0x7f000000:
pkgName = "app";
break;
case 0x01000000:
pkgName = "android";
break;
default:
pkgName = r.getResourcePackageName(id);
break;
}
String typeName = r.getResourceTypeName(id);
String entryName = r.getResourceEntryName(id);
out.append(pkgName);
out.append(":");
out.append(typeName);
out.append("/");
out.append(entryName);
} catch (Resources.NotFoundException e) {
}
}
return out.toString();
}
private String createNotificationChannel (Context context,String channelID, String channelNAME){
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
NotificationManager manager = (NotificationManager)context. getSystemService(NOTIFICATION_SERVICE);
NotificationChannel channel = new NotificationChannel(channelID, channelNAME, NotificationManager.IMPORTANCE_LOW);
manager.createNotificationChannel(channel);
return channelID;
} else {
return null;
}
}
public static boolean resourceHasPackage(int resid) {
return (resid >>> 24) != 0;
}
}
深浅色判断其实有两种方法,第一种是305911公式,第二种是相似度。
下面是305911公式的,其实就是利用视频亮度算法YUV中的Y分量计算,Y分量表示明亮度。
ini
private boolean isLightColor(int color) {
int simpleColor = color | 0xff000000;
int baseRed = Color.red(simpleColor);
int baseGreen = Color.green(simpleColor);
int baseBlue = Color.blue(simpleColor);
double value = (baseRed * 0.299 + baseGreen * 0.587 + baseBlue * 0.114);
if (value < 192.0) {
Log.d("ColorInfo", "亮色");
return true;
}
Log.d("ColorInfo", "深色");
return false;
}
第二种是相似度算法,一般用于检索相似照片,一般用于优化汉明距离算法,不过这里可以用来判断是否接近黑色。 blog.csdn.net/zz_dd_yy/ar...
java
private boolean isSimilarColor(int colorL, int colorR) {
int red = Color.red(colorL);
int green = Color.green(colorL);
int blue = Color.blue(colorL);
int red2 = Color.red(colorR);
int green2 = Color.green(colorR);
int blue2 = Color.blue(colorR);
float vertor = red * red2 + green * green2 + blue * blue2;
// 向量1的模
double vectorMold1 = Math.sqrt(Math.pow(red, 2) + Math.pow(green, 2) + Math.pow(blue, 2));
// 向量2的模
double vectorMold2 = Math.sqrt(Math.pow(red2, 2) + Math.pow(green2, 2) + Math.pow(blue2, 2));
// 向量的夹角[0, PI],当夹角为锐角时,cosθ>0;当夹角为钝角时,cosθ<0
float cosAngle = (float) (vertor / (vectorMold1 * vectorMold2));
float radian = (float) Math.acos(cosAngle);
float degrees = (float) Math.toDegrees(radian);
if(degrees>= 0 && degrees < 30) {
return true;
}
return false;
}
用法
这种适配其实无法拿到背景色,只能拿到文字的颜色,如果文字偏亮则背景必须的是深色,反之区亮色,那么核心方法是下面的实现
java
public boolean isDarkNotificationTheme(Context context) {
NotificationResourceInfo notificationInfo = getNotificationInfoFromReference();
if (notificationInfo == null) {
notificationInfo = parseNotificationInfo(context);
saveNotificationInfoToReference(notificationInfo);
}
if (notificationInfo == null) {
return isLightColor(Color.TRANSPARENT);
}
return !isLightColor(notificationInfo.titleColor);
}
遗留问题
正常情况下,只能取深色和暗色,但是如果存在系统UI Mode的变化时,已经展示出来的通知,显然适配颜色无法动态变化,这也是无法避免的,解决办法是删除通知后重新发送。
总结
本篇到这里就结束了,说实在的,Android的通知的重要性大不如从前,但是必要的适配还是需要的。