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

相关推荐
蓝婷儿4 分钟前
第二章支线八 ·CSS终式:Tailwind与原子风暴
前端·css
Shujie_L4 分钟前
【Android基础回顾】五:AMS(Activity Manager Service)
android
老歌老听老掉牙9 分钟前
使用 SymPy 进行向量和矩阵的高级操作
python·线性代数·算法·矩阵·sympy
我又来搬代码了10 分钟前
【Android】Android Studio项目代码异常错乱问题处理(2020.3版本)
android·ide·android studio
lifallen18 分钟前
Flink checkpoint
java·大数据·算法·flink
vanora111128 分钟前
Vue在线预览excel、word、ppt等格式数据。
前端·javascript·vue.js
树上有只程序猿30 分钟前
低代码不是炫技,而是回归需求的必然答案
前端
比特森林探险记32 分钟前
Go 中的 Map 与字符处理指南
c++·算法·golang
比特森林探险记34 分钟前
Go 中 map 的双值检测写法详解
java·前端·golang
溪饱鱼36 分钟前
React源码阅读-fiber核心构建原理
前端·javascript·react.js