Android 通知文本颜色获取

前言

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的通知的重要性大不如从前,但是必要的适配还是需要的。

相关推荐
CYRUS STUDIO6 分钟前
ARM64汇编寻址、汇编指令、指令编码方式
android·汇编·arm开发·arm·arm64
风影小子8 分钟前
注册登录学生管理系统小项目
算法
黑龙江亿林等保10 分钟前
深入探索哈尔滨二级等保下的负载均衡SLB及其核心算法
运维·算法·负载均衡
lucy1530275107913 分钟前
【青牛科技】GC5931:工业风扇驱动芯片的卓越替代者
人工智能·科技·单片机·嵌入式硬件·算法·机器学习
四喜花露水28 分钟前
Vue 自定义icon组件封装SVG图标
前端·javascript·vue.js
杜杜的man29 分钟前
【go从零单排】迭代器(Iterators)
开发语言·算法·golang
前端Hardy37 分钟前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
小沈熬夜秃头中୧⍤⃝1 小时前
【贪心算法】No.1---贪心算法(1)
算法·贪心算法
weixin_449310841 小时前
高效集成:聚水潭采购数据同步到MySQL
android·数据库·mysql
Zender Han1 小时前
Flutter自定义矩形进度条实现详解
android·flutter·ios