注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

通知栏的那些奇技淫巧

一、问题的由来 前几天,一个网友在微信群提了一个问题: 通知栏监听模拟点击如何实现? 我以为业务情景是在自己应用内,防止脚本模拟点击而引申出来的一个需求,心里还在想,是否可以使用自定义View——onTouchEvent的参数MotionEvent的ge...
继续阅读 »

一、问题的由来




前几天,一个网友在微信群提了一个问题:



通知栏监听模拟点击如何实现?



我以为业务情景是在自己应用内,防止脚本模拟点击而引申出来的一个需求,心里还在想,是否可以使用自定义View——onTouchEvent的参数MotionEventgetPressure来判断是否是模拟点击。后来经过沟通得知,业务需求是如何监听第三方应用的通知栏,实现具体按钮的点击。如下图:


通知栏.jpg


上面是多家音频应用的通知栏在小米手机的样式,而网友的需求是如何实现针对某一个应用通知栏的某一个按钮的点击,比如监听喜马拉雅APP,当接收到通知的时候,需要点击关闭按钮。这个需求该如何接住呢?


二、实现方案之无障碍服务




当需求清晰以后,我心里面想到的第一个方案就是无障碍服务。但是无障碍服务点击通知栏简单,点击通知栏的某一个控件需要打开通知栏,然后找到这个控件的id,然后调用点击方法。同时由于几年前有过写抢红包脚本的经验,提出了一些疑问:



  • 用户使用的业务情景是什么?是否需要正规渠道上架?

  • 无障碍服务权限相当敏感,是否接受交出权限的选择?


沟通结果是正规渠道上架和业务情景不用考虑,但是权限的敏感需要换一个思路。网友指出,NotificationListenerService可以实现监听通知栏,能否在这个地方想点办法呢?而且他还提到一个业务情景:当收到通知的时候,不需要用户打开通知栏列表,不管用户在系统桌面,还是第三方应用页面,均需要实现点击具体按钮的操作。
虽然我此时对NotificationListenerService不熟悉,但是一听到这个反常识的操作,我顿时觉得不现实,至少是需要一些黑科技才能在部分设备实现这个效果。因为操作UI需要在主线程,但是系统当前的主线程可能在其它进程,所以我觉得这个地方反常识了!


三、实现方案之通知监听服务




由于上面的沟通过程因为我不熟悉 NotificationListenerService导致我battle的时候都不敢大声说话,因此我决定去熟悉一下,然后我看到了黄老师的这篇 Android通知监听服务之NotificationListenerService使用篇


看到黄老师实现微信抢红包以后,我也心动了,既然黄老师可以抢红包,那么是不是我也可以抢他的红包?于是就开始了踩坑之旅。


3.1 通知栏的那些事


我们知道,通知栏的显示、刷新、关闭都是依赖于Notification来实现,而通知栏的UI要么是依托系统主题实现,要么是通过自定义RemoteViews实现,而UI的交互则是通过PendingIntent包装的Intent来实现具体的意图。


// 通知栏的`UI`依托系统主题实现
NotificationCompat.Builder(context, Notification.CHANNEL_ID)
.setStyle(androidx.media.app.NotificationCompat.MediaStyle()
// show only play/pause in compact view
.setShowActionsInCompactView(playPauseButtonPosition)
.setShowCancelButton(true)
.setCancelButtonIntent(mStopIntent)
.setMediaSession(mediaSession)
)
.setDeleteIntent(mStopIntent)
.setColorized(true)
.setSmallIcon(smallIcon)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true)
.setContentTitle(songInfo?.songName) //歌名
.setContentText(songInfo?.artist) //艺术家
.setLargeIcon(art)


/**
* 创建RemoteViews
*/

private fun createRemoteViews(isBigRemoteViews: Boolean): RemoteViews {
val remoteView: RemoteViews = if (isBigRemoteViews) {
RemoteViews(packageName, LAYOUT_NOTIFY_BIG_PLAY.getResLayout())
} else {
RemoteViews(packageName, LAYOUT_NOTIFY_PLAY.getResLayout())
}
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_PLAY.getResId(), playIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_PAUSE.getResId(), pauseIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_STOP.getResId(), stopIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_FAVORITE.getResId(), favoriteIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_LYRICS.getResId(), lyricsIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_DOWNLOAD.getResId(), downloadIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_NEXT.getResId(), nextIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_PRE.getResId(), previousIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_CLOSE.getResId(), closeIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_PLAY_OR_PAUSE.getResId(), playOrPauseIntent)
return remoteView
}

// 通过自定义`RemoteViews`实现
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
notificationBuilder
.setOnlyAlertOnce(true)
.setSmallIcon(smallIcon)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentTitle(songInfo?.songName) //歌名
.setContentText(songInfo?.artist) //艺术家

1. StatusBarNotification的逆向之旅


有了上面的了解,那么我们可以考虑通过Notification来获取PendingIntent,实现通知栏模拟点击的效果。
通过NotificationListenerService的回调方法,我们可以获得StatusBarNotification,源码如下:


override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
}

接下来,我们需要从这个地方开始,抽丝剥茧般地一步一步找到我们想要的PendingIntent
先观察一下StatusBarNotification的源码:


@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) 
private final Notification notification;

public StatusBarNotification(String pkg, String opPkg, int id,
String tag, int uid, int initialPid, Notification notification, UserHandle user,
String overrideGr0upKey, long postTime) {
if (pkg == null) throw new NullPointerException();
if (notification == null) throw new NullPointerException();

this.pkg = pkg;
this.opPkg = opPkg;
this.id = id;
this.tag = tag;
this.uid = uid;
this.initialPid = initialPid;
this.notification = notification;
this.user = user;
this.postTime = postTime;
this.overrideGr0upKey = overrideGr0upKey;
this.key = key();
this.groupKey = groupKey();
}

/**
* The {@link android.app.Notification} supplied to
* {@link android.app.NotificationManager#notify(int, Notification)}.
*/
public Notification getNotification() {
return notification;
}

这里我们可以直接获取到Notification这个对象,然后我们继续观察源码,


/**
* The view that will represent this notification in the notification list (which is pulled
* down from the status bar).
*
* As of N, this field may be null. The notification view is determined by the inputs
* to {@link Notification.Builder}; a custom RemoteViews can optionally be
* supplied with {@link Notification.Builder#setCustomContentView(RemoteViews)}.
*/

@Deprecated
public RemoteViews contentView;

虽然这个contentView已经标记为不建议使用了,但是我们可以先尝试跑通流程。然后再将这个思路拓展到非自定义RemoteViews的流程。
经过测试,这里我们已经可以获取到RemoteViews了。按照惯例,这里我们需要继续观察RemoteViews的源码,从设置点击事件开始:


public void setOnClickPendingIntent(@IdRes int viewId, PendingIntent pendingIntent) {
setOnClickResponse(viewId, RemoteResponse.fromPendingIntent(pendingIntent));
}
// 👇
public static class RemoteResponse {
private PendingIntent mPendingIntent;
public static RemoteResponse fromPendingIntent(@NonNull PendingIntent pendingIntent) {
RemoteResponse response = new RemoteResponse();
response.mPendingIntent = pendingIntent;
return response;
}

}
// 👆
public void setOnClickResponse(@IdRes int viewId, @NonNull RemoteResponse response) {
addAction(new SetOnClickResponse(viewId, response));
}


// 响应事件 👆
private class SetOnClickResponse extends Action {

SetOnClickResponse(@IdRes int id, RemoteResponse response) {
this.viewId = id;
this.mResponse = response;
}

SetOnClickResponse(Parcel parcel) {
viewId = parcel.readInt();
mResponse = new RemoteResponse();
mResponse.readFromParcel(parcel);
}

public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(viewId);
mResponse.writeToParcel(dest, flags);
}

@Override
public void apply(View root, ViewGr0up rootParent, final InteractionHandler handler,
ColorResources colorResources)
{
final View target = root.findViewById(viewId);
if (target == null) return;

if (mResponse.mPendingIntent != null) {
// If the view is an AdapterView, setting a PendingIntent on click doesn't make
// much sense, do they mean to set a PendingIntent template for the
// AdapterView's children?
if (hasFlags(FLAG_WIDGET_IS_COLLECTION_CHILD)) {
Log.w(LOG_TAG, "Cannot SetOnClickResponse for collection item "
+ "(id: " + viewId + ")");
ApplicationInfo appInfo = root.getContext().getApplicationInfo();

// We let this slide for HC and ICS so as to not break compatibility. It should
// have been disabled from the outset, but was left open by accident.
if (appInfo != null
&& appInfo.targetSdkVersion >= Build.VERSION_CODES.JELLY_BEAN) {
return;
}
}
target.setTagInternal(R.id.pending_intent_tag, mResponse.mPendingIntent);
} else if (mResponse.mFillIntent != null) {
if (!hasFlags(FLAG_WIDGET_IS_COLLECTION_CHILD)) {
Log.e(LOG_TAG, "The method setOnClickFillInIntent is available "
+ "only from RemoteViewsFactory (ie. on collection items).");
return;
}
if (target == root) {
// Target is a root node of an AdapterView child. Set the response in the tag.
// Actual click handling is done by OnItemClickListener in
// SetPendingIntentTemplate, which uses this tag information.
target.setTagInternal(com.android.internal.R.id.fillInIntent, mResponse);
return;
}
} else {
// No intent to apply, clear the listener and any tags that were previously set.
target.setOnClickListener(null);
target.setTagInternal(R.id.pending_intent_tag, null);
target.setTagInternal(com.android.internal.R.id.fillInIntent, null);
return;
}
target.setOnClickListener(v -> mResponse.handleViewInteraction(v, handler));
}

@Override
public int getActionTag() {
return SET_ON_CLICK_RESPONSE_TAG;
}

final RemoteResponse mResponse;
}




private void addAction(Action a) {
if (hasMultipleLayouts()) {
throw new RuntimeException("RemoteViews specifying separate layouts for orientation"
+ " or size cannot be modified. Instead, fully configure each layouts"
+ " individually before constructing the combined layout.");
}
if (mActions == null) {
mActions = new ArrayList<>();
}
mActions.add(a);
}


上面代码有点多,我画个图方便大家理解:


未命名文件.jpg


至此,我们就知道了PendingIntent的藏身之处了!
通过反射,正常情况下我们就能拿到所有属于SetOnClickResponse#PendingIntent了,上代码:


override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
sbn?:return
if(sbn.packageName == "com.***.******"){
// 获取通知
val cls = sbn.notification.contentView.javaClass
// 点击事件容器
val field = cls.getDeclaredField("mActions")
field.isAccessible = true
// 点击事件容器对象
val result = field.get(sbn.notification.contentView)
// 强转
(result as? ArrayList<Any>?)?.let { list ->
// 筛选点击事件的实现类集合
// 此处需要判断具体的点击事件
list.filter { item -> item.javaClass.simpleName == "SetOnClickResponse" }.first().let { item ->
// 获取响应对象
val response = item.javaClass.getDeclaredField("mResponse")
response.isAccessible = true
// 强转
(response.get(item) as? RemoteViews.RemoteResponse)?.let { remoteResponse ->
// 获取PendingIntent
val intentField = remoteResponse.javaClass.getDeclaredField("mPendingIntent")
intentField.isAccessible = true
val target = intentField.get(remoteResponse) as PendingIntent
Log.e("NotificationMonitorService","最终目标:${Gson().toJson(target)}")
}

}
}

}

}

2. 反射的拦路鬼——Android平台限制对非SDK接口的调用


不出意外的还是有了意外,明明反射的字段存在,就是反射获取不到。


反射失败.png
就在一筹莫展之际,有朋友提出了一个思路——针对非SDK接口的限制。然后经过查询,果然是反射失败的罪魁祸首!


WechatIMG889.png
既然确诊了病症,那么就可以开始开方抓药了!
根据轮子bypassHiddenApiRestriction绕过 Android 9以上非SDK接口调用限制的方法,我们成功的获取到了PendingIntent.


override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
sbn?:return
if(sbn.packageName == "com.lzx.starrysky"){
// 获取通知
val cls = sbn.notification.contentView.javaClass
// 点击事件容器
val field = cls.getDeclaredField("mActions")
field.isAccessible = true
// 点击事件容器对象
val result = field.get(sbn.notification.contentView)
// 强转
(result as? ArrayList<Any>?)?.let { list ->
// 筛选点击事件的实现类集合
// 此处需要判断具体的点击事件
list.filter { item -> item.javaClass.simpleName == "SetOnClickResponse" }.forEach { item ->
// 获取响应对象
val response = item.javaClass.getDeclaredField("mResponse")
response.isAccessible = true
// 强转
(response.get(item) as? RemoteViews.RemoteResponse)?.let { remoteResponse ->
// 获取PendingIntent
val intentField = remoteResponse.javaClass.getDeclaredField("mPendingIntent")
intentField.isAccessible = true
val target = intentField.get(remoteResponse) as PendingIntent
Log.e("NotificationMonitorService","最终目标:${Gson().toJson(target)}")
}

}
}

}

}

WechatIMG892.jpeg
这里的筛选结果有十几个点击事件的响应对象,我们需要做的就是一个一个的去尝试,找到那个目标对象的pendingIntent,通过调用send方法就可以实现模拟点击的效果了!


...
// 获取PendingIntent
val intentField = remoteResponse.javaClass.getDeclaredField("mPendingIntent")
intentField.isAccessible = true
val target = intentField.get(remoteResponse) as PendingIntent
Log.e("NotificationMonitorService","最终目标:${Gson().toJson(target)}")
// 延迟实现点击功能
Handler(Looper.getMainLooper()).postDelayed({
target.send()
},500)

总结




综上,如果第三方应用的通知栏UI是自定义View的话,那么这里的方案是可以直接使用;如果第三方应用的通知栏UI使用的是系统主题,那么按照这个思路应该也可以通过反射实现。
步骤如下:





    1. 接入第三方轮子bypassHiddenApiRestriction(PS:远程依赖的时候使用并未成功,我将项目clone下来打包为aar,导入项目后使用正常!),并初始化:




HiddenApiBypass.startBypass()




    1. AndroidManifest中注册NotificationListenerService,然后启动服务




private fun startService(){
if (NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)){
val intent = Intent(this,NotificationMonitorService::class.java)
startService(intent)
}else{
startActivity(Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"))
}

}


  • 3.在NotificationListenerService监听通知栏


override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
sbn?:return
if(sbn.packageName == "com.***.******"){
// 获取通知
val cls = sbn.notification.contentView.javaClass
// 点击事件容器
val field = cls.getDeclaredField("mActions")
field.isAccessible = true
// 点击事件容器对象
val result = field.get(sbn.notification.contentView)
// 强转
(result as? ArrayList<Any>?)?.let { list ->
// 筛选点击事件的实现类集合
// 此处需要判断具体的点击事件
list.filter { item -> item.javaClass.simpleName == "SetOnClickResponse" }.first().let { item ->
// 获取响应对象
val response = item.javaClass.getDeclaredField("mResponse")
response.isAccessible = true
// 强转
(response.get(item) as? RemoteViews.RemoteResponse)?.let { remoteResponse ->
// 获取PendingIntent
val intentField = remoteResponse.javaClass.getDeclaredField("mPendingIntent")
intentField.isAccessible = true
val target = intentField.get(remoteResponse) as PendingIntent
Log.e("NotificationMonitorService","最终目标:${Gson().toJson(target)}")
// 延迟实现点击功能
Handler(Looper.getMainLooper()).postDelayed({
target.send()
},500)
}

}
}

}

}

参考:


Android通知监听服务之NotificationListenerService使用篇


另一种绕过Android 9以上非SDK接口调用限制的方法


作者:苏灿烤鱼
来源:juejin.cn/post/7190280650283778106
收起阅读 »

触摸Android的心脏跳动

在Android开发中,主线程扮演着至关重要的角色。毫不夸张的说,它就相当于Android的心脏。只要它还在跳动的运行,Android应用就不会终止。 它负责处理UI事件、界面更新、以及与用户交互的各种操作。本文将深入分析Android主线程的原理、独特机制以...
继续阅读 »

在Android开发中,主线程扮演着至关重要的角色。毫不夸张的说,它就相当于Android的心脏。只要它还在跳动的运行,Android应用就不会终止。


它负责处理UI事件、界面更新、以及与用户交互的各种操作。本文将深入分析Android主线程的原理、独特机制以及应用,为开发者提供全面的了解和掌握主线程的知识。


主线程的原理


Android应用的核心原则之一是单线程模型,也就是说,大多数与用户界面相关的操作都必须在主线程中执行。这一原则的背后是Android操作系统的设计,主要有以下几个原因:




  • UI一致性:在单线程模型下,UI操作不会被多线程竞争导致的不一致性问题,确保了用户界面的稳定性和一致性。




  • 性能优化:单线程模型简化了线程管理,降低了多线程带来的复杂性,有助于提高应用性能。




  • 安全性:通过将UI操作限制在主线程,可以减少因多线程竞争而引发的潜在问题,如死锁和竞争条件。




主线程的原理可以用以下伪代码表示:


public class MainThread {
public static void main(String[] args) {
// 初始化应用
Application app = createApplication();

// 创建主线程消息循环
Looper.prepareMainLooper();

// 启动主线程
while (true) {
Message msg = Looper.getMainLooper().getNextMessage();
if (msg != null) {
// 处理消息
app.handleMessage(msg);
}
}
}
}

在上述伪代码中,主线程通过消息循环(Message Loop)来不断处理消息,这些消息通常包括UI事件、定时任务等。应用的UI操作都会被封装成消息,然后由主线程依次处理。


主线程的独特机制


主线程有一些独特的机制,其中最重要的是消息队列(Message Queue)和Handler。


消息队列(Message Queue)


消息队列是主线程用来存储待处理消息的数据结构。每个消息都有一个与之相关的Handler,它负责将消息放入队列中,然后由主线程依次处理。消息队列的机制确保了消息的有序性和及时性。


public Message next() {
final long ptr = mPtr;
if (ptr == 0) {
return null;
}

int pendingIdleHandlerCount = -1;
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}

nativePollOnce(ptr, nextPollTimeoutMillis);

synchronized (this) {
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
if (msg != null && msg.target == null) {
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// Got a message.
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
msg.markInUse();
return msg;
}
} else {
nextPollTimeoutMillis = -1;
}

...
}

...
}
}

Handler


Handler是一个与特定线程关联的对象,它可以用来发送和处理消息。在主线程中,通常使用new Handler(Looper.getMainLooper())来创建一个与主线程关联的Handler。开发者可以使用Handler来将任务提交到主线程的消息队列中。


Handler handler = new Handler(Looper.getMainLooper());
handler.post(new Runnable() {
@Override
public void run() {
// 在主线程执行
}
});

同步屏障


在Android中,消息可以分为同步消息和异步消息。通常,我们发送的消息都是同步消息。
然而,有一种特殊情况,即开启同步屏障。同步屏障是一种消息机制的特性,可以阻止同步消息的处理,只允许异步消息通过。通过调用MessageQueue的postSyncBarrier()方法,可以开启同步屏障。在开启同步屏障后,发送的这条消息它的target为null。


    private int postSyncBarrier(long when) {
synchronized (this) {
final int token = mNextBarrierToken++;
final Message msg = Message.obtain();
msg.markInUse();
// 没有设置target,target为null
msg.when = when;
msg.arg1 = token;

Message prev = null;
Message p = mMessages;
if (when != 0) {
while (p != null && p.when <= when) {
prev = p;
p = p.next;
}
}
if (prev != null) { // invariant: p == prev.next
msg.next = p;
prev.next = msg;
} else {
msg.next = p;
mMessages = msg;
}
return token;
}
}

那么,开启同步屏障后,所谓的异步消息又是如何被处理的呢?
我们又可以回到之前MessageQueue中的next方法了


public Message next() {
// 省略部分代码,只体现出来同步屏障的代码
...
for (;;) {
...

synchronized (this) {
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
//注意这里,开始出来同步屏障
//如果target==null,认为它就是屏障,进行循环遍历,直到找到第一个异步的消息
if (msg != null && msg.target == null) {
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}

...
}

...
}
}

所以同步屏障是会让消息顺序进行调整,让其忽略现有的同步消息,来直接处理临近的异步消息。
现在听起来已经知道了同步屏障的作用,但它的实际应用又有哪些呢?


应用场景


虽然在日常应用开发中,同步屏障的使用频率较低,但在Android系统源码中,同步屏障的使用场景非常重要。一个典型的使用场景是在UI更新时,例如在View的绘制、布局调整、刷新等操作中,系统会开启同步屏障,以确保与UI相关的异步消息得到优先处理。当UI更新完成后,同步屏障会被移除,允许后续的同步消息得以处理。


对应的是ViewRootImpl#scheduleTraversals()


    void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
// 设置同步屏障
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

void unscheduleTraversals() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
// 移除同步屏障
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
mChoreographer.removeCallbacks(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
}
}


经典问题


Android 主线程的消息循环是通过 LooperHandler 来实现的。以下是一段伪代码示例:


// 创建一个 Looper,关联当前线程
Looper.prepare();
Looper loop = Looper.myLooper();

// 创建一个 Handler,它将和当前 Looper 关联
Handler handler = new Handler();

// 进入消息循环
Looper.loop();

开启loop后的核心代码如下:


    public static void loop() {
final Looper me = myLooper();
...
for (;;) {
if (!loopOnce(me, ident, thresholdOverride)) {
return;
}
}
}

private static boolean loopOnce(final Looper me,
final long ident, final int thresholdOverride)
{
// 注意没消息会被阻塞,进入休眠状态
Message msg = me.mQueue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return false;
}

...

try {
msg.target.dispatchMessage(msg);
if (observer != null) {
observer.messageDispatched(token, msg);
}
dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
} catch (Exception exception) {
if (observer != null) {
observer.dispatchingThrewException(token, msg, exception);
}
throw exception;
} finally {
ThreadLocalWorkSource.restore(origWorkSource);
if (traceTag != 0) {
Trace.traceEnd(traceTag);
}
}
...
msg.recycleUnchecked();

return true;
}


在这段示例中,主线程的消息循环被启动,它会等待来自消息队列的消息。有了这个基础下面的问题就简单了:




  1. 为什么主线程不会陷入无限循环?


    主线程的消息循环不会陷入无限循环,因为它不断地从消息队列中获取消息并处理它们。如果没有消息要处理,消息循环会进入休眠状态,不会持续消耗 CPU 资源。只有在有新消息到达时,主线程才会被唤醒来处理这些消息。这个机制确保主线程能够响应用户的操作,而不陷入死循环。




  2. 如果没有消息,主线程会如何处理?


    如果消息队列为空,主线程的消息循环会等待,直到有新消息到达。在等待期间,它不会执行任何操作,也不会陷入循环。这是因为 Android 的消息循环是基于事件驱动的,只有当有事件(消息)到达时,才会触发主线程执行相应的处理代码。当新消息被投递到消息队列后,主线程会被唤醒,执行相应的处理操作,然后再次进入等待状态。




这种事件驱动的消息循环机制使得 Android 应用能够高效地管理用户交互和异步操作,同时保持了响应性和低能耗。所以,主线程不会陷入无限循环,而是在需要处理事件时才会执行相应的代码。


结论


Android主线程是应用的核心,负责处理UI事件、界面更新和定时任务等。了解主线程的原理和独特机制是Android开发的关键,它有助于确保应用的稳定性和性能。通过消息队列和Handler,开发者可以在主线程中安全地处理各种任务,提供流畅的用户体验。


推荐


android_startup: 提供一种在应用启动时能够更加简单、高效的方式来初始化组件,优化启动速度。不仅支持Jetpack App Startup的全部功能,还提供额外的同步与异步等待、线程控制与多进程支持等功能。


AwesomeGithub: 基于Github的客户端,纯练习项目,支持组件化开发,支持账户密码与认证登陆。使用Kotlin语言进行开发,项目架构是基于JetPack&DataBinding的MVVM;项目中使用了Arouter、Retrofit、Coroutine、Glide、Dagger与Hilt等流行开源技术。


flutter_github: 基于Flutter的跨平台版本Github客户端,与AwesomeGithub相对应。


android-api-analysis: 结合详细的Demo来全面解析Android相关的知识点, 帮助读者能够更快的掌握与理解所阐述的要点。


daily_algorithm: 每日一算法,由浅入深,欢迎加入一起共勉。


作者:午后一小憩
来源:juejin.cn/post/7296692742876758027
收起阅读 »

触摸Android的心脏跳动

在Android开发中,主线程扮演着至关重要的角色。毫不夸张的说,它就相当于Android的心脏。只要它还在跳动的运行,Android应用就不会终止。 它负责处理UI事件、界面更新、以及与用户交互的各种操作。本文将深入分析Android主线程的原理、独特机制以...
继续阅读 »

在Android开发中,主线程扮演着至关重要的角色。毫不夸张的说,它就相当于Android的心脏。只要它还在跳动的运行,Android应用就不会终止。


它负责处理UI事件、界面更新、以及与用户交互的各种操作。本文将深入分析Android主线程的原理、独特机制以及应用,为开发者提供全面的了解和掌握主线程的知识。


主线程的原理


Android应用的核心原则之一是单线程模型,也就是说,大多数与用户界面相关的操作都必须在主线程中执行。这一原则的背后是Android操作系统的设计,主要有以下几个原因:




  • UI一致性:在单线程模型下,UI操作不会被多线程竞争导致的不一致性问题,确保了用户界面的稳定性和一致性。




  • 性能优化:单线程模型简化了线程管理,降低了多线程带来的复杂性,有助于提高应用性能。




  • 安全性:通过将UI操作限制在主线程,可以减少因多线程竞争而引发的潜在问题,如死锁和竞争条件。




主线程的原理可以用以下伪代码表示:


public class MainThread {
public static void main(String[] args) {
// 初始化应用
Application app = createApplication();

// 创建主线程消息循环
Looper.prepareMainLooper();

// 启动主线程
while (true) {
Message msg = Looper.getMainLooper().getNextMessage();
if (msg != null) {
// 处理消息
app.handleMessage(msg);
}
}
}
}

在上述伪代码中,主线程通过消息循环(Message Loop)来不断处理消息,这些消息通常包括UI事件、定时任务等。应用的UI操作都会被封装成消息,然后由主线程依次处理。


主线程的独特机制


主线程有一些独特的机制,其中最重要的是消息队列(Message Queue)和Handler。


消息队列(Message Queue)


消息队列是主线程用来存储待处理消息的数据结构。每个消息都有一个与之相关的Handler,它负责将消息放入队列中,然后由主线程依次处理。消息队列的机制确保了消息的有序性和及时性。


public Message next() {
final long ptr = mPtr;
if (ptr == 0) {
return null;
}

int pendingIdleHandlerCount = -1;
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}

nativePollOnce(ptr, nextPollTimeoutMillis);

synchronized (this) {
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
if (msg != null && msg.target == null) {
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// Got a message.
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
msg.markInUse();
return msg;
}
} else {
nextPollTimeoutMillis = -1;
}

...
}

...
}
}

Handler


Handler是一个与特定线程关联的对象,它可以用来发送和处理消息。在主线程中,通常使用new Handler(Looper.getMainLooper())来创建一个与主线程关联的Handler。开发者可以使用Handler来将任务提交到主线程的消息队列中。


Handler handler = new Handler(Looper.getMainLooper());
handler.post(new Runnable() {
@Override
public void run() {
// 在主线程执行
}
});

同步屏障


在Android中,消息可以分为同步消息和异步消息。通常,我们发送的消息都是同步消息。
然而,有一种特殊情况,即开启同步屏障。同步屏障是一种消息机制的特性,可以阻止同步消息的处理,只允许异步消息通过。通过调用MessageQueue的postSyncBarrier()方法,可以开启同步屏障。在开启同步屏障后,发送的这条消息它的target为null。


    private int postSyncBarrier(long when) {
synchronized (this) {
final int token = mNextBarrierToken++;
final Message msg = Message.obtain();
msg.markInUse();
// 没有设置target,target为null
msg.when = when;
msg.arg1 = token;

Message prev = null;
Message p = mMessages;
if (when != 0) {
while (p != null && p.when <= when) {
prev = p;
p = p.next;
}
}
if (prev != null) { // invariant: p == prev.next
msg.next = p;
prev.next = msg;
} else {
msg.next = p;
mMessages = msg;
}
return token;
}
}

那么,开启同步屏障后,所谓的异步消息又是如何被处理的呢?
我们又可以回到之前MessageQueue中的next方法了


public Message next() {
// 省略部分代码,只体现出来同步屏障的代码
...
for (;;) {
...

synchronized (this) {
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
//注意这里,开始出来同步屏障
//如果target==null,认为它就是屏障,进行循环遍历,直到找到第一个异步的消息
if (msg != null && msg.target == null) {
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}

...
}

...
}
}

所以同步屏障是会让消息顺序进行调整,让其忽略现有的同步消息,来直接处理临近的异步消息。
现在听起来已经知道了同步屏障的作用,但它的实际应用又有哪些呢?


应用场景


虽然在日常应用开发中,同步屏障的使用频率较低,但在Android系统源码中,同步屏障的使用场景非常重要。一个典型的使用场景是在UI更新时,例如在View的绘制、布局调整、刷新等操作中,系统会开启同步屏障,以确保与UI相关的异步消息得到优先处理。当UI更新完成后,同步屏障会被移除,允许后续的同步消息得以处理。


对应的是ViewRootImpl#scheduleTraversals()


    void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
// 设置同步屏障
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

void unscheduleTraversals() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
// 移除同步屏障
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
mChoreographer.removeCallbacks(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
}
}


经典问题


Android 主线程的消息循环是通过 LooperHandler 来实现的。以下是一段伪代码示例:


// 创建一个 Looper,关联当前线程
Looper.prepare();
Looper loop = Looper.myLooper();

// 创建一个 Handler,它将和当前 Looper 关联
Handler handler = new Handler();

// 进入消息循环
Looper.loop();

开启loop后的核心代码如下:


    public static void loop() {
final Looper me = myLooper();
...
for (;;) {
if (!loopOnce(me, ident, thresholdOverride)) {
return;
}
}
}

private static boolean loopOnce(final Looper me,
final long ident, final int thresholdOverride)
{
// 注意没消息会被阻塞,进入休眠状态
Message msg = me.mQueue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return false;
}

...

try {
msg.target.dispatchMessage(msg);
if (observer != null) {
observer.messageDispatched(token, msg);
}
dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
} catch (Exception exception) {
if (observer != null) {
observer.dispatchingThrewException(token, msg, exception);
}
throw exception;
} finally {
ThreadLocalWorkSource.restore(origWorkSource);
if (traceTag != 0) {
Trace.traceEnd(traceTag);
}
}
...
msg.recycleUnchecked();

return true;
}


在这段示例中,主线程的消息循环被启动,它会等待来自消息队列的消息。有了这个基础下面的问题就简单了:




  1. 为什么主线程不会陷入无限循环?


    主线程的消息循环不会陷入无限循环,因为它不断地从消息队列中获取消息并处理它们。如果没有消息要处理,消息循环会进入休眠状态,不会持续消耗 CPU 资源。只有在有新消息到达时,主线程才会被唤醒来处理这些消息。这个机制确保主线程能够响应用户的操作,而不陷入死循环。




  2. 如果没有消息,主线程会如何处理?


    如果消息队列为空,主线程的消息循环会等待,直到有新消息到达。在等待期间,它不会执行任何操作,也不会陷入循环。这是因为 Android 的消息循环是基于事件驱动的,只有当有事件(消息)到达时,才会触发主线程执行相应的处理代码。当新消息被投递到消息队列后,主线程会被唤醒,执行相应的处理操作,然后再次进入等待状态。




这种事件驱动的消息循环机制使得 Android 应用能够高效地管理用户交互和异步操作,同时保持了响应性和低能耗。所以,主线程不会陷入无限循环,而是在需要处理事件时才会执行相应的代码。


结论


Android主线程是应用的核心,负责处理UI事件、界面更新和定时任务等。了解主线程的原理和独特机制是Android开发的关键,它有助于确保应用的稳定性和性能。通过消息队列和Handler,开发者可以在主线程中安全地处理各种任务,提供流畅的用户体验。


推荐


android_startup: 提供一种在应用启动时能够更加简单、高效的方式来初始化组件,优化启动速度。不仅支持Jetpack App Startup的全部功能,还提供额外的同步与异步等待、线程控制与多进程支持等功能。


AwesomeGithub: 基于Github的客户端,纯练习项目,支持组件化开发,支持账户密码与认证登陆。使用Kotlin语言进行开发,项目架构是基于JetPack&DataBinding的MVVM;项目中使用了Arouter、Retrofit、Coroutine、Glide、Dagger与Hilt等流行开源技术。


flutter_github: 基于Flutter的跨平台版本Github客户端,与AwesomeGithub相对应。


android-api-analysis: 结合详细的Demo来全面解析Android相关的知识点, 帮助读者能够更快的掌握与理解所阐述的要点。


daily_algorithm: 每日一算法,由浅入深,欢迎加入一起共勉。


作者:午后一小憩
来源:juejin.cn/post/7296692742876758027
收起阅读 »

Android通知栏增加快捷开关的技术实现

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileServ...
继续阅读 »

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileService的方案实现。   


TileService继承自Service,所以它也是Android的四大组件之一,不过它是一个特殊的组件,开发者不需要手动开启调用,系统可以自动识别并完成调用,系统会通过绑定服务(bindService)的方式调用。


创建使用:


快捷开关是Android 7(target 24)的新能力,因此在使用该能力前必须先判断版本大小(大于等于target 24)。


1、自定义一个TileService类。


class MyQSTileService: TileService() {
  override fun onTileAdded() {    
super.onTileAdded()
}

  override fun onStartListening() {    
super.onStartListening()
}

  override fun onStopListening() {    
super.onStopListening()
}

  override fun onClick() {    
super.onClick()
}

  override fun onTileRemoved() {    
super.onTileRemoved()
}
}

TileService是通过绑定服务(bindService)的方式被调用的,因此,绑定服务生命周期包含的四种典型的回调方法(onCreate()、onBind()、onUnbind()和 onDestroy())都会被调用。但是,TileService也包含了以下特殊的生命周期回调方法:



  • onTileAdded():当用户从编辑栏添加快捷开关到通知栏的快速设置中会调用。

  • onTileRemoved():当用户从通知栏的快速设置移除快捷开关时调用。

  • onClick():当用户点击快捷开关时调用。

  • onStartListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileAdded添加之后会调用一次。

  • onStopListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileRemoved移除之前会调用一次。


2、在应用程序的清单文件中声明TileService


name=".MyQSTileService"
android:label="@string/my_default_tile_label"
android:icon="@drawable/my_default_icon_label"
android:exported="true"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">

name="android.service.quicksettings.action.QS_TILE" />




  • name:自定义的TileService的类名。

  • label:快捷开关在通知栏上显示的名称。

  • icon:快捷开关在通知栏上显示的图标。

  • exported:该服务能否被外部应用调用。该属性必须为true。如果为false,那么快捷开关的功能将失效,原因是exported="false"时,TileService将不支持外部应用调起,手机系统自然不能再和该快捷开关交互。必须配置。

  • permission:需要给service配置的权限,BIND_QUICK_SETTINGS_TILE即允许应用程序绑定到第三方快速设置。必须配置。

  • intent-filter:意图过滤器,只有匹配内部的action,才能调起该service。必须配置。


监听模式


TileService的监听模式(或理解为启动模式)有两种,一种是主动模式,另一种是标准模式。



  • 主动模式


在主动模式下,TileService被请求时该服务会被绑定,并且TileService的onStartListening也会被调用。该模式需要在AndroidManifeast清单文件中声明:



name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
...


通过TileService.requestListeningState()这一静态方法,就可以实现对TileService的请求,示例如下:


      TileService.requestListeningState(
applicationContext, ComponentName(
BuildConfig.APPLICATION_ID,
MyQSTileService::class.java.name
)
)

主动模式下值得注意的是:



  • 用户在通知栏快速设置的地方点击快捷开关时,TileService会自动完成绑定、TileService的onStartListening会被调用。

  • TileService无论是通过点击被绑定还是通过requestListeningState请求被绑定,TileService所在的进程都会被调起。


标准模式


     在标准模式下,TileService可见时(即用户下拉通知栏看见快捷开关)该服务会被绑定,并且TileService的onStartListening也会被调用。标准模式不需要在AndroidManifeast清单文件中进行额外的声明,默认就是标准模式。


标准模式下值得注意的是:



  • 和主动模式相同,TileService被绑定时,TileService所在的进程就会被调起。

  • 而和主动模式不同的是,标准模式绑定TileService是通过用户下拉通知栏实现的,这意味着TileService所在的进程会被多次调起。因此为了避免主进程被频繁调起、避免DAU等数据统计受到影响,我们还需要为TileService指定一个特定的子进程,在Androidmanifest清单文件中设置:


      process="自定义子进程的名称">
......


更新快捷开关


如果需要对快捷开关的数据进行更新,可以通过getQsTile()获取快捷开关的对象,然后通过setIcon(更新icon)、setLable(更新名称)、setState(更新状态,包括STATE_ACTIVE——表示开启或启用状态、STATE_INACTIVE——表示关闭或暂停状态、STATE_UNAVAILABLE:表示暂时不可用状态,在此状态下,用户无法与您的磁贴交互)等方法设置快捷开关新的数据,最后调用updateTile()方法实现。


  override fun onStartListening() {
super.onStartListening()
if (qsTile.state === Tile.STATE_ACTIVE) {
qsTile.label = "inactive"
qsTile.icon = Icon.createWithResource(context, R.drawable.inactive)
qsTile.state = Tile.STATE_INACTIVE
} else {
qsTile.label = "active"
qsTile.icon = Icon.createWithResource(context, R.drawable.active)
qsTile.state = Tile.STATE_ACTIVE
}
qsTile.updateTile()
}

操作快捷开关



  • 如果想要实现点击快捷开关时、关闭通知栏并跳转到某个页面,可以调用以下方法:


startActivityAndCollapse(Intent intent)


  • 如果想要在点击快捷开关时弹出对话框进行交互,可以调用以下方法:


override fun onClick() {
super.onClick()
if(!isLocked()) {
showDialog()
}
}

因为快捷开关有可能在用户锁屏时出现,所以必须加上isLocked()的判断。只有非锁屏的情况下,对话框才会出现。



  • 如果快捷开关含有敏感信息,需要使用isSecure()进行设备安全性判断,当设备安全时,才能执行快捷开关相关的逻辑(如点击的逻辑)。当设备不安全时(手机处于锁屏状态时),可调用unlockAndRun(Runnable runnable),提示用户解锁屏幕并执行自定义的runnable操作。


以上是通知栏增加快捷开关的全部介绍。


作者:度熊君
来源:juejin.cn/post/7190663063631036473
收起阅读 »

安卓知识点-应届生扫盲安卓WebView

作者 大家好,我叫Jack冯; 本人20年硕士毕业于广东工业大学,于2020年6月加入37手游安卓团队; 目前主要负责海外游戏发行安卓相关开发。 背景 最近在接触活动相关需求,其中涉及到一个安卓的WebView; 刚毕业的我,对安卓知识积累比较少,所以在这里对...
继续阅读 »

作者


大家好,我叫Jack冯;


本人20年硕士毕业于广东工业大学,于2020年6月加入37手游安卓团队;


目前主要负责海外游戏发行安卓相关开发。


背景


最近在接触活动相关需求,其中涉及到一个安卓的WebView;


刚毕业的我,对安卓知识积累比较少,所以在这里对Webview进行相关学习,希望自己可以在安卓方面逐步积累。


Webview介绍


1、关于MockView


( 1 ) 在targetSdkVersion 28/29的工程里面查看WebView继承关系


java.lang.Object
↳ android.view.View
↳ android.view.ViewGr0up
​ ↳ android.widget.FrameLayout
↳ android.layoutlib.bridge.MockView
↳ android.webkit.WebView

( 2 ) 使用26/27等低版本SDK,查看源码中的WebView 继承关系


java.lang.Object
↳ android.view.View
↳ android.view.ViewGr0up
↳ android.widget.AbsoluteLayout
↳ android.webkit.WebView

( 3 )对比


两种方式对比,AbsoluteLayout和FrameLayout都是重写ViewGr0up的方法,如与布局参数配置相关的 generateDefaultLayoutParams()、checkLayoutParams()等。两种方式明显不同的是多了一层MockView 。这里来看看MockView是什么:


public class MockView extends FrameLayout{
...
//创建方式
public MockView(Context context) {...}
public MockView(Context context,AttributeSet attrs) {...}
public MockView(Context context,AttributeSet attrs,int defStyleRes) {...}
//重写添加view方法
@Override
public void addView(View child){...}
@Override
public void addView(View child,int index){...}
@Override
public void addView(View child,int width,int height){...}
@Override
public void addView(View child,ViewGr0up.LayoutParams params){...}
@Override
public void addView(View child,int index,ViewGr0up.LayoutParams params){...}
public void setText(CharSequence text){...}
public void setGravity(int gravity){...}
}

MockView,译为"虚假的view"。


谷歌发布的Sdk其实只是为了提供App开发运行接口,实际运行时候替换为当前系统的Sdk。


具体说就是当谷歌在新的系统(Framework)版本上准备对WebView实现机制进行改动,同时又希望把新的sdk提前发出来,不影响用到WebView的App开发,于是谷歌提供给Android开发的sdk中让WebView继承自MockView,这个WebView只是暴露了接口,没有具体实现;这样当谷歌关于WebView新的实现做好,利用WebView,app也就做好了


2、基本使用


(1)创建


①一般方式:


WebView webView = findViewById(R.id.webview);

②建议方式:


LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGr0up.LayoutParams.MATCH_PARENT,ViewGr0up.LayoutParams.MATCH_PARENT);
mWebView = new WebView(getApplicationContext());
mWebView.setLayoutParams(params);

好处:构建不用依赖本地xml文件,自定义页面参数;手动销毁避免内存泄露;


③更多方式 : 继承Webview和主要API等进行拓展


public class BaseWebView extends WebView {...}
public class BaseWebClient extends WebClient {...}
public class BaseWebChromeClient extends WebChromeClient {...}

(2)加载


① 加载某个网页


webView.loadUrl("http://www.google.com/");

②新建assets目录,将html文件放到目录下,通过路径加载本地页面


 webView.loadUrl("file:///android_asset/loadFailed.html");

③使用evaluateJavascript(String script, ValueCallback resultCallback)方法加载,(Android4.4+)


mWebView.evaluateJavascript("file:///android_asset/javascript.html",new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
Log.e("测试", "onReceiveValue:"+value );
}
});

3、WebViewClient


当URL即将加载到当前窗口,如果没有提供WebViewClient,默认情况下WebView将使用Activity管理器为URL选择适当的处理器。


如果提供了WebViewClient,按自定义配置要求来继续加载URL。


(1)常用方法


//加载过程对url的处理(webview加载、系统浏览器加载、其他操作等)
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
super.shouldOverrideUrlLoading(view, url);
}
//加载失败页面
@Override
public void onReceivedError(WebView view, int errorCode, String description, String failingUrl){
view.loadUrl("file:///android_asset/js_error.html");
}
//证书错误处理
@Override
public void onReceivedSslError(WebView view, final SslErrorHandler handler, SslError error) {
}
//开始加载页面(可自定义页面加载计时等)
@Override
public void onPageStarted(WebView view, String url, Bitmap favicon) {
super.onPageStarted(view, url, favicon);
Log.e(TAG, "onPageStarted:" + url);
}
//结束加载页面
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
Log.e(TAG, "onPageFinished: " + url);
}

(2)关于shouldOverrideUrlLoading


如果在点击链接加载过程需要更多的控制,就可以在WebViewClient()中重写shouldOverrideUrlLoading()方法。


涉及shouldOverrideUrlLoading()的情形,大概分为三种:


(1)没有设定setWebViewClient(),点击链接使用默认浏览器打开;


(2)设定setWebViewClient(new WebViewClient()),默认shouldOverrideUrlLoading()返回false,点击链接在Webview加载;


(3)设定、重写shouldOverrideUrlLoading()


返回true:可由应用代码处理该 url,WebView 中止处理(若重写方法没加上view.loadUrl(url),不加载);


返回false:由 WebView 处理加载该 url。(即使没加上view.loadUrl(url),也会在当前Webview加载)


【一般应用】


@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
super.shouldOverrideUrlLoading(view, url);
if (url != null) {
if (!(url.startsWith("http") || url.startsWith("https"))) {
return true;
}
//重定向到别的页面
//view.loadUrl("file:///android_asset/javascript.html");
//区别不同链接加载
view.loadUrl(url);
}
return true;
}

(3)常见误区


【误区1】 : 需要重写 shouldOverrideUrlLoading 方法才能阻止浏览器打开页面。


解释:WebViewClient 源码中 shouldOverrideUrlLoading 方法已经返回 false,不设定setWebViewClient(),默认使用系统浏览器加载。如果重写该方法并返回true, 就可以实现在app页面中加载新链接而不去打开浏览器。


【误区2】 : 每一个url加载过程都会经过 shouldOverrideUrlLoading 方法。


Q1:加载一定会触发shouldOverrideUrlLoading?


Q2:触发时机一定在onPageStarted调用之前?


解释:关于shouldOverrideUrlLoading的触发


1)如果在点击页面链接时通过标签跳转,触发方法如下:


​ shouldOverrideUrlLoading() —> onPageStarted()—> onPageFinished()


2)如果使用loadUrl加载时,触发方法如下:


​ onPageStarted()—>onPageFinished()


3)如果使用loadUrl加载重定向地址时,触发方法如下:


​ shouldOverrideUrlLoadings—>onPageStarted —> onPageFinished


ps:多次重定向的过程,


onPage1Started


—>shouldOverrideUrlLoadings


—>onPage2Started —> xxx...


—> onPageNFinished


结论:shouldOverrideUrlLoading()方法不是每次加载都会调用,WebView的前进、后退等不会调用shouldOverrideUrlLoading方法;非loadUrl方式加载 或者 是重定向的,才会调用shouldOverrideUrlLoading方法。


【误区3 】: 重写 shouldOverrideUrlLoading 方法返回true比false的区别,多调用一次onPageStarted()和onPageFinished()。


解释:返回True:应用代码处理url;返回False,则由 WebView 处理加载 url。


ps:低版本系统(华为6.0),测试 False比True会多调用一次onPageStarted()和onPageFinished(),这点还在求证中。


4、WebChromeClient


对比WebviewClient , 添加了处理JavaScript对话框,图标,标题和进度等。


处理对象 : 影响浏览器的事件


(1)常用方法:


//alert弹出框
public boolean onJsAlert(WebView view, String url, String message,JsResult result){
return true;//true表示拦截
}

//confirm弹出框
public boolean onJsConfirm(WebView view, String url, String message,JsResult result){
return false;//false则允许弹出
}

public boolean onJsPrompt(WebView view, String url, String message,String defaultValue, JsPromptResult result)

//打印 console 信息。return true只显示log,不显示js控制台的输入;false则都显示出来
public boolean onConsoleMessage(ConsoleMessage consoleMessage){
Log.e("测试", "consoleMessage:"+consoleMessage.message());
}

//通知程序当前页面加载进度,结合ProgressBar显示
public void onProgressChanged(WebView view, int newProgress){
if (newProgress < 100) {
String progress = newProgress + "%";
Log.e("测试", "加载进度:"+progress);
webProgress.setProgress(newProgress);
}
}

(2)拦截示例:


JsResult.comfirm() --> 确定按钮的调用方法


JsResult.cancle() --> 取消按钮


示例:拦截H5的弹框,并显示自定义弹框,点击按钮后重定向页面到别的url


@Override
public boolean onJsConfirm(final WebView view, String url, String message, final JsResult result) {
Log.e("测试", "onJsConfirm:"+url+",message:"+message+",jsResult:"+result.toString());
new AlertDialog.Builder(chromeContext)
.setTitle("拦截JsConfirm显示!")
.setMessage(message)
.setPositiveButton(android.R.string.ok,
new AlertDialog.OnClickListener() {
public void onClick(DialogInterface dialog,int which) {
//重定向页面
view.loadUrl("file:///android_asset/javascript.html");
result.confirm();
}
}).setCancelable(false).create().show();
return true;
}

5、WebSettings


用于页面状态设置\插件支持等配置.


(1)常用方法


WebSettings webSettings = webView.getSettings();
/**
* 设置缓存模式、支持Js调用、缩放按钮、访问文件等
*/

webSettings.setCacheMode(WebSettings.LOAD_DEFAULT);
webSettings.setJavaScriptEnabled(true);
webSettings.setSupportZoom(true);
webSettings.setBuiltInZoomControls(true);
webSettings.setDisplayZoomControls(true);

//允许WebView使用File协议,访问本地私有目录的文件
webSettings.setAllowFileAccess(true);

//允许通过file url加载的JS页面读取本地文件
webSettings.setAllowFileAccessFromFileURLs(true);

//允许通过file url加载的JS页面可以访问其他来源内容,包括其他的文件和http,https等来源
webSettings.setAllowUniversalAccessFromFileURLs(true);
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
webSettings.setLoadsImagesAutomatically(true);
webSettings.setDefaultTextEncodingName("utf-8")

if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
}

结束语


过程中有问题或者需要交流的同学,可以扫描二维码加好友,然后进群进行问题和技术的交流等;


企业微信截图_5d79a123-2e31-42cc-b03f-9312b8b99df3.png


作者:37手游移动客户端团队
来源:juejin.cn/post/7245084484756144186
收起阅读 »

Android 14 彻底终结大厂流氓应用?

hi 大家好,我是 DHL。大厂程序员,就职于美团、快手、小米。公众号:ByteCode,分享技术干货和编程知识点 在某些大厂内部通常都会有一个神秘的团队,他们的工作内容就是专门研究系统,而的事情就是如何让自家应用在后台存活的更久,达到永生的目的。 其中有个...
继续阅读 »

hi 大家好,我是 DHL。大厂程序员,就职于美团、快手、小米。公众号:ByteCode,分享技术干货和编程知识点



在某些大厂内部通常都会有一个神秘的团队,他们的工作内容就是专门研究系统,而的事情就是如何让自家应用在后台存活的更久,达到永生的目的。


其中有个别公司,甚者利用公开漏洞,达到远程操控用户手机的目的,做更多他们想做的事,可以随意获取用户的隐私,而且一旦安装,普通用户很难删除,之前写了一些揭露他们的文章,但是现在已经被全部删除了,就连评论区抨击他们的内容也全都被删除了。


而 Android 14 的出现,可以说是暂时性的彻底终结了这些流氓软件,想在后台通过保活的方式,让应用在后台达到永生的目的基本不可能了。


为什么这是暂时性的呢,因为没有完美的系统,新的系统虽然修复了公开漏洞,终结了现有的保活的方式,但是新系统可能存在新的漏洞,还是会给某些大厂可乘之机。


我们一起来看一下 Android 工程副总裁 Dave Burke 都介绍 Andorid 14 在性能、隐私、安全性方面做了那些改进,这篇文章是对之前的文章 适配 Android 14,你的应用受影响了吗Android 14 新增权限 的补充。



  • 冻结缓存应用,增强杀进程能力

  • 应用启动更快

  • 减少内存占用

  • 屏幕截图检查

  • 显示全屏系统通知

  • 精确闹钟权限

  • 提供了对照片和视频的部分访问权限

  • 最小 targetSdkVersion 限制

  • 返回手势


本文只会介绍我认为 Android 14 上最大的变化,关于 Android 14 的所有变更,可以前往查看变更
developer.android.com/about/versi…


冻结缓存应用,增强杀进程能力


在 Android 11 以上支持冻结已缓存的应用,当应用切换到后台并且没有其他活动时,系统会在一定时间内通过状态判断,是否冻结该应用,如果一个应用被冻结住了,将完全被 "暂停",不再消耗任何 CPU 资源,可以减少应用在后台消耗的 CPU 资源,从而达到节电的目的。


被冻结已缓存的应用并不会执行终止该应用,冻结的作用只是暂时挂起进程,消耗 CPU 的资源为 0,它有助于提高系统性能和稳定性,同时最大限度地节省设备的资源和电量的消耗,一旦应用再次切换到前台时,系统会将该应用的进程解冻,实现快速启动。


如果你的手机支持冻结已缓存的应用,在开发者选项里会显示 「暂停执行已缓存的应用」设置项。



冻结已缓存应用,在内核层面使用的是 cgroup v2 freezer,相对于使用信号 SIGSTOP 与 SIGCONT 实现的挂起与恢复,cgroup v2 freezer 无法被拦截,也就无法被开发者 Hook,从而彻底终结大厂想在这个基础上做一些事情。


当然 Google 也对 cgroup 进行了封装,提供了 Java API,在上层我们也可以调用对应的方法实现 CPU、内存资源的控制。


public static final native void setProcessFrozen(int pid, int uid, boolean frozen);
public static final native void enableFreezer(boolean enable);

经过测试 Android 14 相比于 Android 13,缓存进程的 CPU 使用量降低了高达 50%,因此,除了传统的 Android 应用生命周期 API,如前台服务、JobScheduler 或 WorkManager,后台工作将受到限制。


另外在 Android 14 上系统在杀进程之前,首先会将应用所有的进程进行 cgroup v2 freezer,被冻结的应用 cpu 资源占用为 0,然后在挨个杀掉进程,想通过进程互相拉取进程的方式,不断的想通过 fork 出子进程,达到应用永生的目的,在 Android 14 以上已经不可能了,这些黑科技都要告别历史的舞台了。


应用启动更快


在 Android 14 上对缓存应用进行优化,增加了缓存应用的最大数量的限制,从而减少了冷启动应用的次数。


而应用的最大缓存数量不是固定的,可以根据设备的内存容量进行调整,Android 测试团队在 8GB 设备上,发现冷启动应用的数量减少了 20%,而在 12GB 设备上减少了超过 30%,冷启动相对于热启动来说速度较慢,而且在电量消耗方面成本较高,这一工作有效地改善了电量使用和整体应用启动时间。


减少内存占用


代码大小是我们关注的关键指标之一,代码量越大虚拟内存占用越高,减少生成的代码大小,对内存(包括 RAM 和存储空间)的影响就越小。


在 Android 14 中,改进 Android 运行时(ART)对 Android 用户体验,ART 包含了优化措施,将代码大小平均减少了 9.3%,而不会影响性能。


屏幕截图检查


在 Android 14 中新增了一个特殊的 API,截取屏幕截图后会有个 ScreenCaptureCallback 的回调,当用户正在使用截取屏幕截图时,将会调用这些回调函数。


要使 API 正常工作,需要在 AndroidManifest 中添加 DETECT_SCREEN_CAPTURE 权限,然后在 onStart() 方法中注册回调,需要在 onStop() 中取消注册。


<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
</manifest>


class MainActivity : Activity() {

private val mainExecutor = MainEcxector()

private val screenshotCallback = ScreenCaptureCallback {
// A screenshot was taken
}

override fun onStart() {
super.onStart()
registerScreenCaptureCallback(mainExecutor, screenshotCallback)
}

override fun onStop() {
super.onStop()
unregisterScreenCaptureCallback(screenshotCallback)
}
}

显示全屏系统通知



Android 11 引入了全屏通知,当全屏应用程序运行时,这些通知将在锁屏屏幕上显示,任何应用都可以在手机处于锁定状态时使用 Notification. Builder. setFullScreenIntent 发送全屏 Intent,不过需要在 AndroidManifest 中声明 USE_FULL_SCREEN_INTENT 权限,在应用安装时自动授予此权限。


从 Android 14 开始,使用此权限的应用仅限于提供通话和闹钟的应用。对于不适合此情况的任何应用,Google Play 商店会撤消其默认的 USE_FULL_SCREEN_INTENT 权限。


在用户更新到 Android 14 之前,在手机上已经安装的应用仍拥有此权限,但是用户可以开启和关闭此权限,所以您可以使用新 API NotificationManager.canUseFullScreenIntent 检查应用是否具有该权限。


如果想在 Android 14 上使用这个权限,我们可以提示用户手动打开授权,通过 Intent(ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT) 来跳转到设置界面。


if(NotificationManager.canUseFullScreenIntent()){
startActivity(Intent(ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT))
}

精确闹钟权限


在 Andorid 12 之前我们可以直接调用 setAlarmClock()setExact()
setExactAndAllowWhileIdle() 等等方法设置精确闹钟时间,


但是在 Android 12 上 Google 引入了一个新的权限 SCHEDULE_EXACT_ALARM,如果想调用 setAlarmClock()setExact()
setExactAndAllowWhileIdle() 等等方法设置精确闹钟时间, 需要在 manifest 中申明 android.permission.SCHEDULE_EXACT_ALARM 权限。


所以运行在 Android 12 ~ Android 13 系统上,我们只需要声明一下权限就可以使用了,但是从 Android 14 开始 SCHEDULE_EXACT_ALARM 权限默认被禁止使用了。


如果你还想在 Andorid 14 以上使用精准闹钟的 API,我们可以提示用户手动打开授权,通过 Intent (ACTION_REQUEST_SCHEDULE_EXACT_ALARM) 来跳转到设置界面,代码如下。


val alarmManager: AlarmManager = context.getSystemService<AlarmManager>()!!
when {
// If permission is granted, proceed with scheduling exact alarms.
alarmManager.canScheduleExactAlarms() -> {
alarmManager.setExact(...)
}
else -> {
// Ask users to go to exact alarm page in system settings.
startActivity(Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
}
}

提供了对照片和视频的部分访问权限


这个限制和 iOS 相似,Android 14 提供了对照片和视频的部分访问权限。当您访问媒体数据时,用户将看到一个对话框,提示用户授予对所有媒体的访问、或者授予单个照片/视频的访问权限,该新功能将适用于 Android 14 上所有应用程序,无论其 targetSdkVersion 是多少。



在 Android 13 上已经引入了单独的照片访问和视频访问权限,但是在 Android 14 上新增了新的权限 READ_MEDIA_VISUAL_USER_SELECTED


<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

<!-- Devices running Android 13 (API level 33) or higher -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

<!-- To handle the reselection within the app on Android 14 -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />

</manifest>

如果没有声明新的权限,当应用程序进入后台或用户终止应用程序时,单独的照片访问和视频访问权限将立即撤销,不会保存 READ_MEDIA_IMAGESREAD_MEDIA_VIDEO 权限的状态,每次都需要检查。


最小 targetSdkVersion 限制


Android 14 当中有一个比较大的变化就是,无法安装 targetSdk <= 23 的应用程序 (Android 6.0),不要将它与 minSdk 混淆。


在 Android 开发中有两个比较重要的版本号:



  • compileSdkVersion :用于编译当前项目的 Android 系统版本

  • targetSdkVersion :App 已经适配好的系统版本,系统会根据这个版本号,来决定是否可以使用新的特性


这个最小 targetSdkVersion 限制,主要是出于安全考虑,在 Android 6.0 中引入了运行时权限机制,App 想要使用一些敏感权限时,必须先弹窗询问用户,用户点击允许之后才能使用相应的权限。


但是一些 App 为了利用权限方便获取到用户的信息,通过不去升级 targetSdk 的版本号的方式,在安装过程中获得所有权限,以最低成本的方式,绕过运行时权限机制。


如果之前已经安装了的 App,就算升级到 Android 14 也会去保留,系统不能代表用户去删除某个应用,其实我在想,为什么不针对这些已经安装好的低版本的 App,Google 给出一些警告提示,让用户可以感知到呢


返回手势


在 Android 13 的时候,Google 已经预示我们在下一个版本中,返回手势将会有一些更新,并以预览屏幕的形式呈现动画,效果如下图所示。



我们来演示一下使用后退导航的动画。



在 Android 14 增加了在 App 中创建过渡动画的功能,比如在 OnBackPressedCallback 接口中添加了一个方法 handleonbackprogress() ,这个方法在返回手势执行过程中被调用,我们可以在这个方法中增加一些过渡动画。


OnBackPressedCallback 接口中还提供了两个方法 handleOnBackPressed()handleOnBackCancelled() 分别在动画完成和取消动画时调用,我们来看看在代码中如何使用。


class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
val box = findViewById<View>(R.id.box)
val screenWidth =
Resources.getSystem().displayMetrics.widthPixels
val maxXShift = (screenWidth / 20)

val callback = object : OnBackPressedCallback(
enabled = true
) {

override fun handleOnBackProgressed(
backEvent: BackEvent
)
{
when (backEvent.swipeEdge) {
BackEvent.EDGE_LEFT ->
box.translationX = backEvent.progress *
maxXShift
BackEvent.EDGE_RIGHT ->
box.translationX = -(backEvent.progress *
maxXShift)
}
box.scaleX = 1F - (0.1F * backEvent.progress)
box.scaleY = 1F - (0.1F * backEvent.progress)
}

override fun handleOnBackPressed() {
// Back Gesture competed
}


override fun handleOnBackCancelled() {
// Back Gesture cancelled
// Reset animation objects to initial state
}
}
this.onBackPressedDispatcher.addCallback(callback)
}
}

API 被废弃


在 Android 中使用 overidePendingTransition () 方法实现进入和退出动画,但是在 Android 14 上提供了新的 overrideActivityTransition () 方法,而 overidePendingTransition () 方法已被标记为弃用。


// New API
overrideActivityTransition(
enterAnim = R.anim.open_trans,
exitAnim = R.anim.exit_trans,
backgroundColor = R.color.bgr_color
)

// deprecated
overridePendingTransition(R.anim.open_trans, R.anim.exit_trans)



全文到这里就结束了,感谢你的阅读,坚持原创不易,欢迎在看、点赞、分享给身边的小伙伴,我会持续分享原创干货!!!




我开了一个云同步编译工具(SyncKit),主要用于本地写代码,同步到远程设备,在远程设备上进行编译,最后将编译的结果同步到本地,代码已经上传到 Github,欢迎前往仓库 hi-dhl/SyncKit 查看。





Hi 大家好,我是 DHL,就职于美团、快手、小米。公众号:ByteCode ,分享有用、有趣的硬核原创内容,Kotlin、Jetpack、性能优化、系统源码、算法及数据结构、动画、大厂面经,真诚推荐你关注我。





最新文章



开源新项目




  • 云同步编译工具(SyncKit),本地写代码,远程编译,欢迎前去查看 SyncKit




  • KtKit 小巧而实用,用 Kotlin 语言编写的工具库,欢迎前去查看 KtKit




  • 最全、最新的 AndroidX Jetpack 相关组件的实战项目以及相关组件原理分析文章,正在逐渐增加 Jetpack 新成员,仓库持续更新,欢迎前去查看 AndroidX-Jetpack-Practice




  • LeetCode / 剑指 offer,包含多种解题思路、时间复杂度、空间复杂度分析,在线阅读




作者:程序员DHL
来源:juejin.cn/post/7298699367791411236
收起阅读 »

写了个APP「原色」—— 基于中国传统色

中国传统色 简介 这是一个工具类APP 颜色筛选以及关模糊查询 颜色详情信息查看以及复制 色卡分享 自定义主题色(长按色卡) 小组件支持 已上架应用宝/App Store,搜索原色即可找到 最初是做了个1.0版本(MVP),功能比较简单,后面感觉没什么可...
继续阅读 »

中国传统色



简介


这是一个工具类APP



  • 颜色筛选以及关模糊查询

  • 颜色详情信息查看以及复制

  • 色卡分享

  • 自定义主题色(长按色卡)

  • 小组件支持


已上架应用宝/App Store,搜索原色即可找到


最初是做了个1.0版本(MVP),功能比较简单,后面感觉没什么可加的就放置一边了


1.0版本.jpeg


最近比较空闲又拿起来,bug修一点加一点,界面改了又改哈哈哈,然后现在迭代到2.0版本(预览图为 iOS)


2.0版本.jpeg
除了界面大换新,也增加了一些功能,比如颜色搜索、筛选、小组件等。Android与iOS基本一致,除了搜索筛选界面不一样:


Android搜索筛选.jpg


下面介绍一下一些功能的实现以及碰到的问题


色卡与文字处理


在1.0版本对色卡的背景颜色和文字颜色关系处理比较粗暴简单,当系统出去浅色模式下。文字就在原来颜色的基础上降低亮度;在深色模式下文字就降低亮度,但是这种方式在部分过亮或者过暗背景上还是很难看清。

2.0版本对色卡和文字颜色都做了动态处理:

色卡:渐变处理,从上往下,比例为0——0.3——1.0。



在浅色模式下颜色为color(alpha=0.7)——color——color;


在深色模式下颜色为color(brightness + 0.2)——color——color



色卡文字:根据颜色是否为亮色进行处理,判断规则为:



颜色为亮色,则降低0.3亮度,否则 降低0.1亮度



在iOS上有用于修改view亮度的方法:brightness(Double),可惜安卓没有直接修改视图或者颜色亮度的方法,于是我就通过修改颜色 HSL来达到类似的效果。为了和ios的brightness 一致,changeBrightness的范围我设置为[-1F, 1F],但outHsl[2]的范围是[0F, 1F],所以计算做了一些调整:


// 修改颜色亮度
@ColorInt
fun @receiver:ColorInt Int.brightness(changeBrightness: Float): Int {
val outHsl = FloatArray(3)
ColorUtils.colorToHSL(this, outHsl)
if (changeBrightness <= 0) {
outHsl[2] = outHsl[2] * (1 + changeBrightness)
} else {
outHsl[2] = outHsl[2] + (1 - outHsl[2]) / 10 * changeBrightness * 10
}
return ColorUtils.HSLToColor(outHsl)
}

// 判断颜色为两色或者暗色
fun @receiver:ColorInt Int.isLight(): Boolean {
val red = Color.valueOf(this).red()
val green = Color.valueOf(this).green()
val blue = Color.valueOf(this).blue()
val brightness = (red * 299 + green * 587 + blue * 114) / 1000
return brightness > 0.5
}

颜色信息展示(BottomSheet)


设置BottomSheet默认完全展开,设置方法如下:


override fun onStart() {
super.onStart()
val behavior = BottomSheetBehavior.from(requireView().parent as View)
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}

至于圆角处理,只需要在主题文件里写好就行了:


 <!--Rounded Bottom Sheet-->
<style name="ThemeOverlay.App.BottomSheetDialog" parent="ThemeOverlay.Material3.BottomSheetDialog">
<item name="bottomSheetStyle">@style/ModalBottomSheetDialog</item>
</style>

<style name="ModalBottomSheetDialog" parent="Widget.Material3.BottomSheet.Modal">
<item name="shapeAppearance">@style/ShapeAppearance.App.LargeComponent</item>
<item name="shouldRemoveExpandedCorners">false</item>
</style>

<style name="ShapeAppearance.App.LargeComponent" parent="ShapeAppearance.Material3.LargeComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">24dp</item>
</style>

如果不修改sheet背景色(默认为白色/黑色),只需要设置以上主题就可以了,但是如果修改了背景色,就需要在代码里对背景进行圆角处理,不能直接设置背景色,不然在圆角下面还会有颜色:


默认设置圆角背景.png


动态设置圆角背景.png


// 会存在背景色
// binding.bottomSheetLayout.setBackgroundColor(sheetBackground)

// 设置圆角背景
binding.bottomSheetLayout.setCornerBackground(24, 24, 0, 0, sheetBackground)

private fun View.setCornerBackground(leftRadius: Int, topRadius: Int, rightRadius: Int, bottomRadius: Int, @ColorInt color: Int) {
val shape = ShapeDrawable(RoundRectShape(
floatArrayOf(
leftRadius.dp(requireContext()).toFloat(),
leftRadius.dp(requireContext()).toFloat(),
topRadius.dp(requireContext()).toFloat(),
topRadius.dp(requireContext()).toFloat(),
rightRadius.dp(requireContext()).toFloat(),
rightRadius.dp(requireContext()).toFloat(),
bottomRadius.dp(requireContext()).toFloat(),
bottomRadius.dp(requireContext()).toFloat(),
), null, null)
)
shape.paint.color = color
this.background = shape
}

fun Int.dp(context: Context): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
this.toFloat(),
context.resources.displayMetrics
).toInt()
}

小技巧(应该算啊吧):当我们有icon需要适配深色模式的时候,可以把android:tint的值设置为?android:attr/textColorPrimary ,就不用自己做额外处理了


<vector android:autoMirrored="true" android:height="24dp"
android:tint="?android:attr/textColorPrimary" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">

<path android:fillColor="@android:color/white" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

SearchView背景色修改


可以看之前发的文章



MD3——SearchView自定义背景



效果参考上面的搜索筛选界面


滚动到指定位置(带偏移)


点击左上角的骰子图标,可以随机颜色(滚动到某一位置),通常我们使用recyclerView.scrollToPosition(int position)就可以实现。但是这个方法,会滚动到item的最边缘(红线位置),但是我希望他能够保留一定边距(绿色框框),看起来界面会和谐一点


image.png


解决办法如下:


private fun RecyclerView.scrollToPositionWithOffset(position: Int, offset: Int) {
(layoutManager as GridLayoutManager)
.scrollToPositionWithOffset(position, offset)
}

// 调用
binding.recyclerView.scrollToPositionWithOffset(
Random.nextInt(0, adapter.itemCount - 1),
16.dp(this)
)

用了kotlin扩展方法方便调用,这里的layoutManager根据实际情况来,我这里用列表到的是GridLayoutManager


小组件(App Widget)


提供了两种布局,小尺寸只显示颜色名称,大尺寸显示拼音和名称,效果如下:


Android小组件.jpg


iOS小组件.png


可能在部分系小尺寸统显示有问题,懒得搞了,这个组件大小搞的我脑壳疼,也没看到过什么好的解决方案,以下是我的配置:


// 31以下
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="57dp"
android:minHeight="51dp"
android:updatePeriodMillis="0"
android:previewImage="@drawable/appwidget_preview"
android:initialLayout="@layout/layout_wide_widget"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen">

</appwidget-provider>

// 31及以上
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:minResizeWidth="57dp"
android:minResizeHeight="51dp"
android:maxResizeWidth="530dp"
android:maxResizeHeight="450dp"
android:updatePeriodMillis="0"
android:previewImage="@drawable/appwidget_preview"
android:initialLayout="@layout/layout_wide_widget"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable">

</appwidget-provider>

因为布局比较简单,所以尺寸兼容效果相对好一点


主动刷新小组件


当我们app没有运行的时候,添加小组件是没有数据的,当我们打开app的时候,通知小组件更新


// 刷新 Widget
sendBroadcast(Intent(this, ColorWidgetProvider::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
val ids = AppWidgetManager.getInstance(application)
.getAppWidgetIds(ComponentName(application, ColorWidgetProvider::class.java))
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
})

周期更新小组件


可通过配置updatePeriodMillis来设置时间,但是容易失效,所以使用WorkManager来通知更新,虽然WorkManager保证了周期执行,但如果app不在后台的话还是无法更新的,因为发送了广播app收不到,可能再加个服务就可以了,不加不加了


遗留的小问题


MIUI无法添加小组件


这段代码在MIUI上不生效,无法弹出添加小组件的弹窗


AppWidgetManager.getInstance(this).requestPinAppWidget(xxx)

如果添加该权限并授权,可以成功添加,但是无任何弹窗提示


<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />

当然最奇怪的还是我居然在MIUI的安卓小部件里找不到我自己的组件,我在原生都能看得到我的小组件的,也不知道是不是还需要配置什么,再一次头大


总结


这个app断断续续也写了好几个月,也也没啥功能还写了这么久。之前还看了下swiftUI,写了个iOS版本的,给我的感觉就是上手简单,写起来效率快多了,

其实这篇文章早就可以发了,就为了等app上架,可真煎熬。

个人开发者上架应用真的是难于上青天,对于安卓平台,国内一些主流应用市场(华米OV)都不对个人开发者开放了,要求低点的比如酷安、应用宝个人是可以上传的,但是需要软著,这又是一个头疼的事,申请基本一个月起步,除非花几百块找别人,三五天下证;

PS:现在App需要备案了,除非你不联网,应用宝就可以上架,酷安也要强制备案

ios也让我很难受,可能是我自己的问题,我注册流程走到付款了,当时想着先写完app再注册好了,就没付款,后来再去注册就提示账户存在问题,邮件联系后告诉我:



您的账号由于一个或多个原因,您无法完成 Apple Developer Program 的注册。



我想问清楚具体是什么原因,客服告知由系统判定,他们无法知道也无法干预,然后我寻思罢了,我再注册一个,还是失败,这次提示:



您使用另一 Apple ID 通过 Apple Developer App 验证了身份。要继续,请使用之前用于验证您身份的 Apple ID。



问号.jpeg


然后我又去把原来的账号注销掉,依旧无法注册成功...,最后无奈使用别人的信息注册了一个乛 з乛

所以,想注册苹果开发者的,注意最好是在同一个设备上一次性完成注册。


作者:FaceBlack
来源:juejin.cn/post/7294441582983626788
收起阅读 »

Android 复杂UI界面分模块解耦的一次实践

一、复杂UI页面开发的问题 常见的比较复杂的UI界面,比如电商首页,我们看看某电商的首页部分UI: 上面是截取的首页部分,如果这个首页如果不分模块开发会遇到哪些问题? 开发任务不方便分割,一个人开发的话周期会很长 在XML文件中写死首页布局不够灵活 逻辑和...
继续阅读 »

一、复杂UI页面开发的问题


常见的比较复杂的UI界面,比如电商首页,我们看看某电商的首页部分UI:


Screenshot_2023-11-03-10-57-45-754_com.jingdong.app.mall.jpg


上面是截取的首页部分,如果这个首页如果不分模块开发会遇到哪些问题?



  • 开发任务不方便分割,一个人开发的话周期会很长

  • 在XML文件中写死首页布局不够灵活

  • 逻辑和UI塞在一起不方便维护

  • 首页不能动态化配置

  • UI和逻辑难以复用


那如何解决这个问题? 下面是基于基于BRVAH 3.0.11版本实现的复杂页面分模块的UI和逻辑的解耦。


二、解决思路


使用RecyclerView在BRVAH中利用不同的ViewType灵活的组装页面。但也面临一些问题,比如:



  • 如何实现模块间的通讯和互传数据?

  • 如何实现模块整理刷新和局部刷新?


下面都会给出答案。


三、具体实践


我们先看看模块拆分组装UI实现的效果:


Screen_Recording_20231103_124525_TestKotlin_V1.gif


模块二中有三个按钮,前面两个按钮可以启动和停止模块一中的计数,最后一个按钮获取模块一中的计数值。对应的就是模块间通讯和获取数据。


先看看模块一中的代码:


/**
* 模块一具有Activity生命周期感知能力
*/

class ModuleOneItemBinder(
private val lifecycleOwner: LifecycleOwner
) : QuickViewBindingItemBinder<ModuleOneData, LayoutModuleOneBinding>(),
LifecycleEventObserver, MultiItemEntity {

private var mTimer: Timer? = null
private var mIsStart: Boolean = true //是否开始计时
private var number: Int = 0
private lateinit var mViewBinding: LayoutModuleOneBinding

init {
lifecycleOwner.lifecycle.addObserver(this)
}

@SuppressLint("SetTextI18n")
override fun convert(
holder: BinderVBHolder<LayoutModuleOneBinding>,
data: ModuleOneData
)
{
//TODO 根据数据设置模块的UI
}

override fun onCreateViewBinding(
layoutInflater: LayoutInflater,
parent: ViewGr0up,
viewType: Int
)
: LayoutModuleOneBinding {
mViewBinding = LayoutModuleOneBinding.inflate(layoutInflater, parent, false)
return mViewBinding
}


/**
* 向外暴露调用方法
* 开始计时
*/

fun startTimer() {
if (mTimer != null) {
mIsStart = true
} else {
mTimer = fixedRateTimer(period = 1000L) {
if (mIsStart) {
number++
//修改Adapter中的值,其他模块可以通过Adapter取到这个值,也可以通过接口抛出去,这里是提供另一种思路。
(data[0] as ModuleOneData).text = number.toString()
mViewBinding.tv.text = "计时:$number"
}
}
}
}

/**
* 向外暴露调用方法
* 停止计时
*/

fun stopTimer() {
mTimer?.apply {
mIsStart = false
}
}

/**
* 生命周期部分的处理
*/

override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
when (event) {
Lifecycle.Event.ON_DESTROY -> {
//页面销毁时计时器也取消和销毁
lifecycleOwner.lifecycle.removeObserver(this)
mTimer?.cancel()
mTimer = null
}

else -> {}
}
}

/**
* 设定itemType
*/

override val itemType: Int
get() = MODULE_ONE_ITEM_TYPE

}

模块一向外暴露了startTimer()stopTimer()二个方法,并且让模块一具备了Activity的生命周期感知能力,用于在页面销毁时取消和销毁计时。具备页面生命周期感知能力是模块很重要的特性。


再看看模块二中的代码:


class ModuleTwoItemBinder(private val moduleTwoItemBinderInterface: ModuleTwoItemBinderInterface) :
QuickViewBindingItemBinder<ModuleTwoData, LayoutModuleTwoBinding>(), MultiItemEntity {

@SuppressLint("SetTextI18n")
override fun convert(
holder: BinderVBHolder<LayoutModuleTwoBinding>,
data: ModuleTwoData
)
{

holder.viewBinding.btStartTimer.setOnClickListener { //接口实现
moduleTwoItemBinderInterface.onStartTimer()
}

holder.viewBinding.btStopTimer.setOnClickListener { //接口实现
moduleTwoItemBinderInterface.onStopTimer()
}

holder.viewBinding.btGetTimerNumber.setOnClickListener { //接口实现
holder.viewBinding.tv.text =
"获取到的模块一的计时数据:" + moduleTwoItemBinderInterface.onGetTimerNumber()
}

}

/**
* 可以做局部刷新
*/

override fun convert(
holder: BinderVBHolder<LayoutModuleTwoBinding>,
data: ModuleTwoData,
payloads: List<Any>
)
{
super.convert(holder, data, payloads)
if (payloads.isNullOrEmpty()) {
convert(holder, data)
} else {
//TODO 根据具体的payloads做局部刷新
}
}

override fun onCreateViewBinding(
layoutInflater: LayoutInflater,
parent: ViewGr0up,
viewType: Int
)
: LayoutModuleTwoBinding {
return LayoutModuleTwoBinding.inflate(layoutInflater, parent, false)
}

override val itemType: Int
get() = MODULE_TWO_ITEM_TYPE

}

模块二中有一个ModuleTwoItemBinderInterface接口对象,用于调用接口方法,具体接口实现在外部。convert有全量刷新和局部刷新的方法,对于刷新也比较友好。


接着看看是如何把不同的模块拼接起来的:


class MultipleModuleTestAdapter(
private val lifecycleOwner: LifecycleOwner,
data: MutableList<Any>? = null
) : BaseBinderAdapter(data) {

override fun getItemViewType(position: Int): Int {
return position + 1
}

/**
* 给类型一和类型二设置数据
*/

fun setData(response: String) {
val moduleOneData = ModuleOneData().apply { text = "模块一数据:$response" }
val moduleTwoData = ModuleTwoData().apply { text = "模块二数据:$response" }
//给Adapter设置数据
setList(arrayListOf(moduleOneData, moduleTwoData))
}

/**
* 添加ItemType类型一
*/

fun addItemOneBinder() {
addItemBinder(
ModuleOneData::class.java,
ModuleOneItemBinder(lifecycleOwner)
)
}

/**
* 添加ItemType类型二
*/

fun addItemTwoBinder(moduleTwoItemBinderInterface: ModuleTwoItemBinderInterface) {
addItemBinder(
ModuleTwoData::class.java,
ModuleTwoItemBinder(moduleTwoItemBinderInterface)
)
}

}

class MainModuleManager(
private val activity: MainActivity,
private val viewModel: MainViewModel,
private val viewBinding: ActivityMainBinding
) {

private var multipleModuleTestAdapter: MultipleModuleTestAdapter? = null

/**
* 监听请求数据的回调
*/

fun observeData() {
viewModel.requestDataLiveData.observe(activity) {
//接口请求到的数据
initAdapter(it)
}
}

private fun initAdapter(response: String) {
//创建Adapter
multipleModuleTestAdapter = MultipleModuleTestAdapter(activity)
//设置RecyclerView
viewBinding.rcy.apply {
layoutManager = LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
adapter = multipleModuleTestAdapter
}
//创建ModuleTwoItemBinder的接口实现类
val moduleTwoItemBinderImpl = ModuleTwoItemBinderImpl(multipleModuleTestAdapter)
//添加Item类型,组装UI,可以根据后台数据动态化
multipleModuleTestAdapter?.addItemOneBinder()
multipleModuleTestAdapter?.addItemTwoBinder(moduleTwoItemBinderImpl)
//给所有的Item添加数据
multipleModuleTestAdapter?.setData(response)
}


/**
* 刷新单个模块的数据,也可以刷新单个模块的某个部分,需要设置playload
*/

fun refreshModuleData(position: Int, newData: Any?) {
multipleModuleTestAdapter?.apply {
newData?.let {
data[position] = newData
notifyItemChanged(position)
}
}
}

}

MultipleModuleTestAdapter中定义了多种ViewType,通过MainModuleManager返回的数据,动态的组装添加ViewType


最后就是在MainActivity中调用MainModuleManager,代码如下:


class MainActivity : AppCompatActivity() {

private val mainViewModel: MainViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val activityMainBinding: ActivityMainBinding =
ActivityMainBinding.inflate(layoutInflater)
setContentView(activityMainBinding.root)

//请求数据
mainViewModel.requestData()

//拆分RecyclerView的逻辑
val mainModuleManager = MainModuleManager(this, mainViewModel, activityMainBinding)
//回调数据到MainModuleManager中
mainModuleManager.observeData()

//TODO 如果有其他控件编写其他控件的逻辑

}

}

这样我们通过定义不同的ItemBinder实现了模块的划分,通过定义接口实现了模块间的通讯,通过后台返回数据动态的组装了页面。


其他代码一并写在末尾,方便阅读和理解:


image.png


ModuleConstant


object ModuleConstant {
//ItemType
const val MODULE_ONE_ITEM_TYPE = 0
const val MODULE_TWO_ITEM_TYPE = 1
}

ModuleOneDataModuleTwoData都是data类,内容完全一致,随便定义的:


data class ModuleOneData(
var text: String? = ""
)

ModuleTwoItemBinderImplModuleTwoItemBinderInterface的实现类,通过Adapter能轻松的获取到不同的ItemBinder,所以可以通过接口互相调用彼此的函数。


class ModuleTwoItemBinderImpl(private val multipleModuleTestAdapter: MultipleModuleTestAdapter?) :
ModuleTwoItemBinderInterface {

/**
* 外部实现里面的方法
*/

override fun onStartTimer() {
//通过`Adapter`能轻松的获取到不同的`ItemBinder`,所以可以通过接口互相调用彼此的函数
val moduleOneItemBinder =
multipleModuleTestAdapter?.getItemBinder(ModuleConstant.MODULE_ONE_ITEM_TYPE + 1) as ModuleOneItemBinder
moduleOneItemBinder.startTimer()
}

override fun onStopTimer() {
//通过`Adapter`能轻松的获取到不同的`ItemBinder`,所以可以通过接口互相调用彼此的函数
val moduleOneItemBinder =
multipleModuleTestAdapter?.getItemBinder(ModuleConstant.MODULE_ONE_ITEM_TYPE + 1) as ModuleOneItemBinder
moduleOneItemBinder.stopTimer()
}

override fun onGetTimerNumber(): String {
multipleModuleTestAdapter?.apply {
//通过Adapter可以轻松的拿到其他模块的数据
return (data[0] as ModuleOneData).text ?: "0"
}
return "0"
}

}

interface ModuleTwoItemBinderInterface {

//开始计时
fun onStartTimer()

//停止计时
fun onStopTimer()

//获取计时数据
fun onGetTimerNumber():String
}

四、总结


通过定义不同的ItemBinder将页面划分为不同模块,实现UI和交互解耦,单个ItemBinder也可以在其他页面进行复用。通过后台数据动态的添加ItemBinder页面组装更灵活。任务分拆,提高开发效率。


五、注意事项


1、不要把太复杂的UI交互放在单一模块,处理起来费劲。

2、如果二个模块中间需要大量的通讯,写太多接口也费劲,最好看能不能放一个模块。

3、数据最好请求好后再塞进去给各个ItemBinder用,方便统一处理UI。当然如果各个模块想自己处理UI,那各个模块也可以自己去请求接口。毕竟模块隔离,彼此也互不影响。

4、页面如果不是很复杂,不需要拆分成模块,不需要使用这种方式,直接一个XML搞定,清晰简单。


时间仓促,如有错误欢迎批评指正!!


作者:TimeFine
来源:juejin.cn/post/7296865632166477833
收起阅读 »

Android 签名、打包、上架

最近在做一些简单的Android需求开发,其他打包的过程碰到的一些问题做一个梳理。 【Android需要通过AS-> Open,打开工程,不然容易出问题】 1.签名 a.keystore.jks文件 接受的项目都是已经比较成熟的项目,在项目的目录下都有一...
继续阅读 »

最近在做一些简单的Android需求开发,其他打包的过程碰到的一些问题做一个梳理。
【Android需要通过AS-> Open,打开工程,不然容易出问题】


1.签名


a.keystore.jks文件

接受的项目都是已经比较成熟的项目,在项目的目录下都有一个.jks的文件,里面会包含一些秘钥信息
image.png
在工程中的Android目录下build.gradle(Module:xxxx.app)里面会有秘钥的详细image.png


b.开始签名

image.png
image.png



如果工程中已经有.jks文件,选择Choose existing...选项,选中Project目录中的.jks文件即可.



image.png
然后继续
image.png



至此,打包完成了,根目录下的app文件夹里面找到debugrelease里面就是刚刚打包成功的.apk文件。
如果需要创建新的秘钥



image.png



拓展:怎么生成.jks文件夹、怎么生成签名秘钥



2.生成.jks文件


a.创建并在Project工程目录下生成.jks文件,与app目录同级

image.png


选择Creat new进入创建界面



重要!!! 需要选择项目下的app目录下,然后修改Untitled名称改为keystore.jks,保存即可,保存之后会返回一下界面,填写相关信息即可成功创建相关秘钥,并保存在刚才创建的.jks文件中,保存即可。



image.png


b.配置打包Signing Configs

image.png
image.png
image.png
image.png
Pasted Graphic.png
image.png



按照图示的步骤来,即可完成配置。
然后在app 目录的build.gradle文件中可看到如下生成的代码配置。



image.png



注意:出现如下图示,不影响apk打包,但是有警告,相对路径去怎么解决这个问题,有知道的,可以告知一下。



Pasted Graphic 3.png


3.处理apk包名显示



正常情况下如果是内部软件,不需要加固,如果是外部软件加固一下【腾讯乐固】,对于生成的包名称可以配置显示【名称+版本+版本号+时间】,配置如下:截图框出的方法需要写在andriod方法里面



image.png


// 自定义打包名称
android.applicationVariants.all { variant ->
variant.outputs.all {
outputFileName = "xxxAPK_${buildType.name}_v${versionName}_${generateTime()}.apk"
}
}

构建时间的方法需要在android方法外


//构建时间
def generateTime() {
return new Date().format("yyyyMMddHHmmss")
}

4.加固包重签名处理



AS打包生成的apk包是签名包,上传到 【腾讯乐固】加固后,这时候的加固包是不能直接安装或者上传应用市场,需要在签名一次才可以。以下就是加固包签名的命令行命令



 jarsigner -verbose -keystore xx[jsk文件绝对路径]xx.jks -signedjar xxx[加固前的apk包绝对路径]xxxAPK_release_v1.0.6_20231026092106.apk   xx[加固后的apk包绝对路径]xx.apk  xx[秘钥的名称keyAlias]xx

中间都是空格隔开就可以,主要理解是加固前和加固后的包的位置。然后秘钥keyAlias的名称需要app目录下的build.gradle文件里面找。



至此,可以上传重签名后的apk包到应用市场了 参考



5.相对路径


在Android工程配置中,可以使用相对路径来表达文件或目录的位置。相对路径是相对于当前文件或目录的路径,而不是完整的绝对路径。


以下是在Android工程配置中使用相对路径的一些示例:



  1. 在Gradle脚本中引用相对路径:


def relativePath = '../subdirectory/myfile.txt'


  1. 在AndroidManifest.xml文件中引用相对路径:


<meta-data
android:name="my_data"
android:value="../subdirectory/myfile.txt" />



  1. 在资源文件(如布局文件或字符串资源文件)中引用相对路径:


<ImageView
android:src="@drawable/../subdirectory/myimage.png" />


在上述示例中,相对路径使用../来表示从当前位置向上一级目录的相对路径。你可以根据实际情况调整相对路径的格式和层数。


使用相对路径的好处是,它提供了一种相对于当前位置的灵活方式来引用文件或目录。这样,当你的工程目录结构发生变化时,不需要修改绝对路径,只需调整相对路径即可。


请注意,相对路径的解析取决于当前位置,因此确保当前位置的准确性和相对路径的正确性。


总而言之,使用相对路径可以在Android工程配置中指定文件或目录的位置,使其更具可移植性和灵活性。根据你的具体需求,可以在相应的配置文件或资源中使用相对路径来引用文件或目录。


作者:AKA
来源:juejin.cn/post/7296011286093168659
收起阅读 »

无悬浮窗权限实现全局Dialog

有些场景下需要显示一些提示弹窗,但把握不好弹出时机容易先弹出弹窗然后界面马上被杀掉进而看不到提示内容,例如强制下线:客户端退回登录界面并弹出提示弹窗。 如果是直接拿的栈顶activity去弹出,没有将弹窗逻辑写到具体activity中,或不好确定activty...
继续阅读 »

有些场景下需要显示一些提示弹窗,但把握不好弹出时机容易先弹出弹窗然后界面马上被杀掉进而看不到提示内容,例如强制下线:客户端退回登录界面并弹出提示弹窗。


如果是直接拿的栈顶activity去弹出,没有将弹窗逻辑写到具体activity中,或不好确定activty的变化就容易出现这种现象。


由于applicationContext没有AppWindowToken,所以dialog无法使用applicationContext创建,要么就使用windowManager配合WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY使用创建全局悬浮窗。但是这种做法需要申请权限。那么,在没有悬浮权限情况下如何做到让dialog不受栈顶activity变化的影响?


我的想法是通过application.registerActivityLifecycleCallbacks在activity变化时,关闭原来的弹窗,并重新创建一个一样的dialog并显示。


效果演示:


1. 栈顶界面被杀


界面退出

2. 有新界面弹出


界面退出

以下是代码实现:


/**
* @Description 无需悬浮权限的全局弹窗,栈顶activity变化后通过反射重建,所以子类构造方法需无参
*/

open class BaseAppDialog<T : ViewModel>() : Dialog(topActivity!!.get()!!), ViewModelStoreOwner {

companion object {
private val TAG = BaseAppDialog::class.java.simpleName
private var topActivity: WeakReference<Activity>? = null
private val staticRestoreList = linkedMapOf<Class<*>, Boolean>() //第二个参数:是否临时关闭
private val staticViewModelStore: ViewModelStore = ViewModelStore()

@JvmStatic
fun init(application: Application) {
application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
topActivity = WeakReference(activity)
}

override fun onActivityStarted(activity: Activity) {

}

override fun onActivityResumed(activity: Activity) {
topActivity = WeakReference(activity)
val tempList = arrayListOf<BaseAppDialog<*>>()
val iterator = staticRestoreList.iterator()
while (iterator.hasNext()) {
val next = iterator.next()
val topName = (topActivity?.get() ?: "")::class.java.name
if (next.value == true) { //避免onCreate创建的弹窗重复弹出
val newInstance = Class.forName(next.key.name).getConstructor().newInstance() as BaseAppDialog<*>
tempList.add(newInstance)
Log.e(TAG, "重新创建${next.key.name},于$topName")
iterator.remove()
}

}

tempList.forEach {
it.show()
}

if (staticRestoreList.size == 0) {
staticViewModelStore.clear()
}
}

override fun onActivityPaused(activity: Activity) {
}

override fun onActivityStopped(activity: Activity) {

}

override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
}

override fun onActivityDestroyed(activity: Activity) {
}
})
}
}


var vm: T? = null

init {
val genericClass = getGenericClass()
if (vm == null) {
(genericClass as? Class<T>)?.let {
vm = ViewModelProvider(this)[it]
}
}

topActivity?.get()?.let {
(it as LifecycleOwner).lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
dismissSilent()
}
})
}
}


//用于栈顶变化时的关闭
private fun dismissSilent() {
super.dismiss()
staticRestoreList.replace(this::class.java, true)
}

override fun show() {
super.show()
staticRestoreList.put(this::class.java, false)
}

override fun dismiss() {
super.dismiss()
staticRestoreList.remove(this::class.java)
}


//获取泛型实际类型
private fun getGenericClass(): Class<*>? {
val superclass = javaClass.genericSuperclass
if (superclass is ParameterizedType) {
val actualTypeArguments: Array<Type>? = superclass.actualTypeArguments
if (!actualTypeArguments.isNullOrEmpty()) {
val type: Type = actualTypeArguments[0]
if (type is Class<*>) {
return type
}
}
}
return ViewModel::class.java
}


//自己管理viewModel以便恢复数据
override fun getViewModelStore(): ViewModelStore {
return staticViewModelStore
}
}

参数传递的话,直接通过修改dialog的viewmodel变量或调用其方法来实现。


class TipDialogVm : ViewModel() {
val content = MutableLiveData<String>("")
}


class TipDialog2 : BaseAppDialog<TipDialogVm>() {

var binding : DialogTip2Binding? = null

init {
binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.dialog_tip2, null, false)
binding?.lifecycleOwner = context as? LifecycleOwner
binding?.vm = vm
setContentView(binding!!.root)

}
}

弹出弹窗


TipDialog2().apply {
vm?.content?.value = "嗨嗨嗨"
}.show()

作者:Abin
来源:juejin.cn/post/7295576843653087266
收起阅读 »

终结屏幕适配这个话题

物理像素、逻辑像素、百分比适配 日常开发中,接触到最多的屏幕相关的单位,分别是物理像素(px),逻辑像素(dp, point)。 那物理像素和逻辑像素的区别是? 这里以一张 3x4px 的图片举例。假设该图片放置在 5x6px 的设屏幕中。如下图所示。 此时...
继续阅读 »

物理像素、逻辑像素、百分比适配


日常开发中,接触到最多的屏幕相关的单位,分别是物理像素(px)逻辑像素(dp, point)


那物理像素和逻辑像素的区别是?


这里以一张 3x4px 的图片举例。假设该图片放置在 5x6px 的设屏幕中。如下图所示。



此时想象这个图片放置在 `10*12px` 的屏幕中会是怎样呢。对比如下,会发现该图片放置在分辨率更高的屏幕中会变得非常狭小。

image.png
继续我们的例子,如果该屏幕想要保证图片能跟前面的低分辨率的设备显示效果一致的话,则图片的宽高应增加1倍的大小。即设备需要2倍的像素比例dpr(device percent ratio)。这样图片3*4逻辑像素的尺寸的图片在高分辨率设备中可以映射成6*8物理像素,而在低分辨率的设备(像素比例时1的设备),则3*4逻辑像素的图片映射为3*4物理像素的图片。


这是逻辑像素的大致机制。逻辑像素会根据目标设备的分辨率和尺寸计算出设备的缩放比例。逻辑像素出现是为了让不同分辨率的设备中显示相同的内容能取得大致相同的效果,当然逻辑像素并不是这样简单的百分比换算。


在Android中这个逻辑像素是dp,而ios中则是pt。在android中dp的换算公式中具体换算公式想了解的可以点击下面链接了解。
betterprogramming.pub/cracking-an…


在Android开发中将不同分辨率设备的中的物理像素比率进行如下分类。所以假设设备是230dpi的话也以hdpi1.5倍进行换算。所以这跟百分比的换算是不太一样的。以“微信”应用举例。


底部的Tab(微信、通讯录、发现,我),假设设计图中屏幕的宽度是375dp,根据tab均分,单个tab为93.75。你如果通过水平布局指定宽度为93.75逻辑像素的话则会发现出来的效果在某些手机上并不是均分的。


如下图类似微信界面运行在Iphone 14 pro。此时应该用百分比进行适配,即在不同的分辨率中基于设计图的尺寸进行等比例换算。如:设计图的分辨率为375*812,而显示设备的分辨率为1080*1920,则设计图上1像素相当于目标设备1 ✖️ “显示设备基于设计图的比例(1080/375=2.88)”像素,即 1✖️2.88=2.88像素。这就是百分比适配。对比下图可以发现逻辑像素适配的“我”是偏左的。


image.png


image.png


百分比适配是一种根据设计图的尺寸和设备的分辨率,以百分比的方式进行换算和适配的方法。通过计算设计图上的像素与目标设备分辨率的比例,可以得到百分比像素的值,从而实现在不同分辨率的设备上保持一致的布局和显示效果。


但是百分比并不是万能的。如下图逻辑像素适配和百分比像素适配的对比。在列表中,百分比布局则会出现一个问题。你会发现在大尺寸高分辨率的设备中,列表中的每一项都特别大。则如果用逻辑像素(dp、pt)则是这样。使用逻辑像素能充分发挥大屏的优势,屏幕越大显示的内容更多。


image.png


什么时候应该用逻辑像素,百分比像素。


具体什么时候应该用逻辑像素和百分比像素适配,取决于设计图UI。根据不同设计意图决定何种方案。大部分情况下使用逻辑像素不会出现什么问题,列表item必定使用逻辑像素。但是什么时候应该用百分比像素呢?


举个例子:



ps: 例子中我会以百分比像素表示将设计图像素根据不同分辨率设备等比例换算的像素。即1百分比像素= 1✖️ [(设计图分辨率)/ (目标设备分辨率)]。



下面是一个“购买成功”的UI图。中间有个票根信息。票根信息有个票根背景图片。


标注图中的屏幕分辨率为 393*852


image.png


这里票根信息UI应该用逻辑像素还是百分比像素适配呢?


通过标注图能明显看出票根信息在宽度上固定需要占用一定比例。所以这里宽度应该为 353百分比像素 。为了宽高比例正确,故高度也应为 346百分比像素 。注意这里高度的 346百分比像素 也应该是基于屏幕宽度 393 的百分比像素。即 目标设备屏幕宽度 * 346 / 393


因为整个票根的宽高都为百分比适配,则里面子部件的摆放、间距都应按照百分比的方式进行适配。不然则会出现子部件没法像标注图那样正确对齐的情况。


总结


物理像素(px)是屏幕上的实际物理点,表示屏幕上显示内容的最小单位。逻辑像素(dp、pt)是开发中使用的抽象单位,与物理像素的关系由设备的像素密度决定。


逻辑像素是开发中使用的抽象单位,它们与物理像素之间有一个映射关系。在不同的设备上,逻辑像素的布局和大小是相对统一的。使用逻辑像素可以让开发者在不同分辨率的设备上保持一致的布局和显示效果。


百分比适配是一种根据设计图的尺寸和设备的分辨率,以百分比的方式进行换算和适配的方法。通过计算设计图上的像素与目标设备分辨率的比例,可以得到百分比像素的值,从而实现在不同分辨率的设备上保持一致的布局和显示效果。


一般情况下,使用逻辑像素可以保持在不同设备上显示内容的一致性和最佳效果,特别是在涉及列表和大屏幕显示的情况下,需要根据设计图,决定使用何种方案。可以通过先分析使用逻辑像素思考是否合理,再考虑百分比适配的情况。在一些特定的设计需求下,如背景图片的铺满屏幕、比例布局等,可以考虑使用百分比适配来实现更精确的布局和显示效果。


作者:淹没
来源:juejin.cn/post/7294853623849812002
收起阅读 »

如何用Compose TV写电视桌面

写在前面 Compose TV 最近出来已经有一段时间,对电视开发支持的非常好,比如标题,横向/纵向列表,焦点等. 下图为最终效果成品。 Demo源码地址 整体UI框架搭建 标题(TabRow) + NatHost(内容切换) + 内容(TvLazyColu...
继续阅读 »

写在前面


Compose TV 最近出来已经有一段时间,对电视开发支持的非常好,比如标题,横向/纵向列表,焦点等.


下图为最终效果成品。



Demo源码地址


整体UI框架搭建


标题(TabRow) + NatHost(内容切换) + 内容(TvLazyColumn)



标题-TabRow



val tabs = listof("我的", "影视", "应用")

TabRow(
selectedTabIndex = selectedTabIndex,
indicator = { tabPositions, isActivated ->
// 移动的白色色块
TopBarMoveIndicator(...
}
) {
tabs.forEachIndexed { index, title ->
Tab(
// colors设置了 默认,上焦,选中的颜色
colors = TabDefaults.pillIndicatorTabColors(
contentColor = Color.White,
focusedContentColor = Color.Black,
selectedContentColor = Color.White,
)
...
) {
Text(...)
}
}
}

移动的白色色块,这里只是我写的Demo,都是可以自定义的.


fun TopBarMoveIndicator(
currentTabPosition: DpRect,
isFocused: Boolean
)
{
val width by animateDpAsState(targetValue = currentTabPosition.width, label = "width")
val height = if (isFocused) currentTabPosition.height else 2.dp
val leftOffset by animateDpAsState(targetValue = currentTabPosition.left, label = "leftOffset")
// 有焦点的时候,是矩形,无焦点的时候,是下划线.
val moveShape = if (isFocused) ShapeDefaults.ExtraLarge else ShapeDefaults.ExtraSmall

Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(Alignment.BottomStart)
.offset(leftOffset, currentTabPosition.top)
.width(width)
.height(height)
.background(color = Color.White, shape = moveShape)
.zIndex(-1f)
)
}

NatHost(内容切换) + 内容(TvLazyColumn)


内容切换


NatHost 功能类似 ViewPager,对 "我的","影视","应用" 几个 页面内容进行切换.


NavHost(
...
builder = {
composable(...) { // 我的
// 我的野蛮
}
composable(...) {// 影视
// 影视页面
}
composable(...) { // 应用
// 应用页面
}
}
)

内容布局


TvLazyColumn 与 LazyColumn 功能是差不多的,纵向布局,就不过多赘述,具体看谷歌的开发文档,网上相关视频教程 或 看DEMO源码.


TvLazyColumn(
...
) {
item {
ImmersiveList(...) // 沉浸式列表
}
item {
TvLazyRow(...) // 热门推荐
}
item {
TvLazyRow(...)
}
item {
TvLazyRow(...) // 豆瓣高分
TvLazyRow(...)
}
item {
TvLazyRow(...) // 预热抢先看
}
... ...
}

TvLazyColumn的相关参数,记住这个参数 pivotOffsets,它是设置滚动的位置的,比如设置滚动一直在中间位置.


fun TvLazyColumn(
modifier: Modifier = Modifier,
state: TvLazyListState = rememberTvLazyListState()
,
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
userScrollEnabled: Boolean = true,
pivotOffsets: PivotOffsets = PivotOffsets(),
content: TvLazyListScope.() -> Unit
)

TvLazyRow + Item


TvLazyColumn 每行又包含了 TvLazyRow 横向布局 (如果是固定的几个,可以用 Row。


自定义的布局可以用 Surface 包含的,几个关键属性, Scale(放大),Border(边框),Glow(阴影)。



TvLazyRow(...) {
items(...) { ...
Surface(
onClick = {//点击事件}
scale = ClickableSurfaceDefaults.scale(...),
border = ClickableSurfaceDefaults.border(...),
glow = ClickableSurfaceDefaults.glow(...)
) {
// 你自定义的卡片内容,比如 图片(AsyncImage) + 文本(Text)
}
}
}

我Demo里面用的是 谷歌提供的一个包含 图片+文本的控件 StandardCardLayout


ImmersiveList 沉浸式列表

有点类似 爱奇艺,腾讯,哔哩哔哩等电视应用这种列表.


ImmersiveList(
modifier = Modifier.onGloballyPositioned { currentYCoord = it.positionInWindow().y },
background = {
// 背景图片内容
}
) {
// 布局内容
// 大标题 + 详情
// TvLazyRow
}

TV其它控件推荐


Carousel 轮播界面



TvLazyVerticalGrid/TvLazyHorizontalGrid


ModalNavigationDrawer抽屉式导航栏


ListItem


分辨率适配


TV开发涉及分辨率适配问题,Compose 也能很简单的处理此问题,无论你在1920x1080,还是1280x720等分辨率下,无缝切换,毫无压力.


val displayMetrics = LocalContext.current.resources.displayMetrics
val fontScale = LocalDensity.current.fontScale
val density = displayMetrics.density
val widthPixels = displayMetrics.widthPixels
val widthDp = widthPixels / density
val display = "density: $density\nwidthPixels: $widthPixels\nwidthDp: $widthDp"
KLog.d("display:$display")
CompositionLocalProvider(
LocalDensity provides Density(
density = widthPixels / 1920f,
fontScale = fontScale
)
) {
// 我们写的Compose主界面布局
}

参考资料


What's new with TV and intro to Compose


Android TV 上使用 Jetpack Compose


Compose TV官方设计文档


JetStreaamCompose TV demo


Compose TV demo


写在后面


近几年Android推出了很多东西,我的心尖尖是 MVI,flow(完爆Rxjava),Compose>>>


TV开发的发展,一开始是 RecycleView,要去解决焦点,优化等问题,后来是Leanback,到现在的Compose TV(开发速度提升了很多很多).


我也真的很喜欢Compose的写法,简单明了,强烈推荐Compose TV开发电视,我相信谷歌,能将Compose性能优化的越来越好.


最后一篇TV开发的文章了,以后搞车载相关去了.


作者:冰雪情缘long
来源:juejin.cn/post/7294907512444010559
收起阅读 »

Android使用Hilt依赖注入,让人看不懂你代码

前言 之前接手的一个项目里有些代码看得云里雾里的,找了半天没有找到对象创建的地方,后来才发现原来使用了Hilt进行了依赖注入。Hilt相比Dagger虽然已经比较简洁,但对初学者来说还是有些门槛,并且网上的许多文章都是搬自官网,入手容易深入难,如果你对Hilt...
继续阅读 »

前言


之前接手的一个项目里有些代码看得云里雾里的,找了半天没有找到对象创建的地方,后来才发现原来使用了Hilt进行了依赖注入。Hilt相比Dagger虽然已经比较简洁,但对初学者来说还是有些门槛,并且网上的许多文章都是搬自官网,入手容易深入难,如果你对Hilt不了解或是想了解得更多,那么接下来的内容将助力你玩转Hilt。


通过本篇文章,你将了解到:




  1. 什么是依赖注入?

  2. Hilt 的引入与基本使用

  3. Hilt 的进阶使用

  4. Hilt 原理简单分析

  5. Android到底该不该使用DI框架?



1. 什么是依赖注入?


什么是依赖?


以手机为例,要组装一台手机,我们需要哪些部件呢?

从宏观上分类:软件+硬件。

由此我们可以说:手机依赖了软件和硬件。

而反映到代码的世界:


class FishPhone(){
val software = Software()
val hardware = Hardware()
fun call() {
//打电话
software.handle()
hardware.handle()
}
}
//软件
class Software() {
fun handle(){}
}
//硬件
class Hardware() {
fun handle(){}
}

FishPhone 依赖了两个对象:分别是Software和Hardware。

Software和Hardware是FishPhone的依赖(项)。


什么是注入?


上面的Demo,FishPhone内部自主构造了依赖项的实例,考虑到依赖的变化挺大的,每次依赖项的改变都要改动到FishPhone,容易出错,也不是那么灵活,因此考虑从外部将依赖传进来,这种方式称之为:依赖注入(Dependency Injection 简称DI)

有几种方式:




  1. 构造函数传入

  2. SetXX函数传入

  3. 从其它对象间接获取



构造函数依赖注入:


class FishPhone(val software: Software, val hardware: Hardware){
fun call() {
//打电话
software.handle()
hardware.handle()
}
}

FishPhone的功能比较纯粹就是打电话功能,而依赖项都是外部传入提升了灵活性。


为什么需要依赖注入框架?


手机制造出来后交给客户使用。


class Customer() {
fun usePhone() {
val software = Software()
val hardware = Hardware()
FishPhone(software, hardware).call()
}
}

用户想使用手机打电话,还得自己创建软件和硬件,这个手机还能卖出去吗?

而不想创建软件和硬件那得让FishPhone自己负责去创建,那不是又回到上面的场景了吗?


你可能会说:FishPhone内部就依赖了两个对象而已,自己负责创建又怎么了?


解耦


再看看如下Demo:


interface ISoftware {
fun handle()
}

//硬件
interface IHardware {
fun handle()
}

//软件
class SoftwareImpl() : ISoftware {
override fun handle() {}
}

//硬件
class HardwareImpl : IHardware {
override fun handle() {}
}

class FishPhone() {
val software: ISoftware = SoftwareImpl()
val hardware: IHardware = HardwareImpl()
fun call() {
//打电话
software.handle()
hardware.handle()
}
}

FishPhone 只关注软件和硬件的接口,至于具体怎么实现它不关心,这就达到了解耦的目的。
既然要解耦,那么SoftwareImpl()、HardwareImpl()就不能出现在FishPhone里。

应该改为如下形式:


class FishPhone(val software: ISoftware, val hardware: IHardware) {
fun call() {
//打电话
software.handle()
hardware.handle()
}
}

消除模板代码


即使我们不考虑解耦,假若HardwareImpl里又依赖了cpu、gpu、disk等模块:


//硬件
class HardwareImpl : IHardware {
val cpu = CPU(Regisgter(), Cal(), Bus())
val gpu = GPU(Image(), Video())
val disk = Disk(Block(), Flash())
//...其它模块
override fun handle() {}
}

现在仅仅只是三个模块,若是依赖更多的模块或者模块的本身也需要依赖其它子模块,比如CPU需要依赖寄存器、运算单元等等,那么我们就需要写更多的模板代码,要是我们只需要声明一下想要使用的对象而不用管它的创建就好了。


class HardwareImpl(val cpu: CPU, val gpu: GPU, val disk: Disk) : IHardware {
override fun handle() {}
}

可以看出,下面的代码比上面的简洁多了。




  1. 从解耦和消除模板代码的角度看,我们迫切需要一个能够自动创建依赖对象并且将依赖注入到目标代码的框架,这就是依赖注入框架

  2. 依赖注入框架能够管理依赖对象的创建,依赖对象的注入,依赖对象的生命周期

  3. 使用者仅仅只需要表明自己需要什么类型的对象,剩下的无需关心,都由框架自动完成



先想想若是我们想要实现这样的框架需要怎么做呢?

相信很多小伙伴最朴素的想法就是:使用工厂模式,你传参告诉我想要什么对象我给你构造出来。

这个想法是半自动注入,因为我们还要调用工厂方法去获取,而全自动的注入通常来说是使用注解标注实现的。


2. Hilt 的引入与基本使用


Hilt的引入


从Dagger到Dagger2再到Hilt(Android专用),配置越来越简单也比较容易上手。

前面说了依赖注入框架的必要性,我们就想迫不及待的上手,但难度可想而知,还好大神们早就造好了轮子。

以AGP 7.0 以上为例,来看看Hilt框架是如何引入的。


一:project级别的build.gradle 引入如下代码:


plugins {
//指定插件地址和版本
id 'com.google.dagger.hilt.android' version '2.48.1' apply false
}

二:module级别的build.gradle引入如下代码:


plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
//使用插件
id 'com.google.dagger.hilt.android'
//kapt生成代码
id 'kotlin-kapt'
}
//引入库
implementation 'com.google.dagger:hilt-android:2.48.1'
kapt 'com.google.dagger:hilt-compiler:2.48.1'

实时更新最新版本以及AGP7.0以下的引用请参考:Hilt最新版本配置


Hilt的简单使用


前置步骤整好了接下来看看如何使用。


一:表明该App可以使用Hilt来进行依赖注入,添加如下代码:


@HiltAndroidApp
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
}
}

@HiltAndroidApp 添加到App的入口,即表示依赖注入的环境已经搭建好。


二:注入一个对象到MyApp里:

有个类定义如下:


class Software {
val name = "fish"
}

我们不想显示的构造它,想借助Hilt注入它,那得先告诉Hilt这个类你帮我注入一下,改为如下代码:


class Software @Inject constructor() {
val name = "fish"
}

在构造函数前添加了@Inject注解,表示该类可以被注入。

而在MyApp里使用Software对象:


@HiltAndroidApp
class MyApp : Application() {
@Inject
lateinit var software: Software

override fun onCreate() {
super.onCreate()
println("inject result:${software.name}")
}
}

对引用的对象使用@Inject注解,表示期望Hilt帮我将这个对象new出来。

最后查看打印输出正确,说明Software对象被创建了。


这是最简单的Hilt应用,可以看出:




  1. 我们并没有显式地创建Software对象,而Hilt在适当的时候就帮我们创建好了

  2. @HiltAndroidApp 只用于修饰Application



如何注入接口?


一:错误示范
上面提到过,使用DI的好处之一就是解耦,而我们上面注入的是类,现在我们将Software抽象为接口,很容易就会想到如下写法:


interface ISoftware {
fun printName()
}

class SoftwareImpl @Inject constructor(): ISoftware{
override fun printName() {
println("name is fish")
}
}

@HiltAndroidApp
class MyApp : Application() {
@Inject
lateinit var software: ISoftware

override fun onCreate() {
super.onCreate()
println("inject result:${software.printName()}")
}
}

不幸的是上述代码编译失败,Hilt提示说不能对接口使用注解,因为我们并没有告诉Hilt是谁实现了ISoftware,而接口本身不能直接实例化,因此我们需要为它指定具体的实现类。


二:正确示范

再定义一个类如下:


@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
abstract fun bindSoftware(impl: SoftwareImpl):ISoftware
}



  1. @Module 表示该类是一个Hilt的Module,固定写法

  2. @InstallIn 表示模块在哪个组件生命周期内生效,SingletonComponent::class指的是全局

  3. 一个抽象类,类名随意

  4. 抽象方法,方法名随意,返回值是需要被注入的对象类型(接口),而参数是该接口的实现类,使用@Binds注解标记,



如此一来我们就告诉了Hilt,SoftwareImpl是ISoftware的实现类,于是Hilt注入ISoftware对象的时候就知道使用SoftwareImpl进行实例化。
其它不变运行一下:
image.png


可以看出,实际注入的是SoftwareImpl。



@Binds 适用在我们能够修改类的构造函数的场景



如何注入第三方类


上面的SoftwareImpl是我们可以修改的,因为使用了@Inject修饰其构造函数,所以可以在其它地方注入它。

在一些时候我们不想使用@Inject修饰或者说这个类我们不能修改,那该如何注入它们呢?


一:定义Provides模块


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():Hardware {
return Hardware()
}
}



  1. @Module和@InstallIn 注解是必须的

  2. 定义object类

  3. 定义函数,方法名随意,返回类型为我们需要注入的类型

  4. 函数体里通过构造或是其它方式创建具体实例

  5. 使用@Provides注解函数



二:依赖使用

而Hardware定义如下:


class Hardware {
fun printName() {
println("I'm fish")
}
}

在MyApp里引用Hardware:

在这里插入图片描述


虽然Hardware构造函数没有使用@Inject注解,但是我们依然能够使用依赖注入。


当然我们也可以注入接口:


interface IHardware {
fun printName()
}

class HardwareImpl : IHardware {
override fun printName() {
println("name is fish")
}
}

想要注入IHardware接口,需要定义provides模块:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():IHardware {
return HardwareImpl()
}
}


@Provides适用于无法修改类的构造函数的场景,多用于注入第三方的对象



3. Hilt 的进阶使用


限定符


上述 ISoftware的实现类只有一个,假设现在有两个实现类呢?

比如说这些软件可以是美国提供,也可以是中国提供的,依据上面的经验我们很容易写出如下代码:


class SoftwareChina @Inject constructor() : ISoftware {
override fun printName() {
println("from china")
}
}

class SoftwareUS @Inject constructor() : ISoftware {
override fun printName() {
println("from US")
}
}

@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
abstract fun bindSoftwareCh(impl: SoftwareChina):ISoftware

@Binds
abstract fun bindSoftwareUs(impl: SoftwareUS):ISoftware
}

//依赖注入:
@Inject
lateinit var software: ISoftware

兴高采烈的进行编译,然而却报错:
image.png


也就是说Hilt想要注入ISoftware,但不知道选择哪个实现类,SoftwareChina还是SoftwareUS?没人告诉它,所以它迷茫了,索性都绑定了。


这个时候我们需要借助注解:@Qualifier 限定符注解来对实现类进行限制。

改造一下:


@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
@China
abstract fun bindSoftwareCh(impl: SoftwareChina):ISoftware

@Binds
@US
abstract fun bindSoftwareUs(impl: SoftwareUS):ISoftware
}

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class US

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class China

定义新的注解类,使用@Qualifier修饰。

而后在Module里,分别使用注解类修饰返回的函数,如bindSoftwareCh函数指定返回SoftwareChina来实现ISoftware接口。


最后在引用依赖注入的地方分别使用@China @US修饰。


    @Inject
@US
lateinit var software1: ISoftware

@Inject
@China
lateinit var software2: ISoftware

此时,虽然software1、software2都是ISoftware类型,但是由于我们指定了限定符@US、@China,因此最后真正的实现类分别是SoftwareChina、SoftwareUS。



@Qualifier 主要用在接口有多个实现类(抽象类有多个子类)的注入场景



预定义限定符


上面提及的限定符我们还可以扩展其使用方式。

你可能发现了,上述提及的可注入的类构造函数都是无参的,很多时候我们的构造函数是需要有参数的,比如:


class Software @Inject constructor(val context: Context) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}
//注入
@Inject
lateinit var software: Software

这个时候编译会报错:

image.png
意思是Software依赖的Context没有进行注入,因此我们需要给它注入一个Context。


由上面的分析可知,Context类不是我们可以修改的,只能通过@Provides方式提供其注入实例,并且Context有很多子类,我们需要使用@Qualifier指定具体实现类,因此很容易我们就想到如下对策。

先定义Module:


@Module
@InstallIn(SingletonComponent::class)
object MyContextModule {
@Provides
@GlobalContext
fun provideContext(): Context? {
return MyApp.myapp
}
}

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class GlobalContext

再注入Context:


class Software @Inject constructor(@GlobalContext val context: Context?) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}

可以看出,借助@Provides和@Qualifier,可以实现全局的Context。

当然了,实际上我们无需如此麻烦,因为这部分工作Hilt已经预先帮我们弄了。

与我们提供的限定符注解GlobalContext类似,Hilt预先提供了:


@Qualifier
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
public @interface ApplicationContext {}

因此我们只需要在需要的地方引用它即可:


class Software @Inject constructor(@ApplicationContext val context: Context?) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}

如此一来我们无需重新定义Module。




  1. 除了提供Application级别的上下文:@ApplicationContext,Hilt还提供了Activity级别的上下文:@ActivityContext,因为是Hilt内置的限定符,因此称为预定义限定符。

  2. 如果想自己提供限定符,可以参照GlobalContext的做法。



组件作用域和生命周期


Hilt支持的注入点(类)


以上的demo都是在MyApp里进行依赖,MyApp里使用了注解:@HiltAndroidApp 修饰,表示当前App支持Hilt依赖,Application就是它支持的一个注入点,现在想要在Activity里使用Hilt呢?


@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {

除了Application和Activity,Hilt内置支持的注入点如下:
image.png


除了Application和ViewModel,其它注入点都是通过使用@AndroidEntryPoint修饰。



注入点其实就是依赖注入开始的点,比如Activity里需要注入A依赖,A里又需要注入B依赖,B里又需要注入C依赖,从Activity开始我们就能构建所有的依赖



Hilt组件的生命周期


什么是组件?在Dagger时代我们需要自己写组件,而在Hilt里组件都是自动生成的,无需我们干预。
依赖注入的本质实际上就是在某个地方悄咪咪地创建对象,这个地方的就是组件,Hilt专为Android打造,因此势必适配了Android的特性,比如生命周期这个Android里的重中之重。

因此Hilt的组件有两个主要功能:




  1. 创建、注入依赖的对象

  2. 管理对象的生命周期



Hilt组件如下:
image.png


可以看出,这些组件的创建和销毁深度绑定了Android常见的生命周期。

你可能会说:上面貌似没用到组件相关的东西,看了这么久也没看懂啊。

继续看个例子:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():IHardware {
return HardwareImpl()
}
}

@InstallIn(SingletonComponent::class) 表示把模块安装到SingletonComponent组件里, SingletonComponent组件顾名思义是全局的,对应的是Application级别。因此安装的这个模块可在整个App里使用。


问题来了:SingletonComponent是不是表示@Provides修饰的函数返回的实例是同一个?

答案是否定的。


这就涉及到组件的作用域。


组件的作用域


想要上一小结的代码提供全局唯一实例,则可用组件作用域注解修饰函数:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
@Singleton
fun provideHardware():IHardware {
return HardwareImpl()
}
}

当我们在任何地方注入IHardware时,获取到的都是同一个实例。

除了@Singleton表示组件的作用域,还有其它对应组件的作用域:

image.png


简单解释作用域:

@Singleton 被它修饰的构造函数或是函数,返回的始终是同一个实例

@ActivityRetainedScoped 被它修饰的构造函数或是函数,在Activity的重建前后返回同一实例

@ActivityScoped 被它修饰的构造函数或是函数,在同一个Activity对象里,返回的都是同一实例

@ViewModelScoped 被它修饰的构造函数或是函数,与ViewModel规则一致




  1. Hilt默认不绑定任何作用域,由此带来的结果是每一次注入都是全新的对象

  2. 组件的作用域要么不指定,要指定那必须和组件的生命周期一致



以下几种写法都不符合第二种限制:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
@ActivityScoped//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}

@Module
@InstallIn(ActivityComponent::class)
object HardwareModule {
@Provides
@Singleton//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}

@Module
@InstallIn(ActivityRetainedComponent::class)
object HardwareModule {
@Provides
@ActivityScoped//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}

除了修饰Module,作用域还可以用于修饰构造函数:


@ActivityScoped
class Hardware @Inject constructor(){
fun printName() {
println("I'm fish")
}
}

@ActivityScoped表示不管注入几个Hardware,在同一个Activity里注入的实例都是一致的。


构造函数里无法注入的字段


一个类的构造函数如果被@Inject注入,那么构造函数的其它参数都需要支持注入。


class Hardware @Inject constructor(val context: Context) {
fun printName() {
println("I'm fish")
}
}

以上代码是无法编译通过的,因为Context不支持注入,而通过上面的分析可知,我们可以使用限定符:


class Hardware @Inject constructor(@ApplicationContext val context: Context) {
fun printName() {
println("I'm fish")
}
}

这就可以成功注入了。


再看看此种场景:


class Hardware @Inject constructor(
@ApplicationContext val context: Context,
val version: String,
) {
fun printName() {
println("I'm fish")
}
}

很显然String不支持注入,当然我们可以向@ApplicationContext 一样也给String提供一个@Provides和@Qualifier注解,但可想而知很麻烦,关键是String是动态变化的,我们确实需要Hardware构造的时候传入合适的String。


由此引入新的写法:辅助注入


class Hardware @AssistedInject constructor(
@ApplicationContext val context: Context,
@Assisted
val version: String,
) {

//辅助工厂类
@AssistedFactory
interface Factory{
//不支持注入的参数都可以放这,返回值为待注入的类型
fun create(version: String):Hardware
}

fun printName() {
println("I'm fish")
}
}

在引用注入的地方不能直接使用Hardware,而是需要通过辅助工厂进行创建:


@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {
private lateinit var binding: ActivitySecondBinding
@Inject
lateinit var hardwareFactory : Hardware.Factory

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySecondBinding.inflate(layoutInflater)
setContentView(binding.root)

val hardware = hardwareFactory.create("3.3.2")
println("${hardware.printName()}")
}
}

如此一来,通过辅助注入,我们还是可以使用Hilt,值得一提的是辅助注入不是Hilt独有,而是从Dagger继承来的功能。


自定义注入点


Hilt仅仅内置了常用的注入点:Application、Activity、Fragment、ViewModel等。

思考一种场景:小明同学写的模块都是需要注入:


class Hardware @Inject constructor(
val gpu: GPU,
val cpu: CPU,
) {
fun printName() {
println("I'm fish")
}
}

class GPU @Inject constructor(val videoStorage: VideoStorage){}

//显存
class VideoStorage @Inject constructor() {}

class CPU @Inject constructor(val register: Register) {}

//寄存器
class Register @Inject() constructor() {}

此时小刚需要引用Hardware,他有两种选择:




  1. 使用注入方式很容易就引用了Hardware,可惜的是他没有注入点,仅仅只是工具类。

  2. 不选注入方式,则需要构造Hardware实例,而Hardware依赖GPU和CPU,它们又分别依赖VideoStorage和Register,想要成功构造Hardware实例需要将其它的依赖实例都手动构造出来,可想而知很麻烦。



这个时候适合小刚的方案是:



自定义注入点



方案实施步骤:

一:定义入口点


@InstallIn(SingletonComponent::class)
interface HardwarePoint {
//该注入点负责返回Hardware实例
fun getHardware(): Hardware
}

二:通过入口点获取实例


class XiaoGangPhone {
fun getHardware(context: Context):Hardware {
val entryPoint = EntryPointAccessors.fromApplication(context, HardwarePoint::class.java)
return entryPoint.getHardware()
}
}

三:使用Hardware


        val hardware = XiaoGangPhone().getHardware(this)
println("${hardware.printName()}")

注入object类


定义了object类,但在注入的时候也需要,可以做如下处理:


object MySystem {
fun getSelf():MySystem {
return this
}
fun printName() {
println("I'm fish")
}
}

@Module
@InstallIn(SingletonComponent::class)
object MiddleModule {
@Provides
@Singleton
fun provideSystem():MySystem {
return MySystem.getSelf()
}
}
//使用注入
class Middleware @Inject constructor(
val mySystem:MySystem
) {
}

4. Hilt 原理简单分析


@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {}

Hilt通过apt在编译时期生成代码:


public abstract class Hilt_SecondActivity extends AppCompatActivity implements GeneratedComponentManagerHolder {

private boolean injected = false;

Hilt_SecondActivity() {
super();
//初始化注入监听
_initHiltInternal();
}

Hilt_SecondActivity(int contentLayoutId) {
super(contentLayoutId);
_initHiltInternal();
}

private void _initHiltInternal() {
addOnContextAvailableListener(new OnContextAvailableListener() {
@Override
public void onContextAvailable(Context context) {
//真正注入
inject();
}
});
}

protected void inject() {
if (!injected) {
injected = true;
//通过manager获取组件,再通过组件注入
((SecondActivity_GeneratedInjector) this.generatedComponent()).injectSecondActivity(UnsafeCasts.<SecondActivity>unsafeCast(this));
}
}
}

在编译期,SecondActivity的父类由AppCompatActivity变为Hilt_SecondActivity,因此当SecondActivity构造时就会调用父类的构造器监听create()的回调,回调调用时进行注入。



由此可见,Activity.onCreate()执行后,Hilt依赖注入的字段才会有值



真正注入的过程涉及到不少的类,都是自动生成的类,有兴趣可以对着源码查找流程,此处就不展开说了。


5. Android到底该不该使用DI框架?


有人说DI比较复杂,还不如我直接构造呢?

又有人说那是你项目不复杂,用不到,在后端流行的Spring全家桶,依赖注入大行其道,Android复杂的项目也需要DI来解耦。


从个人的实践经验看,Android MVVM/MVI 模式还是比较适合引入Hilt的。
image.png


摘抄官网的:现代Android 应用架构

通常来说我们这么设计UI层到数据层的架构:


class MyViewModel @Inject constructor(
val repository: LoginRepository
) :ViewModel() {}

class LoginRepository @Inject constructor(
val rds : RemoteDataSource,
val lds : LocalDataSource
) {}

//远程来源
class RemoteDataSource @Inject constructor(
val myRetrofit: MyRetrofit
) {}

class MyRetrofit @Inject constructor(
) {}

//本地来源
class LocalDataSource @Inject constructor(
val myDataStore: MyDataStore
) {}

class MyDataStore @Inject constructor() {}

可以看出,层次比较深,使用了Hilt简洁了许多。


本文基于 Hilt 2.48.1

参考文档:

dagger.dev/hilt/gradle…

developer.android.com/topic/archi…

repo.maven.apache.org/maven2/com/…


作者:小鱼人爱编程
来源:juejin.cn/post/7294965012749320218
收起阅读 »

Flutter开发者,需要会原生吗?-- Android 篇

前言:随着Flutter在国内移动应用的成熟度,大部分企业都开始认可Flutter的可持续发展,逐步引入Flutter技术栈。 由此关于开发人员的技能储备问题,会产生一定的疑问。今天笔者将从我们在OS中应用Flutter的各种玩法,聊聊老生常谈的话题:Flut...
继续阅读 »

前言:随着Flutter在国内移动应用的成熟度,大部分企业都开始认可Flutter的可持续发展,逐步引入Flutter技术栈。

由此关于开发人员的技能储备问题,会产生一定的疑问。今天笔者将从我们在OS中应用Flutter的各种玩法,聊聊老生常谈的话题:Flutter开发者到底需不需要懂原生平台?



缘起


《Flutter开发者需要掌握原生Android吗?》

这个话题跟Flutter与RN对比Flutter会不会凉同属一类,都是前两年社群最喜欢争论的话题。激烈的讨论无非是观望者太多,加之Flutter不成熟,在使用过程中会遇到不少坑。


直到今年3.7.0、3.10.0相继发布,框架改进和社区的丰富,让更多人选择拥抱Flutter,关于此类型的话题才开始沉寂下来。很多招聘网站也直接出现了Flutter开发这个岗位,而且技能也不要求原生,甚至加分项前端的技能。似乎Flutter开发者在开发过程中很少用到原生的技能,然而事实绝非如此。


我专攻Flutter有3年了,期间Android、iOS、Windows应用做过不少,Web、Linux也都略有研究;这次我将直接从Android平台出发,用切身经历来论述下:Flutter开发者,真的需要懂Android。


Flutter只是个UI框架


打开一个Flutter的项目,我们可以看到整个应用其实是基于一个Activity运行的,属于单页应用。


package com.wxq.test

import io.flutter.embedding.android.FlutterActivity

class MainActivity: FlutterActivity() {
}

Activity继承自FlutterActivity,FlutterActivityonCreate内会创建FlutterActivityAndFragmentDelegate


// io/flutter/embedding/android/FlutterActivity.java
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
switchLaunchThemeForNormalTheme();

super.onCreate(savedInstanceState);
// 创建代理,ActivityAndFragment都支持哦
delegate = new FlutterActivityAndFragmentDelegate(this);
delegate.onAttach(this); // 这个方法创建引擎,并且将context吸附上去
delegate.onRestoreInstanceState(savedInstanceState);

lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE);

configureWindowForTransparency();

// 设置Activity的View,createFlutterView内部也是调用代理的方法
setContentView(createFlutterView());
configureStatusBarForFullscreenFlutterExperience();
}

这个代理将会通过engineGr0up管理FlutterEngine,通过onAttach创建FlutterEngine,并且运行createAndRunEngine方法


// io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java
void onAttach(@NonNull Context context) {
ensureAlive();

if (flutterEngine == null) {
setupFlutterEngine();
}

if (host.shouldAttachEngineToActivity()) {

Log.v(TAG, "Attaching FlutterEngine to the Activity that owns this delegate.");
flutterEngine.getActivityControlSurface().attachToActivity(this, host.getLifecycle());
}
platformPlugin = host.providePlatformPlugin(host.getActivity(), flutterEngine);

host.configureFlutterEngine(flutterEngine);
isAttached = true;
}

@VisibleForTesting
/* package */ void setupFlutterEngine() {
Log.v(TAG, "Setting up FlutterEngine.");

// 省略处理引擎缓存的代码
String cachedEngineGr0upId = host.getCachedEngineGr0upId();
if (cachedEngineGr0upId != null) {
FlutterEngineGr0up flutterEngineGr0up =
FlutterEngineGr0upCache.getInstance().get(cachedEngineGr0upId);
if (flutterEngineGr0up == null) {
throw new IllegalStateException(
"The requested cached FlutterEngineGr0up did not exist in the FlutterEngineGr0upCache: '"
+ cachedEngineGr0upId
+ "'");
}

// *** 重点 ***
flutterEngine =
flutterEngineGr0up.createAndRunEngine(
addEntrypointOptions(new FlutterEngineGr0up.Options(host.getContext())));
isFlutterEngineFromHost = false;
return;
}

// Our host did not provide a custom FlutterEngine. Create a FlutterEngine to back our
// FlutterView.
Log.v(
TAG,
"No preferred FlutterEngine was provided. Creating a new FlutterEngine for"
+ " this FlutterFragment.");

FlutterEngineGr0up group =
engineGr0up == null
? new FlutterEngineGr0up(host.getContext(), host.getFlutterShellArgs().toArray())
: engineGr0up;
flutterEngine =
group.createAndRunEngine(
addEntrypointOptions(
new FlutterEngineGr0up.Options(host.getContext())
.setAutomaticallyRegisterPlugins(false)
.setWaitForRestorationData(host.shouldRestoreAndSaveState())));
isFlutterEngineFromHost = false;
}

再调用onCreateView创建SurfaceView或者外接纹理TextureView,这个View就是Flutter的赖以绘制的画布。


// io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java
@NonNull
View onCreateView(
LayoutInflater inflater,
@Nullable ViewGr0up container,
@Nullable Bundle savedInstanceState,
int flutterViewId,
boolean shouldDelayFirstAndroidViewDraw)
{
Log.v(TAG, "Creating FlutterView.");
ensureAlive();

if (host.getRenderMode() == RenderMode.surface) {
FlutterSurfaceView flutterSurfaceView =
new FlutterSurfaceView(
host.getContext(), host.getTransparencyMode() == TransparencyMode.transparent);

// Allow our host to customize FlutterSurfaceView, if desired.
host.onFlutterSurfaceViewCreated(flutterSurfaceView);

// Create the FlutterView that owns the FlutterSurfaceView.
flutterView = new FlutterView(host.getContext(), flutterSurfaceView);
} else {
FlutterTextureView flutterTextureView = new FlutterTextureView(host.getContext());

flutterTextureView.setOpaque(host.getTransparencyMode() == TransparencyMode.opaque);

// Allow our host to customize FlutterSurfaceView, if desired.
host.onFlutterTextureViewCreated(flutterTextureView);

// Create the FlutterView that owns the FlutterTextureView.
flutterView = new FlutterView(host.getContext(), flutterTextureView);
}

flutterView.addOnFirstFrameRenderedListener(flutterUiDisplayListener);
// 忽略一些代码...
return flutterView;
}

由此可见,Flutter的引擎实际上是运行在Android提供的View上,这个View必然是设置在Android的组件上,可以是Activity、Framgent,也可以是WindowManager。

这就给我们带来了很大的可塑性,只要你能掌握这套原理,混合开发就随便玩了。


Android,是必须的能力


通过对Flutter运行机制的剖析,我们很明确它就是个单纯的UI框架,惊艳的跨端UI都离不开Android的能力,这也说明Flutter开发者不需要会原生注定走不远

下面几个例子,也可以充分论证这个观点。


一、Flutter插件从哪里来


上面讲述到的原理,Flutter项目脚手架已经帮我们做好,但这只是UI绘制层面的;实际上很多Flutter应用,业务能力都是由Pub.dev提供的,随着社区框架的增多,开发者大多时候是感知不到需要Android能力的。

然而业务的发展是迅速的,我们开始需要很多pub社区并不支持的能力,比如:getMetaDatagetMacAddressreboot/shutdownsendBroadcast等,这些能力都需要我们使用Android知识,以编写插件的形式,提供给Flutter调用。

Flutter Plugin在Dart层和Android层都实现了MethodChannel对象,同一个Engine下,只要传入一致的channelId字符串,就能建立双向的通道互相传输基本类型数据。


class FlutterNativeAbilityPlugin : FlutterPlugin, MethodCallHandler {
private var applicationContext: Context? = null

private lateinit var channel: MethodChannel

override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = flutterPluginBinding.applicationContext
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "flutter_native_ability")
channel.setMethodCallHandler(this)
}

class MethodChannelFlutterNativeAbility extends FlutterNativeAbilityPlatform {
/// The method channel used to interact with the native platform.
@visibleForTesting
final methodChannel = const MethodChannel('flutter_native_ability');
}

发送端通过invokeMethod调用对应的methodName,传入arguments;接收端通过实现onMethodCall方法,接收发送端的invokeMethod操作,执行需要的操作后,通过Result对象返回结果。


@override
Future<String> getMacAddress() async {
final res = await methodChannel.invokeMethod<String>('getMacAddress');
return res ?? '';
}

@override
Future<void> reboot() async {
await methodChannel.invokeMethod<String>('reboot');
}

"getMacAddress" -> {
Log.i(TAG, "onMethodCall: getMacAddress")
val macAddress = CommonUtils().getDeviceMac(applicationContext)
result.success(macAddress)
}
"reboot" -> {
Log.i(TAG, "onMethodCall: reboot")
beginToReboot(applicationContext)
result.success(null)
}

ps:invokeMethod和onMethodCall双端都能实现,都能作为发送端和接收端。


二、Flutter依赖于Android机制,得以“横行霸道”


目前我们将Flutter应用于OS的开发,这需要我们不单是从某个独立应用去思考。很多应用、服务都需要从整个系统业务去设计,在以下这些需求中,我们深切感受到:Flutter跟Android配合后,能发挥更大的业务价值。



  • Android服务运行dart代码,广播接收器与Flutter通信


我们很多服务需要开机自启,这必须遵循Android的机制。通常做法是:接收开机广播,在广播接收器中启动Service,然后再去运行DartEngie,执行跨平台的代码;


class MyTestService : Service() {

private lateinit var engineGr0up: FlutterEngineGr0up

override fun onCreate() {
super.onCreate()
startForeground()

engineGr0up = FlutterEngineGr0up(this)
// initService是Flutter层的方法入口点
val dartEntrypoint = DartExecutor.DartEntrypoint(
FlutterInjector.instance().flutterLoader().findAppBundlePath(),
"initService"
)
val flutterEngine = engineGr0up.createAndRunEngine(this, dartEntrypoint)
// Flutter调用Native方法的 MethodChannel 也初始化一下,调用安装接口需要
FlutterToNativeChannel(flutterEngine, this)
}
}

同时各应用之间需要通信,这时我们也会通过Broadcat广播机制,在Android的广播接收器中,通过MechodChannel发送给Flutter端。


总而言之,我们必须 遵循系统的组件规则,基于Flutter提供的通信方式,将Android的消息、事件等发回给Flutter, 带来的跨端效益是实实在在的!



  • 悬浮窗需求


悬浮窗口在视频/直播场景下用的最多,当你的应用需要开启悬浮窗的时候,Flutter将完全无法支持这个需求。

实际上我们只需要在Android中创建一个WindowManager,基于EngineGround创建一个DartEngine;然后创建flutterView,把DartEngine吸附到flutterView上,最后把flutterView Add to WindowManager即可。


private lateinit var flutterView: FlutterView
private var windowManager = context.getSystemService(Service.WINDOW_SERVICE) as WindowManager
private val inflater =
context.getSystemService(Service.LAYOUT_INFLATER_SERVICE) as LayoutInflater
private val metrics = DisplayMetrics()

@SuppressLint("InflateParams")
private var rootView = inflater.inflate(R.layout.floating, null, false) as ViewGr0up

windowManager.defaultDisplay.getMetrics(metrics)
layoutParams.gravity = Gravity.START or Gravity.TOP

windowManager.addView(rootView, layoutParams)

flutterView = FlutterView(inflater.context, FlutterSurfaceView(inflater.context, true))
flutterView.attachToFlutterEngine(engine)

engine.lifecycleChannel.appIsResumed()

rootView.findViewById<FrameLayout>(R.id.floating_window)
.addView(
flutterView,
ViewGr0up.LayoutParams(
ViewGr0up.LayoutParams.MATCH_PARENT,
ViewGr0up.LayoutParams.MATCH_PARENT
)
)
windowManager.updateViewLayout(rootView, layoutParams)


  • 不再局限单页应用


最近我们在升级应用中,遇到一个比较尴尬的需求:在原有OTA功能下,新增一个U盘插入本地升级的功能,希望升级能力和UI都能复用,且互不影响各自流程。


如果是Android项目很简单,把升级的能力抽象,通过多个Activity管理自己的业务流程,互不干扰。但是Flutter项目属于单页应用,不可能同时展示两个路由页面各自处理,所以也必须 走Android的机制,让Flutter应用同时运行多个Activity。


我们在Android端监听了U盘的插入事件,在需要本地升级的时候直接弹出Activity。Activity是继承FlutterActivity的,通过<metadata>标签指定方法入口点。与MainActivity运行main区分开,然后通过重写getDartEntrypointArgs方法,把必要的参数传给Flutter入口函数,从而独立运行本地升级的业务,而且UI和能力都能复用。


class LocalUpgradeActivity : FlutterActivity() {
}

<activity
android:name=".LocalUpgradeActivity"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/Theme.Transparent"
android:windowSoftInputMode="adjustResize">

<meta-data
android:name="io.flutter.Entrypoint"
android:value="runLocalUpgradeApp" />
<!-- 这里指定Dart层的入口点-->
</activity>

override fun getDartEntrypointArgs(): MutableList<String?> {
val filePath: String? = intent?.getStringExtra("filePath")
val tag: String? = intent?.getStringExtra("tag")
return mutableListOf(filePath, tag)
}

至此,我们的Flutter应用不再是单页应用,而且所有逻辑和UI都将在Flutter层实现!


总结


我们遵循Android平台的机制,把逻辑和UI都尽可能的交给Flutter层,让其在跨平台上发挥更大的可能性,在落地过程确实切身体会到Android的知识是何等的重要!

当然我们的应用场景可能相对复杂,一般应用也许不会有这么多的应用组合;但无论Flutter如何完善,社区更加壮大,它都离不开底层平台的支持。

作为Flutter开发者,有精力的情况下,一定要多学各个平台的框架和能力,让Flutter、更让自己走的更远!


作者:Karl_wei
来源:juejin.cn/post/7295571705689423907
收起阅读 »

HarmonyOS开发:基于http开源一个网络请求库

前言 网络封装的目的,在于简洁,使用起来更加的方便,也易于我们进行相关动作的设置,如果,我们不封装,那么每次请求,就会重复大量的代码逻辑,如下代码,是官方给出的案例: // 引入包名 import http from '@ohos.net.http'; //...
继续阅读 »

前言


网络封装的目的,在于简洁,使用起来更加的方便,也易于我们进行相关动作的设置,如果,我们不封装,那么每次请求,就会重复大量的代码逻辑,如下代码,是官方给出的案例:


// 引入包名
import http from '@ohos.net.http';

// 每一个httpRequest对应一个HTTP请求任务,不可复用
let httpRequest = http.createHttp();
// 用于订阅HTTP响应头,此接口会比request请求先返回。可以根据业务需要订阅此消息
// 从API 8开始,使用on('headersReceive', Callback)替代on('headerReceive', AsyncCallback)。 8+
httpRequest.on('headersReceive', (header) => {
console.info('header: ' + JSON.stringify(header));
});
httpRequest.request(
// 填写HTTP请求的URL地址,可以带参数也可以不带参数。URL地址需要开发者自定义。请求的参数可以在extraData中指定
"EXAMPLE_URL",
{
method: http.RequestMethod.POST, // 可选,默认为http.RequestMethod.GET
// 开发者根据自身业务需要添加header字段
header: {
'Content-Type': 'application/json'
},
// 当使用POST请求时此字段用于传递内容
extraData: {
"data": "data to send",
},
expectDataType: http.HttpDataType.STRING, // 可选,指定返回数据的类型
usingCache: true, // 可选,默认为true
priority: 1, // 可选,默认为1
connectTimeout: 60000, // 可选,默认为60000ms
readTimeout: 60000, // 可选,默认为60000ms
usingProtocol: http.HttpProtocol.HTTP1_1, // 可选,协议类型默认值由系统自动指定
}, (err, data) => {
if (!err) {
// data.result为HTTP响应内容,可根据业务需要进行解析
console.info('Result:' + JSON.stringify(data.result));
console.info('code:' + JSON.stringify(data.responseCode));
// data.header为HTTP响应头,可根据业务需要进行解析
console.info('header:' + JSON.stringify(data.header));
console.info('cookies:' + JSON.stringify(data.cookies)); // 8+
// 取消订阅HTTP响应头事件
httpRequest.off('headersReceive');
// 当该请求使用完毕时,调用destroy方法主动销毁
httpRequest.destroy();
} else {
console.info('error:' + JSON.stringify(err));
// 取消订阅HTTP响应头事件
httpRequest.off('headersReceive');
// 当该请求使用完毕时,调用destroy方法主动销毁。
httpRequest.destroy();
}
}
);

以上的案例,每次请求书写这么多代码,在实际的开发中,是无法承受的,所以基于此,封装是很有必要的,把公共的部分进行抽取包装,固定不变的参数进行初始化设置,重写基本的请求方式,这是我们封装的基本宗旨。


我们先看一下封装之后的调用方式:


异步请求


Net.get("url").requestString((data) => {
//data 为 返回的json字符串
})

同步请求


const data = await Net.get("url").returnData<string>(ReturnDataType.STRING)
//data 为 返回的json字符串

装饰器请求


@GET("url")
private getData():Promise<string> {
return null
}

封装之后,不仅使用起来更加的便捷,而且还拓展了请求类型,满足不同需求的场景。


本篇的文章内容大致如下:


1、net库主要功能点介绍


2、net库快速依赖使用


3、net库全局初始化


4、异步请求介绍


5、同步请求介绍


6、装饰器请求介绍


7、上传下载介绍


8、Dialog加载使用


9、相关总结


一、net库主要功能点介绍


目前net库一期已经开发完毕,har包使用,大家可以看第二项,截止到发文前,所支持的功能如下:


■ 支持全局初始化


■ 支持统一的BaseUrl


■ 支持全局错误拦截


■ 支持全局头参拦截


■ 支持同步方式请求(get/post/delete/put/options/head/trace/connect)


■ 支持异步方式请求(get/post/delete/put/options/head/trace/connect)


■ 支持装饰器方式请求(get/post/delete/put/options/head/trace/connect)


■ 支持dialog加载


■ 支持返回Json字符串


■ 支持返回对象


■ 支持返回数组


■ 支持返回data一层数据


■ 支持上传文件


■ 支持下载文件


□ 数据缓存开发中……


二、net库快速依赖使用


私服和远程依赖,由于权限和审核问题,预计需要等到2024年第一季度面向所有开发者,所以,只能使用本地静态共享包和源码 两种使用方式,本地静态共享包类似Android中的aar依赖,直接复制到项目中即可,目前源码还在优化中,先暴露静态共享包这一使用方式。


本地静态共享包har包使用


首先,下载har包,点击下载


下载之后,把har包复制项目中,目录自己创建,如下,我创建了一个libs目录,复制进去



引入之后,进行同步项目,点击Sync Now即可,当然了你也可以,将鼠标放置在报错处会出现提示,在提示框中点击Run 'ohpm install'。


需要注意,@app/net,是用来区分目录的,可以自己定义,比如@aa/bb等,关于静态共享包的创建和使用,请查看如下我的介绍,这里就不过多介绍。


HarmonyOS开发:走进静态共享包的依赖与使用


查看是否引用成功


无论使用哪种方式进行依赖,最终都会在使用的模块中,生成一个oh_modules文件,并创建源代码文件,有则成功,无则失败,如下:



三、net库全局初始化


推荐在AbilityStage进行初始化,初始化一次即可,初始化参数可根据项目需要进行选择性使用。


Net.getInstance().init({
baseUrl: "https://www.vipandroid.cn", //设置全局baseurl
connectTimeout: 10000, //设置连接超时
readTimeout: 10000, //设置读取超时
netErrorInterceptor: new MyNetErrorInterceptor(), //设置全局错误拦截,需要自行创建,可在这里进行错误处理
netHeaderInterceptor: new MyNetHeaderInterceptor(), //设置全局头拦截器,需要自行创建
header: {}, //头参数
resultTag: []//接口返回数据参数,比如data,items等等
})

1、初始化属性介绍


初始化属性,根据自己需要选择性使用。


属性类型概述
baseUrlstring一般标记为统一的请求前缀,也就是域名
connectTimeoutnumber连接超时,默认10秒
readTimeoutnumber读取超时,默认10秒
netErrorInterceptorINetErrorInterceptor全局错误拦截器,需继承INetErrorInterceptor
netHeaderInterceptorINetHeaderInterceptor全局请求头拦截器,需继承INetHeaderInterceptor
headerObject全局统一的公共头参数
resultTagArray接口返回数据参数,比如data,items等等

2、设置请求头拦截


关于全局头参数传递,可以通过以上的header参数或者在请求头拦截里均可,如果没有同步等逻辑操作,只是固定的头参数,建议直接使用header参数。


名字自定义,实现INetHeaderInterceptor接口,可在netHeader方法里打印请求头或者追加请求头。


import { HttpHeaderOptions, NetHeaderInterceptor } from '@app/net'

class MyNetHeaderInterceptor implements NetHeaderInterceptor {
getHeader(options: HttpHeaderOptions): Promise<Object> {
//可以进行接口签名,传入头参数
return null
}
}

HttpHeaderOptions对象


返回了一些常用参数,可以用于接口签名等使用。


export class HttpHeaderOptions {
url?: string //请求地址
method?: http.RequestMethod //请求方式
header?: Object //头参数
params?: Object //请求参数
}

3、设置全局错误拦截器


名字自定义,实现INetErrorInterceptor接口,可在httpError方法里进行全局的错误处理,比如统一跳转,统一提示等。


import { NetError } from '@app/net/src/main/ets/error/NetError';
import { INetErrorInterceptor } from '@app/net/src/main/ets/interceptor/INetErrorInterceptor';

export class MyNetErrorInterceptor implements INetErrorInterceptor {
httpError(error: NetError) {
//这里进行拦截错误信息

}
}

NetError对象


可通过如下方法获取错误code和错误描述信息。


/*
* 返回code
* */

getCode():number{
return this.code
}

/*
* 返回message
* */

getMessage():string{
return this.message
}

四、异步请求介绍


1、请求说明


为了方便数据的针对性返回,目前异步请求提供了三种请求方法,在实际的 开发中,大家可以针对需要,选择性使用。


request方法


Net.get("url").request<TestModel>((data) => {
//data 就是返回的TestModel对象
})

此方法,针对性返回对应的data数据对象,如下json,则会直接返回需要的data对象,不会携带外层的code等其他参数,方便大家直接的拿到数据。


{
"code": 0,
"message": "数据返回成功",
"data": {}
}

如果你的data是一个数组,如下json:


{
"code": 0,
"message": "数据返回成功",
"data": []
}

数组获取


Net.get("url").request<TestModel[]>((data) => {
//data 就是返回的TestModel[]数组
})

//或者如下

Net.get("url").request<Array<TestModel>>((data) => {
//data 就是返回的TestModel数组
})

可能大家有疑问,如果接口返回的json字段不是data怎么办?如下:


举例一


{
"code": 0,
"message": "数据返回成功",
"items": {}
}

举例二


{
"code": 0,
"message": "数据返回成功",
"models": {}
}

虽然网络库中默认取的是json中的data字段,如果您的数据返回类型字段有多种,如上json,可以通过全局初始化resultTag进行传递或者局部setResultTag传递即可。


全局设置接口返回数据参数【推荐】


全局设置,具体设置请查看上边的全局初始化一项,只设置一次即可,不管你有多少种返回参数,都可以统一设置。


 Net.getInstance().init({
resultTag: ["data", "items", "models"]//接口返回数据参数,比如data,items等等
})

局部设置接口返回数据参数


通过setResultTag方法设置即可。


Net.get("")
.setResultTag(["items"])
.request<TestModel>((data) => {

})

requestString方法


requestString就比较简单,就是普通的返回请求回来的json字符串。


Net.get("url").requestString((data) => {
//data 为 返回的json字符串
})

requestObject方法


requestObject方法也是获取对象,和request不同的是,它不用设置返回参数,因为它是返回的整个json对应的对象, 也就是包含了code,message等字段。


Net.get("url").requestObject<TestModel>((data) => {
//data 为 返回的TestModel对象
})

为了更好的复用共有字段,你可以抽取一个基类,如下:


export class ApiResult<T> {
code: number
message: string
data: T
}

以后就可以如下请求:


Net.get("url").requestObject<ApiResult<TestModel>>((data) => {
//data 为 返回的ApiResult对象
})

回调函数

回调函数有两个,一个成功一个失败,成功回调必调用,失败可选择性调用。


只带成功


Net.get("url").request<TestModel>((data) => {
//data 为 返回的TestModel对象
})

成功失败都带


Net.get("url").request<TestModel>((data) => {
//data 为 返回的TestModel对象
}, (error) => {
//失败
})

2、get请求


 Net.get("url").request<TestModel>((data) => {
//data 为 返回的TestModel对象
})

3、post请求


Net.post("url").request<TestModel>((data) => {
//data 为 返回的TestModel对象
})

4、delete请求


 Net.delete("url").request<TestModel>((data) => {
//data 为 返回的TestModel对象
})

5、put请求


Net.put("url").request<TestModel>((data) => {
//data 为 返回的TestModel对象
})

6、其他请求方式


除了常见的请求之外,根据系统api所提供的,也封装了如下的请求方式,只需要更改请求方式即可,比如Net.options。


OPTIONS
HEAD
TRACE
CONNECT

7、各个方法调用


除了正常的请求方式之外,你也可以调用如下的参数:


方法类型概述
setHeadersObject单独添加请求头参数
setBaseUrlstring单独替换BaseUrl
setParamsstring / Object / ArrayBuffer单独添加参数,用于post
setConnectTimeoutnumber单独设置连接超时
setReadTimeoutnumber单独设置读取超时
setExpectDataTypehttp.HttpDataType设置指定返回数据的类型
setUsingCacheboolean使用缓存,默认为true
setPrioritynumber设置优先级 默认为1
setUsingProtocolhttp.HttpProtocol协议类型默认值由系统自动指定
setResultTagArray接口返回数据参数,比如data,items等等
setContextContext设置上下文,用于下载文件
setCustomDialogControllerCustomDialogController传递的dialog控制器,用于展示dialog

代码调用如下:


Net.get("url")
.setHeaders({})//单独添加请求头参数
.setBaseUrl("")//单独替换BaseUrl
.setParams({})//单独添加参数
.setConnectTimeout(10000)//单独设置连接超时
.setReadTimeout(10000)//单独设置读取超时
.setExpectDataType(http.HttpDataType.OBJECT)//设置指定返回数据的类型
.setUsingCache(true)//使用缓存,默认为true
.setPriority(1)//设置优先级 默认为1
.setUsingProtocol(http.HttpProtocol.HTTP1_1)//协议类型默认值由系统自动指定
.setResultTag([""])//接口返回数据参数,比如data,items等等
.setContext(this.context)//设置上下文,用于上传文件和下载文件
.setCustomDialogController()//传递的dialog控制器,用于展示dialog
.request<TestModel>((data) => {
//data 为 返回的TestModel对象
})

五、同步请求介绍


同步请求需要注意,需要await关键字和async关键字结合使用。


 private async getTestModel(){
const testModel = await Net.get("url").returnData<TestModel>()
}

1、请求说明


同步请求和异步请求一样,也是有三种方式,是通过参数的形式,默认直接返回data层数据。


返回data层数据


和异步种的request方法类似,只返回json种的data层对象数据,不会返回code等字段。


 private async getData(){
const data = await Net.get("url").returnData<TestModel>()
//data为 返回的 TestModel对象
}

返回Json对象


和异步种的requestObject方法类似,会返回整个json对象,包含code等字段。


 private async getData(){
const data = await Net.get("url").returnData<TestModel>(ReturnDataType.OBJECT)
//data为 返回的 TestModel对象
}

返回Json字符串


和异步种的requestString方法类似。


private async getData(){
const data = await Net.get("url").returnData<string>(ReturnDataType.STRING)
//data为 返回的 json字符串
}

返回错误


异步方式有回调错误,同步方式如果发生错误,也会直接返回错误,结构如下:


{
"code": 0,
"message": "错误信息"
}

除了以上的错误捕获之外,你也可以全局异常捕获,


2、get请求



const data = await Net.get("url").returnData<TestModel>()

3、post请求



const data = await Net.post("url").returnData<TestModel>()

4、delete请求



const data = await Net.delete("url").returnData<TestModel>()

5、put请求



const data = await Net.put("url").returnData<TestModel>()

6、其他请求方式


除了常见的请求之外,根据系统api所提供的,也封装了如下的请求方式,只需要更改请求方式即可,比如Net.options


OPTIONS
HEAD
TRACE
CONNECT

7、各个方法调用


除了正常的请求方式之外,你也可以调用如下的参数:


方法类型概述
setHeadersObject单独添加请求头参数
setBaseUrlstring单独替换BaseUrl
setParamsstring / Object / ArrayBuffer单独添加参数,用于post
setConnectTimeoutnumber单独设置连接超时
setReadTimeoutnumber单独设置读取超时
setExpectDataTypehttp.HttpDataType设置指定返回数据的类型
setUsingCacheboolean使用缓存,默认为true
setPrioritynumber设置优先级 默认为1
setUsingProtocolhttp.HttpProtocol协议类型默认值由系统自动指定
setResultTagArray接口返回数据参数,比如data,items等等
setContextContext设置上下文,用于下载文件
setCustomDialogControllerCustomDialogController传递的dialog控制器,用于展示dialog

代码调用如下:


const data = await Net.get("url")
.setHeaders({})//单独添加请求头参数
.setBaseUrl("")//单独替换BaseUrl
.setParams({})//单独添加参数
.setConnectTimeout(10000)//单独设置连接超时
.setReadTimeout(10000)//单独设置读取超时
.setExpectDataType(http.HttpDataType.OBJECT)//设置指定返回数据的类型
.setUsingCache(true)//使用缓存,默认为true
.setPriority(1)//设置优先级 默认为1
.setUsingProtocol(http.HttpProtocol.HTTP1_1)//协议类型默认值由系统自动指定
.setResultTag([""])//接口返回数据参数,比如data,items等等
.setContext(this.context)//设置上下文,用于上传文件和下载文件
.setCustomDialogController()//传递的dialog控制器,用于展示dialog
.returnData<TestModel>()
//data为 返回的 TestModel对象

六、装饰器请求介绍


网络库允许使用装饰器的方式发起请求,也就是通过注解的方式,目前采取的是装饰器方法的形式。


1、请求说明


装饰器和同步异步有所区别,只返回两种数据类型,一种是json字符串,一种是json对象,暂时不提供返回data层数据。 在使用的时候,您可以单独创建工具类或者ViewModel或者直接使用,都可以。


返回json字符串


@GET("url")
private getData():Promise<string> {
return null
}

返回json对象


@GET("url")
private getData():Promise<TestModel> {
return null
}

2、get请求


@GET("url")
private getData():Promise<TestModel> {
return null
}

3、post请求


@POST("url")
private getData():Promise<TestModel> {
return null
}

4、delete请求


@DELETE("url")
private getData():Promise<TestModel> {
return null
}

5、put请求


@PUT("url")
private getData():Promise<TestModel> {
return null
}

6、其他请求方式


除了常见的请求之外,根据系统api所提供的,也封装了如下的请求方式,只需要更改请求方式即可,比如@OPTIONS。


OPTIONS
HEAD
TRACE
CONNECT

当然,大家也可以使用统一的NET装饰器,只不过需要自己设置请求方法,代码如下:


@NET("url", { method: http.RequestMethod.POST })
private getData():Promise<string> {
return null
}

7、装饰器参数传递


直接参数传递


直接参数,在调用装饰器请求时,后面添加即可,一般针对固定参数。


@GET("url", {
baseUrl: "", //baseUrl
header: {}, //头参数
params: {}, //入参
connectTimeout: 1000, //连接超时
readTimeout: 1000, //读取超时
isReturnJson: true//默认false 返回Json字符串,默认返回json对象
})
private getData():Promise<string> {
return null
}

动态参数传递


动态参数适合参数可变的情况下传递,比如分页等情况。


@GET("url")
private getData(data? : HttpOptions):Promise<string> {
return null
}

调用时传递


private async doHttp(){
const data = await this.getData({
baseUrl: "", //baseUrl
header: {}, //头参数
params: {}, //入参
connectTimeout: 1000, //连接超时
readTimeout: 1000, //读取超时
isReturnJson: true//默认false 返回Json字符串,默认返回json对象
})
}

装饰器参数传递


使用DATA装饰器,DATA必须在上!


@DATA({
baseUrl: "", //baseUrl
header: {}, //头参数
params: {}, //入参
connectTimeout: 1000, //连接超时
readTimeout: 1000, //读取超时
isReturnJson: true//默认false 返回Json字符串,默认返回json对象
})
@GET("url")
private getData():Promise<string> {
return null
}

七、上传下载介绍


1、上传文件


Net.uploadFile("")//上传的地址
.setUploadFiles([])//上传的文件 [{ filename: "test", name: "test", uri: "internal://cache/test.jpg", type: "jpg" }]
.setUploadData([])//上传的参数 [{ name: "name123", value: "123" }]
.setProgress((receivedSize, totalSize) => {
//监听上传进度
})
.request((data) => {
if (data == UploadTaskState.COMPLETE) {
//上传完成
}
})

方法介绍


方法类型概述
uploadFilestring上传的地址
setUploadFilesArray上传的文件数组
setUploadDataArray上传的参数数组
setProgress回调函数监听进度,receivedSize下载大小, totalSize总大小
request请求上传,data类型为UploadTaskState,有三种状态:START(开始),COMPLETE(完成),ERROR(错误)

其他方法


删除上传进度监听

uploadRequest.removeProgressCallback()

删除上传任务

uploadRequest.deleteUploadTask((result) => {
if (result) {
//成功
} else {
//失败
}
})

2、下载文件


Net.downLoadFile("http://10.47.24.237:8888/harmony/log.har")
.setContext(EntryAbility.context)
.setFilePath(EntryAbility.filePath)
.setProgress((receivedSize, totalSize) => {
//监听下载进度
})
.request((data) => {
if (data == DownloadTaskState.COMPLETE) {
//下载完成
}
})

方法介绍


方法类型概述
downLoadFilestring下载的地址
setContextContext上下文
setFilePathstring下载后保存的路径
setProgress回调函数监听进度,receivedSize下载大小, totalSize总大小
request请求下载,data类型为DownloadTaskState,有四种状态:START(开始),COMPLETE(完成),PAUSE(暂停),REMOVE(结束)

其他方法


移除下载的任务

    downLoadRequest.deleteDownloadTask((result) => {
if (result) {
//移除成功
} else {
//移除失败
}
})

暂停下载任务

downLoadRequest.suspendDownloadTask((result) => {
if (result) {
//暂停成功
} else {
//暂停失败
}
})

重新启动下载任务

downLoadRequest.restoreDownloadTask((result) => {
if (result) {
//成功
} else {
//失败
}
})

删除监听下载进度

downLoadRequest.removeProgressCallback()

八、Dialog加载使用



1、定义dialog控制器


NetLoadingDialog是net包中自带的,菊花状弹窗,如果和实际业务不一致,可以更换。


private mCustomDialogController = new CustomDialogController({
builder: NetLoadingDialog({
loadingText: '请等待...'
}),
autoCancel: false,
customStyle: true
})

2、调用传递控制器方法


此方法会自动显示和隐藏dialog,如果觉得不合适,大家可以自己定义即可。


setCustomDialogController(this.mCustomDialogController)

九、相关总结


开发环境如下:


DevEco Studio 4.0 Beta2,Build Version: 4.0.0.400

Api版本:9

hvigorVersion:3.0.2

目前呢,暂时不支持缓存,后续会逐渐加上,大家在使用的过程中,需要任何的问题,都可以进行反馈,都会第一时间进行解决。


作者:程序员一鸣
来源:juejin.cn/post/7295397683397181450
收起阅读 »

协程-来龙去脉

首先必须声明,此文章是观看油管《KotlinConf 2017 - Introduction to Coroutines by Roman Elizarov》的感想,如何可以,更建议您去观看这个视频而不是阅读本篇文章 代码的异步控制 举个例子,假设你要去论坛...
继续阅读 »

首先必须声明,此文章是观看油管《KotlinConf 2017 - Introduction to Coroutines by Roman Elizarov》的感想,如何可以,更建议您去观看这个视频而不是阅读本篇文章


代码的异步控制


举个例子,假设你要去论坛上发布消息,你必须先获取token以表明你的身份,然后创建一个消息,最后去发送它


//这是一个耗时操作
fun requestToken():Token = {
... //block to wait to receive token
returen token
}

//这也是一个耗时操作
fun creatMessage() : Message ={
... //block to wait to creat Message
returen token
}

fun sendMessage(token :Token,message:Message)

fun main() {
val token = requestToken()
val meassage = creatMessage()
sendMessage(token,message)
}


这种情况显然不符合我们的现实情况,我们不可能在没有拿到token就等待在那里,我们可以开启一个线程去异步执行



fun requestToken():Token = {
... //new thread to request token
returen token
}

像这样一个任务我们就需要创建一个线程,creatMessage与requestToken可以并行进行,因此我们需要再次创建一个线程去执行creatMessage,从而创建两个线程。现在我们的手机性能很很高,我们可以创建一个,两个,甚至是一百个个线程去执行任务,但是到达一千个,一万个呢,恐怕手机的不足以支撑。
怎么解决这样的问题呢。我们只需要建立一种通知机制,在token返回后告诉我们,我们再继续完成creatMessage,进而sendMessage。这也就是callback方式



fun requestTokenCallback(callback : (Token) -> Unit) {
... //block to wait to receive token
callback.invoke(token)
}

fun creatMessageCallback : (Message) -> Unit){
... //nblock to wait to creat Message
callback.invoke(message)
}

fun sendMessage(token :Token,message:Message)

fun main() {
//创建一个线程
Thead {
requestTokenCallback { token ->
creatMessageCallback { message ->
{
sendMessage(token, message)
}
}
}

}

}


这仅仅是一个简单的案例,就产生了如此多的嵌套和连续的右括号,在实际业务中往往更为复杂,比如请求失败或者一些异常情况,甚至是一些特定的业务操作,想想这样叠加下去,简直是灾难。
如何解决这种问题呢,java中有一个CompleteFuture,正如其名,它能够异步处理任务,以期获取未来的结果去处理,我们只需要允诺我们将来在某个时间点一定会返回某种类型的数据,我们就可以以预知未来的方式使用他,


//创建一个线程去异步执行
fun requestToken() :CompletableFuture<Token> = ...

//创建一个线程去异步执行
fun creatMessage(token) : CompletableFuture<Send> = ...

fun sendMessage(send :Send)

fun main() {
requestToken()
.thenCompose{token -> creatMessage(token)}
.thenAccept{send -> sendMessage(send)}
}


令人头疼的代码已不复存在,我们可以自由组合我们任务
creatMessage的调用方式和sendMessage并不相同,kotlin为了统一这两种调用,产生了一个suspend 关键字,它能够像拥有魔法一样,让世界的时间暂停,自己又不会暂停,然后去执行自己的任务,执行完成之后,时间恢复,任务继续执行



suspend fun requestToken() :Token = ...

suspend fun creatMessage() : message = ...


fun sendMessage(token :Token,message:Message)

fun main() {
val token = requestToken()
val meassage = creatMessage()
sendMessage(token,message)
}


执行这段代码的时候,会发生编译错误,因为main函数只是一个普通的函数,并没有被suspend标记,当requestToken使时间暂停的同时,主程序也时间暂停了,那这与最开始的阻塞方法有什么不一样的呢


协程Coroutine


本文要介绍的是协程,协程是什么呢,在我看来,协程就是一个容器,让suspend标记的函数可以运行,可以开启魔法 ,让它在时间暂停的同时,并不影响主线程


public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,//创建容器的上下文
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T//拥有魔法的函数
)
: Deferred<T> {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy) //创建协程
LazyDeferredCoroutine(newContext, block) else
DeferredCoroutine<T>(newContext, active = true)
coroutine.start(start, coroutine, block) //启动魔法开关
return coroutine
}

public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext, //创建容器的上下文
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit //拥有魔法的函数
)
: Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy) //创建协程
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block) //启动魔法开关,启动协程
return coroutine
}

我们可以通过Deferred.await() 获取将来的值T。自此,我们有了新的代码



suspend fun requestToken() :Token = ...

suspend fun creatMessage() : message = ...


fun sendMessage(token :Token,message:Message)

fun main() {
val token = async {requestToken()}
val meassage = async{creatMessage()}
sendMessage(token.await() ,message.await() )
}


实际业务中,我们的任务并不是一直都是需要返回结果的,所以还有另一种容器,只需要去执行


public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
)
: Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}


轻量级线程


协程被视为轻量级线程,轻量在哪呢?


实际上,async与launch 并没有什么魔法,只是将封装好的任务交由线程池去执行,所以suspend标记的函数可以任意暂停


协程只是代码层级的概念,操作系统对于此是无感知的,但是线程作为cpu调度的基本单位,创建和调用是很重的,需要中断机制去进行调度,消耗很多额外的资源,所以协程被视为轻量级线程。


上面的代码中launch 与async 都是CoroutineScope 的函数,那么CoroutineScope 是什么呢
这个就是协程运行的温床,也可说是运行的基础,也就是作用域。创建处一个协程,必须有一个管理容器去管理协程的创建,分发,调度,这就是CoroutineScope。


有一种常见的需求是网络执行完成切换UI线程去执行,因此,我们需要在创建容器的时候需要一个参数,去声明协程到底在线程池中执行,UI线程池还是普通线程池,


val scope = CoroutineScope(Dispatchers.IO) //IO线程池去处理
val scope = CoroutineScope(Dispatchers.Main) // UI线程去处理

题外


阅读到这里可以知道suspend实际上就是一个callback封装, 对于其如何将标记函数转化为可挂起恢复的,可浏览# Kotlin Vocabulary | 揭秘协程中的 suspend 修饰符,个人觉得讲的不错


在推荐的这篇文章中,可以看到使用了状态机,其目的是为了节省Continuation对象的创建,可以借鉴学习


关于我


一个希望友友们能提出建议的代码下毒糕手


作者:小黑不黑
来源:juejin.cn/post/7294852698460373004
收起阅读 »

重生!入门级开源音乐播放器APP —— 波尼音乐

前言 不知道是否还有人记得,7年前的那个 「Android开源在线音乐播放器——波尼音乐」? 本来只是作为毕设项目,没想到很多人感兴趣,就断断续续的在维护,当时在网络上找到了一个百度开放的在线音乐 API,勉强实现了本地 + 网络播放能力。 可惜没过多久 AP...
继续阅读 »

前言


不知道是否还有人记得,7年前的那个 「Android开源在线音乐播放器——波尼音乐」?


本来只是作为毕设项目,没想到很多人感兴趣,就断断续续的在维护,当时在网络上找到了一个百度开放的在线音乐 API,勉强实现了本地 + 网络播放能力。


可惜没过多久 API 就被百度关闭了,从此以后便黯然失色,一度沦落为本地播放器,在这个万物互联时代显得有点落寞,我也因此没有太多更新的动力。


最近无意间发现开源社区已经有大神发布了「网易云音乐 API」,喜出望外,遂有了重整旗鼓的想法,顺便对之前的架构做一次重构,来一次脱胎换骨的升级!


经过3个多月断断续续的开发,今天,它来了!


展示


视频


截图
image.jpg


功能



后续可能会根据需要增加功能




  • 本地功能

    • 添加和播放本地音乐文件

    • 专辑封面显示

    • 歌词显示,支持拖动歌词调节播放进度

    • 通知栏控制

    • 夜间模式

    • 定时关闭



  • 在线功能

    • 登录网易云

    • 同步网易云歌单

    • 每日推荐

    • 歌单广场

    • 排行榜

    • 搜索歌曲和歌单




体验



欢迎大家体验,如果发现功能问题或兼容性问题,可以在本文评论或者 GitHub Issue



环境要求



  • Android 手机

  • 电脑(非必须)


安装步骤



  1. 搭建网易云服务器

    clone NeteaseCloudMusicApi 服务端项目到本地,根据项目说明安装并运行服务,需要确认电脑和手机处于同一局域网

  2. 安装 APP

    点击下载最新安装包

  3. 设置域名

    打开 APP,点击左上角汉堡按钮,打开抽屉,点击「域名设置」,输入步骤1中的地址(包含端口)

  4. 设置完成即可体验



没有电脑,如何体验?


其实有一些同仁已经将网易云服务部署到公网了,我们可以直接用🐶。


这里不方便直接贴地址,下面教大家如何找到可以用的服务:


用 Google 搜索「网易云音乐API」,点击结果,如果页面是下图这样(注意:非作者的 GitHub.io 页面),恭喜,你找到了可以直接使用的服务,拷贝地址栏链接,输入到步骤3即可。


screenshot-20231026-152715.png



源码


wangchenyan/ponymusic: Android online music player use okhttp&gson&material design (github.com)


欢迎感兴趣的朋友 Star、Fork、PR,有你们的支持,我会非常开心😄


开源技术



站在巨人的肩膀上




作者:王晨彦
来源:juejin.cn/post/7294072229003952143
收起阅读 »

解决Android卡顿性能瓶颈的深度探讨

在移动应用开发中,Android卡顿是一个常见但令人讨厌的问题,它可能导致用户体验下降,甚至失去用户。本文将深入探讨Android卡顿的原因,以及如何通过代码优化和性能监测来提高应用的性能。 卡顿现象 卡顿是指应用在运行时出现的明显延迟和不流畅的感觉。这可能包...
继续阅读 »

在移动应用开发中,Android卡顿是一个常见但令人讨厌的问题,它可能导致用户体验下降,甚至失去用户。本文将深入探讨Android卡顿的原因,以及如何通过代码优化和性能监测来提高应用的性能。


卡顿现象


卡顿是指应用在运行时出现的明显延迟和不流畅的感觉。这可能包括滑动不流畅、界面响应缓慢等问题。要解决卡顿问题,首先需要了解可能导致卡顿的原因。


卡顿原因


主线程阻塞


主线程负责处理用户界面操作,如果在主线程上执行耗时任务,会导致界面冻结。


public void doSomeWork() {
// 这里执行耗时操作
// ...
// 下面的代码会导致卡顿
updateUI();
}

内存泄漏


内存泄漏可能会导致内存消耗过多,最终导致应用变得缓慢。


public class MyActivity extends AppCompatActivity {
private static List<SomeObject> myList = new ArrayList<>();

@Override
protected void onCreate(Bundle savedInstanceState) {
// 向myList添加数据,但没有清除
myList.add(new SomeObject());
}
}

过多的布局层次


复杂的布局层次会增加UI绘制的负担,导致卡顿。


<RelativeLayout>
<LinearLayout>
<ImageView />
<TextView />
<!-- 更多视图 -->
</LinearLayout>
</RelativeLayout>

大量内存分配


频繁的内存分配与回收,会导致性能下降,发生卡顿。


// 创建大量对象
List<Object> objects = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
objects.add(new Object());
}

优化策略


使用异步任务


避免在主线程上执行耗时操作,使用异步任务或线程池来处理它们。
协程提供了一种更清晰和顺序化的方式来执行异步任务,并且能够很容易地切换线程



// 创建一个协程作用域
val job = CoroutineScope(Dispatchers.IO).launch {
// 在后台线程执行后台任务
val result = performBackgroundTask()

// 切换到主线程更新UI
withContext(Dispatchers.Main) {
updateUI(result)
}
}

// 取消协程
fun cancelJob() {
job.cancel()
}

suspend fun performBackgroundTask(): String {
// 执行后台任务
return "Background task result"
}

fun updateUI(result: String) {
// 更新UI
}

在此示例中,我们首先创建一个协程作用域,并在后台线程(Dispatchers.IO)中启动一个协程(launch)。协程执行后台任务(performBackgroundTask),然后使用withContext函数切换到主线程(Dispatchers.Main)来更新UI。


内存管理


确保在不再需要的对象上及时释放引用,以避免内存泄漏。


public class MyActivity extends AppCompatActivity {
private List<SomeObject> myList = new ArrayList<>();

@Override
protected void onCreate(Bundle savedInstanceState) {
myList.add(new SomeObject());
}

@Override
protected void onDestroy() {
super.onDestroy();
myList.clear(); // 清除引用
}
}

精简布局


减少不必要的布局嵌套,使用ConstraintLayout等优化性能的布局管理器。


<ConstraintLayout>
<ImageView />
<TextView />
<!-- 更少的视图层次 -->
</ConstraintLayout>

使用对象池


避免频繁的内存分配和回收。尽量重用对象,而不是频繁创建新对象。
使用对象池来缓存和重用对象,特别是对于复杂的数据结构。


// 使用对象池来重用对象
ObjectPool objectPool = new ObjectPool();
for (int i = 0; i < 10000; i++) {
Object obj = objectPool.acquireObject();
// 使用对象
objectPool.releaseObject(obj);
}

卡顿监测


Android提供了性能分析工具,如Android Profiler和Systrace,用于帮助您找到性能瓶颈并进行优化。


为了更深入地了解应用性能,您还可以监测主线程处理时间。通过解析Android系统内部的消息处理日志,您可以获取每条消息的实际处理时间,提供了高度准确的性能信息。


for (;;) {
Message msg = queue.next();

final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what)
;
}

msg.target.dispatchMessage(msg);

if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
}

当消息被取出并准备处理时,通过 logging.println(...) 记录了">>>>> Dispatching to" 日志,标志了消息的处理开始。同样,在消息处理完成后,记录了"<<<<< Finished to" 日志,标志了消息的处理结束。这些日志用于追踪消息的处理时间点。


这段代码对 Android 卡顿相关内容的分析非常重要。通过记录消息的处理起点和终点时间,开发者可以分析主线程消息处理的性能瓶颈。如果发现消息的处理时间过长,就可能导致卡顿,因为主线程被长时间占用,无法响应用户交互。


Looper.getMainLooper().setMessageLogging(new LogPrinter(new String("MyApp"), Log.DEBUG) {
@Override
public void println(String msg) {
if (msg.startsWith(">>>>> Dispatching to ")) {
// 记录消息开始处理时间
startTime = System.currentTimeMillis();
} else if (msg.startsWith("<<<<< Finished to ")) {
// 记录消息结束处理时间
long endTime = System.currentTimeMillis();
// 解析消息信息
String messageInfo = msg.substring("<<<<< Finished to ".length());
String[] parts = messageInfo.split(" ");
String handlerInfo = parts[0];
String messageInfo = parts[1];
// 计算消息处理时间
long executionTime = endTime - startTime;
// 记录消息处理时间
Log.d("DispatchTime", "Handler: " + handlerInfo + ", Message: " + messageInfo + ", Execution Time: " + executionTime + "ms");
}
}
});

这种方法适用于需要深入分析主线程性能的情况,但需要权衡性能开销和代码复杂性。


结语


Android卡顿问题可能是用户体验的重要破坏因素。通过了解卡顿的原因,采取相应的优化策略,利用性能分析工具和消息处理日志监测,您可以提高应用的性能,使用户体验更加流畅。卡顿问题的解决需要不断的监测、测试和优化,通过不断发现与解决卡顿问题,才能让应用更加流畅。


推荐


android_startup: 提供一种在应用启动时能够更加简单、高效的方式来初始化组件,优化启动速度。不仅支持Jetpack App Startup的全部功能,还提供额外的同步与异步等待、线程控制与多进程支持等功能。


AwesomeGithub: 基于Github的客户端,纯练习项目,支持组件化开发,支持账户密码与认证登陆。使用Kotlin语言进行开发,项目架构是基于JetPack&DataBinding的MVVM;项目中使用了Arouter、Retrofit、Coroutine、Glide、Dagger与Hilt等流行开源技术。


flutter_github: 基于Flutter的跨平台版本Github客户端,与AwesomeGithub相对应。


android-api-analysis: 结合详细的Demo来全面解析Android相关的知识点, 帮助读者能够更快的掌握与理解所阐述的要点。


daily_algorithm: 每日一算法,由浅入深,欢迎加入一起共勉。


作者:午后一小憩
来源:juejin.cn/post/7293342627813425167
收起阅读 »

Android:解放自己的双手,无需手动创建shape文件

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。 现在的移动应用中为了美化界面,会给各类视图增加一些圆角、描边、渐变等等效果。当然系统也提供了对应的功能,那就是创建shape标签的XML文件,例如下图就是创建一个圆角为10dp,填充...
继续阅读 »

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。



现在的移动应用中为了美化界面,会给各类视图增加一些圆角、描边、渐变等等效果。当然系统也提供了对应的功能,那就是创建shape标签的XML文件,例如下图就是创建一个圆角为10dp,填充是白色的shape文件。再把这个文件设置给目标视图作为背景,就达到了我们想要的圆角效果。


<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="#FFFFFF" />
</shape>

//圆角效果
android:background="@drawable/shape_white_r10"

但不是所有的圆角和颜色都一样,甚至还有四个角单独一个有圆角的情况,当然还有描边、虚线描边、渐变填充色等等各类情况。随着页面效果的多样和复杂性,我们添加的shape文件也是成倍增加。


这时候不少的技术大佬出现了,大佬们各显神通打造了许多自定义View。这样我们就可以使用三方库通过在目标视图外嵌套一层视图来达到原本的圆角等效果。不得不说,这确实能够大大减少我们手动创建各类shape的情况,使用起来也是得心应手,方便了不少。


问题:


简单的布局,嵌套层级较少的页面使用起来还好。但往往随着页面的复杂程度越高,嵌套层级也越来多,这个时候再使用三方库外层嵌套视图会越来越臃肿和复杂。那么有没有一种方式可以直接在XML中当前视图中增减圆角等效果呢?


还真有,使用DataBinding可以办到!


这里就不单独介绍DataBinding的基础配置,网上一搜到处都是。咱们直接进入正题,使用**@BindingAdapter** 注解,这是用来扩展布局XML属性行为的注解。


使用DataBinding实现圆角


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = ["shape_radius""shape_solidColor"])
fun View.setViewBackground(radius: Int = 0,solidColor: Int = Color.TRANSPARENT){
val drawable = GradientDrawable()
drawable.cornerRadius = context.dp2px(radius.toFloat()).toFloat()
drawable.setColor(solidColor)
background = drawable
}

//xml文件中
shape_radius="@{10}"
shape_solidColor="@{@color/white}"

其实就是对当前视图的一个扩展,有点和kotlin的扩展函数类似。既然这样我们可以通过代码配置更多自定义的属性:


各方向圆角的实现:


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = ["
"shape_solidColor",//填充颜色
"shape_tl_radius",//上左圆角
"shape_tr_radius",//上右圆角
"shape_bl_radius",//下左圆角
"shape_br_radius"//下右圆角
])
fun View.setViewBackground(radius: Int = 0,solidColor: Int = Color.TRANSPARENT){
val drawable = GradientDrawable()
drawable.setColor(solidColor)
drawable.cornerRadii = floatArrayOf(
context.dp2px(shape_tl_radius.toFloat()).toFloat(),
context.dp2px(shape_tl_radius.toFloat()).toFloat(),
context.dp2px(shape_tr_radius.toFloat()).toFloat(),
context.dp2px(shape_tr_radius.toFloat()).toFloat(),
context.dp2px(shape_br_radius.toFloat()).toFloat(),
context.dp2px(shape_br_radius.toFloat()).toFloat(),
context.dp2px(shape_bl_radius.toFloat()).toFloat(),
context.dp2px(shape_bl_radius.toFloat()).toFloat(),
)
background = drawable
}

//xml文件中
shape_radius="@{10}"
shape_tl_radius="@{@color/white}"//左上角
shape_tr_radius="@{@color/white}"//右上角
shape_bl_radius="@{@color/white}"//左下角
shape_br_radius="@{@color/white}"//右下角

虚线描边:


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = [
"shape_radius"
"shape_solidColor"
"shape_strokeWitdh",//描边宽度
"shape_dashWith",//描边虚线单个宽度
"shape_dashGap",//描边间隔宽度
])
fun View.setViewBackground(
radius: Int = 0,
solidColor: Int = Color.TRANSPARENT,
strokeWidth: Int = 0,
shape_dashWith: Int = 0,
shape_dashGap: Int = 0
){
val drawable = GradientDrawable()
drawable.setStroke(
context.dp2px(strokeWidth.toFloat()),
strokeColor,
shape_dashWith.toFloat(),
shape_dashGap.toFloat()
)
drawable.setColor(solidColor)
background = drawable
}

//xml文件中
shape_radius="@{10}"
shape_solidColor="@{@color/white}"
strokeWidth="@{1}"
shape_dashWith="@{2}"
shape_dashGap="@{3}"

渐变色的使用:


//自定义shape_radius、shape_solidColor字段  即圆角和填充颜色
@BindingAdapter(value = [
"shape_startColor",//渐变开始颜色
"shape_centerColor",//渐变中间颜色
"shape_endColor",//渐变结束颜色
"shape_gradualOrientation",//渐变角度
])
fun View.setViewBackground(
shape_startColor: Int = Color.TRANSPARENT,
shape_centerColor: Int = Color.TRANSPARENT,
shape_endColor: Int = Color.TRANSPARENT,
shape_gradualOrientation: Int = 1,//TOP_BOTTOM = 1 ,TR_BL = 2,RIGHT_LEFT = 3,BR_TL = 4,BOTTOM_TOP = 5,BL_TR = 6,LEFT_RIGHT = 7,TL_BR = 8
){
val drawable = GradientDrawable()
when (shape_gradualOrientation) {
1 -> drawable.orientation = GradientDrawable.Orientation.TOP_BOTTOM
2 -> drawable.orientation = GradientDrawable.Orientation.TR_BL
3 -> drawable.orientation = GradientDrawable.Orientation.RIGHT_LEFT
4 -> drawable.orientation = GradientDrawable.Orientation.BR_TL
5 -> drawable.orientation = GradientDrawable.Orientation.BOTTOM_TOP
6 -> drawable.orientation = GradientDrawable.Orientation.BL_TR
7 -> drawable.orientation = GradientDrawable.Orientation.LEFT_RIGHT
8 -> drawable.orientation = GradientDrawable.Orientation.TL_BR
}
drawable.gradientType = GradientDrawable.LINEAR_GRADIENT//线性
drawable.shape = GradientDrawable.RECTANGLE//矩形方正
drawable.colors = if (shape_centerColor != Color.TRANSPARENT) {//有中间色
intArrayOf(
shape_startColor,
shape_centerColor,
shape_endColor
)
} else {
intArrayOf(shape_startColor, shape_endColor)
}//渐变色
background = drawable
}

//xml文件中
shape_startColor="@{@color/cl_F1E6A0}"
shape_centerColor="@{@color/cl_F8F8F8}"
shape_endColor=@{@color/cl_3CB9FF}

不止设置shape功能,只要可以通过代码设置的功能一样可以在BindingAdapter注解中自定义,使用起来是不是更加方便了。


总结:



  • 注解BindingAdapter中value数组的自定义属性一样要和方法内的参数一一对应,否则会报错。

  • 布局中使用该自定义属性时需要将布局文件最外层修改为layout标签

  • XML中使用自定义属性时一定要添加@{}


好了,以上便是解放自己的双手,无需手动创建shape文件的全部内容,希望能给大家带来帮助!


作者:似曾相识2022
来源:juejin.cn/post/7278858311596359739
收起阅读 »

Jetpack Compose 实现仿淘宝嵌套滚动

前言 嵌套滚动是日常开发中常见的需求,能够在有限的屏幕中动态展示多样的内容。以淘宝搜索页为例,使用 Jetpack Compose 实现嵌套滚动。 NestedScrollConnection Compose 中可以使用 nestedScroll 修饰...
继续阅读 »

前言


嵌套滚动是日常开发中常见的需求,能够在有限的屏幕中动态展示多样的内容。以淘宝搜索页为例,使用 Jetpack Compose 实现嵌套滚动。






NestedScrollConnection


Compose 中可以使用 nestedScroll 修饰符来自定义嵌套滚动的逻辑,其中 NestedScrollConnetcion 是连接组件与嵌套滚动体系的关键,它提供了四个回调函数,可以在子布局获得滑动事件前预先消费掉部分或全部手势偏移量,也可以获取子布局消费后剩下的手势偏移量。


interface NestedScrollConnection {

fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero

fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
)
: Offset = Offset.Zero

suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero

suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return Velocity.Zero
}
}

onPreScroll


方法描述:预先劫持滑动事件,消费后再交由子布局。


参数列表:



  • available:当前可用的滑动事件偏移量

  • source:滑动事件的类型


返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回 Offset.Zero


onPostScroll


方法描述:获取子布局处理后的滑动事件


参数列表:



  • consumed:之前消费的所有滑动事件偏移量

  • available:当前剩下还可用的滑动事件偏移量

  • source:滑动事件的类型


返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回 Offset.Zero ,则剩下偏移量会继续交由当前布局的父布局进行处理


onPreFling


方法描述:获取 Fling 开始时的速度。


参数列表:



  • available:Fling 开始时的速度


返回值:当前组件消费的速度,如果不想消费可返回 Velocity.Zero


onPostFling


方法描述:获取 Fling 结束时的速度信息。


参数列表:



  • consumed:之前消费的所有速度

  • available:当前剩下还可用的速度


返回值:当前组件消费的速度,如果不想消费可返回Velocity.Zero,剩下速度会继续交由当前布局的父布局进行处理


实现嵌套滚动


示例分析


如截图所示的搜索页可以分为5个部分。




  • 搜索栏位置固定,不随滑动而改变




  • Tab栏、店铺卡片、筛选栏、商品列表随滑动事件改变位置



    • 当手指向上滑动时,首先店铺卡片向上滑动,伴随透明度降低,接着tab栏和排序栏一起向上滑动,最后列表内的条目才会被向上滑动。

    • 当手指向下滑动,首先tab栏和排序栏向下滑动,接着列表内的条目向下滑动,最后店铺卡片才会出现。





设计实现方案


选择 LazyColumn 作为子布局实现商品列表,Tab栏、店铺卡片、筛选栏作为另外三个部分,放置在同一个父布局中统一管理。LazyColumn 已经支持嵌套滚动系统,能够将滑动事件传递给父布局,因此我们希望在子布局消费滑动事件的前、后,由父布局消费一部分滑动事件,从而改变Tab栏、店铺卡片、筛选栏的布局位置。

































滑动事件 消费顺序 处理的位置
手指上滑
available.y < 0
1. 店铺卡片上滑 onPreScroll 拦截
2. Tab栏、筛选栏上滑
3. 列表上滑 子布局消费
手指下滑
available.y > 0
1. Tab栏、筛选栏下滑 onPreScroll 拦截
2. 列表下滑 子布局消费
3. 店铺卡片下滑 自动分发到父布局

实现 SearchState 管理滚动状态


模仿 ScrollState,实现 SearchState 以管理父布局的滚动状态。value 代表当前滚动的位置,maxValue 代表父布局滚动的最大距离,从0到 maxValue 的范围又被商品卡片的高度 cardHeight 划分为两个阶段。定义 canScrollForward2 标记是否处在应该由Tab栏、筛选栏滑动的区间。


value消费滑动事件的控件
0 <= value < cardHeight店铺卡片滑动
cardHeight <= value < maxValueTab栏、筛选栏滑动
value = maxValue商品列表滑动

@Stable
class SearchState {
// 当前滚动的位置
var value: Int by mutableStateOf(0)
private set
var maxValue: Int
get() = _maxValueState.value
internal set(newMax) {
_maxValueState.value = newMax
if (value > newMax) {
value = newMax
}
}
var cardHeight: Int
get() = _cardHeightState.value
internal set(newHeight) {
_cardHeightState.value = newHeight
}
private var _maxValueState = mutableStateOf(Int.MAX_VALUE)
private var _cardHeightState = mutableStateOf(Int.MAX_VALUE)
private var accumulator: Float = 0f

// 同 ScrollState 实现,父布局不会消费超过 maxValue 的部分
val scrollableState = ScrollableState {
val absolute = (value + it + accumulator)
val newValue = absolute.coerceIn(0f, maxValue.toFloat())
val changed = absolute != newValue
val consumed = newValue - value
val consumedInt = consumed.roundToInt()
value += consumedInt
accumulator = consumed - consumedInt

// Avoid floating-point rounding error
if (changed) consumed else it
}

private fun consume(available: Offset): Offset {
val consumedY = -scrollableState.dispatchRawDelta(-available.y)
return available.copy(y = consumedY)
}

// 是否应该进行第二阶段滚动,改变Tab栏和搜索栏的偏移
val canScrollForward2 by derivedStateOf { value in cardHeight..maxValue }
}

@Composable
fun rememberSearchState(): SearchState {
return remember { SearchState() }
}

实现 NestedScrollConnection


根据上文所述,需要在 onPreScroll 回调函数在合适的时机拦截滑动事件,使得父布局在子布局之前消费滑动事件。


internal val nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// 手指向上滑动时,直接拦截,由父布局消费,直到超过 maxValue,再由子布局消费
return if (available.y < 0) consume(available)
// 手指向下滑动时,在 cardHeight 到 maxValue 的区间内由父布局拦截,在子布局之前消费
else if (available.y > 0 && canScrollForward2) {
val deltaY = available.y.coerceAtMost((value - cardHeight).toFloat())
consume(available.copy(y = deltaY))
} else super.onPreScroll(available, source)
}
}

另外,为了操作体验的连续性,如果触摸了 LazyColumn 以外的区域,并且手指不离开屏幕持续向上滑动,在超出父布局能消费的范围后,我们希望能将剩余滑动事件再传递给子布局继续消费。为了实现这一功能,增加一个 NestedScrollConnection 对象,在 onPostScroll 回调中,将父布局消费后剩余的滑动事件传递到 LazyColumn 内部。这里处理了拖拽的情况,对于这种情况下 fling 速度的传递,也将在下文处理。


@Composable
fun Search(modifier: Modifier = Modifier, state: SearchState = rememberSearchState()) {
val flingBehavior = ScrollableDefaults.flingBehavior()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val outerNestedScrollConnection = object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
)
: Offset {
if (available.y < 0) {
scope.launch {
// 由子布局 LazyColumn 继续消费剩余滑动距离
listState.scrollBy(-available.y)
}
return available
}
return super.onPostScroll(consumed, available, source)
}
}
Layout(...) {...}
}

实现父布局及其 MeasurePolicy


由于需要改变父布局中内容的放置位置,使用 Layout 作为父布局,其中前三个子布局使用 Text 控件标识,对店铺卡片设置动态透明度。


Layout(
content = {
// TopBar()
Text(text = "TopBar")
// ShopCard()
Text(
text = "ShopCard",
// 背景和文字都随着滑动距离改变透明度
modifier = Modifier
.background(
alpha = 1 - state.value / state.maxValue.toFloat()
)
.alpha(1 - state.value / state.maxValue.toFloat())
)
// SortBar()
Text(text = "SortBar")
// CommodityList()
List(listState)
},
...
)

Layout 控件并不默认支持嵌套滚动,因此需要使用 scrollable 修饰符使其能够滚动并参与到嵌套滚动系统中。将 SearchState 中的 scrollableState 作为 state 入参,在 flingBehavior 入参中将父布局未消费完的 fling 速度,传递给子布局 LazyColumn 继续消费,使得操作体验连续。


前文实现了两个 NestedScrollConnection 对象,分别用于处理父布局和子布局消费前后的滑动事件,在 Layout 的 Modifier 对象中使用 nestedScroll 修饰符进行组装。由于 Modifier 链中后加入的节点能先被遍历到,SearchState 中的 nestedScrollConnection 更靠后被调用,因此能更先拦截到子布局的触摸事件;outerNestedScrollConnection 在 scrollable 修饰符前被调用,因此能拦截 scrollable 处理父布局的触摸事件。


Layout(
...
modifier = modifier
// 获取父布局的触摸事件,在父布局消费前、后进行处理
.nestedScroll(outerNestedScrollConnection)
.scrollable(
state = state.scrollableState,
orientation = Orientation.Vertical,
reverseDirection = true,
flingBehavior = remember {
object : FlingBehavior {
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
val remain = with(this) {
with(flingBehavior) {
performFling(initialVelocity)
}
}
// 父布局未消费完的速度,传递给子布局继续消费
if (remain > 0) {
listState.scroll {
performFling(remain)
}
return 0f
}
return remain
}
}
},
)
// 获取子布局的触摸事件,在子布局消费前、后进行处理
.nestedScroll(state.nestedScrollConnection)
)

实现 MeasurePolicy,根据 SearchState 中的 value 计算各个组件的放置位置,以实现组件被滑动的视觉效果。


Layout(...) { measurables, constraints ->
check(constraints.hasBoundedHeight)
val height = constraints.maxHeight
val firstPlaceable = measurables[0].measure(
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
)
val secondPlaceable = measurables[1].measure(
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
)
val thirdPlaceable = measurables[2].measure(
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
)
// LazyColumn 限制高度为父布局最大高度
val bottomPlaceable = measurables[3].measure(
constraints.copy(minHeight = height, maxHeight = height)
)
// 更新 maxValue 和 cardHeight
state.maxValue = secondPlaceable.height + firstPlaceable.height + thirdPlaceable.height
state.cardHeight = secondPlaceable.height
layout(constraints.maxWidth, constraints.maxHeight) {
secondPlaceable.placeRelative(0, firstPlaceable.height - state.value)
// TopBar 覆盖在 ShopCard 上面,所以后放置
firstPlaceable.placeRelative(
0,
// 搜索栏在 value 超过 cardHeight 后才会开始移动
secondPlaceable.height - state.value.coerceAtLeast(secondPlaceable.height)
)
thirdPlaceable.placeRelative(
0,
firstPlaceable.height + secondPlaceable.height - state.value
)
bottomPlaceable.placeRelative(
0,
firstPlaceable.height + secondPlaceable.height + thirdPlaceable.height - state.value
)
}
}

效果


动图展示了 scroll 和 fling 两种情况下的效果。淘宝还实现了搜索栏、Tab栏、店铺卡片的透明度变化,营造了更自然的视觉效果,这里不再展开实现,聚焦使用 Jetpack Compose 实现嵌套滚动的效果。






示例源码


Search.kt


作者:Ovaltinez
来源:juejin.cn/post/7287773353309749303
收起阅读 »

鸿蒙开发之页面路由(router)

今天继续来学点鸿蒙相关的知识,本次内容讲的是页面路由,也就是我们熟悉的页面之间跳转传参等一系列操作,鸿蒙里面主要使用Router模块来完成这些页面路由相关操作,下面来详细介绍下 页面跳转 router.pushUrl()和router.replaceUrl()...
继续阅读 »

今天继续来学点鸿蒙相关的知识,本次内容讲的是页面路由,也就是我们熟悉的页面之间跳转传参等一系列操作,鸿蒙里面主要使用Router模块来完成这些页面路由相关操作,下面来详细介绍下


页面跳转


router.pushUrl()和router.replaceUrl()


这两个函数都可以用来页面跳转,区别在于



  • router.pushUrl():就像字面意思那样,会将一个新的页面推到页面栈的顶部,而旧页面依然存在,如果按下返回键或者调用router.back(),旧页面会回到栈顶.

  • router.replaceUrl():也像字面意思那样,会把当前旧页面用新页面来代替,旧页面会被销毁,如果按下返回键或者调用router.back(),不会回到旧页面.


知道了概念后来写俩例子实践下,首先是第一个页面文件,命名它为FirstPage.ets,所对应的路径是pages/FirstPage,里面的代码是这样的


image.png

页面结构很简单,就是一个文案加一个按钮,按钮点击事件就是跳转至SecondPage页面,我们看到这里的跳转方式是使用的pushUrl方式,也就是把SecondPage页面覆盖在FirstPage上,SecondPage里面的代码与FirstPage基本相似,我们看下


image.png

也是一个按钮加一个文案,按钮的事件是调用router.back()执行返回操作,这样一个完整的页面跳转加返回的操作就写完了,实际效果如下


1018aa1.gif

实际效果也证实了,使用pushUrl方式跳转,新页面会被加在页面栈顶端,而旧页面不会销毁,那么replaceUrl又是怎么样的呢?我们将代码更改下


image.png

第一个页面里面,将跳转的方式改了一下,改成replaceUrl,现在再看看效果


1018aa2.gif

可以发现跳转到第二个页面之后,再点击返回已经回不到第一个页面了,那是因为第一个页面已经从栈里面销毁了


RouterMode


页面跳转分为两种模式,分别是RouterMode.StandardRouterMode.Single,前者为跳转的默认模式,可不写,表示每次都新建一个页面实例,后者则表示单例模式,如果栈里面已经存在该页面实例,在启动它的时候会直接从栈里面回到栈顶,同样下面用代码来解释下这两种模式的区别,这里再新增一个页面ThirdPage


image.png

这个页面里面也有一个文案,另外还有两个按钮,返回按钮执行回退操作,跳转按钮则是跳转至SecondPage,这里跳转的方式是用的pushUrl,模式是Standard,另外我们在SecondPage里面也加一个跳转按钮,点击跳转至ThirdPage,方式也是pushUrlStandard


image.png

代码都写完了,目前这样的跳转逻辑等于是如果我不停的在新页面里面点击跳转按钮,那就会一直新建页面,如果在某一个页面点击返回并一直点下去,会将之前创建好的页面一个不差的都经过一遍,最终才能回到第一个页面,我们看下实际效果


1018aa3.gif

可以看到事实跟刚才讲的一样,但是很明显,将已经存在的实例重复创建是一件很消耗内存的事情,所以在这种需要再一次打开栈里面已经存在的实例的场景中,我们还是比较推荐使用Single模式,我们将上述代码中跳转SecondPageThirdPage的模式改成Single再试一次


1018aa4.gif

我们看见仍旧是无限跳转下去,最终停在了SecondPage上,但是如果从SecondPage里面开始点击返回,还会不会原路返回呢,我们看下


1018aa5.gif

我们看到,先返回到了ThirdPage,然后TirdPage点击返回直接回到了第一个页面,那是因为Single模式下,SecondPageThirdPage是在不停的做着从栈内回到栈顶的操作,所以当点击返回时,第一个页面上面始终只覆盖了两个页面


页面传参


有些场景下除了页面需要跳转,另外还需要将当前页面的数据传递到新页面里面去,如何传递呢?可以先看下pushUrl里面第一个参数RouterOption里面都有哪些属性


image.png

第一个参数url已经不用说了,都用过了,第二个参数params就是页面跳转中携带的参数,可以看到是一个Object,所以如果我们想传一个字符串到下一个页面,就不能直接将一个string给到params,得这样做


image.png

params里面以一个key-value形式传递参数,而在新页面里面,通过传递过来的key把对应值取出来,我们在下一个页面获取参数的代码是这样写的


image.png

首先通过router.getParams()将参数对象取出来,然后访问对应key值就能将传递过来的数据取出来了,在SecondPage里面还多增加了一个Text组件用来显示传递过来的数据,最终运行下代码后看看数据有没有传过去


1018bb1.gif

可以看到数据已经传过去了,但这里的场景比较简单,有的复杂的场景需要传递的数据不仅仅只有一个,会以一个model的形式作为参数传递,那么遇到这样的场景该怎么做呢?


image.png

我们看到直接传递了一个UserModel对象,而UserModel就是我们封装的一个数据类,基本在实际开发中类似于UserModel这样的数据就是一个接口的Response,我们传递参数时候,只需将Response传递过去就好了,而接收参数的地方的代码如下


image.png

可以发现,从页面跳转以及传参的这部分代码上,基本就与TypeScript的方式很相似了,看下实际效果


1018bb2.gif

页面返回


说过了页面的跳转,那么跳完之后的返回操作也要说下,其实在上面的例子中,我们已经使用到了页面返回的函数,也就是router.back(),这是其中一种返回方式,它总共有三种返回方式,分别如下


返回到上一个页面


使用router.back()方式,如果当前页面是栈中唯一页面,返回将无效


返回到指定页面


可以通过传递一个url返回到指定页面,如果该页面不在页面栈中,返回将无效,如果返回页与指定页面之间存在若干页面,那么指定页面被推到栈顶,返回页与中间的若干页面会被销毁,我们现在在之前的ThirdPage中的返回按钮中加入如下代码
image.png


前面所有跳转方式都改为Standard模式,在第三个页面中点击返回的时候,原来是直接退到第二个页面,现在指定了路径以后,我们看下调到哪里去了


1018bb3.gif

直接回到第一个页面了,其他两个页面已经被销毁


返回并传递参数


有些场景需要在指定页面点击返回后,将一些数据从指定页面传递到返回后的页面,这种数据传递方式与跳转时候传递方式基本一致,因为back函数中接收的参数也是RouterOptions,比如现在从第一个页面跳到第二个页面再跳到第三个页面后,第三个页面点击返回跳到第一个页面,并且传递一些参数在第一个页面展示,代码如下


image.png

第一个页面中接收参数我们也在onPageShow()里面进行


image.png

运行效果如下


1018bb4.gif

返回时添加询问弹窗


这个操作主要是在一些重要页面里面,比如支付页面,或者一些信息填写页面里面,用户在未保存或者提交当前页面的信息时就点击了返回按钮,页面中会弹出个询问框来让用户二次确认是否要进行返回操作,这个询问框可以是系统弹框,也可以是自定义弹框


系统弹框


系统弹框可以使用router.showAlertBeforeBackPage去实现,这个函数里面接收的参数为EnableAlertOptions,这个类里面只有一个message属性,用来在弹框上显示文案


image.png

使用方式如下,在router.back()操作之前,调用一下router.showAlertBeforeBackPage,弹框上会有确定和取消两个按钮,点击取消关闭弹窗停留在当前页面,点击确定就执行router.back()操作


image.png

我们在ThirdPage里面的返回操作中加入了系统询问框,可以看到我们要做的只是需要确定下弹框的文案就好,看下效果


1018bb5.gif

但是如果我们想要更改下按钮文案,或者顺序,或者自定义按钮的点击事件,就不能用系统弹框了,得使用自定义询问框


自定义询问框


自定义询问框使用promptAction.showDialog,在showDialog里面接收的参数为showDialogOptions,可以看下这个类里面有哪些属性


image.png

可以看到比系统弹框那边多了两个属性,能够设置弹框标题的title以及按钮buttons,可以看到buttons是一个Button的数组,最多可以设置三个按钮,注意这个Button并不是我们熟悉的Button组件,它内部只支持自定义文案以及颜色


image.png

知道了这些属性之后,我们可以把上面额系统询问框替换成这个自定义的询问框了,代码如下


image.png
image.png

可以看到弹框上面就多了一个标题,以及按钮的文案与颜色也变了,那么如何设置点击事件呢,现在两个按钮点了除了关闭按钮之外是没有别的操作的,如果想要添加其他操作,就需要通过then操作符进行,在then里面会拿到一个ShowDialogSuccessResponse,这个类里面只有一个index属性,这个index就表示按钮的下标,可以通过判断下标来给指定按钮添加事件,代码如下


image.png
1018bb6.gif

现在按钮点击后已经可以响应我们添加进去的事件了


总结


鸿蒙页面路由的所有内容都已经讲完了,总体感受比Android原生跳转要方便很多,完全就是按照TS的跳转方式写的,再一次证明了如果有声明式语言开发经验的,去学鸿蒙会相对轻松很多


作者:Coffeeee
来源:juejin.cn/post/7291479799519526967
收起阅读 »

懒汉式逆向APK

通过各方神仙文档,以及多天调试,整理了这篇极简反编译apk的文档(没几个字,吧).轻轻松松对一个apk(没壳的)进行逆向分析以及调试.其实主要就是4个命令. 准备 下载apktool 下载Android SDK Build-Tools,其中对齐和签名所需的命...
继续阅读 »

通过各方神仙文档,以及多天调试,整理了这篇极简反编译apk的文档(没几个字,吧).轻轻松松对一个apk(没壳的)进行逆向分析以及调试.其实主要就是4个命令.


准备



  1. 下载apktool

  2. 下载Android SDK Build-Tools,其中对齐和签名所需的命令都在此目录下对应的版本的目录中,比如我的在D:\sdk\build-tools\30.0.3目录下,可以将此目录加入环境变量中,后续就可以直接使用签名和对齐所需的命令了

  3. 可选,下载jadx-gui,可查看apk文件,并可导出为gralde项目供AS打开


流程




  1. 解压apk: apktool d C:\Users\CSP\Desktop\TEMP\decompile\test.apk -o C:\Users\CSP\Desktop\TEMP\decompile\test,第一个参数是要解压的apk,第二个参数(-o后面)是解压后的目录




  2. 修改: 注意寄存器的使用别错乱,特别注意,如果需要使用更多的寄存器,要在方法开头的.locals x或.registers x中对x+1



    • 插入代码:在idea上使用java2smali插件先生成smali代码,可复制整个.smali文件到包内,或者直接复制smali代码,注意插入后修改包名;

    • 修改代码:需要熟悉smali语法,可自行百度;

    • 修改so代码,需要IDA,修改完重新保存so文件,并替换掉原so文件,注意如有多个架构的so,需要都进行修改并替换;

    • 删除代码:不建议,最好逻辑理清了再删,但千万别删一半;

    • 资源:修改AndroidManifest.xml,可在application标签下加入android:debuggable="true",重新打包后方可对代码进行调试;




  3. 重打包: apktool b C:\Users\CSP\Desktop\TEMP\decompile\test -o C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk,第一个参数是要进行打包的目录文件,第二个参数(-o后面)是重新打包后的apk路径.重新打包成功,会出现Press any key to continue ...




  4. 对齐: zipalign -v 4 C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk,第一个参数是需要进行对齐的apk路径,第二个参数是对齐后的apk路径.对齐成功,会出现Verification succesful




  5. 签名: apksigner sign -verbose --ks C:\Users\CSP\Desktop\软件开发\反编译\mykeys.jks --v1-signing-enabled true --v2-signing-enabled true --ks-key-alias key0 --ks-pass pass:mykeys --key-pass pass:mykeys --out C:\Users\CSP\Desktop\TEMP\decompile\test_b_sign.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk,第一个参数(--ks后面)是密钥路径,后面跟着是否开启V1、V2签名,在后面跟着签名密码,最后两个参数(--out后面)是签名后的apk路径以及需要签名的apk(注意需对齐)路径.签名成功,会出现Signed




  6. 安装: adb install C:\Users\CSP\Desktop\TEMP\decompile\test_b_sign.apk




  7. 调试: 用jdax将apk导出为gradle项目,在AS中打开,即可通过attach debugger的方式对刚重新打包的项目进行调试.注意,调试时因为行号对不上,所以只能在方法上打上断点(菱形图标,缺点,运行速度极慢)




  8. 注意事项:



    • 上述命令中,将目录和项目'test'改成自己的目录和项目名即可;

    • apktool,zipalign,apksigner,adb命令需加入环境变量,否则在各自的目录下./xxx 去执行命令;

    • zipalign,apksigner所需的运行文件在X:XX\sdk\build-tools\30.0.3目录下;

    • 使用apksigner签名,对齐操作必须在签名之前(推荐使用此签名方式);

    • 新版本Android Studio生成的签名密钥,1.8版本JDK无法使用,我是安装了20版本的JDK(AS自带的17也行)




假懒


为了将懒进行到底,写了个bat脚本(需要在test文件目录下):


::关闭回显
@echo off
::防止中文乱码
chcp 65001
title 一键打包

start /wait apktool b C:\Users\CSP\Desktop\TEMP\decompile\test -o C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk
start /b /wait zipalign -v 4 C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk
start /b /wait apksigner sign -verbose --ks C:\Users\CSP\Desktop\软件开发\反编译\mykeys.jks --v1-signing-enabled true --v2-signing-enabled true --ks-key-alias key0 --ks-pass pass:mykeys --key-pass pass:mykeys --out C:\Users\CSP\Desktop\TEMP\decompile\test_b_sign.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk

大家将此脚本复制进bat文件,即可一键输出.


不过目前略有瑕疵:1.重新打包需要新开窗口,并且完成后还需手动关闭;2.关闭后还要输入'N'才能进行后续的对齐和签名操作有无bat大神帮忙优化下/(ㄒoㄒ)/~~!


-------更新


真懒


对于'假懒'中的打包脚本,会有2个瑕疵,使得不能将懒进行到底.经过查找方案,便有了以下'真懒'的方案,使得整个打包可以真正一键执行:


::关闭回显
@echo off
::防止中文乱码
chcp 65001
title 一键打包

call apktool b C:\Users\CSP\Desktop\TEMP\decompile\test -o C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk
call zipalign -v 4 C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk
del test_b.apk
call apksigner sign -verbose --ks C:\Users\CSP\Desktop\软件开发\反编译\mykeys.jks --v1-signing-enabled true --v2-signing-enabled true --ks-key-alias key0 --ks-pass pass:mykeys --key-pass pass:mykeys --out C:\Users\CSP\Desktop\TEMP\decompile\test_b_sign.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk
del test_b_zipalign.apk

echo 打包结束
echo 输出文件是-----test_b_sign.apk

pause

可以看到,把start换成了call,并且删除了重新打包和对齐后的文件,只留下最后签完名的文件


image.png


到此够了吗?不够,因为运行第一个apktool b命令时,重新打包完,会被pasue,让你按一个按键再继续.


image.png


这当然不行,这可不算一键,那么我们找到apktool的存放路径,打开apktool.bat,找到最后一行


image.png


就是这里对程序暂停了,那么就把这一行删了,当然最好是注释了就行,在最前面rem即可对命令进行注释,处理完之后,再重新运行我们的'一键打包.bat'脚本,这时候在中途重新打包后就不会出现'Press any key to continue...'了,即可一键实现打包-对齐-签名的流程了( •̀ ω •́ )y.


当然,如果想使脚本到处运行,可以给脚本增加一个变量,在运行前通过环境变量的形式把要打包的目录路径加入进来,这个大家可以自行尝试.


最后,感谢大家的阅读.这里面有对smali和so的修改,有机会和时间,我也会继续分享!!!


作者:果然翁
来源:juejin.cn/post/7253291597042319418
收起阅读 »

android 13 解决无法推送问题(notifications 相关)

最近,接手的 app (react native 技术栈) 需要添加一些关于推送的流程,根据后端返回的 json 到达对应的页面,这个也不难,根据旧代码添加相应的流程就行了。加上,让 qa 人员测试,发现 android 13 无法推送。以下是总结的解决思路 ...
继续阅读 »

最近,接手的 app (react native 技术栈) 需要添加一些关于推送的流程,根据后端返回的 json 到达对应的页面,这个也不难,根据旧代码添加相应的流程就行了。加上,让 qa 人员测试,发现 android 13 无法推送。以下是总结的解决思路


添加权限


在 AndroidManifest.xml 加上 POST_NOTIFICATIONS 权限


<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

请求权限


在上面我们添加了通知权限,但默认是关闭的,需要用户长按 app 的图标到应用程序信息那手动把通知权限打开,这肯定是不现实的,因此得主动请求,并让用户选择是否给予通知权限


既然是用 react naitve,那就用 js 代码请求好了,由于只有在安卓13需要用到,因此需要判断系统和版本


import React, {Component} from 'react';
import {
Platform,
PermissionsAndroid,
} from 'react-native';

export default class App extends Component {

componentDidMount() {
if (Platform.OS === 'android' && Platform.Version === 13) {
PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
}
}

}

但实际上出了问题,PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONSundefined,github 也有相关的 issue,是跟 RN 的版本有关,v0.70.7 版本才解决了这个问题,很显然升级 rn 的代价太大(接手的项目还不支持 function component 和 hook 呢),因此采用原生方法请求


在 MainActivity.java 添加下列代码,其中 requestPermissions 的第二个参数 requestCode 是自定义的,不重复即可,下面我就定义为了 101


import android.Manifest;
import android.os.Build;

public class MainActivity extends ReactActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
// 同样判断 android 版本为 13
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU) {
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 101);
}
}
}


好了,重新编译安装后,打开 app 会出现 “运行 app 向你发送通知吗” 的类似弹窗,如果用户拒绝的话,还是得手动去应用程序信息那里设置,当然,如果用户选择允许的话,我们的问题就解决了。


引导用户打开权限


如果用户选择不允许的话,又有重要的需要推送,就可能需要引导用户去打开权限了,因此我们写个桥接文件,提供两个方法,checkEnablejumpToNotificationsSettingPage,第一个判断权限有没有打开,第二个跳转到设置页面


NotificationsModule.java


package com.xxxapp;

import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;

import androidx.annotation.NonNull;
import androidx.core.app.NotificationManagerCompat;

import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;

public class NotificationsModule extends ReactContextBaseJavaModule {

private final Context context;

public NotificationsModule(ReactApplicationContext reactApplicationContext) {
super(reactApplicationContext);
context = reactApplicationContext;
}

@NonNull
@Override
public String getName() {
return "Notifications";
}

@ReactMethod
public void checkEnable(final Promise promise) {
promise.resolve(NotificationManagerCompat.from(context).areNotificationsEnabled());
}

@ReactMethod
public void jumpToNotificationsSettingPage() {
final ApplicationInfo applicationInfo = context.getApplicationInfo();
Intent intent = new Intent();
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setAction("android.settings.APP_NOTIFICATION_SETTINGS");
intent.putExtra("android.provider.extra.APP_PACKAGE", applicationInfo.packageName);
context.startActivity(intent);
}

}

NotificationsPackage.java


package com.xxxapp;

import androidx.annotation.NonNull;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class NotificationsPackage implements ReactPackage {

@NonNull
@Override
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
return new ArrayList<>(Collections.singletonList(new NotificationsModule(reactContext)));
}

@NonNull
@Override
public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) {
return Collections.emptyList();
}
}

作者:张二三
来源:juejin.cn/post/7289952867052994619
收起阅读 »

Android进阶之路 - 字体自适应

开发中有很多场景需要进行自适应适配,但是关于这种字体自适应,我也是为数不多的几次使用,同时也简单分析了下源码,希望我们都有收获 很多时候控件的宽度是有限的,而要实现比较好看的UI效果,常见的处理方式应该有以下几种 默认执行多行显示 单行显示,不足部分显示....
继续阅读 »

开发中有很多场景需要进行自适应适配,但是关于这种字体自适应,我也是为数不多的几次使用,同时也简单分析了下源码,希望我们都有收获



很多时候控件的宽度是有限的,而要实现比较好看的UI效果,常见的处理方式应该有以下几种



  • 默认执行多行显示

  • 单行显示,不足部分显示...

  • 自适应字体


静态设置


宽度是有限的,内部文字会根据配置进行自适应


在这里插入图片描述


TextView 自身提供了自适应的相关配置,可直接在layout中进行设置


主要属性



  • maxLines="1"

  • autoSizeMaxTextSize

  • autoSizeMinTextSize

  • autoSizeTextType

  • autoSizeStepGranularity


    <TextView
android:id="@+id/tv_text3"
android:layout_width="50dp"
android:layout_height="40dp"
android:layout_marginTop="10dp"
android:autoSizeMaxTextSize="18sp"
android:autoSizeMinTextSize="10sp"
android:autoSizeStepGranularity="1sp"
android:autoSizeTextType="uniform"
android:gravity="center"
android:maxLines="1"
android:text="自适应字体" />


源码:自定义属性


在这里插入图片描述




动态设置


 // 设置自适应文本默认配置(基础配置)
TextViewCompat.setAutoSizeTextTypeWithDefaults(textView, TextView.AUTO_SIZE_TEXT_TYPE_UNIFORM)
// 主动设置自适应字体相关配置
TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(textView, 20, 48, 2, TypedValue.COMPLEX_UNIT_SP)



源码分析


如果你有时间,也有这方面的个人兴趣,可以一起分享学习一下


setAutoSizeTextTypeWithDefaults


根据源码来看的话,内部做了兼容处理,主要是设置自适应文本的默认配置


在这里插入图片描述


默认配置方法主要根据不同类型设置自适应相关配置,默认有AUTO_SIZE_TEXT_TYPE_NONE or AUTO_SIZE_TEXT_TYPE_UNIFORM ,如果没有设置的话就会报 IllegalArgumentException 异常



  • AUTO_SIZE_TEXT_TYPE_NONE 清除自适应配置

  • AUTO_SIZE_TEXT_TYPE_UNIFORM 添加一些默认的配置信息


在这里插入图片描述




setAutoSizeTextTypeUniformWithConfiguration


根据源码来看主传4个参数,内部也做了兼容处理,注明 Build.VERSION.SDK_INT>= 27 or 属于 AutoSizeableTextView 才能使用文字自定义适配



  • textView 需进行自适应的控件

  • autoSizeMinTextSize 自适应自小尺寸

  • autoSizeMaxTextSize 自适应自大尺寸

  • autoSizeStepGranularity 自适应配置

  • unit 单位,如 sp(字体常用)、px、dp


在这里插入图片描述


unit 有一些常见的到单位,例如 dp、px、sp等


在这里插入图片描述


作者:Shanghai_MrLiu
来源:juejin.cn/post/7247027677223485498
收起阅读 »

一个功能强大的Flutter开源聊天列表插件

flutter_im_list是一款高性能、轻量级的Flutter聊天列表插件。可以帮助你快速创建出类微信的聊天列表的效果。 目录 预览图 示例 视频教程 如何使用 API 预览图 整体长按输入中 示例 Examples 视频教程 欢迎通过视频教程学习...
继续阅读 »

flutter_im_list是一款高性能、轻量级的Flutter聊天列表插件。可以帮助你快速创建出类微信的聊天列表的效果。


目录



预览图


整体长按输入中
flutter_im_listflutter_im_listflutter_im_list

示例



视频教程


欢迎通过视频教程学习交流。


如何使用


第一步添加依赖


在项目根目录下运行:


flutter pub add flutter_im_list

第二步:初始化ChatController


@override
void initState() {
super.initState();
chatController = ChatController(
initialMessageList: _messageList,
timePellet: 60,
scrollController: ScrollController());
}

第三步:在布局中添加ChatList


  @override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: ChatList(
chatController: chatController,
));
}

第四步:设置初始化数据


final List<MessageModel> _messageList = [
MessageModel(
id: 1,
content: "介绍下《ChatGPT + Flutter快速开发多端聊天机器人App》",
ownerType: OwnerType.sender,
createdAt: 1696142392000,
avatar: 'https://o.devio.org/images/o_as/avatar/tx18.jpeg',
ownerName: "Jack"),
MessageModel(
id: 2,
content:
"当前ChatGPT应用如雨后春笋般应运而生,给移动端开发者也带来了极大的机会。本课程将整合ChatGPT与Flutter高级技术,手把手带你从0到1开发一款可运行在多端的聊天机器人App,帮助你抓住机遇,快速具备AI运用能力,成为移动端领域的AI高手。@https://coding.imooc.com/class/672.html",
ownerType: OwnerType.receiver,
createdAt: 1696142393000,
avatar: 'https://o.devio.org/images/o_as/avatar/tx2.jpeg',
ownerName: "ChatGPT"),
];

如果没有,可以将_messageList赋值为[]。



了解更多请查看视频教程



API


IChatController


abstract class IChatController {
/// 在列表中添加消息
void addMessage(MessageModel message);
/// 在列表中删除消息
void deleteMessage(MessageModel message);
/// 批量添加消息(适用于下来加载更多的场景)
void loadMoreData(List<MessageModel> messageList);
}

ChatController


class ChatController implements IChatController {
/// 列表的初始化数据可以为[]
final List<MessageModel> initialMessageList;
final ScrollController scrollController;

///支持提供一个MessageWidgetBuilder来自定义气泡样式
final MessageWidgetBuilder? messageWidgetBuilder;

///设置显示的时间分组的间隔,单位秒
final int timePellet;
List<int> pelletShow = [];

ChatController({required this.initialMessageList,
required this.scrollController,
required this.timePellet,
this.messageWidgetBuilder}) {
for (var message in initialMessageList.reversed) {
inflateMessage(message);
}
}
...

ChatList


class ChatList extends StatefulWidget {
/// ChatList的控制器
final ChatController chatController;

/// 插入子项的空间大小
final EdgeInsetsGeometry? padding;

/// 气泡点击事件
final OnBubbleClick? onBubbleTap;

/// 奇葩长按事件
final OnBubbleClick? onBubbleLongPress;
/// 文本选择回调
final HiSelectionArea? hiSelectionArea;

const ChatList(
{super.key,
required this.chatController,
this.padding,
this.onBubbleTap,
this.onBubbleLongPress,
this.hiSelectionArea});

@override
State<ChatList> createState() => _ChatListState();
}


了解更多请查看视频教程



Contribution


欢迎在issues上报告问题。请附上bug截图和代码片段。解决问题的最快方法是在示例中重现它。


欢迎提交拉取请求。如果您想更改API或执行重大操作,最好先创建一个问题并进行讨论。




MIT Licensed


作者:CrazyCodeBoy
来源:juejin.cn/post/7292427026874368040
收起阅读 »

Android:这个需求搞懵了,产品说要实现富文本回显展示

一、前言 不就展示一个富文本吗,有啥难的,至于这么大惊小怪吗,哎,各位老铁,莫慌,先看需求,咱们再一探究竟。 1、大致需求 要求,用户内容编辑页面,实现图文混排,1、图片随意位置插入,并且可长按拖动排序;2、图片拖动完成之后,上下无内容,则需要空出输入位置,有...
继续阅读 »

一、前言


不就展示一个富文本吗,有啥难的,至于这么大惊小怪吗,哎,各位老铁,莫慌,先看需求,咱们再一探究竟。


1、大致需求


要求,用户内容编辑页面,实现图文混排,1、图片随意位置插入,并且可长按拖动排序;2、图片拖动完成之后,上下无内容,则需要空出输入位置,有内容,则无需空出;3、内容支持随意位置插入;4、以富文本的形式传入后台;5、解析富文本,回显内容。


2、大致效果图



实现这个需求倒不是很难,直接一个RecyclerView就搞定了,无非就是使用ItemTouchHelper,再和RecyclerView绑定之后,在onMove方法里实现Item的位置转换,当然需要处理一些图片和输入框之间的逻辑,这个不是本篇文章的重点,以后再说一块。


效果的话,我又单独的写了一个Demo,和项目中用到的一样,具体效果如下:



获取富文本的方式也是比较的简单,无论文本还是图片,最终都是存到集合中,我们直接遍历集合,给图片和文字设置对应的富文本标签即可,具体的属性,比如宽高,颜色大小,可以自行定义,大致如下:


/**
* AUTHOR:AbnerMing
* INTRODUCE:返回富文本数据
*/

fun getRichContent(): String {
val endContent = StringBuffer()
mRichList.forEach {
if (it.isPic == 0) {
//图片
endContent.append("<img src="" + it.image + ""/>")
} else {
//文本
endContent.append("<p>" + it.text + "</p>")
}
}
return endContent.toString()
}

以上的各个环节,不管怎么说,还是比较的顺利,接下来就到了我们今日的话题了,富文本我们是传上去了,但是如何回显呢?


二、富文本回显分析


回显有两种情况,第一种是编辑之后,可以保存至草稿,下次再编辑时,需要回显;第二种情况是,内容已经发布了,可以再次编辑内容。


具体的草稿回显有多种方式,我们不是使用RecyclerView实现的吗,直接保存列表数据就可以了,可以使用本地或者数据库形式的存储方式,不管使用哪种,实现起来绝非难事,回显的时候也是以集合的形式传入RecyclerView即可。


内容已经发布过的,这才是探究的重点,由于接口返回的是富文本信息,一开始无脑想到的是,富文本信息还得要解析里边的内容,着实麻烦,想着每次发布成功之后在本地存储一份数据,在编辑的时候,根据约定好的标识去存储的数据里找,确实可以实现,但是忽略了这是网络数据,是可以更换设备的,换个设备,数据从哪取呢?哈哈,这种投机取巧的方案,实在是不可取。


那没办法了,解析富文本呗,然后逐次取出图片和内容,再封装成集合,回显到RecyclerView中即可。


三、富文本解析


以下是发布成功后,某个字段的富文本信息,我们拿到之后,需要回显到编辑的页面,也就是自定义的RecyclerView中,老铁们,你们的第一解决方案是什么?


<p>我是测试内容</p><p>我是测试内容12333</p><img src="https://www.vipandroid.cn/ming/image/gan.png"/><p>我是测试内容88888</p><p>我是测试内容99999999</p><img src="https://www.vipandroid.cn/ming/image/zao.png"/>

我们最终需要拿到的数据,如下,只有这样,我们才能逐一封装到集合,回显到列表中。


    我是测试内容
我是测试内容12333
https://www.vipandroid.cn/ming/image/gan.png
我是测试内容88888
我是测试内容99999999
https://www.vipandroid.cn/ming/image/zao.png

字符串截取呗,我相信这是大家的第一直觉,以什么方式截取,才能拿到标签里的内容呢?可以负责任的告诉大家,截取是可以实现的,需要实现的逻辑有点多,我简单的举一个截取的例子:


            content = content.replace("<p>", "")
val split = content.split("</p>")
val contentList = arrayListOf<String>()
for (i in split.indices) {
val pContent = split[i]
if (TextUtils.isEmpty(pContent)) {
continue
}
if (pContent.contains("img")) {
//包含了图片
val imgContent = pContent.split("/>")
for (j in imgContent.indices) {
val img = imgContent[j]
if (img.contains("img")) {
//图片,需要再次截取
val index = img.indexOf(""")
val last = img.lastIndexOf("""
)
val endImg = img.substring(index + 1, last)//最终的图片内容
contentList.add(endImg)
} else {
//文本内容
contentList.add(img)
}
}
} else {
contentList.add(pContent)
}
}

截取的方式有很多种,但是无论哪一种,你的判断是少不了的,为了取得对应的内容,不得不多级嵌套,不得不一而再再而三的进行截取,虽然实现了,但是其冗余了代码,丢失了效率,目前还是仅有两种标签,如果说以后的富文本有多种标签呢?想想都可怕。


有没有一种比较简洁的方式呢?必须有,那就是正则表达式,需要解决两个问题,第一、正则怎么用?第二,正则表达式如何写?搞明白这两条之后,获取富文本中想要的内容就很简单了。


四、Kotlin中的正则使用


说到正则,咱就不得不聊聊Java中的正则,这是我们做Android再熟悉不过的,一般也是最常用的,基本代码如下:


    String str = "";//匹配内容
String pattern = "";//正则表达式
Pattern r = Pattern.compile(pattern);
Matcher m = r.matcher(str);
System.out.println(m.matches());

获取匹配内容的话,取对应的group即可,这个例子太多了,就不单独举了,除了Java中提供的Api之外,在Kotlin当中,也提供了相关的Api,使用起来也是无比的简单。


在Kotlin中,我们可以使用Regex这个对象,主要用于搜索字符串或替换正则表达式对象,我们举几个简单的例子。


1、判定是否包含某个字符串,containsMatchIn


     val regex = Regex("Ming")//定义匹配规则
val matched = regex.containsMatchIn("AbnerMing")//传入内容
print(matched)

输出结果


    true

2、匹配目标字符串matches


     val regex = """[A-Za-z]+""".toRegex()//只匹配英文字母
val matches1 = regex.matches("abcdABCD")
val matches2 = regex.matches("12345678")
println(matches1)
println(matches2)

输出结果


    true
false

3、返回首次出现指定字符串find


    val time = Regex("""\d{4}-\d{1,2}-\d{1,2}""")
val timeValue= time.find("今天是2023-6-28,北京,有雨,请记得带雨伞!")?.value
println(timeValue)

输出结果


    2023-6-28

4、返回所有情况出现目标字符串findAll


     val time = Regex("""\d{4}-\d{1,2}-\d{1,2}""")
val timeValue = time.findAll(
"今天是2023-6-28,北京,有雨,请记得带雨伞!" +
"明天是2023-6-29,可能就没有雨了,具体得等到后天2023-6-30日才能知晓!"
)
timeValue.forEach {
println(it.value)
}

输出结果


    2023-6-28
2023-6-29
2023-6-30

ok,当然了,里面还有许多方法,比如替换,分割等,这里就不介绍了,后续有时间补一篇,基本上常用的就是以上的几个方法。


五、富文本使用正则获取内容


一个富文本里的标签有很多个,显然我们都需要进行获取里面的内容,这里肯定是要使用findAll这个方法了,但是,我们该如何设置标签的正则表达式呢?


我们知道,富文本中的标签,都是有左右尖括号组成的,比如<p></p>,<a></a>,当然也有单标签,比如<img/>,<br/>等,那这就有规律了,无非就是开头<开始,然后是不确定字母,再加上结尾的>就可以了。


1、标签精确匹配


比如有这样一个富文本,我们要获取所有的<p></p>标签。


 <div>早上好啊</div><p>我是一个段落</p><a>我是一个链接</a><p>我是另一个一个段落</p>

我们的正则表达式就如下:


  <p.*?>(.*?)</p>

什么意思呢,就是以<p开头,</p>结尾,这个点. 是 除换行符以外的所有字符,* 为匹配 0 次或多次,? 为0 次或 1 次匹配,之所以开头这样写<p.*?>而不是<p>,一个重要的原因就是需要匹配到属性或者空格,要不然富文本中带了属性或空格,就无法匹配了,这个需要注意!


基本代码


         val content = "<div>早上好啊</div><p>我是一个段落</p><a>我是一个链接</a><p>我是另一个一个段落</p>"
val matchResult = Regex("""<p.*?>(.*?)</p>""").findAll(content)
matchResult.forEach {
println(it.value)
}

运行结果


   <p>我是一个段落</p>
<p>我是另一个一个段落</p>

看到上面的的结果,有的老铁就问了,我要的是内容啊,怎么把标签也返回了,这好像有点不对吧,如果说我们只要匹配到的字符串,目前是对的,但是想要标签里的内容,那么我们的正则需要再优化一下,怎么优化呢,就是增加一个开始和结束的位置,内容的开始位置是”<“结束位置是”>“,如下图



我们只需要更改下起始位置即可:


匹配内容


     val content = "<div>早上好啊</div><p>我是一个段落</p><a>我是一个链接</a><p>我是另一个一个段落</p>"
val matchResult = Regex("""(?<=<p>).*?(?=</p>)""").findAll(content)
matchResult.forEach {
println(it.value)
}

运行结果


    我是一个段落
我是另一个一个段落

2、所有标签进行匹配


有了标签精确匹配之后,针对富文本里的所有的标签内容匹配,就变得很是简单了,无非就是要把上边案例中的p换成一个不确定字母即可。


匹配内容


     val content = "<div>早上好啊</div><p>我是一个段落</p><a>我是一个链接</a><p>我是另一个一个段落</p>"
val matchResult = Regex("""(?<=<[A-Za-z]*>).+?(?=</[A-Za-z]*>)""").findAll(content)
matchResult.forEach {
println(it.value)
}

运行结果


    早上好啊
我是一个段落
我是一个链接
我是另一个一个段落

3、单标签匹配


似乎已经满足我们的需求了,因为富文本中的内容已经拿到了,封装到集合之中,传递到列表中即可,但是,以上的正则似乎只针对双标签的,带有单标签就无法满足了,比如,我们再看下初始我们要匹配的富文本,以上的正则是匹配不到img标签里的src内容的,怎么搞?


 <p>我是测试内容</p><p>我是测试内容12333</p><img src="https://www.vipandroid.cn/ming/image/gan.png"/><p>我是测试内容88888</p><p>我是测试内容99999999</p><img src="https://www.vipandroid.cn/ming/image/zao.png"/>

很简单,单标签单独处理呗,还能咋弄,多个正则表达式,用或拼接即可,属性值也是这样的获取原则,定位开始和结束位置,比如以上的img标签,如果要获取到src中的内容,只需要定位开始位置”src="“,和结束位置”"“即可。


匹配内容


    val content =
"<p>我是测试内容</p><p>我是测试内容12333</p><img src="https://www.vipandroid.cn/ming/image/gan.png"/><p>我是测试内容88888</p><p>我是测试内容99999999</p><img src="https://www.vipandroid.cn/ming/image/zao.png"/>"
val matchResult =
Regex("""((?<=<[A-Za-z]*>).+?(?=</[A-Za-z]*>))|((?<=src=").+?(?="))""").findAll(content)
matchResult.forEach {
println(it.value)
}

运行结果


    我是测试内容
我是测试内容12333
https://www.vipandroid.cn/ming/image/gan.png
我是测试内容88888
我是测试内容99999999
https://www.vipandroid.cn/ming/image/zao.png

这不就完事了,简简单单,心心念念的数据就拿到了,拿到富文本标签内容之后,再封装成集合,回显到RcyclerView中就可以了,这不很easy吗,哈哈~


点击草稿,我们看下效果:



六、总结


在正向的截取思维下,正则表达式无疑是最简单的,富文本,无论是标签匹配还是内容以及属性,都可以使用正则进行简单的匹配,轻轻松松就能搞定,需要注意的是,不同属性的匹配规则是不一样的,需要根据特有的情况去分析。


作者:程序员一鸣
来源:juejin.cn/post/7249604020875984955
收起阅读 »

三分钟教会你微信炸一炸,满屏粑粑也太可爱了!

相信这个特效你和你的朋友(或对象)一定玩过 当你发送一个便便的表情,对方如果扔一个炸弹表情,就会立刻将这个便便炸开,实现满屏粑粑的“酷炫”画面。可谓是“臭味十足”,隔着屏幕都能感受到来自微信爸爸的满满恶意。 不清楚大家对这个互动设计怎么看,反正一恩当时是喜欢...
继续阅读 »

相信这个特效你和你的朋友(或对象)一定玩过

请添加图片描述

当你发送一个便便的表情,对方如果扔一个炸弹表情,就会立刻将这个便便炸开,实现满屏粑粑的“酷炫”画面。可谓是“臭味十足”,隔着屏幕都能感受到来自微信爸爸的满满恶意。


不清楚大家对这个互动设计怎么看,反正一恩当时是喜欢的不行,拉着朋友们就开始“炸”得不亦乐乎。


同样被虏获芳心的设计小哥哥在玩到尽兴后,突然灵感大发,连夜绘制出了设计稿,第二天就拉上产品和研发开始脑暴。


“微信炸💩打动我的一点是他满屏的设计,能够将用户强烈的情绪抒发出来;同时他能够与上一条表情进行捆绑,加强双方的互动性。”设计小哥哥声情并茂道。


“所以,让我们的表情也‘互动’起来吧!”


这不,需求文档就来了:



改掉常见的emoji表情发送方式,替换为动态表情交互方式。即,

当用户发送或接收互动表情,表情会在屏幕上随机分布,展示一段时间后会消失。

用户可以频繁点击并不停发送表情,因此屏幕上的表情是可以非常多且重叠的,能够将用户感情强烈抒发。

请添加图片描述

(暂用微信的聊天界面进行解释说明,图1为原样式,图2是需求样式)



这需求一出,动态表情在屏幕上的分布方案便引起了研发内部热烈讨论:当用户点击表情时,到底应该将表情放置在屏幕哪个位置比较好呢?


最直接的做法就是完全随机方式:取0到屏幕宽度和高度中随机值,放置表情贴纸。但这么做的不确定因素太多,比如存在一定几率所有表情都集中在一个区域,布局边缘化以及最差的重叠问题。因此简单的随机算法对于用户的体验是无法接受的。


在这里插入图片描述


我们开始探索新的方案:

因为目前点的选择依赖于较多元素,比如与屏幕已有点的间距,与中心点距离以及屏幕已有点的数目。因此最终决定采用点权随机的方案,根据上述元素决策出屏幕上可用点的优度,选取优度最高的插入表情。


基本思路


维护对应屏幕像素的二维数组,数组元素代指新增图形时,图形中心取该点的优度。

采用懒加载的方式,即每次每次新增图形后,仅记录现有方块的位置,当需要一个点的优度时再计算。


遍历所有方块的位置,将图形内部的点优度全部减去 A ,将图形外部的点按到图形的曼哈顿距离从 0 到 max (W,H),映射,减 0 到 A * K2。

每次决策插入位置时,随机取 K + n * K1 个点,取这些点中优度最高的点为插入中心

A, K, K1, K2 四个常数可调整



一次选择的复杂度是 n * randT,n 是场上方块数, randT 是本次决策需要随机取多少个点。 从效率和 badcase来说,这个方案目前最优。



在这里插入图片描述


代码展示



```cpp
#include <iostream>
#include <vector>
using namespace std;

const int screenW = 600;
const int screenH = 800;

const int kInnerCost = 1e5;
const double kOuterCof = .1;

const int kOutterCost = kInnerCost * kOuterCof;

class square
int x1;

int x2;
int y1;
int y2;
};

int lineDist(int x, int y, int p){
if (p < x) {
return x - p;
} else if (p > y) {
return p - y;
} else {
return 0;
}
}

int getVal(const square &elm, int px, int py){
int dx = lineDist(elm.x1, elm.x2, px);
int dy = lineDist(elm.y1, elm.y2, py);
int dist = dx + dy;
constexpr int maxDist = screenW + screenH;
return dist ? ( (maxDist - dist) * kOutterCost / maxDist ) : kInnerCost;
}

int getVal(const vector<square> &elmArr, int px, int py){
int rtn = 0;
for (auto elm:elmArr) {
rtn += getVal(elm, px, py);
}
return rtn;
}

int main(void){

int n;
cin >> n;

vector<square> elmArr;
for (int i=0; i<n; i++) {
square cur;
cin >> cur.x1 >> cur.x2 >> cur.y1 >> cur.y2;
elmArr.push_back(cur);
}


for (;;) {
int px,py;
cin >> px >> py;
cout << getVal(elmArr, px, py) << endl;
}

}

优化点



  1. 该算法最优解偏向边缘。因此随着随机值设置越多,得出来的点越偏向边缘,因此随机值不能设置过多。

  2. 为了解决偏向边缘的问题,每一个点在计算优度getVal需要加上与屏幕中心的距离 * n * k3


效果演示


最后就是给大家演示一下最后的效果啦!

请添加图片描述

圆满完成任务,收工,下班!


作者:李一恩
来源:juejin.cn/post/7257410685118677048
收起阅读 »

Android一秒带你定位当前页面Activity

前言 假设有以下路径 在过去开发时,我们在点击多层页面的后,想知道当前页面的类名是什么,以上图下单页面为例,我们首先 1、查找首页的搜索酒店按钮的ID XML布局中找到首页的搜索酒店按钮的ID:假设按钮的ID是 R.id.bt_search_hotel ...
继续阅读 »

前言


假设有以下路径


image.png
在过去开发时,我们在点击多层页面的后,想知道当前页面的类名是什么,以上图下单页面为例,我们首先



  • 1、查找首页的搜索酒店按钮的ID

    • XML布局中找到首页的搜索酒店按钮的ID:假设按钮的ID是 R.id.bt_search_hotel



  • 2、从首页Activity中查找按钮的点击事件

    • 假设你有一个点击事件处理器方法 onSearchHotelClick(View view),你可以在首页Activity中找到这个方法的实现



  • 3、进入下一个酒店列表页面Activity

    • 在点击事件处理方法中,启动酒店列表页面的Activity,示例参数值:




Intent intent = new Intent(this, HotelListActivity.class);
startActivity(intent);


  • 4、若多个RecyclerView,需要找到RecyclerView的ID,并在适配器中处理点击事件

    • 在酒店列表页面的XML布局中找到RecyclerView的ID:假设RecyclerView的ID是 R.id.rvHotel

    • 在适配器中处理点击事件,示例参数值




rvHotel.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(View view, int position) {
// 处理点击事件,启动酒店详情页面的Activity
Intent intent = new Intent(context, HotelDetailActivity.class);
intent.putExtra("hotel_id", hotelList.get(position).getId());
startActivity(intent);
}
});


  • 在酒店详情页面中找到XML中预定按钮的ID,并处理点击事件:

    • 在酒店详情页面的XML布局中找到预定按钮的ID:假设按钮的ID是 R.id.stv_book

    • 在详情页面Activity中找到预定按钮的点击事件处理方法,示例参数值




Button bookButton = findViewById(R.id.bookButton);
bookButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
// 处理点击事件,启动下单页面的Activity
Intent intent = new Intent(DetailActivity.this, OrderActivity.class);
startActivity(intent);
}
});

上面我们发现存在两个问题:



  1. 在定位Activity这个过程中可能会消耗大量的时间和精力,特别是在页面层级较深或者页面结构较为复杂的情况下。

  2. 我们点击某个属性的时候,有时候想知道当前属性的id是什么,然后去做一些逻辑或者赋值等,我们只能去找布局,如果布局层次深,又会浪费大量的时间去定位属性


如果我们能够在1s快速准确地获取当前Activity的类名,那么在项目开发过程中将起到关键性作用,节省了大量时间,减少了开发中的冗余工作。开发人员的开发流程将更加高效,能更专注于业务逻辑和功能实现,而不用花费过多时间在页面和属性定位上


为什么要实现一秒定位当前页面Activity



  • 优化了Android应用程序的性能,实现了快速的页面定位,将当前Activity的定位时间从秒级缩短至仅1秒

  • 提高了开发效率,允许团队快速切换页面和快速查找当前页面的类名,减少了不必要的开发时间浪费

  • 这一优化对项目推进产生了显著影响,提高了整体开发流程的高效性,使我们能够更专注于业务逻辑的实现和功能开发


使用的库是:AsmActualCombat



  • AsmActual利用ASM技术将合规插件会侵入到编译流程中, 插件会把App中所有系统敏感API或属性替换为SDK的收口方法 , 从而解决直接使用系统方法时面临的隐私合规问题


AsmActualCombat库的使用


使用文档链接:github.com/Peakmain/As…


How To


旧版本添加方式


ASM插件依赖
Add it in your root build.gradle at the end of repositories:


buildscript {
repositories {
maven {
url "https://plugins.gradle.org/m2/"
}
}
dependencies {
classpath "io.github.peakmain:plugin:1.1.4"
}
}

apply plugin: "com.peakmain.plugin"

拦截事件sdk的依赖



  • Step 1. Add the JitPack repository to your build file
    Add it in your root build.gradle at the end of repositories:


   allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}


  • Step 2. Add the dependency


   dependencies {
implementation 'com.github.Peakmain:AsmActualCombat:1.1.5'
}

新版本添加方式


settings.gradle


pluginManagement {
repositories {
//插件依赖
maven {
url "https://plugins.gradle.org/m2/"
}
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
//sdk仓库
maven { url 'https://jitpack.io' }
}
}

插件依赖


根目录下的build.gradle文件


plugins {
//插件依赖和版本
id "io.github.peakmain" version "1.1.4" apply false
}

sdk版本依赖


implementation 'com.github.Peakmain:AsmActualCombat:1.1.5'

使用


我们只需要在application的时候调用以下即可


SensorsDataAPI.init(this);
SensorsDataAPI.getInstance().setOnUploadSensorsDataListener((state, data) -> {
switch (state) {
case SensorsDataConstants.APP_START_EVENT_STATE:
//$AppStart事件
case SensorsDataConstants.APP_END__EVENT_STATE:
//$AppViewScreen事件
break;
case SensorsDataConstants.APP_VIEW_SCREEN__EVENT_STATE:
if (BuildConfig.DEBUG) {
Log.e("TAG", data);
}
StatisticsUtils.statisticsViewHeader(
GsonUtils.getGson().fromJson(data, SensorsEventBean.class));
break;
case SensorsDataConstants.APP_VIEW_CLICK__EVENT_STATE:
if (BuildConfig.DEBUG) {
Log.e("TAG", data);
}
SensorsEventBean sensorsEventBean =
GsonUtils.getGson().fromJson(data, SensorsEventBean.class);
StatisticsUtils.statisticsClickHeader(sensorsEventBean);
break;
default:
break;

}
});

随后我们点击按钮在控制台便可以看到效果



  • 页面埋点


image.png



  • 点击埋点


image.png


总结



  • 是不是很简单呢,只需要简单配置即可1s实现定位当前页面Activity的类名是什么,不需要再花费大量的时间去查找当前页面的类名。

  • 当然,AsmActualCombat项目不仅仅可以实现全埋点、定位当前Activity类名功能,还可以拦截隐私方法调用的拦截哦。

  • 如果大家觉得项目或者文章对你有一点点作用,欢迎点赞收藏哦,非常感谢


作者:peakmain9
来源:juejin.cn/post/7289047550741397564
收起阅读 »

在Flutter上封装一套类似电报的图片组件

前言 最近项目需要封装一个图片加载组件,boss让我们实现类似电报的那种效果,直接上图: 就像把大象装入冰箱一样,图片加载拢共有三种状态:loading、success、fail。 首先是loading,电报的实现效果是底部展示blur image, 上面盖...
继续阅读 »

前言


最近项目需要封装一个图片加载组件,boss让我们实现类似电报的那种效果,直接上图:


581697505195_.pic.jpg


就像把大象装入冰箱一样,图片加载拢共有三种状态:loading、success、fail。


首先是loading,电报的实现效果是底部展示blur image, 上面盖了个progress indicator。blur image有三方库可以实现:flutter_thumbhash | Flutter Package (pub.dev),但是这个库有个bug: 它使用到了MemoryImage, 并且MemoryImage的bytes参数每次都是重新生成的,因而无法使用缓存。所以上面的progress刷新时底部的blur image都会不停闪烁。


//MemoryImage
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is MemoryImage
&& other.bytes == bytes
&& other.scale == scale;
}

@override
int get hashCode => Object.hash(bytes.hashCode, scale);

笔者覆写了equals和hashcode方法,通过listEquals方法来比较bytes,考虑到thumb_hash一般数据量都比较小估计不会有性能问题。
也有人给了个一次性比较8个byte的算法【StackOverflow摘抄】😄


/// Compares two [Uint8List]s by comparing 8 bytes at a time.
bool memEquals(Uint8List bytes1, Uint8List bytes2) {
if (identical(bytes1, bytes2)) {
return true;
}

if (bytes1.lengthInBytes != bytes2.lengthInBytes) {
return false;
}

// Treat the original byte lists as lists of 8-byte words.
var numWords = bytes1.lengthInBytes ~/ 8;
var words1 = bytes1.buffer.asUint64List(0, numWords);
var words2 = bytes2.buffer.asUint64List(0, numWords);

for (var i = 0; i < words1.length; i += 1) {
if (words1[i] != words2[i]) {
return false;
}
}

// Compare any remaining bytes.
for (var i = words1.lengthInBytes; i < bytes1.lengthInBytes; i += 1) {
if (bytes1[i] != bytes2[i]) {
return false;
}
}

return true;
}

图片加载和取消重试


电报在loading的时候可以手动取消下载,这个在Flutter官方Image组件和cached_network_iamge组件都是不支持的,因为在设计者看来既然图片加载失败了,那重试也肯定还是失败(By design)。
extended_image库对cancel和retry做了支持,这里要给作者点赞👍🏻


取消加载


加载图片是通过官方http库来实现的, 核心逻辑是:


final HttpClientRequest request = await httpClient.getUrl(resolved);
headers?.forEach((String name, String value) {
request.headers.add(name, value);
});
final HttpClientResponse response = await request.close();
if (timeLimit != null) {
response.timeout(
timeLimit!,
);
}
return response;

返回的response是个Stream对象,通过它来获取图片数据


final Uint8List bytes = await consolidateHttpClientResponseBytes(
response,
onBytesReceived: chunkEvents != null
? (int cumulative, int? total) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: cumulative,
expectedTotalBytes: total,
));
}
: null,
);

图片加载进度就是通过ImageChunkEvent来获取的,cumulative代表当前已加载的长度,total是总长度,所有图片加载库都是通过它来显示进度的。所以,如何取消呢?这里就需要用到Flutter异步的一个API了:


Future.any(<Future<T>>[Future cancelTokenFuture, Future<Uint8List> imageLoadingFuture])

在加载的时候除了加载图片数据的Future,我们再额外生成一个Future,当需要取消加载的时候只需要后者抛出Error那加载就会直接终止,extended_image就是这么做的:


class CancellationTokenSource {
CancellationTokenSource._();

static Future<T> register<T>(
CancellationToken? cancelToken, Future<T> future) {
if (cancelToken != null && !cancelToken.isCanceled) {
final Completer<T> completer = Completer<T>();
cancelToken._addCompleter(completer);

///CancellationToken负责管理cancel completer
return Future.any(<Future<T>>[completer.future, future])
.then<T>((T result) async {
cancelToken._removeCompleter(completer);
return result;
}).catchError((Object error) {
cancelToken._removeCompleter(completer);
throw error;
});
} else {
return future;
}
}
}

这种取消机制有个问题:虽然上层会捕获抛出的异常终止加载,但是网络请求还是会继续下去直到加载完图片所有数据,我于是翻看了Flutter的API,发现上面提到的解析HttpResponse的方法consolidateHttpClientResponseBytes有个注释:


/// The `onBytesReceived` callback, if specified, will be invoked for every
/// chunk of bytes that is received while consolidating the response bytes.
/// If the callback throws an error, processing of the response will halt, and
/// the returned future will complete with the error that was thrown by the
/// callback. For more information on how to interpret the parameters to the
/// callback, see the documentation on [BytesReceivedCallback].

即onBytesReceived方法如果抛出异常那么就会终止数据传输,所以可以根据chunkEvents是否alive来判断是否需要继续传输,如果不需要就直接抛出异常,从而终止http请求。


重试


图片加载有两种重试:第一种是自动重试,笔者遇到了一个connection closed before full header was received错误,而且是高概率出现,目前没有好的解决办法,加上自动重试机制后好了很多。


第二种就是手动重试,自动重试达到阈值后还是失败,手动触发加载。我这里主要讲第二种,在电报里的展示效果是这样:


591697507850_.pic.jpg


这里卡了我好久,主要是我对Flutter的ImageCache了解不深入导致的,首先看几个问题:


1. 页面有一张图片加载失败,退出页面重新进来图片会自动重新加载吗?


答案是不一定,Flutter图片缓存存储的是ImageStreamController对象,这个对象里有一个FlutterErrorDetails? _currentError;属性,当加载图片失败后_currentError会被赋值,所以退出后重进页面虽然会导致页面重新加载,但是获取到的缓存对象有Error,那就会直接进入fail状态。
缓存的清理是个很复杂的问题, ImageStreamCompleter的清理逻辑主要靠两个属性:_listeners_keepAliveHandles


List<ImageStreamListener> _listeners = [];

@mustCallSuper
void _maybeDispose() {
if (!_hadAtLeastOneListener || _disposed || _listeners.isNotEmpty || _keepAliveHandles != 0) {
return;
}

_currentImage?.dispose();
_currentImage = null;
_disposed = true;
}

_listerners的add和remove时机和Image组件有关


/// image.dart
/// 加载图片
void _resolveImage() {
......
final ImageStream newStream =
provider.resolve(createLocalImageConfiguration(
context,
size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
));
_updateSourceStream(newStream);
}

void _updateSourceStream(ImageStream newStream) {
......
/// 向ImageStreamCompleter注册Listener
_imageStream!.addListener(_getListener());
}

既然有了_listeners那为什么还需要_keepAliveHandles属性呢,原因就是在image组件所在页面不在前台时会移除注册的listerner,如果没有_keepAliveHandles属性那缓存可能会被错误清理:


@override
void didChangeDependencies() {
_updateInvertColors();
_resolveImage();

if (TickerMode.of(context)) {
///页面在前台的时候获取最新的ImageStreamCompleter对象
_listenToStream();
} else {
///页面不在前台移除Listener
_stopListeningToStream(keepStreamAlive: true);
}
super.didChangeDependencies();
}

回到最开始的问题:如果加载失败的图片组件在其他页面不存在,那image组件dispose的时候就会清理掉缓存,第二次进入该页面的时候就会重新加载。反之,如果其他页面也在使用该缓存,那二次进入的时候就会直接fail。


一个很好玩的现象是,假如两个页面在加载同一张图片,那么其中一个页面图片加载失败另外一个页面也会同步失败。


2. 判定加载的是同一张图片


这里的相同很重要,因为它决定了ImageCache的存储,比如笔者自定义一个NetworkImage:


class _NetworkImage extends ImageProvider<_NetworkImage> {

_NetworkImage(this.url);

final String url;

@override
ImageStreamCompleter loadImage(NetworkImage key, ImageDecoderCallback decode);

@override
Future<ExtendedNetworkImageProvider> obtainKey();
}

obtainKey一般都会返回SynchronousFuture<_NetworkImage>(this),它代表的是ImageCache使用的键,ImageCache判断当前是否存在缓存的时候会拿Key和缓存的所有键进行比对,这个时候equals和hashcode就开始起作用了:


@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is _NetworkImage
&& other.url == url;
}

@override
int get hashCode => Object.hash(url);

因为我们需要支持取消加载,所以最初我考虑加上cancel token到相同逻辑的判定,但是这会导致同一张图片被不停重复加载,缓存完全失效。


为了解决上面的问题,我对ImageCache起了歪脑筋:能不能在没有加载成功的时候允许并行下载,但是只要有一张图片成功,那后续都可以复用缓存?
如果要实现这个效果,那就必须缓存下所有下载成功的ImageProvider或者对应的CancelToken。下载成功的监听好办,在MultiFrameImageStreamCompleter加个监听就完事。难的是缓存消除的时机判断,ImageCache的缓存机制很复杂(_pendingImages,_cacheImage,_liveImages),并且没有缓存移除的回调。


最终,我放弃了这个方案,不把cancel token放入ImageProvider的比较逻辑中。


3. 实现图片重新加载


首先,我给封装的图片组件加了个reloadFlag参数,当需要重新加载的时候+1即可:


@override
void didUpdateWidget(OldWidget old) {
if(old.reloadFlag != widget.reloadFlag) {
_resolveImage();
}
}

但是,这个时候不会起作用,因为之前失败的缓存没被清理,ImageProvider的evict方法可实现清理操作。


4. 多图状态管理


我在适配折叠屏的时候发现了一个场景:多页面下载相同图片时有时无法联动,首先看cancel:



  • A页面加载图片时使用CancelToken A,新建缓存

  • B页面使用CancelToken B, 复用缓存


B的CancelToken完全没用到,所以是cancel不了的。为了解决这个问题,我创建了一个CancelTokenManager,按需生成CancelToken,并在加载成功或失败时清理掉。


然后是重试,多图无法同时触发重试,虽然可以复用同一个ImageStreamCompleter对象,但ImageStream对象却是Image组件单独生成的,所以只能借助状态管理框架或者事件总线来实现同步刷新。


作者:芭比Q达人
来源:juejin.cn/post/7290732297427107895
收起阅读 »

认识车载神器-Android Auto

什么是Android Auto 首先,Android Auto 不是 OS。它是集成在 Android OS 里的 feature。当通过 USB、Wi-Fi 将 Android Phone 连接到支持 Android Auto 的车机上后,Android O...
继续阅读 »

什么是Android Auto


首先,Android Auto 不是 OS。它是集成在 Android OS 里的 feature。当通过 USB、Wi-Fi 将 Android Phone 连接到支持 Android Auto 的车机上后,Android OS 将自动加载支持 Auto 模式下的 App 并将图像投屏到车机屏幕上。


Android-Auto示意图


跟苹果的 CarPlay、百度的 CarLife、小米的 CarWith 一样,其本质上是投屏。Phone 提供计算、渲染,车机只是 Display,Display 和按键回传 Input 的事件,Phone 处理好之后将新的帧数据回传进行 Display。


如何使用Android Auto


Google官网已经明确介绍了使用 Android Auto 的步骤




  1. 确保您的汽车或售后音响与 Android Auto 兼容;




  2. 手机上必须安装 Android Auto 应用,Android 10 以下的手机可以到 Google Play 下载安装,Android 10 及以上内置了 Android Auto;


    Auto设置界面.png




  3. 使用 USB 数据线将手机连接到汽车,然后在汽车显示屏上查看 Android Auto;


    Auto界面




虽然简单的三个步骤,但使用Android Auto有一个大前提:



  • 使用 Android Auto 的手机需要使用Google服务框架


因此需要通过GMS认证,国内汽车品牌基本不支持 Android Auto,一些沿用了国外车机系统的合资车型可能会支持 Android Auto。


关于 Android Auto 支持的汽车和音响品牌,可查阅官网资料,里面列举得很详细。


如何开发Android Auto支持的应用


Google Developer 官网已经将 Android for Cars 的开发流程和规范写得很详细了,这里就不再详细赘述了,把官方的内容简单归纳一下,并列出一些注意项:



  • 我们可以基于 Android Auto 开发媒体应用(音乐,视频)、即时通讯应用、地图导航应用、并且有相应的测试方案和分发方案;

  • Google针对 Android Auto 应用专门提供了SDK,即 Android for Cars App Library。为了兼容非 Car 的设备集成到了 AndroidX 中;

  • Android Auto 不支持自定义 UI,你的应用只负责与车载屏幕进行数据和事件交互,因此,所有的 Android Auto 应用都长得大同小异;

  • 开发的 Android Auto 应用必须经过 Google Play Store 分发,否则屏幕是不显示的,Google Play Store 有四个分发渠道:internal、closed testing、open testing、production,分别对应内部、内测、公测、产品,开发调试阶段用 internal 渠道即可;

  • 因为车载场景事关驾驶员生命安全,所以 Google 对 Android Auto 应用审核很严格。所有支持 Android Auto 的应用,必须满足质量规范才可能通过 Google Play Store 的审核;

  • 音乐app可参考官方开发的uamp,它是支持 Android Auto 的;

  • 国产手机基本都把 Android Auto 应用给删减掉了,所以都需要手动安装,但 Android Auto 启动时会安装谷歌服务框架,因此,第一次使用 Android Auto 需要科学上网。

  • 在使用国产手机调试 Android Auto 时,会出现车机屏幕黑屏的情况,原因可能是没有经过 Google Play Store 分发,也有可能是其他未知原因,因此,建议使用 pixel 手机进行开发调试;


Android Auto与Android Automotive的区别




  • Android Auto是 Android 的扩展功能,包含 Android Auto 应用、支持 Android Auto 的Apps,车机屏幕,缺一不可;




  • Android Automotive是基于 Android 定制的适用于车载的OS,简称 AAOS,归属于AOSP项目,编译的时候选择Automotive的target combo即可;


    automotive桌面




国内汽车厂商普遍使用的Android Automotive,主要原因有:



  • 可以不需要通过GMS认证;

  • 兼容 Android Phone 和 Android Auto 的应用;

  • 独立的系统,不需要手机投屏,开发App和扩展车载功能非常方便;


参考链接


Android for Cars 概览

Android Auto

androidx.​car.​app

Android 车机初体验:Auto,Automotive 傻傻分不清楚?

Android Auto 开发指北


作者:小迪vs同学
来源:juejin.cn/post/7290372531218628649
收起阅读 »

四个有用的Android开发技巧,又来了

大家好,本篇文章会继续给大家分享一些Android常见的开发技巧,希望能对读者有所帮助。 一. 通过堆栈快速定位系统版本 这个地方主要分享大家两个个技巧,通过问题堆栈简快速定位当前系统版本: 1. 快速区分当前系统版本是Android10以下,还是Androi...
继续阅读 »

大家好,本篇文章会继续给大家分享一些Android常见的开发技巧,希望能对读者有所帮助。


一. 通过堆栈快速定位系统版本


这个地方主要分享大家两个个技巧,通过问题堆栈简快速定位当前系统版本:


1. 快速区分当前系统版本是Android10以下,还是Android10及以上;


首先Android10及以上引入了一个新的服务Service:ActivityTaskManagerService,将原本ActivityMangerService原本负责的一些职能拆分给了前者,所以当你的问题堆栈中出现了ActivityTaskManagerService相关的字眼,那肯定是Android10及以上了



大家在Android9及以下的源码中是找不到这个类的。


2. 快速区分当前系统版本是Android12以下,还是Android12及以上;


这个就得借助Looper了,给大家看下Android12上Looper的源码:



Looper分发消息的核心方法loop(),现在会转发给loopOnce()进行处理,这个可是Android12及以上特有的,而Looper又是Android处理消息必要的一环,是咱们问题堆栈的源头祖宗,类似于下面的:



所以这个技巧相信还是非常有必要的:当你从问题堆栈中一看有loopOnce() 这个方法,那必定是Android12无疑了。


二. 实现按钮间距的一种奇特方式


最近看了一个新的项目代码,发现该项目实现按钮之间、按钮与顶部底部之间间距实现了,用了一种我之前没了解过的方式,于是这里分享给大家瞧瞧。


这里就以TextView和屏幕顶部间设置间距为例,初始的效果如下:



接下来我们来进行一步步改造:


1. 首先TextView是有一个自定义的xml背景:


<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:height="70dp"
android:gravity="center_vertical">

<shape>
<solid android:color="#ff0000" />
</shape>
</item>
</layer-list>

核心就是定义了android:heightandroid:gravity这两个属性,来确保我们自定义背景在组件中的高度及居中位置。


2. 其次将布局中TextView的属性调整下:




  1. 首先height属性一定要调整为wrap_content保证最后TextView按钮的高度的测量最终取minHeight设置的属性值和背景设置的高度这两者的最大值



  1. 其次还要设置minHeight最小高度属性,注意一定要比背景设置的高度值大,保证能和屏幕顶部产生边距效果;



  1. 最后要设置字体的位置为垂直居中,保证字体位置和背景不发生错位


经过上面处理,效果就出来了:



其实上下空白的部分都是属于TextView,设置点击事件也会被响应,这算是其中的缺点之一,当前也可能在业务场景中认为这是一种合理表现。


上面实现的逻辑和TextView的测量逻辑密不可分,感兴趣的同学可以看下这块代码,这里就不带大家进行一一分析了:




三. logcat快速查看当前跳转的Activity类信息


忘了是在哪里看到的了,只要日志过滤start u0,就可以看到每次跳转的Activity信息,非常的有帮助,既不需要改动业务层,也不需要麻烦的安装一些插件啥的。


使用时记得将logcat右边的过滤条件置为,否则你就只能在左边切换到系统进程去看了:


这里我们演示下效果:


1. 跳转到Google浏览器



logcat界面会输出:



会打印一些跳转到包名类名等相关信息。


2. 跳转到系统设置界面



logcat输出:



可以说start u0还是相当好用的。


四. 项目gradle配置最好指向同一本地路径


最近开发中经常存在需要一次性检索多个项目的场景,而这样项目的gradle版本都是相同的,没啥区别。但每打开一个项目就得重新走一遍gradle下载流程,下载速度又是蜗牛一样的慢。


所以强烈建议大家,本地提前准备好几个gradle版本,然后通过设置将项目的gradle指向本地已存在好的gradle:



这样项目第一次打开的速度将是非常快的,而且按道理来说相同gradle版本的项目指向同一本地路径,也可以实现缓存共享。猜的


如果项目好好的编译运行着,突然没网了,可能会提示一些找不到依赖库资源啥的,其实你本地都已经缓存好依赖库资源了,只需要设置下off-mode,不走网络直接通过本地资源编译运行即可



总结


本篇文章主要是介绍了Android开发一些技巧,感觉都是项目中挺常用到的,算是我最近一个月收获的吧,后续准备研究研究compose了,毕竟看到大家们都在搞这个,羡慕的口水都流了一地了哈哈。


历史文章


两个Kotlin优化小技巧,你绝对用的上


Kotlin1.9.0-Beta,它来了!!


Kotlin1.8新增特性,进来了解一下


聊聊Kotlin1.7.0版本提供的一些特性


聊聊kotlin1.5和1.6版本提供的一些新特性


优化@BuilderInference注解,Kotlin高版本下了这些“毒手”!


@JvmDefaultWithCompatibility优化小技巧,了解一下~


作者:长安皈故里
来源:juejin.cn/post/7250080519069007933
收起阅读 »

什么情况下Activity会被杀掉呢?

首先一个报错来作为开篇: Caused by androidx.fragment.app.Fragment$InstantiationException Unable to instantiate fragment xxx: could not find Fr...
继续阅读 »

首先一个报错来作为开篇:


Caused by androidx.fragment.app.Fragment$InstantiationException
Unable to instantiate fragment xxx: could not find Fragment constructor

这个报错原因就是Fragment如果重载了有参的构造方法,没有实现默认无参构造方法。Activity被回收又回来尝试重新恢复Fragment的时候报错的。


那如何模拟Activity被回收呢?

可能有人知道,一个方便快捷的方法就是:打开 开发者选项 - 不保留活动,这样每次Activity回到后台都会被回收,也就可以很方便的测试这种case。


但抛开这种方式我怎么来复现这种情况呢?

这里我提出一种方式:我是不是可以打开我的App,按Home回到后台,然后疯狂的打开手机里其他的大型应用或者游戏这类的能占用大量手机内存的App,等手机内存占用大的时候是不是可以复现这种情况呢?


结论是不可以,不要混淆两个概念,系统内存不足App内存不足,两者能引起的后果也是不同的



  • 系统内存不足 -> 杀掉应用进程

  • App内存不足 -> 杀掉后台Activity


首先明确一点,Android框架对进程创建与管理进行了封装,对于APP开发者只需知道Android四大组件的使用。当Activity, Service, ContentProvider, BroadcastReceiver任一组件启动时,当其所承载的进程存在则直接使用,不存在则由框架代码自动调用startProcessLocked创建进程。所以说对APP来说进程几乎是透明的,但了解进程对于深刻理解Android系统是至关关键的。


1. 系统内存不够 -> 杀掉应用进程


1.1. LKM简介

Android底层还是基于Linux,在Linux中低内存是会有oom killer去杀掉一些进程去释放内存,而Android中的lowmemorykiller就是在此基础上做了一些调整来的。因为手机上的内存毕竟比较有限,而Android中APP在不使用之后并不是马上被杀掉,虽然上层ActivityManagerService中也有很多关于进程的调度以及杀进程的手段,但是毕竟还需要考虑手机剩余内存的实际情况,lowmemorykiller的作用就是当内存比较紧张的时候去及时杀掉一些ActivityManagerService还没来得及杀掉但是对用户来说不那么重要的进程,回收一些内存,保证手机的正常运行。


lowmemkiller中会涉及到几个重要的概念:

/sys/module/lowmemorykiller/parameters/minfree:里面是以”,”分割的一组数,每个数字代表一个内存级别

/sys/module/lowmemorykiller/parameters/adj: 对应上面的一组数,每个数组代表一个进程优先级级别


比如:

/sys/module/lowmemorykiller/parameters/minfree:18432, 23040, 27648, 32256, 55296, 80640

/sys/module/lowmemorykiller/parameters/adj: 0, 100, 200, 300, 900, 906


代表的意思是两组数一一对应:



  • 当手机内存低于80640时,就去杀掉优先级906以及以上级别的进程

  • 当内存低于55296时,就去杀掉优先级900以及以上的进程


可能每个手机的配置是不一样的,可以查看一下手头的手机,需要root。


1.2. 如何查看ADJ

如何查看进程的ADJ呢?比如我们想看QQ的adj


-> adb shell ps | grep "qq" 
UID PID PPID C STIME TTY TIME CMD
u0_a140 9456 959 2 10:03:07 ? 00:00:22 com.tencent.mobileqq
u0_a140 9987 959 1 10:03:13 ? 00:00:07 com.tencent.mobileqq:mini3
u0_a140 16347 959 0 01:32:48 ? 00:01:12 com.tencent.mobileqq:MSF
u0_a140 21475 959 0 19:47:33 ? 00:01:25 com.tencent.mobileqq:qzone

# 看到QQ的PID为 9456,这个时候打开QQ,让QQ来到前台
-> adb shell cat /proc/9456/oom_score_adj
0

# 随便打开一个其他的App
-> adb shell cat /proc/9456/oom_score_adj
700

# 再随便打开另外一个其他的App
-> adb shell cat /proc/9456/oom_score_adj
900

我们可以看到adj是在根据用户的行为不断变化的,前台的时候是0,到后台是700,回到后台后再打开其他App后是900

常见ADJ级别如下:


ADJ级别取值含义
NATIVE_ADJ-1000native进程
SYSTEM_ADJ-900仅指system_server进程
PERSISTENT_PROC_ADJ-800系统persistent进程
PERSISTENT_SERVICE_ADJ-700关联着系统或persistent进程
FOREGROUND_APP_ADJ0前台进程
VISIBLE_APP_ADJ100可见进程
PERCEPTIBLE_APP_ADJ200可感知进程,比如后台音乐播放
BACKUP_APP_ADJ300备份进程
HEAVY_WEIGHT_APP_ADJ400重量级进程
SERVICE_ADJ500服务进程
HOME_APP_ADJ600Home进程
PREVIOUS_APP_ADJ700上一个进程
SERVICE_B_ADJ800B List中的Service
CACHED_APP_MIN_ADJ900不可见进程的adj最小值
CACHED_APP_MAX_ADJ906不可见进程的adj最大值

So,当系统内存不足的时候会kill掉整个进程,皮之不存毛将焉附,Activity也就不在了,当然也不是开头说的那个case。


2. App内存不足 -> 杀掉后台Activity


上面分析了是直接kill掉进程的情况,一旦出现进程被kill掉,说明内存情况已经到了万劫不复的情况了,抛开内存泄漏的情况下,framework也需要一些策略来避免无内存可用的情况。下面我们来找一找fw里面回收Activity的逻辑(代码Base Android-30)。



Android Studio查看源码无法查看com.android.internal包名下的代码,双击Shift,勾选右上角Include non-prject Items.



入口定位到ActivityThreadattach方法,ActivityThread是App的入口程序,main方法中创建并调用atttach


// ActivityThread.java
private void attach(boolean system, long startSeq) {
...
// Watch for getting close to heap limit.
BinderInternal.addGcWatcher(new Runnable() {
@Override public void run() {
// mSomeActivitiesChanged在生命周期变化的时候会修改为true
if (!mSomeActivitiesChanged) {
return;
}
Runtime runtime = Runtime.getRuntime();
long dalvikMax = runtime.maxMemory();
long dalvikUsed = runtime.totalMemory() - runtime.freeMemory();
if (dalvikUsed > ((3*dalvikMax)/4)) {
mSomeActivitiesChanged = false;
try {
ActivityTaskManager.getService().releaseSomeActivities(mAppThread);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
}
});
...
}

这里关注BinderInternal.addGcWatcher, 下面有几个点需要理清:



  1. addGcWatcher是干嘛的,这个Runnable什么时候会被执行。

  2. 这里的maxMemory() / totalMemory() / freeMemory()都怎么理解,值有什么意义

  3. releaseSomeActivities()做了什么事情,回收Activity的逻辑是什么。


还有一个小的点是这里还用了mSomeActivitiesChanged这个标记位来标记让检测工作不会过于频繁的执行,检测到需要releaseSomeActivities后会有一个mSomeActivitiesChanged = false;赋值。而所有的mSomeActivitiesChanged = true操作都在handleStartActivity/handleResumeActivity...等等这些操作Activity声明周期的地方。控制了只有Activity声明周期变化了之后才会继续去检测是否需要回收。


2.1. GcWatcher

BinderInternal.addGcWatcher是个静态方法,相关代码如下:


public class BinderInternal {
private static final String TAG = "BinderInternal";
static WeakReference<GcWatcher> sGcWatcher = new WeakReference<GcWatcher>(new GcWatcher());
static ArrayList<Runnable> sGcWatchers = new ArrayList<>();
static Runnable[] sTmpWatchers = new Runnable[1];

static final class GcWatcher {
@Override
protected void finalize() throws Throwable {
handleGc();
sLastGcTime = SystemClock.uptimeMillis();
synchronized (sGcWatchers) {
sTmpWatchers = sGcWatchers.toArray(sTmpWatchers);
}
for (int i=0; i<sTmpWatchers.length; i++) {
if (sTmpWatchers[i] != null) {
sTmpWatchers[i].run();
}
}
sGcWatcher = new WeakReference<GcWatcher>(new GcWatcher());
}
}

public static void addGcWatcher(Runnable watcher) {
synchronized (sGcWatchers) {
sGcWatchers.add(watcher);
}
}
...
}

两个重要的角色:sGcWatcherssGcWatcher



  • sGcWatchers保存了调用BinderInternal.addGcWatcher后需要执行的Runnable(也就是检测是否需要kill Activity的Runnable)。

  • sGcWatcher是个装了new GcWatcher()的弱引用。


弱引用的规则是如果一个对象只有一个弱引用来引用它,那GC的时候就会回收这个对象。那很明显new出来的这个GcWatcher()只会有sGcWatcher这一个弱引用来引用它,所以每次GC都会回收这个GcWatcher对象,而回收的时候会调用这个对象的finalize()方法,finalize()方法中会将之前注册的Runnable来执行掉。
注意哈,这里并没有移除sGcWatcher中的Runnable,也就是一开始通过addGcWatcher(Runnable watcher)进来的runnable一直都在,不管执行多少次run的都是它。


为什么整个系统中addGcWatcher只有一个调用的地方,但是sGcWatchers确实一个List呢?我在自己写了这么一段代码并且想着怎么能反射搞到系统当前的BinderInternal一探究竟的时候明白了一点点,我觉着他们就是怕有人主动调用了addGcWatcher给弄了好多个GcWatcher导致系统的失效了才搞了个List吧。。


2.2. App可用的内存

上面的Runnable是如何检测当前的系统内存不足的呢?通过以下的代码


        Runtime runtime = Runtime.getRuntime();
long dalvikMax = runtime.maxMemory();
long dalvikUsed = runtime.totalMemory() - runtime.freeMemory();
if (dalvikUsed > ((3*dalvikMax)/4)) { ... }

看变量名字就知道,在使用的内存到达总内存的3/4的时候去做一些事情,这几个方法的注释如下:


    /**
* Returns the amount of free memory in the Java Virtual Machine.
* Calling the gc method may result in increasing the value returned by freeMemory.
* @return an approximation to the total amount of memory currently available for future allocated objects, measured in bytes.
*/

public native long freeMemory();

/**
* Returns the total amount of memory in the Java virtual machine.
* The value returned by this method may vary over time, depending on the host environment.
* @return the total amount of memory currently available for current and future objects, measured in bytes.
*/

public native long totalMemory();

/**
* Returns the maximum amount of memory that the Java virtual machine will attempt to use.
* If there is no inherent limit then the value java.lang.Long#MAX_VALUE will be returned.
* @return the maximum amount of memory that the virtual machine will attempt to use, measured in bytes
*/

public native long maxMemory();

首先确认每个App到底有多少内存可以用,这些Runtime的值都是谁来控制的呢?


可以使用adb shell getprop | grep "dalvik.vm.heap"命令来查看手机给每个虚拟机进程所分配的堆配置信息:


yocn@yocn ~ % adb shell getprop | grep "dalvik.vm.heap"
[dalvik.vm.heapgrowthlimit]: [256m]
[dalvik.vm.heapmaxfree]: [8m]
[dalvik.vm.heapminfree]: [512k]
[dalvik.vm.heapsize]: [512m]
[dalvik.vm.heapstartsize]: [8m]
[dalvik.vm.heaptargetutilization]: [0.75]

这些值分别是什么意思呢?



  • [dalvik.vm.heapgrowthlimit]和[dalvik.vm.heapsize]都是当前应用进程可分配内存的最大限制,一般heapgrowthlimit < heapsize,如果在Manifest中的application标签中声明android:largeHeap=“true”,APP直到heapsize才OOM,否则达到heapgrowthlimit就OOM

  • [dalvik.vm.heapstartsize] Java堆的起始大小,指定了Davlik虚拟机在启动的时候向系统申请的物理内存的大小,后面再根据需要逐渐向系统申请更多的物理内存,直到达到MAX

  • [dalvik.vm.heapminfree] 堆最小空闲值,GC后

  • [dalvik.vm.heapmaxfree] 堆最大空闲值

  • [dalvik.vm.heaptargetutilization] 堆目标利用率


比较难理解的就是heapminfree、heapmaxfree和heaptargetutilization了,按照上面的方法来说:
在满足 heapminfree < freeMemory() < heapmaxfree的情况下使得(totalMemory() - freeMemory()) / totalMemory()接近heaptargetutilization


所以一开始的代码就是当前使用的内存到达分配的内存的3/4的时候会调用releaseSomeActivities去kill掉某些Activity.


2.3. releaseSomeActivities

releaseSomeActivities在API 29前后差别很大,我们来分别看一下。


2.3.1. 基于API 28的版本的releaseSomeActivities实现如下:

// step①:ActivityManagerService.java
@Override
public void releaseSomeActivities(IApplicationThread appInt) {
synchronized(this) {
final long origId = Binder.clearCallingIdentity();
try {
ProcessRecord app = getRecordForAppLocked(appInt);
mStackSupervisor.releaseSomeActivitiesLocked(app, "low-mem");
} finally {
Binder.restoreCallingIdentity(origId);
}
}
}

// step②:ActivityStackSupervisor.java
void releaseSomeActivitiesLocked(ProcessRecord app, String reason) {
TaskRecord firstTask = null;
ArraySet<TaskRecord> tasks = null;
for (int i = 0; i < app.activities.size(); i++) {
ActivityRecord r = app.activities.get(i);
// 如果当前有正在销毁状态的Activity,Do Nothing
if (r.finishing || r.state == DESTROYING || r.state == DESTROYED) {
return;
}
// 只有Activity在可以销毁状态的时候才继续往下走
if (r.visible || !r.stopped || !r.haveState || r.state == RESUMED || r.state == PAUSING
|| r.state == PAUSED || r.state == STOPPING) {
continue;
}
if (r.task != null) {
if (firstTask == null) {
firstTask = r.task;
} else if (firstTask != r.task) {
// 2.1 只有存在两个以上的Task的时候才会到这里
if (tasks == null) {
tasks = new ArraySet<>();
tasks.add(firstTask);
}
tasks.add(r.task);
}
}
}
// 2.2 只有存在两个以上的Task的时候才不为空
if (tasks == null) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Didn't find two or more tasks to release");
return;
}
// If we have activities in multiple tasks that are in a position to be destroyed,
// let's iterate through the tasks and release the oldest one.
// 2.3 遍历找到ActivityStack释放最旧的那个
final int numDisplays = mActivityDisplays.size();
for (int displayNdx = 0; displayNdx < numDisplays; ++displayNdx) {
final ArrayList<ActivityStack> stacks = mActivityDisplays.valueAt(displayNdx).mStacks;
// Step through all stacks starting from behind, to hit the oldest things first.
// 从后面开始遍历,从最旧的开始匹配
for (int stackNdx = 0; stackNdx < stacks.size(); stackNdx++) {
final ActivityStack stack = stacks.get(stackNdx);
// Try to release activities in this stack; if we manage to, we are done.
// 尝试在这个stack里面销毁这些Activities,如果成功就返回。
if (stack.releaseSomeActivitiesLocked(app, tasks, reason) > 0) {
return;
}
}
}
}

上面代码都加了注释,我们来理一理重点需要关注的点。整个流程可以观察tasks的走向



  • 2.1 & 2.2: 第一次循环会给firstTask赋值,当firstTask != r.task的时候才会给tasks赋值,后续会继续对tasks操作。所以单栈的应用不会回收,如果tasks为null,就直接return了,什么都不做

  • 2.3: 这一大段的双重for循环其实都没有第一步遍历出来的tasks参与,真正释放Activity的操作在ActivityStack中,所以尝试找到这些tasks对应的ActivityStack,让ActivityStack去销毁tasks,直到成功销毁。


继续查看releaseSomeActivitiesLocked:


// step③ ActivityStack.java
final int releaseSomeActivitiesLocked(ProcessRecord app, ArraySet<TaskRecord> tasks, String reason) {
// Iterate over tasks starting at the back (oldest) first.
int maxTasks = tasks.size() / 4;
if (maxTasks < 1) {
maxTasks = 1;
}
// 3.1 maxTasks至少为1,至少清理一个
int numReleased = 0;
for (int taskNdx = 0; taskNdx < mTaskHistory.size() && maxTasks > 0; taskNdx++) {
final TaskRecord task = mTaskHistory.get(taskNdx);
if (!tasks.contains(task)) {
continue;
}
int curNum = 0;
final ArrayList<ActivityRecord> activities = task.mActivities;
for (int actNdx = 0; actNdx < activities.size(); actNdx++) {
final ActivityRecord activity = activities.get(actNdx);
if (activity.app == app && activity.isDestroyable()) {
destroyActivityLocked(activity, true, reason);
if (activities.get(actNdx) != activity) {
// Was removed from list, back up so we don't miss the next one.
// 3.2 destroyActivityLocked后续会调用TaskRecord.removeActivity(),所以这里需要将index--
actNdx--;
}
curNum++;
}
}
if (curNum > 0) {
numReleased += curNum;
// 移除一个,继续循环需要判断 maxTasks > 0
maxTasks--;
if (mTaskHistory.get(taskNdx) != task) {
// The entire task got removed, back up so we don't miss the next one.
// 3.3 如果整个task都被移除了,这里同样需要将获取Task的index--。移除操作在上面3.1的destroyActivityLocked,移除Activity过程中,如果task为空了,会将task移除
taskNdx--;
}
}
}
return numReleased;
}



  • 3.1: ActivityStack利用maxTasks 保证,最多清理tasks.size() / 4,最少清理1个TaskRecord,同时,至少要保证保留一个前台可见TaskRecord,比如如果有两个TaskRecord,则清理先前的一个,保留前台显示的这个,如果三个,则还要看看最老的是否被有效清理,也就是是否有Activity被清理,如果有则只清理一个,保留两个,如果没有,则继续清理次老的,保留一个前台展示的,如果有四个,类似,如果有5个,则至少两个清理。一般APP中,很少有超过两个TaskRecord的。




  • 3.2: 这里清理的逻辑很清楚,for循环,如果定位到了期望的activity就清理掉,但这里这个actNdx--是为什么呢?注释说activity从list中移除了,为了能继续往下走,需要index--,但在这个方法中并没有将activity从lsit中移除的操作,那肯定是在destroyActivityLocked方法中。继续追进去可以一直追到TaskRecord.java#removeActivity(),从当前的TaskRecord的mActivities中移除了,所以需要index--。




  • 3.3: 我们弄懂了上面的actNdx--之后也就知道这里为什么要index--了,在ActivityStack.java#removeActivityFromHistoryLocked()中有




	if (lastActivity) {
removeTask(task, reason, REMOVE_TASK_MODE_DESTROYING);
}

如果task中没有activity了,需要将这个task移除掉。


以上就是基于API 28的releaseSomeActivities分析。


2.3.2. 基于29+的版本的releaseSomeActivities实现如下:

// ActivityTaskManagerService.java
@Override
public void releaseSomeActivities(IApplicationThread appInt) {
synchronized (mGlobalLock) {
final long origId = Binder.clearCallingIdentity();
try {
final WindowProcessController app = getProcessController(appInt);
app.releaseSomeActivities("low-mem");
} finally {
Binder.restoreCallingIdentity(origId);
}
}
}

// WindowProcessController.java
void releaseSomeActivities(String reason) {
// Examine all activities currently running in the process. Candidate activities that can be destroyed.
// 检查进程里所有的activity,看哪些可以被关掉
ArrayList<ActivityRecord> candidates = null;
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Trying to release some activities in " + this);
for (int i = 0; i < mActivities.size(); i++) {
final ActivityRecord r = mActivities.get(i);
// First, if we find an activity that is in the process of being destroyed,
// then we just aren't going to do anything for now; we want things to settle
// down before we try to prune more activities.
// 首先,如果我们发现一个activity正在执行关闭中,在关掉这个activity之前什么都不做
if (r.finishing || r.isState(DESTROYING, DESTROYED)) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Abort release; already destroying: " + r);
return;
}
// Don't consider any activities that are currently not in a state where they can be destroyed.
// 如果当前activity不在可关闭的state的时候,不做处理
if (r.mVisibleRequested || !r.stopped || !r.hasSavedState() || !r.isDestroyable()
|| r.isState(STARTED, RESUMED, PAUSING, PAUSED, STOPPING)) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Not releasing in-use activity: " + r);
continue;
}

if (r.getParent() != null) {
if (candidates == null) {
candidates = new ArrayList<>();
}
candidates.add(r);
}
}

if (candidates != null) {
// Sort based on z-order in hierarchy.
candidates.sort(WindowContainer::compareTo);
// Release some older activities
int maxRelease = Math.max(candidates.size(), 1);
do {
final ActivityRecord r = candidates.remove(0);
r.destroyImmediately(true /*removeFromApp*/, reason);
--maxRelease;
} while (maxRelease > 0);
}
}

新版本的releaseSomeActivities放到了ActivityTaskManagerService.java这个类中,这个类是API 29新添加的,承载部分AMS的工作。
相比API 28基于Task栈的回收Activity策略,新版本策略简单清晰, 也激进了很多。


遍历所有Activity,刨掉那些不在可销毁状态的Activity,按照Activity堆叠的顺序,也就是Z轴的顺序,从老到新销毁activity。


有兴趣的读者可以自行编写测试代码,分别在API 28和API 28+的手机上测试看一下回收策略是否跟上面分析的一致。

也可以参考我写的TestKillActivity,单栈和多栈的情况下在高于API 28和低于API 28的手机上的表现。


总结:



  1. 系统内存不足时LMK会根据内存配置项来kill掉进程释放内存

  2. kill时会按照进程的ADJ规则来kill

  3. App内存不足时由GcWatcher来决定回收Activity的时机

  4. 可以使用getprop命令来查看当前手机的JVM内存分配和OOM配置

  5. releaseSomeActivities在API 28和API 28+的差别很大,低版本会根据Task数量来决定清理哪个task的。高版本简单粗暴,遍历activity,按照z order排序,优先release掉更老的activity。


参考资料:
Android lowmemorykiller分析
解读Android进程优先级ADJ算法
http://www.jianshu.com/p/3233c33f6…
juejin.cn/post/706306…
Android可见APP的不可见任务栈(TaskRecord)销毁分析


作者:Yocn
来源:juejin.cn/post/7231742100844871736
收起阅读 »

当你按下方向键,电视是如何寻找下一个焦点的

我工作的第一家公司主要做的是一个在智能电视上面运行的APP,其实就是一个安卓APP,也是混合开发的应用,里面很多页面是H5开发的。 电视我们都知道,是通过遥控器来操作的,没有鼠标也不能触屏,所以“点击”的操作变成了按遥控器的“上下左右确定”键,那么必然需要一个...
继续阅读 »

我工作的第一家公司主要做的是一个在智能电视上面运行的APP,其实就是一个安卓APP,也是混合开发的应用,里面很多页面是H5开发的。


电视我们都知道,是通过遥控器来操作的,没有鼠标也不能触屏,所以“点击”的操作变成了按遥控器的“上下左右确定”键,那么必然需要一个“焦点”来告诉用户当前聚焦在哪里。


当时开发页面使用的是一个前人开发的焦点库,这个库会自己监听方向键并且自动计算下一个聚焦的元素。


为什么时隔多年会突然想起这个呢,其实是因为最近在给我开源的思维导图添加方向键导航的功能时,想到其实和电视聚焦功能很类似,都是按方向键,来计算并且自动聚焦到下一个元素或节点:



那么如何寻找下一个焦点呢,结合我当时用的焦点库的原理,接下来实现一下。


1.最简单的算法


第一种算法最简单,根据方向先找出当前节点该方向所有的其他节点,然后再找出直线距离最近的一个,比如当按下了左方向键,下面这些节点都是符合要求的节点:



从中选出最近的一个即为下一个聚焦节点。


节点的位置信息示意如下:



focus(dir) {
// 当前聚焦的节点
let currentActiveNode = this.mindMap.renderer.activeNodeList[0]
// 当前聚焦节点的位置信息
let currentActiveNodeRect = this.getNodeRect(currentActiveNode)
// 寻找的下一个聚焦节点
let targetNode = null
let targetDis = Infinity
// 保存并维护距离最近的节点
let checkNodeDis = (rect, node) => {
let dis = this.getDistance(currentActiveNodeRect, rect)
if (dis < targetDis) {
targetNode = node
targetDis = dis
}
}
// 1.最简单的算法
this.getFocusNodeBySimpleAlgorithm({
currentActiveNode,
currentActiveNodeRect,
dir,
checkNodeDis
})
// 找到了则让目标节点聚焦
if (targetNode) {
targetNode.active()
}
}

无论哪种算法,都是先找出所有符合要求的节点,然后再从中找出和当前聚焦节点距离最近的节点,所以维护最近距离节点的函数是可以复用的,通过参数的形式传给具体的计算函数。


// 1.最简单的算法
getFocusNodeBySimpleAlgorithm({
currentActiveNode,
currentActiveNodeRect,
dir,
checkNodeDis
}
) {
// 遍历思维导图节点树
bfsWalk(this.mindMap.renderer.root, node => {
// 跳过当前聚焦的节点
if (node === currentActiveNode) return
// 当前遍历到的节点的位置信息
let rect = this.getNodeRect(node)
let { left, top, right, bottom } = rect
let match = false
// 按下了左方向键
if (dir === 'Left') {
// 判断节点是否在当前节点的左侧
match = right <= currentActiveNodeRect.left
// 按下了右方向键
} else if (dir === 'Right') {
// 判断节点是否在当前节点的右侧
match = left >= currentActiveNodeRect.right
// 按下了上方向键
} else if (dir === 'Up') {
// 判断节点是否在当前节点的上面
match = bottom <= currentActiveNodeRect.top
// 按下了下方向键
} else if (dir === 'Down') {
// 判断节点是否在当前节点的下面
match = top >= currentActiveNodeRect.bottom
}
// 符合要求,判断是否是最近的节点
if (match) {
checkNodeDis(rect, node)
}
})
}

效果如下:


基本可以工作,但是可以看到有个很大的缺点,比如按上键,我们预期的应该是聚焦到上面的兄弟节点上,但是实际上聚焦到的是子节点:



因为这个子节点确实是在当前节点上面,且距离最近的,那么怎么解决这个问题呢,接下来看看第二种算法。


2.阴影算法


该算法也是分别处理四个方向,但是和前面的第一种算法相比,额外要求节点在指定方向上的延伸需要存在交叉,延伸处可以想象成是节点的阴影,也就是名字的由来:



找出所有存在交叉的节点后也是从中找出距离最近的一个节点作为下一个聚焦节点,修改focus方法,改成使用阴影算法:


focus(dir) {
// 当前聚焦的节点
let currentActiveNode = this.mindMap.renderer.activeNodeList[0]
// 当前聚焦节点的位置信息
let currentActiveNodeRect = this.getNodeRect(currentActiveNode)
// 寻找的下一个聚焦节点
// ...
// 保存并维护距离最近的节点
// ...

// 2.阴影算法
this.getFocusNodeByShadowAlgorithm({
currentActiveNode,
currentActiveNodeRect,
dir,
checkNodeDis
})

// 找到了则让目标节点聚焦
if (targetNode) {
targetNode.active()
}
}

// 2.阴影算法
getFocusNodeByShadowAlgorithm({
currentActiveNode,
currentActiveNodeRect,
dir,
checkNodeDis
}
) {
bfsWalk(this.mindMap.renderer.root, node => {
if (node === currentActiveNode) return
let rect = this.getNodeRect(node)
let { left, top, right, bottom } = rect
let match = false
if (dir === 'Left') {
match =
left < currentActiveNodeRect.left &&
top < currentActiveNodeRect.bottom &&
bottom > currentActiveNodeRect.top
} else if (dir === 'Right') {
match =
right > currentActiveNodeRect.right &&
top < currentActiveNodeRect.bottom &&
bottom > currentActiveNodeRect.top
} else if (dir === 'Up') {
match =
top < currentActiveNodeRect.top &&
left < currentActiveNodeRect.right &&
right > currentActiveNodeRect.left
} else if (dir === 'Down') {
match =
bottom > currentActiveNodeRect.bottom &&
left < currentActiveNodeRect.right &&
right > currentActiveNodeRect.left
}
if (match) {
checkNodeDis(rect, node)
}
})
}

就是判断条件增加了是否交叉的比较,效果如下:


可以看到阴影算法成功解决了前面的跳转问题,但是它也并不完美,比如下面这种情况按左方向键找不到可聚焦节点了:



因为左侧没有存在交叉的节点,但是其实可以聚焦到父节点上,怎么办呢,我们先看一下下一种算法。


3.区域算法


所谓区域算法也很简单,把当前聚焦节点的四周平分成四个区域,对应四个方向,寻找哪个方向的下一个节点就先找出中心点在这个区域的所有节点,再从中选择距离最近的一个即可:



focus(dir) {
// 当前聚焦的节点
let currentActiveNode = this.mindMap.renderer.activeNodeList[0]
// 当前聚焦节点的位置信息
let currentActiveNodeRect = this.getNodeRect(currentActiveNode)
// 寻找的下一个聚焦节点
// ...
// 保存并维护距离最近的节点
// ...

// 3.区域算法
this.getFocusNodeByAreaAlgorithm({
currentActiveNode,
currentActiveNodeRect,
dir,
checkNodeDis
})

// 找到了则让目标节点聚焦
if (targetNode) {
targetNode.active()
}
}

// 3.区域算法
getFocusNodeByAreaAlgorithm({
currentActiveNode,
currentActiveNodeRect,
dir,
checkNodeDis
}
) {
// 当前聚焦节点的中心点
let cX = (currentActiveNodeRect.right + currentActiveNodeRect.left) / 2
let cY = (currentActiveNodeRect.bottom + currentActiveNodeRect.top) / 2
bfsWalk(this.mindMap.renderer.root, node => {
if (node === currentActiveNode) return
let rect = this.getNodeRect(node)
let { left, top, right, bottom } = rect
// 遍历到的节点的中心点
let ccX = (right + left) / 2
let ccY = (bottom + top) / 2
// 节点的中心点坐标和当前聚焦节点的中心点坐标的差值
let offsetX = ccX - cX
let offsetY = ccY - cY
if (offsetX === 0 && offsetY === 0) return
let match = false
if (dir === 'Left') {
match = offsetX <= 0 && offsetX <= offsetY && offsetX <= -offsetY
} else if (dir === 'Right') {
match = offsetX > 0 && offsetX >= -offsetY && offsetX >= offsetY
} else if (dir === 'Up') {
match = offsetY <= 0 && offsetY < offsetX && offsetY < -offsetX
} else if (dir === 'Down') {
match = offsetY > 0 && -offsetY < offsetX && offsetY > offsetX
}
if (match) {
checkNodeDis(rect, node)
}
})
}

比较的逻辑可以参考下图:



效果如下:


结合阴影算法和区域算法


前面介绍阴影算法时说了它有一定局限性,区域算法计算出的结果则可以对它进行补充,但是理想情况下阴影算法的结果是最符合我们的预期的,那么很简单,我们可以把它们两个结合起来,调整一下顺序,先使用阴影算法计算节点,如果阴影算法没找到,那么再使用区域算法寻找节点,简单算法也可以加在最后:


focus(dir) {
// 当前聚焦的节点
let currentActiveNode = this.mindMap.renderer.activeNodeList[0]
// 当前聚焦节点的位置信息
let currentActiveNodeRect = this.getNodeRect(currentActiveNode)
// 寻找的下一个聚焦节点
// ...
// 保存并维护距离最近的节点
// ...

// 第一优先级:阴影算法
this.getFocusNodeByShadowAlgorithm({
currentActiveNode,
currentActiveNodeRect,
dir,
checkNodeDis
})

// 第二优先级:区域算法
if (!targetNode) {
this.getFocusNodeByAreaAlgorithm({
currentActiveNode,
currentActiveNodeRect,
dir,
checkNodeDis
})
}

// 第三优先级:简单算法
if (!targetNode) {
this.getFocusNodeBySimpleAlgorithm({
currentActiveNode,
currentActiveNodeRect,
dir,
checkNodeDis
})
}

// 找到了则让目标节点聚焦
if (targetNode) {
targetNode.active()
}
}

效果如下:


1.gif


是不是很简单呢,详细体验可以点击思维导图


作者:街角小林
来源:juejin.cn/post/7199666255883927612
收起阅读 »

一个全新的 Android 组件化通信工具

GitHub Gitee ComponentBus 这个项目已经内部使用了一段时间, 经过几次迭代. 他非常小巧, 且功能强大, 并且配有 IDEA 插件作为辅助. ComponentBus 利用 ASM、KSP, 使组件间的通信变得简单且高效. 第一步组件间...
继续阅读 »

GitHub

Gitee


ComponentBus 这个项目已经内部使用了一段时间, 经过几次迭代.

他非常小巧, 且功能强大, 并且配有 IDEA 插件作为辅助.

ComponentBus 利用 ASM、KSP, 使组件间的通信变得简单且高效.


第一步组件间通信


新建一个 Module, 我们给他添加一个接口


@Component(componentName = "Test")
object ComponentTest {

@Action(actionName = "init")
fun init(debug: Boolean) {
...
}

@Action(actionName = "getId")
fun getId(): String {
return "id-001"
}

@Action(actionName = "openUserPage", interceptorName = ["LoginInterceptor"])
fun openUserPage() {
val newIntent = Intent(MyApplication.application, UserActivity::class.java)
newIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
MyApplication.application.startActivity(newIntent)
}
}

我们可以看到, 任何方法、参数、返回值都可作为通信 Action, 只要给他加上 Action 注解.

并且我们可以给他添加拦截器, 当条件不满足时进行拦截, 并做其他操作.



由于 module 间没有依赖, 返回值应该是所有 module 都可以引用到的类型.

组件间调用, 参数默认值目前不支持使用.



第二部调用其他组件API


新建一个 Module, 我们调用另一个 Module 的 API


ComponentBus.with("Test", "init")
.params("debug", true)
.callSync<Unit>()

val result = ComponentBus.with("Test", "getId")
.callSync<String>()
if (result.isSuccess) {
val id = result.data!!
}

就是这么简单, 不需要接口下沉.



这里有个问题, 那就是 componentName、actionName 都是字符串, 使用上不方便, 需要查看名称、复制.

为了解决这个问题, 我专门开发了一款 IDEA 插件, 辅助使用.



IDEA 插件


插件搜索 componentBus


ComponentBusPlugin.gif


拦截器


全局拦截器


/**  
* 全局日志拦截器
*/

object LogGlobalInterceptor : GlobalInterceptor() {
override suspend fun <T> intercept(chain: Chain) = chain.proceed<T>().apply {
UtilsLog.log("Component: ${chain.request.componentName}${Utils.separatorLine}Action: ${chain.request.action}${Utils.separatorLine}Result: ($code) $msg $data", "Component")
}
override fun <T> interceptSync(chain: Chain) = chain.proceedSync<T>().apply {
UtilsLog.log("Component: ${chain.request.componentName}${Utils.separatorLine}Action: ${chain.request.action}${Utils.separatorLine}Result: ($code) $msg $data", "Component")
}
}

普通拦截器


/**  
* 判断是否是登录的拦截器
* 未登录会进入登录页面
*/

object LoginInterceptor : IInterceptor {
override suspend fun <T> intercept(chain: Chain): Result<T> {
return if (UsercenterComponent.isLoginLiveData.value == true) {
chain.proceed()
} else {
showLogin()
Result.resultError(-3, "拦截, 进入登录页")
}
}

override fun <T> interceptSync(chain: Chain): Result<T> {
return if (UsercenterComponent.isLoginLiveData.value == true) {
chain.proceedSync()
} else {
showLogin()
Result.resultError(-3, "拦截, 进入登录页")
}
}
}

END


更多详情在 GitHub

欢迎感兴趣的朋友提供反馈和建议。


作者:WJ
来源:juejin.cn/post/7287817398315892777
收起阅读 »

如何在10分钟内让Android应用大小减少 60%?

一个APP的包之所以大,主要包括一下文件 代码 lib so本地库 资源文件(图片,音频,字体等) 瘦身就主要瘦这些。 一、打包的時候刪除不用的代码 buildTypes {        debug {            ...        ...
继续阅读 »

一个APP的包之所以大,主要包括一下文件



  • 代码

  • lib

  • so本地库

  • 资源文件(图片,音频,字体等)


瘦身就主要瘦这些。


一、打包的時候刪除不用的代码


buildTypes {
       debug {
           ...
           shrinkResources true // 是否去除无效的资源文件(如果你的Debug也需要瘦身)
      }
       release {
           ...
           shrinkResources true // 是否去除无效的资源文件
      }
  }

二、减少不必要的打包


defaultConfig {
   ...
   //打包的语言类型(语种的翻译)
   resConfigs "en", "de", "fr", "it"
   //打包的文件夹
   resConfigs "nodpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"
}

或者


android {
 ...
 splits {
   density {
     enable true
     exclude "ldpi", "tvdpi", "xxxhdpi"
     compatibleScreens 'small', 'normal', 'large', 'xlarge'

     //reset()
     //include 'x86', 'armeabi-v7a', 'mips'
     //universalApk true
  }
}

三、lib


尽量不用太复杂的lib,轻量级lib是首选。如果你的应用没用到兼容库,可以考虑去掉support包。


四、资源文件


我们可以通过Lint工具找到没有使用的资源(在Android Studio的“Analyze”菜单中选择“Inspect Code…”)


五、把现有图片转换为webP


我们可以通过 智图 或者isparta将其它格式的图片转换成webP格式,isparta可实现批量转换。


五、图片相关



  • 在Android 5.0及以上的版本可以通过tintcolor实现只提供一张按钮的图片,在程序中实现按钮反选效果,前提是图片的内容一样,只是正反选按钮的颜色不一样。


Drawable.setColorFilter( 0xffff0000, Mode.MULTIPLY )


  • 在Android 5.0及以上的版本,可以使用VectorDrawable和SVG图片来替换原有图片


六、混淆


1 构建多个版本



  • 在gradle中的buildTypes中增加不同的构建类型,使用applicationSuffixversionNameSuffix可以生成多个版本在同一设备上运行

  • 创建src/[buildType]/res/设置不同的ic_launcher以区别不同版本


2 混淆参数


{ 
   debug { minifyEnabled false }
   release {
     signingConfig signingConfigs.release
     minifyEnabled true
     proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
  }
}

minifyEnabled true



  • 是否要启用通过 ProGuard 实现的代码压缩(true启用)

  • 请注意,代码压缩会拖慢构建速度,因此您应该尽可能避免在调试构建中使用。 :Android Studio 会在使用Instant Run时停用 ProGuard。


proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'



  • getDefaultProguardFile(‘proguard-android.txt')方法可从 Android SDKtools/proguard/文件夹获取默认 ProGuard 设置。

  • 提示: 要想做进一步的代码压缩,可尝试使用位于同一位置的proguard-android-optimize.txt文件。它包括相同的 ProGuard 规则,但还包括其他在字节码一级(方法内和方法间)执行分析的优化,以进一步减小 APK 大小和帮助提高其运行速度。

  • proguard-rules.pro文件用于添加自定义 ProGuard 规则。默认情况下,该文件位于模块根目录(build.gradle文件旁)。

  • 要添加更多各构建变体专用的 ProGuard 规则,请在相应的productFlavor代码块中再添加一个proguardFiles属性。例如,以下 Gradle 文件会向flavor2产品风味添加flavor2-rules.pro。现在flavor2使用所有三个 ProGuard 规则,因为还应用了来自release代码块的规则。

  • 每次构建时 ProGuard 都会输出下列文件 dump.txt 说明 APK 中所有类文件的内部结构。mapping.txt:提供原始与混淆过的类、方法和字段名称之间的转换。seeds.txt:列出未进行混淆的类和成员。usage.txt:列出从 APK 移除的代码。这些文件保存在/build/outputs/mapping/release/

  • 要修正错误并强制 ProGuard 保留特定代码,请在 ProGuard 配置文件中添加一行-keep代码。例如: -keeppublicclassMyClass

  • 您还可以向您想保留的代码添加[@Keep] (developer.android.com/reference/a…)注解。在类上添加@Keep可原样保留整个类。在方法或字段上添加它可完整保留方法/字段(及其名称)以及类名称。请注意,只有在使用注解支持库时,才能使用此注解。

  • 在使用-keep选项时,有许多事项需要考虑;如需了解有关自定义配置文件的详细信息,请阅读ProGuard 手册问题排查一章概述了您可能会在混淆代码时遇到的其他常见问题。

  • 请注意,您每次使用 ProGuard 创建发布构建时都会覆盖mapping.txt文件,因此您每次发布新版本时都必须小心地保存一个副本。通过为每个发布构建保留一个mapping.txt文件副本,您就可以在用户提交的已混淆堆叠追踪来自旧版本应用时对问题进行调试。

  • 在每次添加库的时候,需要及时进行make a release build

  • DexGuard时Proguard同一个团队开发的软件, 优化代码,分离dex文件从而解决65k方法限制的文件


关于proguard-android.txt文件:


-dontusemixedcaseclassnames: 表示混淆时不使用大小写混淆类名。 -dontskipnonpubliclibraryclasses:不跳过library中的非public方法。 -verbose: 打印混淆的详细信息。 -dontoptimize: 不进行优化,优化可能会造成一些潜在风险,不能保证在所有版本的Dalvik上都正常运行。 -dontpreverify: 不进行预校验。 -keepattributes Annotation :对注解参数进行保留。 -keep public class com.google.vending.licensing.ILicensingService -keep public class com.android.vending.licensing.ILicensingService: 表示不混淆上述声明的两个类。


proguard中一共有三组六个keep关键字的含义


keep  保留类和类中的成员,防止它们被混淆或移除。
keepnames 保留类和类中的成员,防止它们被混淆,但当成员没有被引用时会被移除。
keepclassmembers  只保留类中的成员,防止它们被混淆或移除。
keepclassmembernames  只保留类中的成员,防止它们被混淆,但当成员没有被引用时会被移除。
keepclasseswithmembers  保留类和类中的成员,防止它们被混淆或移除,前提是指名的类中的成员必须存在,如果不存在则还是会混淆。
keepclasseswithmembernames  保留类和类中的成员,防止它们被混淆,但当成员没有被引用时会被移除,前提是指名的类中的成员必须存在,如果不存在则还是会混淆。

keepclasseswithmember和keep关键字的区别: 如果这个类没有native的方法,那么这个类会被混淆


-keepclasseswithmember class * {
   native <methods>;
}

不管这个类有没有native的方法,那么这个类不会被混淆


-keep class * {
   native <methods>;
}



另外、 你可以使用 APK Analyser 分解你的 APK


Android Studio 提供了一个有用的工具:APK Analyser。APK Analyser 将会拆解你的应用并让你知道 .apk 文件中的那个部分占据了大量空间。让我们看一下 Anti-Theft 在没有经过优化之前的截图。


img


从 Apk Analyser 的输出来看,应用的原大小是 3.1MB。经过 Play 商店的压缩,大致是 2.5MB。


从截图中可以看出主要有 3 个文件夹占据了应用的大多数空间。



classes.dex —— 这是 dex 文件,包含了所有会运行在你的 DVM 或 ART 里的字节码文件。 res —— 这个文件夹包含了所有在 res 文件夹下的文件。大部分情况下它包含所有图片,图标和源文件,菜单文件和布局。



img



resources.arsc —— 这个文件包含了所有 value 资源。这个文件包含了你 value 目录下的所有数据。包括 strings、dimensions、styles、intergers、ids 等等。



img


你有两个默认的混淆文件。


proguard-android-optimize.txt proguard-android.txt 就像文件名写的那样,“proguard-android-optimize.txt”是更积极的混淆选项。我们将这个作为默认的混淆配置。你可以在 /app 目录下的 proguard-rules.pro 里添加自定义的混淆配置。


 release {
//Enable the proguard
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), "proguard-rules.pro"

//Other parameters
debuggable false
jniDebuggable false
renderscriptDebuggable false
signingConfig playStoreConfig //Add your own signing config
pseudoLocalesEnabled false
zipAlignEnabled true
}

通过设置 minifyEnabled 为 true,混淆将会移除所有未使用的方法、指令以减小 classes.dex 文件。


这是启用了 minify 之后的 APK。


七、AndroidStudio使用lint清除无用的资源文件 在使用AndroidStudio进行App开发的时候,我们经常会在项目中引用多种资源文件,包括图片,布局文件,常量引用定义。随着项目版本开发的迭代,每一期的资源会有变动必定会留下一些无用的资源这个时候我们手动去一个一个寻找效率就会很低下。这个时候我们就要学会AndroidStudio使用lint清除无用的资源文件。



  • 打开AndroidStudio在项目中,点击最上方的菜单栏Analyze -> Run Inspection by Name 如下图:


img



  • 点击 Run Inspection by Name会弹出一个对话框。在对话框里面输入unused resource 如下图:


img



  • 然后点击下拉列表中的unused resource。 之后会弹出一个对话框如下图


img


结尾


好啦,如此文章到这里就结束了,希望这篇文章能够帮到正在看的你们,能够解决Android小伙伴们应用内存问题~


更多Android进阶指南 可以详细Vx关注公众号:Android老皮 解锁               《Android十大板块文档》


1.Android车载应用开发系统学习指南(附项目实战)


2.Android Framework学习指南,助力成为系统级开发高手


3.2023最新Android中高级面试题汇总+解析,告别零offer


4.企业级Android音视频开发学习路线+项目实战(附源码)


5.Android Jetpack从入门到精通,构建高质量UI界面


6.Flutter技术解析与实战,跨平台首要之选


7.Kotlin从入门到实战,全方面提升架构基础


8.高级Android插件化与组件化(含实战教程和源码)


9.Android 性能优化实战+360°全方面性能调优


10.Android零基础入门到精通,高手进阶之路


敲代码不易,关注一下吧。ღ( ´・ᴗ・` ) 🤔


作者:花海blog
来源:juejin.cn/post/7287473826060763197
收起阅读 »

聊聊陈旧的插件化

不长不短的职业生涯里,有一段搞插件化的经历,当时所在的团队也是行业里比较知名的最早搞插件化的团队之一。虽然理论上是使用方,但因为业务的需要,要把大插件拆成更小颗粒度的小插件,所以会比较深度的做源码级别的定制修改。 1 什么是插件化 插件化要解决的问题总的来说有...
继续阅读 »

不长不短的职业生涯里,有一段搞插件化的经历,当时所在的团队也是行业里比较知名的最早搞插件化的团队之一。虽然理论上是使用方,但因为业务的需要,要把大插件拆成更小颗粒度的小插件,所以会比较深度的做源码级别的定制修改。


1 什么是插件化


插件化要解决的问题总的来说有三个方面



  • 动态性:也就是更新功能无需依赖发版,动态下发应用的新功能。

  • 包体积:一个巨型的APP功能模块很多,包体积自然小不了。插件化可以把不同的功能模块制作成单独的插件,按需下载应用,有效控制包体积。同时,对于一些“边缘功能”,对于每个用户个体来说可能,使用不到,插件化按需下载的优势也就体现出来了。

  • 热修复: 对于线上的bug,利用插件化技术的动态性,可以在不发版的情况下实现热修。


说了这么多,简单的讲,插件化就是不依赖于发版使APP具备动态更新的能力。业界也管这个叫【免安装】。


2 怎么实现插件化


Android要实现插件化就是要解决三方面的问题。



  • 代码动态化。

  • 组件插件化。

  • 资源的插件化


2.1 代码(类)的动态化


正常情况下,程序员写代码 -> 打包APK -> 发版 -> 用户安装、使用。


现在要解决这样一个问题,不重新安装app的情况下,如何让程序员编写的代码在已经被用户安装了的APP上跑起来。


Java语言的特性天然具备动态性。ClassLoader可以动态加载类文件(.class ,.dex,.jar)。Android插件化的基石之一在于此。


编写代码然后打包成dex或者apk,然后App获取到对应的类文件,利用classLoader动态加载类,创建对象,利用反射就可以调用类/对象的方法,获取类/对象的属性。


让代码能够动态的下发动态的执行。


当然这只是一个最基本的原理,里面还有涉及到很多的细节,比如



  • 不同插件是相同classloader加载还是不同classloader加载。

  • 宿主APP与插件APP是否是使用同一ClassLoader。

  • 如果涉及到不同ClassLoader,加载的类如何进行通信。


对于这些问题的解决,不同的插件化框架也有不同的方案,各有利弊,如果大家感兴趣,后续会单独开篇详细的聊一聊。


2.2 组件插件化


上一节,说到我们利用classloader的动态加载机制配合反射,可以让代码动态化起来。有一个很重要的问题,Android系统中Activity、Service等组件是系统组件。他的特点是系统调用系统管理的。比如Activity著名的那些回调函数,都是System_Server进程那挂了号,对于系统进程来讲是有感知的。另外一方面我们每创建一个Activity组件都要在Manifest.xm里注册上,这个动作的意义就是让系统知道我们的应用里有哪些组件。相应的AMS都会对注册进行校验。


如果我们动态的下发一个Activity类,是不能像正常的类一样运行起来。如何实现组件的插件化?


简单的说,就是占坑+转掉.


既然不能动态的在Manifest.xml清单文件里动态的注册,但是可以在Manifest里预埋几个等用的时候拿出来用,解决注册问题。


既然生命周期函数都是系统调用的,不能我们触发,我们可以实现转调。简单的说启动一个插件Activty的时候,其实先启动占坑的Activity -> 加载创建插件Activity(当作一个普通的类对象) -> 占坑的Activity转调插件Activity。


关于组件的插件化大概思想如此,具体实现上也不同框架也会有不同的方案,hook的点也不一样。Replugin hook了ClassLoader,使得在加载占坑activity的时候替换为了加载插件的Activity。VirtualApk hook 了Instrumentation来模拟系统启动Activity等。


当然真正实现起来还是有一些问题需要解决,比如多进程的实现、不同启动模式的实现等。


2.3 资源的插件化


正常开发我们使用诸如 R.xx.x的方式索引资源,但是如果我们在一个插件的Activity中如果不做处理,直接使用该方式去是索引不到资源的。因为此时是在宿主的Resource中查找插件的资源。


插件Apk中的图片,layout等资源也是需要进行插件化处理,使得能够正确的访问到插件中的资源。资源的插件化核心是对插件APK中的Resource对象实例化,这样通过Resource对像代码中可能访问到插件的资源。


实现 的方式主要有两种,




  • 一种是把插件中的资源合并到宿主中,这样使用宿主的Resource对象既能访问到插件的资源也能访问到宿主的资源。这种方式也会带来一个比较头疼的问题,资源冲突问题,通常的方案是id固定,这里就不做展开。




  • 另外一种方案为插件创建单独的Resource对象。




packageArchiveInfo.applicationInfo.publicSourceDir = archiveFilePath    
packageArchiveInfo.applicationInfo.sourceDir = archiveFilePath
val resource = packageManager.getResourcesForApplication(packageArchiveInfo.applicationInfo)

3 其他


经过以上,可以实现一个插件化最核心的东西,除此之外,还需要做



  • 插件的安装,插件apk的解压,释放。

  • 插件的注册,使得宿主和其他插件能够发现目标插件并与之通信。

  • 试想这样一种场景,宿主中已经依赖了某个library(A),我们插件中也依赖A。作为插件中A的这个依赖是不是就是重复的,如何解决这一个问题。

  • 编译器插件的生成。


4 结


从比较宏观的视角聊了下,插件化解决的问题,以及实现一个插件化大概的主体思路,是很粗颗粒度的描述。每一部分单独拆出来去分析研究会有很多东西挖掘出来。也在文中埋了一些坑,今后视具体情况再做分享。


thx 😊


作者:Drummor
来源:juejin.cn/post/7283087306604314636
收起阅读 »

Kotlin中 四个提升逼格的关键字你都会了吗?

开篇看结论 let let扩展函数的实际上是一个作用域函数,当你需要去定义一个变量在一个特定的作用域范围内,let函数的是一个不错的选择;let函数另一个作用就是可以避免写一些判断null的操作。 let函数的一般结构 object.let{ it.to...
继续阅读 »

开篇看结论


img


let


let扩展函数的实际上是一个作用域函数,当你需要去定义一个变量在一个特定的作用域范围内,let函数的是一个不错的选择;let函数另一个作用就是可以避免写一些判断null的操作。



  • let函数的一般结构


object.let{
it.todo()//在函数体内使用it替代object对象去访问其公有的属性和方法
...
}

//另一种用途 判断object为null的操作
object?.let{//表示object不为null的条件下,才会去执行let函数体
it.todo()
}


  • let函数的kotlin和Java转化


//kotlin

fun main(args: Array<String>) {
val result = "testLet".let {
println(it.length)
1000
}
println(result)
}

//java

public final class LetFunctionKt {
public static final void main(@NotNull String[] args) {
Intrinsics.checkParameterIsNotNull(args, "args");
String var2 = "testLet";
int var4 = var2.length();
System.out.println(var4);
int result = 1000;
System.out.println(result);
}
}


  • let函数使用前后的对比


mVideoPlayer?.setVideoView(activity.course_video_view)
mVideoPlayer?.setControllerView(activity.course_video_controller_view)
mVideoPlayer?.setCurtainView(activity.course_video_curtain_view)
------------------------------------------------------------------------------------------------------------------------------
mVideoPlayer?.let {
it.setVideoView(activity.course_video_view)
it.setControllerView(activity.course_video_controller_view)
it.setCurtainView(activity.course_video_curtain_view)
}


  • let函数适用的场景


场景一: 最常用的场景就是使用let函数处理需要针对一个可null的对象统一做判空处理。 场景二: 然后就是需要去明确一个变量所处特定的作用域范围内可以使用


with



  • with函数使用的一般结构


with(object){
//todo
}


  • with函数的kotlin和Java转化


//kotlin
fun main(args: Array<String>) {
val user = User("Kotlin", 1, "1111111")

val result = with(user) {
println("my name is $name, I am $age years old, my phone number is $phoneNum")
1000
}
println("result: $result")
}
------------------------------------------------------------------------------------------------------------------------------
//java
public static final void main(@NotNull String[] args) {
Intrinsics.checkParameterIsNotNull(args, "args");
User user = new User("Kotlin", 1, "1111111");
String var4 = "my name is " + user.getName() + ", I am " + user.getAge() + " years old, my phone number is " + user.getPhoneNum();
System.out.println(var4);
int result = 1000;
String var3 = "result: " + result;
System.out.println(var3);
}


  • with函数使用前后的对比


override fun onBindViewHolder(holder: ViewHolder, position: Int){
val item = getItem(position)?: return
with(item){
holder.tvNewsTitle.text = StringUtils.trimToEmpty(titleEn)
holder.tvNewsSummary.text = StringUtils.trimToEmpty(summary)
holder.tvExtraInf.text = "难度:$gradeInfo | 单词数:$length | 读后感: $numReviews"
}
}
------------------------------------------------------------------------------------------------------------------------------
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
ArticleSnippet item = getItem(position);
if (item == null) {
return;
}
holder.tvNewsTitle.setText(StringUtils.trimToEmpty(item.titleEn));
holder.tvNewsSummary.setText(StringUtils.trimToEmpty(item.summary));
String gradeInfo = "难度:" + item.gradeInfo;
String wordCount = "单词数:" + item.length;
String reviewNum = "读后感:" + item.numReviews;
String extraInfo = gradeInfo + " | " + wordCount + " | " + reviewNum;
holder.tvExtraInfo.setText(extraInfo);
}


  • with函数的适用的场景 适用于调用同一个类的多个方法时,可以省去类名重复,直接调用类的方法即可,经常用于Android中RecyclerView中onBinderViewHolder中,数据model的属性映射到UI上


run



  • run函数使用的一般结构


object.run{
//todo
}


  • run函数的kotlin和Java转化


//java
public static final void main(@NotNull String[] args) {
Intrinsics.checkParameterIsNotNull(args, "args");
User user = new User("Kotlin", 1, "1111111");
String var5 = "my name is " + user.getName() + ", I am " + user.getAge() + " years old, my phone number is " + user.getPhoneNum();
System.out.println(var5);
int result = 1000;
String var3 = "result: " + result;
System.out.println(var3);
}
------------------------------------------------------------------------------------------------------------------------------
//kotlin
fun main(args: Array<String>) {
val user = User("Kotlin", 1, "1111111")

val result = user.run {
println("my name is $name, I am $age years old, my phone number is $phoneNum")
1000
}
println("result: $result")
}


  • run函数使用前后对比


override fun onBindViewHolder(holder: ViewHolder, position: Int){
val item = getItem(position)?: return
with(item){
holder.tvNewsTitle.text = StringUtils.trimToEmpty(titleEn)
holder.tvNewsSummary.text = StringUtils.trimToEmpty(summary)
holder.tvExtraInf = "难度:$gradeInfo | 单词数:$length | 读后感: $numReviews"
...
}
}
// 使用后
override fun onBindViewHolder(holder: ViewHolder, position: Int){
getItem(position)?.run{
holder.tvNewsTitle.text = StringUtils.trimToEmpty(titleEn)
holder.tvNewsSummary.text = StringUtils.trimToEmpty(summary)
holder.tvExtraInf = "难度:$gradeInfo | 单词数:$length | 读后感: $numReviews"
...
}
}


  • run函数使用场景


适用于let,with函数任何场景。因为run函数是let,with两个函数结合体,准确来说它弥补了let函数在函数体内必须使用it参数替代对象,在run函数中可以像with函数一样可以省略,直接访问实例的公有属性和方法,另一方面它弥补了with函数传入对象判空问题,在run函数中可以像let函数一样做判空处理


apply



  • apply函数使用的一般结构


object.apply{
//todo
}


  • apply函数的kotlin和Java转化


//java
public final class ApplyFunctionKt {
public static final void main(@NotNull String[] args) {
Intrinsics.checkParameterIsNotNull(args, "args");
User user = new User("Kotlin", 1, "1111111");
String var5 = "my name is " + user.getName() + ", I am " + user.getAge() + " years old, my phone number is " + user.getPhoneNum();
System.out.println(var5);
String var3 = "result: " + user;
System.out.println(var3);
}
}

//kotlin
fun main(args: Array<String>) {
val user = User("Kotlin", 1, "1111111")
val result = user.apply {
println("my name is $name, I am $age years old, my phone number is $phoneNum")
1000
}
println("result: $result")
}


  • apply函数使用前后的对比


//使用前
mSheetDialogView = View.inflate(activity, R.layout.biz_exam_plan_layout_sheet_inner, null)
mSheetDialogView.course_comment_tv_label.paint.isFakeBoldText = true
mSheetDialogView.course_comment_tv_score.paint.isFakeBoldText = true
mSheetDialogView.course_comment_tv_cancel.paint.isFakeBoldText = true
mSheetDialogView.course_comment_tv_confirm.paint.isFakeBoldText = true
mSheetDialogView.course_comment_seek_bar.max = 10
mSheetDialogView.course_comment_seek_bar.progress = 0
//使用后
mSheetDialogView = View.inflate(activity, R.layout.biz_exam_plan_layout_sheet_inner, null).apply{
course_comment_tv_label.paint.isFakeBoldText = true
course_comment_tv_score.paint.isFakeBoldText = true
course_comment_tv_cancel.paint.isFakeBoldText = true
course_comment_tv_confirm.paint.isFakeBoldText = true
course_comment_seek_bar.max = 10
course_comment_seek_bar.progress = 0

}
//多级判空
if (mSectionMetaData == null || mSectionMetaData.questionnaire == null || mSectionMetaData.section == null) {
return;
}
if (mSectionMetaData.questionnaire.userProject != null) {
renderAnalysis();
return;
}
if (mSectionMetaData.section != null && !mSectionMetaData.section.sectionArticles.isEmpty()) {
fetchQuestionData();
return;
}

mSectionMetaData?.apply{
//mSectionMetaData不为空的时候操作mSectionMetaData
}?.questionnaire?.apply{
//questionnaire不为空的时候操作questionnaire
}?.section?.apply{
//section不为空的时候操作section
}?.sectionArticle?.apply{
//sectionArticle不为空的时候操作sectionArticle
}

also



  • also函数使用的一般结构


object.also{
//todo
}

复制



  • also函数编译后的class文件


//java
public final class AlsoFunctionKt {
public static final void main(@NotNull String[] args) {
Intrinsics.checkParameterIsNotNull(args, "args");
String var2 = "testLet";
int var4 = var2.length();
System.out.println(var4);
System.out.println(var2);
}
}
//kotlin
fun main(args: Array<String>) {
val result = "testLet".also {
println(it.length)
1000
}
println(result)
}


  • also函数的适用场景


适用于let函数的任何场景,also函数和let很像,只是唯一的不同点就是let函数最后的返回值是最后一行的返回值而also函数的返回值是返回当前的这个对象。一般可用于多个扩展函数链式调用


最后


如果你看到了这里,觉得文章写得不错就给个赞呗?


更多Android进阶指南 可以详细Vx关注公众号:Android老皮 解锁            《Android十大板块文档》


1.Android车载应用开发系统学习指南(附项目实战)


2.Android Framework学习指南,助力成为系统级开发高手


3.2023最新Android中高级面试题汇总+解析,告别零offer


4.企业级Android音视频开发学习路线+项目实战(附源码)


5.Android Jetpack从入门到精通,构建高质量UI界面


6.Flutter技术解析与实战,跨平台首要之选


7.Kotlin从入门到实战,全方面提升架构基础


8.高级Android插件化与组件化(含实战教程和源码)


9.Android 性能优化实战+360°全方面性能调优


10.Android零基础入门到精通,高手进阶之路


敲代码不易,关注一下吧。ღ( ´・ᴗ・` ) 🤔


作者:花海blog
来源:juejin.cn/post/7282752001900494882
收起阅读 »

compose 实现时间轴效果

新项目完全用了compose来实现,这两天有个时间轴的需求,搜索了一下完全由compose实现的几个,效果都不算特别好,而且都是用canvas画的,这样的话和原来的view没什么区别,不能发挥compose可定制组合的长处,所以自己实现了一个。由于我自己平时基...
继续阅读 »

新项目完全用了compose来实现,这两天有个时间轴的需求,搜索了一下完全由compose实现的几个,效果都不算特别好,而且都是用canvas画的,这样的话和原来的view没什么区别,不能发挥compose可定制组合的长处,所以自己实现了一个。由于我自己平时基本不写文章,并且内容也是偏向compose新手的,所以可能写的比较啰嗦,大佬们想看的可以直接跳到第三部分。欢迎指导!



在开始之前,先介绍一下这次实现的重点:Layout


Layout用于实现自定义的布局,可用于测量和定位其布局子项。我们可以用这个实现之前自定义view的效果,不过这里画的不是点线之类的东西,而是composable,并且只用计算放的位置就好,基于此我们可以实现有多个插槽的布局。


先来看一下UI效果是什么样的
体检报告详情.png


一、分解UI


通过观察UI,我们可以将每个item分解为以下四个元素:圆点、线、时间、内容。一个合格的组件,要允许使用者随意定义各个元素位置的实现,比如圆点可能变成方的,或者换成图片,线也可能是条实线,并且颜色是渐变的。所以这里这几个元素准确的来说,应该是四个插槽,这几个插槽提供了默认的样式是长这样。


圆点槽和时间槽是垂直居中对齐的,圆点槽和线槽是水平居中对齐的,内容槽和时间槽是左对齐,在圆点槽和时间槽中间有一定间距,我们管他叫内容距左间距。


每个item的最大宽度是圆点槽的宽+内容距左间距+内容的宽。每个item的最大高度是圆点或者时间槽的最大高度+内容的高度,不直接用时间槽的高度是因为圆点槽如果放个图片的话,可能高度比时间槽的高度要高。


由于这个线应该是连接两个圆点槽的,所以它的最大高度和最小高度其实都是一个,取决于两个圆点之间的距离,正好是一个item的高度。


在多个item时,第一个元素的线从点开始往下,而最后一个则没有线(说高度为0也行)


二、实现每个插槽的默认UI



  • 圆点


这个很简单,任意一个空的组件设置下修饰符就可以了。


Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape) // 变圆
.background(MaterialTheme.colorScheme.primary)
)


  • 线


实线很好实现,也通过background就可以


// 实线单色
Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.primary)
)

// 渐变也简单
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.primaryContainer
)
)
)
)

虚线稍微麻烦一点,Brush中没有直接实现虚线的方法,所以我用drawBehind来实现了。drawBehind这里的作用和Canvas()是一样的,你可以直接用canvas来实现,重点就是里面的pathEffect。


Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.drawBehind {
drawLine(
color = Color.LightGray,
strokeWidth = size.width,
start = Offset(x = 0f, y = 0f),
end = Offset(x = 0f, y = size.height),
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(8.dp.toPx(), 4.dp.toPx())
)
)
}
)


  • 时间


简单一个Text就可以。


Text("2023928日")


  • 内容


根据具体的内容来实现。


三、通过自定义的Layout将小UI组装起来


现在我们根据第一步的思路,来定义一个组件。


@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit, // 圆点槽
line: @Composable () -> Unit, // 线槽
time: @Composable () -> Unit,// 时间槽
content: @Composable () -> Unit, // 内容槽
contentStartOffset: Dp = 8.dp // 内容距左间距
)

然后我们将第二步中的插槽的默认UI放上去。主要是圆点槽和线槽。


@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit = {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
},
line: @Composable () -> Unit = {
Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.drawBehind {
drawLine(
color = Color.LightGray,
strokeWidth = size.width,
start = Offset(x = 0f, y = 0f),
end = Offset(x = 0f, y = size.height),
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(8.dp.toPx(), 4.dp.toPx())
)
)
}
)
},
time: @Composable () -> Unit,
content: @Composable () -> Unit,
contentStartOffset: Dp = 8.dp
)

定义好以后就可以开始做实现了,上面已经说过,我们是通过自定义Layout来实现的,那么先看一下Layout的构成。


@UiComposable
@Composable inline fun Layout(
content: @Composable @UiComposable () -> Unit, // 可组合子项。
modifier: Modifier = Modifier, // 布局的修饰符
measurePolicy: MeasurePolicy //布局的测量和定位的策略
)

这其中的content,就是指我们这四个槽的内容。


Layout(
modifier = modifier,
content = {
dot()
// 通过ProvideTextStyle给时间槽提供了一个默认字体颜色。
ProvideTextStyle(value = LocalTextStyle.current.copy(color = Color(0xff999999))) {
time()
}
content()
line()
},
measurePolicy = ...

我们可以看到在content中,我们将四个槽的内容全放进去了,那他们的位置和大小是怎么决定的呢,就是在measurePolicy中定义的。
MeasurePolicy类要求我们必须实现measure方法。


fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
)
: MeasureResult

measurables列表中的每个Measurable都对应于布局的一个布局子级,就是我们刚才在content中传入的内容,将按先后顺序存入这个列表。可以使用Measurable.measure方法来测量子级的大小。该方法需要子级自己所需要的约束Constraints(就是这个子级的最小最大尺寸);不同的子级可以用不同的约束来测量,而不是统一用给出的这个constraints参数。测量子级会返回一个Placeable,它的属性有该子级经过对应约束测量后的大小(一旦经过测量,这个子级的大小就确定了,不能再次测量)。最后在MeasureResult中,设置每个子级的位置就可以。


现在我们的代码变成了这样:


@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit = {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
},
line: @Composable () -> Unit = {
Box(modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.drawBehind {
drawLine(
color = Color.LightGray,//Color(0xffeeeeee)
strokeWidth = size.width,
start = Offset(x = 0f, y = 0f),
end = Offset(x = 0f, y = size.height),
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(8.dp.toPx(), 4.dp.toPx())
)
)
}
)
},
time: @Composable () -> Unit,
content: @Composable () -> Unit,
contentStartOffset: Dp = 8.dp,
position: TimelinePosition = TimelinePosition.Center
) {
Layout(
modifier = modifier,
content = {
dot()
ProvideTextStyle(value = LocalTextStyle.current.copy(color = Color(0xff999999))) {
time()
}
content()
line()
},
measurePolicy = object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
TODO: 具体的四个子级测量大小的位置设置。
}
}
)
}

现在我们来做具体的实现。
我们先来测量一下这里的圆点槽的大小。
val dot = measurables[0].measure(constraints)
因为我们在content中第一个传入的就是dot(),所以这里measurables[0]就是圆点槽组件,这样就得到了其对应的Placeable。
我们先放置下这个圆点槽显示下看看效果。


override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
)
: MeasureResult {
val dot = measurables[0].measure(constraints)

return layout(constraints.maxWidth, constraints.maxHeight) {
dot.place(0, 0, 1f)
}
}

理论上我们应该看到一个大小8dp,主题色的圆点在左上角。大家可以跑一下看看是不是符合预期。


要指出的是,这个方法给出的constraints并不是合适dot的约束,其最小宽度将可能远远大于dot的宽,这将导致测量后dot的宽远超设定的8dp。所以这里我们需要使用dot正确的约束, 而这个圆点槽理论上是不限制大小的,所以其最小宽度应该设置为0。我们依次将圆,时间,和内容的大小也测量出来。


val constraintsFix = constraints.copy(minWidth = 0)
val dot = measurables[0].measure(constraintsFix)
val time = measurables[1].measure(constraintsFix)
val content = measurables[2].measure(constraintsFix)

之所以不一并把线槽的大小也测量了,是因为我们在第一步中说的,线槽的高度,实际上是由圆点或者时间槽的最大高度+内容的高度来决定的。


val topHeight = max(time.height, dot.height) // 取圆点槽和时间槽中最大槽位的高度。
val lineHigh = topHeight + content.height // 整个组件的高度
val line = measurables[3].measure(
constraints.copy(
minWidth = 0,
minHeight = lineHeight,
maxHeight = lineHeight
)
)

至此我们已经将四个槽位的大小全部确定了下来。接下来就该指定每个槽位的位置,在第一步我们已经分析过每个槽位应该所在的位置。


val height = topHeight + content.height // 整个组件的高度
// 时间或内容的最大宽度 + 内容距左间距 + 圆点宽度 = 整个组件的宽度
val width =
max(content.width, time.width) + contentStartOffset.roundToPx() + dot.width

return layout(width, height) { // 设置layout占据的大小
val dotY = (topHeight - dot.height) / 2 // 计算圆点槽y轴位置
dot.place(0, dotY, 1f) // 放圆点槽
val timeY = (topHeight - time.height) / 2 // 计算时间槽y轴位置
time.place(dot.width + contentStartOffset.roundToPx(), timeY) // 放时间槽
content.place(dot.width + contentStartOffset.roundToPx(), topHeight) // 放内容槽,x和时间槽一样,形成左对齐效果。
line.place(
dot.width / 2, // x在圆中间
dotY + dot.height // y从圆的最下面开始
)
}

至此我们就有了一个时间轴节点组件,马上在LazyColumn或者Column中试试效果吧!


四、完善效果


如果你刚才测试了效果,你会发现,在列表中最后一个节点,也有虚线,并且长度超出了列表,而最后一个节点,不应该显示虚线才对。所以我们要来完善一下效果。


@Composable
fun TimelineItem(
modifier: Modifier = Modifier,
dot: @Composable () -> Unit = ...,
line: @Composable () -> Unit = ...,
time: @Composable () -> Unit,
content: @Composable () -> Unit,
contentStartOffset: Dp = 8.dp,
isEnd: Boolean = false, // 添加是否为最后一个节点的参数
)
...
//在最后根据是否是最后一个节点来设置是否放置线槽内容。
if (!isEnd){
line.place(
dot.width / 2,
dotY + dot.height
)
}

而在调用时,只要简单的根据是否位于列表最后就可以了,调用示例:


LazyColumn(
Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
itemsIndexed(list.itemSnapshotList) { index, item ->
item?.let {
TimelineItem(
modifier = Modifier.fillMaxWidth(),
time = {
Text(text = it.time)
},
content = {
Column {
// 最好在Column最上面和最下面也添加个spacer来间隔开
}
},
isEnd = index == list.itemCount - 1
)
}
}
}

最后


至此本文就结束啦,由于内容比较简单,且所以的代码均有表现,为了不占篇幅,就不再粘贴完整代码内容了。如果本文有错误之处或者可以改进的地方,请大家一定回复指正;如果文章的内容也对你有帮忙,也请回复鼓励我,谢谢大家!


作者:拎壶冲
来源:juejin.cn/post/7283719464906244151
收起阅读 »

为什么需要弱引用 wp?

Android 中的智能指针是通过引用计数的方式方式来实现内存自动回收的。在大多数情况下我们使用强指针 sp 就好了,那么弱指针 wp 的存在意义有是什么呢? 从使用的角度来说,wp 扮演的是一个指针缓存的角色,想用时候可以用,但不想因此阻止资源被释放。其实,...
继续阅读 »

Android 中的智能指针是通过引用计数的方式方式来实现内存自动回收的。在大多数情况下我们使用强指针 sp 就好了,那么弱指针 wp 的存在意义有是什么呢?


从使用的角度来说,wp 扮演的是一个指针缓存的角色,想用时候可以用,但不想因此阻止资源被释放。其实,简单的裸指针也能很好地完成指针缓存的功能,其功能性并不是 wp 存在的必要条件。


wp 存在的核心原因是:解决循环引用导致的死锁问题


1. 循环引用导致的死锁问题


接下来,我们就通过一个简单的示例程序来演示循环引用导致的死锁问题


首先有两个类,其内部都有一个智能指针指向对方,形成循环引用:


Class A : public RefBase
{
public:
A()
{

}

virtual ~A()
{

}

void setB(sp& b)
{
mB = b;
}

private:
sp mB;
}

Class B : public RefBase
{
public:
B()
{

}

virtual ~B()
{

}

void setA(sp& a)
{
mA = a;
}

private:
sp
mA;
}

整体结构如下图所示:



接下来看 main 函数:


int main(int argc, char** argv)
{
//初始化两个指针
A *a = new A();
B *b = new B();

// 触发构造函数调用 spA 内部强弱计数值 (1,1)
sp
spA = a;
// 触发构造函数调用 spB 内部强弱计数值 (1,1)
sp spB = b;

//setB 内部有赋值操作 mB = b,触发等于操作符函数重载
//spB 内部强弱计数值 (2,2)
spA->setB(spB);

//setA 内部有赋值操作 mA = a,触发等于操作符函数重载
//spA 内部强弱计数值 (2,2)
spB->setA(spA);

return 0;
// spA 析构 内部强弱计数值 (1,1),内存无法回收
// spB 析构 内部强弱计数值 (1,1),内存无法回收
}

//等于操作符函数重载
template<typename T>
sp& sp::operator =(const sp& other) {
// Force m_ptr to be read twice, to heuristically check for data races.
T* oldPtr(*const_castvolatile*>(&m_ptr));
T* otherPtr(other.m_ptr);
// 强弱引用计数分别加 1
if (otherPtr) otherPtr->incStrong(this);
if (oldPtr) oldPtr->decStrong(this);
if (oldPtr != *const_castvolatile*>(&m_ptr)) sp_report_race();
m_ptr = otherPtr;
return *this;
}

从这个示例可以看出,在循环引用的情况下,指针指针在作用域结束后,强弱引用计数值无法变回 (0,0),内存无法回收,导致内存泄漏;


2. 解决方案


只需要把其中一个智能指针改为弱引用即可解决上面的问题:


Class A : public RefBase
{
public:
A()
{

}

virtual ~A()
{

}

void setB(sp& b)
{
mB = b;
}

private:
sp mB;
}

Class B : public RefBase
{
public:
B()
{

}

virtual ~B()
{

}

//函数参数也要变一下
void setA(sp
& a)
{
//触发另外的等于操作符函数重载
mA = a;
}

private:
//这里改成 wp 弱引用
wp
mA;
}

主函数稍作修改:


int main(int argc, char** argv)
{
//初始化两个指针
A *a = new A();
B *b = new B();

// 触发构造函数调用 spA 内部强弱计数值 (1,1)
sp
spA = a;
// 触发构造函数调用 spB 内部强弱计数值 (1,1)
sp spB = b;

//setB 内部有赋值操作 mB = b,触发等于操作符函数重载
//spB 内部强弱计数值 (2,2)
spA->setB(spB);

//setA 内部有赋值操作 mA = a,触发等于操作符函数重载
//spA 内部强弱计数值 (1,2)
spB->setA(spA);

return 0;
// spB 析构 内部强弱计数值 (1,1),内存无法回收
// spA 析构 内部强弱计数值 (0,1),强引用为 0 ,回收 sp
spA 内部的目标对象 A,
// 随着 A 的析构, A 的成员变量 mB 也开始析构, 目标对象 B 强弱引用计数减 1,内部强弱计数值变为 (0,0),回收目标对象 B 以及内部管理对象,B 对象的内存回收工作完成,接着触发 B 对象的成员 mA 的析构函数
// mA 执行析构函数,弱引用计数减 1,内部强弱计数值变为 (0,0),回收 A 对象内部对应的管理对象,A 对象的内存回收工作完成
}

//等于操作符函数重载
template<typename T>
wp& wp::operator = (const sp& other)
{
weakref_type* newRefs =
other != nullptr ? other->createWeak(this) : nullptr; //增加弱引用计数
T* otherPtr(other.m_ptr);
if (m_ptr) m_refs->decWeak(this);
m_ptr = otherPtr;
m_refs = newRefs;
return *this;
}

当程序的一个引用修改为 wp 时,main 函数结束时:



这样就解决了上一节中提出的内存泄漏问题!


3. 总结



  • wp 的基本作用:wp 扮演了指针缓存的角色,想用时候可以用,但不想因此阻止资源被释放

  • wp 存在的根本原因:解决循环引用导致的死锁问题

作者:阿豪讲Framework
来源:juejin.cn/post/7283376651906646035

收起阅读 »

解决Android13上读取本地文件权限错误记录

Android13 WRITE_EXTERNAL_STORAGE 权限失效 1. 需求及问题 需求是读取sdcard上txt文件 Android13(targetSDK = 33)上取消了WRITE_EXTERNAL_STORAGE,READ_EXTERN...
继续阅读 »

Android13 WRITE_EXTERNAL_STORAGE 权限失效


1. 需求及问题



  1. 需求是读取sdcard上txt文件

  2. Android13(targetSDK = 33)上取消了WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGE权限。

  3. 取而代之的是READ_MEDIA_VIDEOREAD_MEDIA_AUDIOREAD_MEDIA_IMAGES权限

  4. 测试发现,即便动态申请上面三个权限,仍旧无法读取本地txt文件


image.png


2. 解决方案



  1. AndroidManifest.xml中增加


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LocationDemo"
tools:targetApi="31">


<activity
android:name=".MainActivity"
android:exported="true">

<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>


  1. Activity中新增代码


// 方案一:跳转到系统文件访问页面,手动赋予
Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
intent.setData(Uri.parse("package:" + this.getPackageName()));
startActivity(intent);

Screenshot_20230927-131444[1].png


// 方案二:跳转到系统所有需要文件访问页面,选择你的APP,手动赋予权限
Intent intent = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
startActivity(intent);

image.png


作者:OpenGL
来源:juejin.cn/post/7283152332622610492
收起阅读 »

父母在家千万注意别打开“共享屏幕”,银行卡里的钱一秒被转走......

打开屏幕共享,差点直接被转账 今天和爸妈聊天端午回家的事情,突然说到最近AI诈骗的事情,千叮咛万嘱咐说要对方说方言才行,让他们充分了解一下现在骗子诈骗的手段,顺便也找了一下骗子还有什么其他的手段,打算一起和他们科普一下,结果就发现下面这一则新闻: 在辽宁大连务...
继续阅读 »

打开屏幕共享,差点直接被转账


今天和爸妈聊天端午回家的事情,突然说到最近AI诈骗的事情,千叮咛万嘱咐说要对方说方言才行,让他们充分了解一下现在骗子诈骗的手段,顺便也找了一下骗子还有什么其他的手段,打算一起和他们科普一下,结果就发现下面这一则新闻:


在辽宁大连务工的耿女士接到一名自称“大连市公安局民警”的电话,称其涉嫌广州一起诈骗案件,让她跟广州警方对接。耿女士在加上所谓的“广州警官”的微信后,这位“警官”便给耿女士发了“通缉令”,并要求耿女士配合调查,否则将给予“强制措施”。随后,对方与耿女士视频,称因办案需要,要求耿女士提供“保证金”,并将所有存款都集中到一张银行卡上,再把钱转到“安全账户”。


图片


期间,通过 “屏幕共享”,对方掌握了耿女士银行卡的账号和密码。耿女士先后跑到多家银行,取出现金,将钱全部存到了一张银行卡上。正当她打算按照对方指示,进行下一步转账时,被民警及时赶到劝阻。在得知耿女士泄露了银行卡号和密码后,银行工作人员立即帮助耿女士修改了密码,幸运的是,银行卡的近6万元钱没有受到损失。


就这手段,我家里的老人根本无法预防,除非把手机从他们手里拿掉,与世隔绝还差不多,所以还是做APP的各大厂商努力一下吧!


希望各大厂商都能看看下面这个防劫持SDK,让出门在外打工的我们安心一点。


防劫持SDK


一、简介


防劫持SDK是具备防劫持兼防截屏功能的SDK,可有效防范恶意程序对应用进行界面劫持与截屏的恶意行为。


二、iOS版本


2.1 环境要求


条目说明
兼容平台iOS 8.0+
开发环境XCode 4.0 +
CPU架构armv7, arm64, i386, x86_64
SDK依赖libz, libresolv, libc++

2.2 SDK接入


2.2.1 DxAntiHijack获取

官网下载SDK获取,下面是SDK的目录结构


1.png


DXhijack_xxx_xxx_xxx_debug.zip 防劫持debug 授权集成库 DXhijack_xxx_xxx_xxx_release.zip 防劫持release 授权集成库




  • 解压DXhijack_xxx_xxx_xxx_xxx.zip 文件,得到以下文件




    • DXhijack 文件夹



      • DXhijack.a 已授权静态库

      • Header/DXhijack.h 头文件

      • dx_auth_license.description 授权描述文件

      • DXhijackiOS.framework 已授权framework 集成库






2.2.2 将SDK接入XCode

2.2.2.1 导入静态库及头文件

将SDK目录(包含静态库及其头文件)直接拖入工程目录中,或者右击总文件夹添加文件。 或者 将DXhijackiOS.framework 拖进framework存放目录


2.2.2.2 添加其他依赖库

在项目中添加 libc++.tbd 库,选择Target -> Build Phases,在Link Binary With Libraries里点击加号,添加libc++.tbd


2.2.2.3 添加Linking配置

在项目中添加Linking配置,选择Target -> Build Settings,在Other Linker Flags里添加-ObjC配置


2.3 DxAntiHijack使用


2.3.1 方法及参数说明

@interface DXhijack : NSObject

+(void)addFuzzy; //后台模糊效果
+(void)removeFuzzy;//后台移除模糊效果
@end

2.3.2 使用示例

在对应的AppDelegate.m 文件中头部插入


#import "DXhijack.h"

//在AppDelegate.m 文件中applicationWillResignActive 方法调用增加
- (void)applicationWillResignActive:(UIApplication *)application {
[DXhijack addFuzzy];
}

//在AppDelegate.m 文件中applicationDidBecomeActive 方法调用移除
- (void)applicationDidBecomeActive:(UIApplication *)application {
[DXhijack removeFuzzy];
}


三、Android版本


3.1 环境要求


条目说明
开发目标Android 4.0+
开发环境Android Studio 3.0.1 或者 Eclipse + ADT
CPU架构ARM 或者 x86
SDK三方依赖

3.2 SDK接入


3.2.1 SDK获取


  1. 访问官网,注册账号

  2. 登录控制台,访问“全流程端防控->安全键盘SDK”模块

  3. 新增App,填写相关信息

  4. 下载对应平台SDK


3.2.2 SDK文件结构



  • SDK目录结构 android-dx-hijack-sdk.png



    • dx-anti-hijack-${version}.jar Android jar包

    • armeabiarmeabi-v7aarm64-v8ax86 4个abi平台的动态库文件




3.2.3 Android Studio 集成

点击下载Demo


3.2.3.1 Android Studio导入jar, so

把dx-anti-hijack-x.x.x.jar, so文件放到相应模块的libs目录下


android-dx-hijack-as.png



  • 在该Module的build.gradle中如下配置:


 android{
sourceSets {
main {
jniLibs.srcDirs = ['libs']
}
}

repositories{
flatDir{
dirs 'libs'
}
}
}


dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
}



3.2.3.2 权限声明

Android 5.0(不包含5.0)以下需要在项目AndroidManifest.xml文件中添加下列权限配置:


<uses-permission android:name="android.permission.GET_TASKS"/>

3.2.3.3 混淆配置

-dontwarn *.com.dingxiang.mobile.**
-dontwarn *.com.mobile.strenc.**
-keep class com.security.inner.**{*;}
-keep class *.com.dingxiang.mobile.**{*;}
-keep class *.com.mobile.strenc.**{*;}
-keep class com.dingxiang.mobile.antihijack.** {*;}

3.3 DxAntiHijack 类使用


3.3.1 方法及参数说明

3.3.1.1 初始化


建议在Application的onCreate下調用


/**
* 使用API前必須先初始化
* @param context
*/

public static void init(Context context);

3.3.1.2 反截屏功能


/**
* 反截屏功能
* @param activity
*/

public static void DGCAntiHijack.antiScreen(Activity activity);

/**
* 反截屏功能
* @param dialog
*/

public static void DGCAntiHijack.antiScreen(Dialog dialog);

3.3.1.3 反劫持检测


/**
* 调用防劫持检测,通常现在activity的onPause和onStop调用
* @return 是否存在被劫持风险
*/

public static boolean DGCAntiHijack.antiHijacking();

3.3.2 使用示例

//使用反劫持方法
@Override
protected void onPause() {
boolean safe = DXAntiHijack.antiHijacking();
if(!safe){
Toast.makeText(getApplicationContext(), "App has entered the background", Toast.LENGTH_LONG).show();
}
super.onPause();
}

@Override
protected void onStop() {
boolean safe = DXAntiHijack.antiHijacking();
if(!safe){
Toast.makeText(getApplicationContext(), "App has entered the background", Toast.LENGTH_LONG).show();
}
super.onStop();
}



//使用反截屏方法
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
DXAntiHijack.antiScreen(MainActivity.this);
}

以上。


结语


这种事情层出不穷,真的不是吾等普通民众能解决的,最好有从上至下的政策让相应的厂商(尤其是银行和会议类的APP)统一做处理,这样我们在外打工的人才能安心呀。


作者:昀和
来源:juejin.cn/post/7242145254057312311
收起阅读 »

Kotlin Flow入门

Flow作为Android开发中的重要的作用。尤其在Jetpack Compose里左一个collect,右一个collect。不交接Flow而开发Android是寸步难行。作为一个入门文章,如果你还不是很了解Flow的话,本文可以带你更进一步的了解Flow。...
继续阅读 »

Flow作为Android开发中的重要的作用。尤其在Jetpack Compose里左一个collect,右一个collect。不交接Flow而开发Android是寸步难行。作为一个入门文章,如果你还不是很了解Flow的话,本文可以带你更进一步的了解Flow。


Flow是一个异步数据流,它会发出数据给收集者,最终带或者不带异常的完成任务。下面我们通过例子来学习。


假设我们正在下载一幅图片。在下载的时候,还要把下载的百分比作为值发出来,比如:1%,2%,3%,等。收集者(collector)会接收到这些值并在界面上以合适的方式显示出来。但是如果出现网络问题,任务也会因此终止。


现在我们来看一下Flow里的几个API:



  • 流构建器(Flow builder)

  • 操作符(Operator)

  • 收集器(Collector)


流构建器


简单来说,它会执行一个任务并把值发出来,有时也会只发出值而不会执行什么任务。比如简单的发出一些数字值。你可以把流构建器当做一个发言人。这个发言人会思考(做任务)和说(发出值).


操作符


操作符可以帮助转化数据。


我们可以把操作符当做是一个翻译。一个发言人说了法语,但是听众(收集器)只能听懂英语。这就需要一个翻译来帮忙了。它可以把法语都翻译成英语让听众理解。


当然,操作符可以做的远不止这些。以上的例子只是帮助理解。


收集器


Flow发出的值经过操作符的处理之后会被收集器收集。


收集器可以当做是收听者。实际上收集器也是一种操作符,它有时被称作终端操作符


第一个例子


flow { 
(0..10).forEach {
emit(it)
}
}.map {
it * it
}.collect {
Log.d(TAG, it.toString())
}

flow {}->流构建器
map {}->操作符
collect {}->收集器

我们来过一下上面的代码:



  • 首先,流构建器会发出从0到10的值

  • 之后,一个map操作符会把每个值计算(it * it)

  • 之后,收集器收集这些发出来的值并打印出来:0,1,4,9,16,25,36,49,64,81,100.


注意:collect方法把流构建器和收集器连到了一起,这个方法调用之后流就开始执行了。


流构建器的不同类型


流构建器有四种:



  1. flowOf():从一个给定的数据集合生成流

  2. asFlow(): 一个扩展方法,可以把某个类型转化成流

  3. flow{}: 我们例子中使用的方法

  4. channelFlow{}:使用构造器自带的send方法发送的元素构建流


例如:


flowOf()


flowOf(4, 2, 5, 1, 7) 
.collect {
Log.d(TAG, it.toString())
}

asFlow()


(1..5).asFlow()
.collect {
Log.d(TAG, it.toString())
}

flow{}


flow {
(0..10).forEach {
emit(it)
}
}
.collect {
Log.d(TAG, it.toString())
}

channelFlow{}


channelFlow {
(0..10).forEach {
send(it)
}
}
.collect {
Log.d(TAG, it.toString())
}

flowOn操作符


flowOn这个操作符可以控制flow任务执行的线程的类型。在Android里一般是在一个后台线程执行任务,之后在界面上更新结果。


下面的例子里加了一个500毫秒的延迟来模拟实际任务。


val flow = flow {
// Run on Background Thread (Dispatchers.Default)
(0..10).forEach {
// emit items with 500 milliseconds delay
delay(500)
emit(it)
}
}
.flowOn(Dispatchers.Default)

CoroutineScope(Dispatchers.Main).launch {
flow.collect {
// Run on Main Thread (Dispatchers.Main)
Log.d(TAG, it.toString())
}
}

本例,流的任务就会在Dispatchers.Default这个“线程”里执行。接下来就是要在UI线程里更新UI了。为了做到这一点就需要在UI线程里collect


flowOn操作符就是用来控制任务执行的线程的。它的作用和RxJava的subscribeOn类似。


Dispatchers主要有这些类型:IODefaultMain。flowOn和CoroutineScope都可以使用Dispatchers来执行任务执行的“线程”(暂且这么理解)。


使用流构造器


我们通过几个例子学习。


移动文件


这里我们用流构造器新建一个流,让流任务在后台线程执行。完成后在UI线程显示状态。


val moveFileflow = flow {
// move file on background thread
FileUtils.move(source, destination)
emit("Done")
}
.flowOn(Dispatchers.IO)

CoroutineScope(Dispatchers.Main).launch {
moveFileflow.collect {
// when it is done
}
}

下载图片


这个例子构造一个流在后台线程下载图片,并且不断的在UI线程更新下载的百分比。


val downloadImageflow = flow {
// start downloading
// send progress
emit(10)
// downloading...
// ......
// send progress
emit(75)
// downloading...
// ......
// send progress
emit(100)
}
.flowOn(Dispatchers.IO)

CoroutineScope(Dispatchers.Main).launch {
downloadImageflow.collect {
// we will get the progress here
}
}

现在你对kotlin的流也有初步的了解了,在项目中可以使用简单的流来处理异步任务。


什么是终端操作符


上文已经提到过collect()方法是一个终端操作符。所谓的终端操作符就是让流跑起来的挂起方法(suspend function)。在以上的例子中,流构造器构造出来的流是不动的,让这个流动起来的操作符就是终端操作符。比如collect


还有:



  • 转化为各种集合的,toList, toSet

  • 获取第一个first,与确保流发射单个值的操作符single

  • 使用reduce, fold这类的把流的值规约到单个值的操作符。


比如:


val sum = (1..5).asFlow()
.map { it * it } // 数字 1 至 5 的平方
.reduce { a, b -> a + b } // 求和(末端操作符)
println(sum)

冷热流


前面的例子里的流都是冷流。我们来对比一下流的不同:


冷流热流
收集器调用的时候开始发出值没有收集器也会发出值
不存储数据可以存储数据
不支持多个收集器可以支持多个收集器

冷流,如果带上了多个收集器,流会每次遇到一个收集器就从头把完整的数据发送一次。


热流遇到多个收集器的时候,流会一直发出数据,收集器开始收集数据的时候遇到的是什么数据就收集什么数据。热流的多个收集器共享一份数据。


冷流是推模式,热流是拉模式。


下面看几个例子:


冷流实例


fun getNumbersColdFlow(): ColdFlow<Int> {
return someColdflow {
(1..5).forEach {
delay(1000)
emit(it)
}
}
}

开始收集


val numbersColdFlow = getNumbersColdFlow()

numbersColdFlow
.collect {
println("1st Collector: $it")
}

delay(2500)

numbersColdFlow
.collect {
println("2nd Collector: $it")
}

输出:


1st Collector: 1
1st Collector: 2
1st Collector: 3
1st Collector: 4
1st Collector: 5

2nd Collector: 1
2nd Collector: 2
2nd Collector: 3
2nd Collector: 4
2nd Collector: 5

两个收集器都从头获取到流的数据,在每次收集的时候都相当于遇到了一个全新的流。


热流实例。本例会设置一个热流每隔一秒发出一个1到5的数值。


fun getNumbersHotFlow(): HotFlow<Int> {
return someHotflow {
(1..5).forEach {
delay(1000)
emit(it)
}
}
}

现在开始收集:


val numbersHotFlow = getNumbersHotFlow()

numbersHotFlow
.collect {
println("1st Collector: $it")
}

delay(2500)

numbersHotFlow
.collect {
println("2nd Collector: $it")
}

输出:


1st Collector: 1
1st Collector: 2
1st Collector: 3
1st Collector: 4
1st Collector: 5

2nd Collector: 3
2nd Collector: 4
2nd Collector: 5

StateFlow


在Android开发中,热流的一个很重要的应用就是StateFlow


StateFlow是一种特殊的热流,它可以允许多个订阅者。如果你使用了jetpack compose来开发app的话,StateFlow可以简单而高效的在app的不同地方享状态(state)。因为热流只发送当前的状态(而不像冷流那样从开始发送值)。


要新建一个StateFlow,可以使用MutableStateFlow,然后给它一个初始值:


val count = MutableStateFlow(0)

在这里新建了一个叫做count的StateFlow,初始值为0。要更新它的值可以使用update方法,或者value属性:


this.count.update { v -> v + 1 }
this.count.value = 10

这时,订阅了count状态的订阅者就可以收到更新之后的值了。要订阅可以这样:


count.collect {
//...
}

在冷热流之外还有两种流:回调流和通道流。这个后面会详细讲到。


SharedFlow


SharedFlow也是一种热流,主要用于事件流。它会对所有的活的收集器发送事件。不同的消费者可以在同一时间收到同一个事件。


可以使用MutableSharedFlow()方法来创建一个SharedFlow对象。可以通过replay参数指明多少个已经发送的事件可以再发送给新的收集器,默认的是0。也即是在默认情况下,收集器只会接收到开始收集之后发送过来的事件。


这个时候可以来一个例子了:


class TickHandler(
    private val externalScope: CoroutineScope,
    private val tickIntervalMs: Long = 5000
) {
    // Backing property to avoid flow emissions from other classes
    private val _tickFlow = MutableSharedFlow<Unit>(replay = 0) // 1
    val tickFlow: SharedFlow<Event<String>> = _tickFlow // 2

    init {
        externalScope.launch {
            while(true) {
                _tickFlow.emit(Unit) // 3
                delay(tickIntervalMs)
            }
        }
    }
}

class NewsRepository(
    ...,
    private val tickHandler: TickHandler, // 4
    private val externalScope: CoroutineScope
) {
    init {
        externalScope.launch {
            // Listen for tick updates
            tickHandler.tickFlow.collect { // 5
                refreshLatestNews()
            }
        }
    }

    suspend fun refreshLatestNews() { ... }
    ...
}

示例解析:



  1. MutableSharedFlow声明了一个变量_tickFlow

  2. 定义了属性tickFlow

  3. 在初始化的时候使用SharedFlow成员变量_tickFlow每隔一段时间发送一个空事件

  4. NewsRepository类里声明成员变量tickHandler

  5. NewsRepository初始化之后开始收集事件,并在收集到事件之后调用refreshLatestNews方法来更新新闻。


看完这个例子再结合上面的介绍就会更加深入的了解SharedFlow了。


注意



  • 这SharedFlow是用于事件流处理的,可不是用来维护状态(state)的。

  • SharedFlow的另外一个重要的参数是extraBufferCapacity,它决定了流要在缓存里保留多少个发送过的事件。缓存满了之后会把缓存里面的一个值清理掉,并放入新的值。

  • 要处理缓存溢出的问题可以给onBufferOverflow指定一个方法。比如当缓存满了之后,并遇到新的事件的时候清理掉最旧的值或者暂停发送新事件一直到缓存有空余。

  • 可以使用tryEmit方法来检测是否存在一个活的收集器。这样可以避免无效的事件发送。


热流的坑


如果在同一个协成里订阅了多个热流,只有第一个才会被收集。其他的永远不会得到数据。


所以,要在同一个协成里订阅多个热流可以使用combine或者zip操作符把这些热流都合成到同一个流里。或者分别在每个协程订阅一个热流。


例如:


coroutineScope.launch {
hotFlow1.collect { value ->
// 处理收到的数据
}
hotFlow2.collect { value ->
// 永远不会执行到
}
}

在本例中,第二个collect不会收到数据。因为第一个collect会运行一个无限循环。


背压 (Backpressure)


背压,顾名思义,当消费者消费的速度没有生产者生产的速度快了。在Flow遇到这个情况的时候,生产者就会挂起直到消费者可以消费更多的数据。


runBlocking {
getFastFlow().collect { value ->
delay(1000) // simulate a slow collector
process(value)
}
}

在这个例子中,getFastFlow()会生成数据的速度比process(value)的速度快。因为collect是一个挂起函数,在process(value)数据处理不过来的时候getFastFlow()就会自动挂起。这样就防止了没有处理的数据的堆积。


使用缓存处理背压


有的时候,即使消费者处理速度已经慢于生产者产生数据的速度的时候,你还是想让生产者继续生产数据。这时就可以引入缓存了。Flow可以使用buffer操作符。如:


runBlocking {
getFastFlow().buffer().collect { value -> process(value) }
}

这个例子里使用了buffer操作符,这样在process(value)还在处理旧数据的时候getFastFlow()可以接着生产新的数据。


今日份先更新到这了。to be continued...


作者:小红星闪啊闪
来源:juejin.cn/post/7271153372793946168
收起阅读 »

环信FCM推送详细步骤

集成FCM推推送
准备的地址有 :https://firebase.google.com
1.firebase官网选择我们自己创建的项目

2.点到这个设置按键

3.我们打开到项目设置->常规 拉到最下面有一个“您的应用” 点击下载json文件,json文件的使用是客户端放在安卓项目的app目录下

4.首先环信需要的信息有 项目设置中-> 服务账号 生成新的私钥 生成的文件我们要上传到环信的管理后台证书部分(V1)

5.点击上传证书会选择你下载的文件,注意!! 名称是由你设置的项目名称的json文件 并不是 google-services.json
6.项目名称 是你的发送者ID 这个id 我们在firebase官网中的项目设置-〉常规 -〉您的项目->的项目编号就是您的SenderID 填写到环信官网即可 另外客户端的 google-services.json 这个文件 打开后 project number 也是SenderID

7.将我们下载好的 google-services.json 文件放到app的目录下 (文件获取可以反回步骤3 查看)

8.打开build的根目录添加 :
buildscript {
dependencies {
// classpath 'com.android.tools.build:gradle:7.2.2'
classpath 'com.google.gms:google-services:4.3.8'
}
}

9.build.gradle.app部分添加:
implementation platform('com.google.firebase:firebase-bom:28.4.1')
implementation 'com.google.firebase:firebase-messaging'

10.对应好appkey 以及我们的客户端初始化fcm的senderID

11.在登陆前 初始化以后 添加以下代码:
EMPushHelper.getInstance().setPushListener(new PushListener() {
@Override
public void onError(EMPushType pushType, long errorCode) {
EMLog.e("PushClient", "Push client occur a error: " + pushType + " - " + errorCode);
}

@Override
public boolean isSupportPush(EMPushType pushType, EMPushConfig pushConfig) {
if(pushType==EMPushType.FCM)
{
return GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(MainActivity.this)
== ConnectionResult.SUCCESS;
}
return super.isSupportPush(pushType, pushConfig);
}
});

12.登陆成功后的第一个页面添加 :
if(GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(NewAcitivty.this) != ConnectionResult.SUCCESS) {
return;
}
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(new OnCompleteListener() {
@Override
public void onComplete(@NonNull Task task) {
if (!task.isSuccessful()) {
EMLog.d("PushClient", "Fetching FCM registration token failed:"+task.getException());
return;
}
// 获取新的 FCM 注册 token
String token = task.getResult();
EMClient.getInstance().sendFCMTokenToServer(token);
}
});

13.清单文件注册sevices 主要是为了继承FCM的服务 必要操作!

添加代码: 重写onMessageReceived
收到消息后 就在这个方法中 自己调用 本地通知 因为fCM的推送只有唤醒
public class FireBaseservice extends FirebaseMessagingService {
@Override
public void onMessageReceived(@NonNull RemoteMessage message) {
super.onMessageReceived(message);
if (message.getData().size() > 0) {
String alter = message.getData().get("alter");
Log.d("", "onMessageReceived: " + alter);
}

}
@Override
public void onNewToken(@NonNull String token) {
Log.i("MessagingService", "onNewToken: " + token);
// 若要对该应用实例发送消息或管理服务端的应用订阅,将 FCM 注册 token 发送至你的应用服务器。
if(EMClient.getInstance().isSdkInited()) {
EMClient.getInstance().sendFCMTokenToServer(token);
}
}
}
14.准备测试 这个时候我们就要验证我们的成果了 首先要看自己登录到环信后 是否有绑定证书 借用环信的即时推送功能查看是否有绑定证书
这个时候看到登录了证书还是没有绑定上 那肯定是客户端出现问题了

15.检查错误 看到提示了com.xxxx.play 安装 这个是因为 你的设备没有打开 VPN 或者VPN不稳定,所以你首先要确定VPN打开并且 稳定 然后我们在重新登录测试

16.这个时候我们在借用即时推送查看 看看有没有绑定到环信 看到该字样就证明你的证书已经绑定上了 直接杀掉进程离线 测试离线推送,(一定要在清单文件注册的谷歌服务中 重新的onMessageReceived 中写入本地通知展示 不然fcm的推送只有唤醒)

安卓开发中如何实现一个定时任务

定时任务方式优点缺点使用场景所用的API普通线程sleep的方式简单易用,可用于一般的轮询Polling不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Thread.sleep(long)Timer定时器简单易用,可以设置固定周期或者...
继续阅读 »

定时任务方式优点缺点使用场景所用的API
普通线程sleep的方式简单易用,可用于一般的轮询Polling不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Thread.sleep(long)
Timer定时器简单易用,可以设置固定周期或者延迟执行的任务不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Timer.schedule(TimerTask,long)
ScheduledExecutorService灵活强大,可以设置固定周期或者延迟执行的任务,并支持多线程并发不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间且需要多线程并发的定时任务Executors.newScheduledThreadPool(int).schedule(Runnable,long,TimeUnit)
Handler中的postDelayed方法简单易用,可以设置延迟执行的任务,并与UI线程交互不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间且需要与UI线程交互的定时任务Handler.postDelayed(Runnable,long)
Service + AlarmManger + BroadcastReceiver可靠稳定,可以设置精确或者不精确的闹钟,并在后台长期运行需要声明相关权限,并受系统时间影响需要在App外部执行长期且对时间敏感的定时任务AlarmManager.set(int,PendingIntent), BroadcastReceiver.onReceive(Context,Intent), Service.onStartCommand(Intent,int,int)
WorkManager可靠稳定,不受系统时间影响,并可以设置多种约束条件来执行任务需要添加依赖,并不能保证准时执行需要在App外部执行长期且对时间不敏感且需要满足特定条件才能执行的定时任务WorkManager.enqueue(WorkRequest), Worker.doWork()
RxJava简洁、灵活、支持多线程、支持背压、支持链式操作学习曲线较高、内存占用较大需要处理复杂的异步逻辑或数据流io.reactivex:rxjava:2.2.21
CountDownTimer简单易用、不需要额外的线程或handler不支持取消或重置倒计时、精度受系统时间影响需要实现简单的倒计时功能android.os.CountDownTimer
协程+Flow语法简洁、支持协程作用域管理生命周期、支持流式操作和背压需要引入额外的依赖库、需要熟悉协程和Flow的概念和用法需要处理异步数据流或响应式编程kotlinx-coroutines-core:1.5.0
使用downTo关键字和Flow实现一个定时任务1、可以使用简洁的语法创建一个倒数的范围 2 、可以使用Flow异步地发射和收集倒数的值3、可以使用onEach等操作符对倒数的值进行处理或转换1、需要注意倒数的范围是否包含0,否则可能会出现偏差 2、需要注意倒数的间隔是否与delay函数的参数一致,否则可能会出现不准确 3、需要注意取消或停止Flow的时机,否则可能会出现内存泄漏或资源浪费1、适合于需要实现简单的倒计时功能,例如显示剩余时间或进度 2、适合于需要在倒计时过程中执行一些额外的操作,例如播放声音或更新UI 3、适合于需要在倒计时结束后执行一些额外的操作,例如跳转页面或弹出对话框implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0"
Kotlin 内联函数的协程和 Flow 实现很容易离开主线程,样板代码最少,协程完全活用了 Kotlin 语言的能力,包括 suspend 方法。可以处理大量的异步数据,而不会阻塞主线程。可能会导致内存泄漏和性能问题。处理 I/O 阻塞型操作,而不是计算密集型操作。kotlinx.coroutines 和 kotlinx.coroutines.flow

安卓开发中如何实现一个定时任务


在安卓开发中,我们经常会遇到需要定时执行某些任务的需求,比如轮询服务器数据、更新UI界面、发送通知等等。那么,我们该如何实现一个定时任务呢?本文将介绍安卓开发中实现定时任务的五种方式,并比较它们的优缺点,以及适用场景。


1. 普通线程sleep的方式


这种方式是最简单也最直观的一种实现方法,就是在一个普通线程中使用sleep方法来延迟执行某个任务。例如:


// 创建一个普通线程
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// 循环执行
while (true) {
// 执行某个任务
doSomething();
// 延迟10秒
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
// 启动线程
thread.start();


这种方式的优点是简单易懂,不需要借助其他类或组件。但是它也有很多缺点:



  • sleep方法会阻塞当前线程,导致资源浪费和性能下降。

  • sleep方法不准确,它只能保证在指定时间后醒来,但不能保证立即执行。

  • sleep方法受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • sleep方法不可靠,如果线程被异常终止或者进入休眠状态,会导致计时中断。


因此,这种方式只适合一般的轮询Polling场景。


2. Timer定时器


这种方式是使用Java API里提供的Timer类来实现定时任务。Timer类可以创建一个后台线程,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个Timer对象
Timer timer = new Timer();
// 创建一个TimerTask对象
TimerTask task = new TimerTask() {
@Override
public void run() {
// 执行某个任务
doSomething();
}
};
// 设置在5秒后开始执行,并且每隔10秒重复执行一次
timer.schedule(task, 5000, 10000);


这种方式相比第一种方式有以下优点:



  • Timer类内部使用wait和notify方法来控制线程的执行和休眠,不会浪费资源和性能。

  • Timer类可以设置固定频率或者固定延迟来执行任务,更加灵活和准确。

  • Timer类可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • Timer类只创建了一个后台线程来执行所有的任务,如果其中一个任务耗时过长或者出现异常,则会影响其他任务的执行。

  • Timer类受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • Timer类不可靠,如果进程被杀死或者进入休眠状态,会导致计时中断。


因此,这种方式适合一些不太重要的定时任务。


3. ScheduledExecutorService


这种方式是使用Java并发包里提供的ScheduledExecutorService接口来实现定时任务。ScheduledExecutorService接口可以创建一个线程池,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个ScheduledExecutorService对象
ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
// 创建一个Runnable对象
Runnable task = new Runnable() {
@Override
public void run() {
// 执行某个任务
doSomething();
}
};
// 设置在5秒后开始执行,并且每隔10秒重复执行一次
service.scheduleAtFixedRate(task, 5, 10, TimeUnit.SECONDS);


这种方式相比第二种方式有以下优点:



  • ScheduledExecutorService接口可以创建多个线程来执行多个任务,避免了单线程的弊端。

  • ScheduledExecutorService接口可以设置固定频率或者固定延迟来执行任务,更加灵活和准确。

  • ScheduledExecutorService接口可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • ScheduledExecutorService接口受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • ScheduledExecutorService接口不可靠,如果进程被杀死或者进入休眠状态,会导致计时中断。


因此,这种方式适合一些需要多线程并发执行的定时任务。


4. Handler中的postDelayed方法


这种方式是使用Android API里提供的Handler类来实现定时任务。Handler类可以在主线程或者子线程中发送和处理消息,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个Handler对象
Handler handler = new Handler();
// 创建一个Runnable对象
Runnable task = new Runnable() {
@Override
public void run() {
// 执行某个任务
doSomething();
// 延迟10秒后再次执行该任务
handler.postDelayed(this, 10000);
}
};
// 延迟5秒后开始执行该任务
handler.postDelayed(task, 5000);


这种方式相比第三种方式有以下优点:



  • Handler类不受系统时间影响,它使用系统启动时间作为参考。

  • Handler类可以在主线程中更新UI界面,避免了线程间通信的问题。


但是这种方式也有以下缺点:



  • Handler类只能在当前进程中使用,如果进程被杀死或者进入休眠状态,会导致计时中断。

  • Handler类需要手动循环调用postDelayed方法来实现周期性地执行任务。


因此,这种方式适合一些需要在主线程中更新UI界面的定时任务.


5. Service + AlarmManager + BroadcastReceiver


这种方式是使用Android API里提供的三个组件来实现定时任务. Service组件可以在后台运行某个长期的服务;AlarmManager组件可以设置一个闹钟,在指定的时间发送一个



  • Intent,用于指定要启动的Service组件和传递一些参数。

  • AlarmManager组件可以设置一个闹钟,在指定的时间发送一个Intent给BroadcastReceiver组件。

  • BroadcastReceiver组件可以接收AlarmManager发送的Intent,并启动Service组件来执行任务。


这种方式相比第四种方式有以下优点:



  • Service组件可以在后台运行,即使进程被杀死或者进入休眠状态,也不会影响计时。

  • AlarmManager组件可以设置精确或者不精确的闹钟,根据不同的需求节省电量。

  • BroadcastReceiver组件可以在系统开机后自动注册,实现开机自启动。


但是这种方式也有以下缺点:



  • Service组件需要在AndroidManifest.xml文件中声明,并申请相关的权限。

  • AlarmManager组件受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • BroadcastReceiver组件需要在代码中动态注册和注销,避免内存泄漏。


因此,这种方式适合一些需要长期在后台执行的定时任务。


6. WorkManager


这种方式是使用Android Jetpack里提供的WorkManager库来实现定时任务. WorkManager库是一个用于管理后台任务的框架,它可以在满足一定条件下执行某个任务,并保证任务一定会被执行。例如:


// 创建一个PeriodicWorkRequest对象
PeriodicWorkRequest request = new PeriodicWorkRequest.Builder(MyWorker.class, 15, TimeUnit.MINUTES)
.setConstraints(new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.build();
// 获取一个WorkManager对象
WorkManager workManager = WorkManager.getInstance(this);
// 将PeriodicWorkRequest对象加入到队列中
workManager.enqueue(request);


这种方式相比第五种方式有以下优点:



  • WorkManager库不受系统时间影响,它使用系统启动时间作为参考。

  • WorkManager库可以设置多种约束条件来执行任务,例如网络状态、电量状态、设备空闲状态等。

  • WorkManager库可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • WorkManager库需要添加依赖并配置相关的权限。

  • WorkManager库不能保证任务准时执行,它会根据系统资源和约束条件来调度任务。


因此,这种方式适合一些对时间不敏感且需要满足特定条件才能执行的定时任务


您好,这是必应。我根据您的要求,从网络上搜集了一些关于如何使用RXJAVA、CountDownTimer、协程安卓实现一个定时任务的资料,并整理成了一篇文章。文章的结构如下:


7.如何使用RXJAVA实现定时任务


RXJAVA是一个基于观察者模式和函数式编程的库,它可以用来处理异步事件和数据流。RXJAVA提供了一些操作符,可以用来创建和处理定时任务。其中,interval操作符可以用来创建一个按照固定时间间隔发射整数序列的Observable,可以用来实现周期性的定时任务。例如,以下代码可以实现每隔1秒发射一个整数,从0开始:


//创建一个Observable,每隔1秒发射一个整数,从0开始
Observable timer = Observable.interval(1, TimeUnit.SECONDS);
//订阅这个Observable,打印发射的数据
timer.subscribe(new Observer() {
@Override
public void onSubscribe(Disposable d) {
//订阅时的操作,可以用来取消订阅
}

@Override
public void onNext(Long aLong) {
//收到数据时的操作,可以用来更新UI或者执行其他逻辑
Log.d("RXJAVA", "onNext: " + aLong);
}

@Override
public void onError(Throwable e) {
//发生错误时的操作,可以用来处理异常
}

@Override
public void onComplete() {
//完成时的操作,可以用来释放资源
}
});


如果要实现倒计时的效果,可以使用map操作符,将发射的整数转换为倒计时的秒数。例如,以下代码可以实现一个10秒的倒计时:


//创建一个Observable,每隔1秒发射一个整数,从0开始,共发射10个
Observable timer = Observable.interval(1, TimeUnit.SECONDS).take(10);
//使用map操作符,将发射的整数转换为倒计时的秒数
timer = timer.map(new Function() {
@Override
public Long apply(Long aLong) throws Exception {
//将发射的整数转换为倒计时的秒数,例如0转换为10,1转换为9,以此类推
return 10 - aLong;
}
});
//订阅这个Observable,打印发射的数据
timer.subscribe(new Observer() {
@Override
public void onSubscribe(Disposable d) {
//订阅时的操作,可以用来取消订阅
}

@Override
public void onNext(Long aLong) {
//收到数据时的操作,可以用来更新UI或者执行其他逻辑
Log.d("RXJAVA", "onNext: " + aLong);
}

@Override
public void onError(Throwable e) {
//发生错误时的操作,可以用来处理异常
}

@Override
public void onComplete() {
//完成时的操作,可以用来释放资源
}
});


RXJAVA的优点是可以方便地处理异步事件和数据流,可以使用各种操作符来实现复杂的逻辑,可以避免内存泄漏和线程安全


8.如何使用CountDownTimer实现定时任务


CountDownTimer是Android中提供的一个倒计时器类,它可以用来实现一个在一定时间内递减的倒计时。CountDownTimer的构造方法接受两个参数:总时间和间隔时间。例如,以下代码可以创建一个10秒的倒计时,每隔1秒更新一次:


//创建一个10秒的倒计时,每隔1秒更新一次
CountDownTimer timer = new CountDownTimer(10000, 1000) {
@Override
public void onTick(long millisUntilFinished) {
//每隔一秒调用一次,可以用来更新UI或者执行其他逻辑
Log.d("CountDownTimer", "onTick: " + millisUntilFinished / 1000);
}

@Override
public void onFinish() {
//倒计时结束时调用,可以用来释放资源或者执行其他逻辑
Log.d("CountDownTimer", "onFinish");
}
};
//开始倒计时
timer.start();
//取消倒计时
timer.cancel();


CountDownTimer的优点是使用简单,可以直接在UI线程中更新UI,不需要额外的线程或者Handler。CountDownTimer的缺点是只能实现倒计时的效果,不能实现周期性的定时任务,而且精度受系统时间的影响,可能不够准确。


9.如何使用协程实现定时任务


协程可以用来简化异步编程和线程管理。协程是一种轻量级的线程,它可以在不阻塞线程的情况下挂起和恢复执行。协程安卓提供了一些扩展函数,可以用来创建和处理定时任务。其中,delay函数可以用来暂停协程的执行一段时间,可以用来实现倒计时或者周期性的定时任务。例如,以下代码可以实现一个10秒的倒计时,每隔1秒更新一次:


//创建一个协程作用域,可以用来管理协程的生命周期
val scope = CoroutineScope(Dispatchers.Main)
//在协程作用域中启动一个协程,可以用来执行异步任务
scope.launch {
//创建一个变量,表示倒计时的秒数
var seconds = 10
//循环执行,直到秒数为0
while (seconds > 0) {
//打印秒数,可以用来更新UI或者执行其他逻辑
Log.d("Coroutine", "seconds: $seconds")
//暂停协程的执行1秒,不阻塞线程
delay(1000)
//秒数减一
seconds--
}
//倒计时结束,打印日志,可以用来释放资源或者执行其他逻辑
Log.d("Coroutine", "finish")
}
//取消协程作用域,可以用来取消所有的协程
scope.cancel()


协程安卓的优点是可以方便地处理异步任务和线程切换,可以使用简洁的语法来实现复杂的逻辑,可以避免内存泄漏和回调。协程的缺点是需要引入额外的依赖,而且需要一定的学习成本,不太适合初学者。


10.使用kotlin关键字 ‘downTo’ 搭配Flow


// 创建一个倒计时器,从10秒开始,每秒减一
val timer = object: CountDownTimer(10000, 1000) {
override fun onTick(millisUntilFinished: Long) {
// 在每个间隔,发射剩余的秒数
emitSeconds(millisUntilFinished / 1000)
}

override fun onFinish() {
// 在倒计时结束时,发射0
emitSeconds(0)
}
}

// 创建一个Flow,用于发射倒数的秒数
fun emitSeconds(seconds: Long): Flow = flow {
// 使用downTo关键字创建一个倒数的范围
for (i in seconds downTo 0) {
// 发射当前的秒数
emit(i.toInt())
}
}


11.kotlin内联函数的协程和 Flow 实现


fun FragmentActivity.timerFlow(
time: Int = 60,
onStart: (suspend () -> Unit)? = null,
onEach: (suspend (Int) -> Unit)? =
null,
onCompletion: (suspend () -> Unit)? =
null
): Job {
return (time downTo 0)
.asFlow()
.cancellable()
.flowOn(Dispatchers.Default)
.onStart { onStart?.invoke() }
.onEach {
onEach?.invoke(it)
delay(
1000L)
}.onCompletion { onCompletion?.invoke() }
.launchIn(lifecycleScope)
}


//在activity中使用
val job = timerFlow(
time = 60,
onStart = { Log.d("Timer", "Starting timer...") },
onEach = { Log.d("Timer", "Seconds remaining: $it") },
onCompletion = { Log.d("Timer", "Timer completed.") }
)

//取消计时
job.cancel()

作者:淘淘养乐多
来源:juejin.cn/post/7270173192789737487
收起阅读 »

用一个RecyclerView实现抖音二级评论

前一阵,看到一位掘友分享了一篇文章:Android简单的两级评论功能实现,看得出来,是一位Android萌新记录的学习过程。我当时还留了一条建议: 建议用单RecyclerView+多ItemType+ListAdapter实现,保持UI层的清洁,把逻辑处理...
继续阅读 »

前一阵,看到一位掘友分享了一篇文章:Android简单的两级评论功能实现,看得出来,是一位Android萌新记录的学习过程。我当时还留了一条建议:



建议用单RecyclerView+多ItemType+ListAdapter实现,保持UI层的清洁,把逻辑处理集中在数据源的转换上,比如展开/收起二级评论可以利用flatMap和groupBy等操作符转换



其实我之前在工作中,也曾经做过类似抖音的二级评论的需求。但那个时候自己很菜,还没有用过Kotlin,协程更是没有接触过,这个功能和另一位同事一起开发了两周才搞定。


刚好这个周末没啥事,就想着写一个简单实现抖音二级评论基本功能的Demo。一方面,想试试自己现在开发这样一个需求会是什么样的体验;另一方面,也算是给Android掘友,尤其是萌新,分享一点业务开发的心得。


先上个效果图(没有UI,将就看吧),写代码的整个过程花了4个小时左右,相比当初自己开发需求已经快了很多了哈。



给产品估个两天时间,摸一天半的鱼不过分吧(手动斜眼)



评论.gif


需求拆分


这种大家常用的评论功能其实也就没啥好拆分的了,简单列一下:



  • 默认展示一级评论和二级评论中的热评,可以上拉加载更多。

  • 二级评论超过两条时,可以点击展开加载更多二级评论,展开后可以点击收起折叠到初始状态。

  • 回复评论后插入到该评论的下方。


技术选型


前面我在给掘友的评论中,也提到了技术选型的要点:


单RecyclerView + 多ItemType + ListAdapter


这是基本的UI框架。


为啥要只用一个RecyclerView?最重要的原因,就是在RecyclerView中嵌套同方向RecyclerView,会有性能问题和滑动冲突。其次,当下声明式UI正是各方大佬推崇的最佳开发实践之一,虽然我们没有使用声明式UI基础上开发的Compose/Flutter技术,但其构建思想仍然对我们的开发具有一定的指导意义。我猜测,androidx.recyclerview.widget.ListAdapter可能也是响应声明式UI号召的一个针对RecyclerView的解决方案吧。


数据源的转换


数据驱动UI!


既然选用了ListAdapter,那么我们就不应该再手动操作adapter的数据,再用各种notifyXxx方法来更新列表了。更提倡的做法是,基于data class浅拷贝,用Collection操作符对数据源的进行转换,然后将转换后的数据提交到adapter。为了提高数据转换性能,我们可以基于协程进行异步处理。


graph LR
start[原List] --异步数据处理--> 新List --> stop[ListAdapter.submitList]
stop --> start

要点:



  • 浅拷贝


低成本生成一个全新的对象,以保证数据源的安全性。


data class Foo(val id: Int, val content: String)

val foo1 = Foo(0, "content")
val foo2 = foo1.copy(content = "updated content")


  • Collection操作符


Kotlin中提供了大量非常好用的Collection操作符,能灵活使用的话,非常有利于咱们向声明式UI转型。


前面我提到了groupByflatMap这两个操作符。怎么使用呢?


以这个需求为例,我们需要显示一级评论、二级评论和展开更多按钮,想要分别用一个data class来表示,但是后端返回的数据中又没有“展开更多”这样的数据,就可以这样处理:


// 从后端获取的数据List,包括有一级评论和二级评论,二级评论的parentId就等于一级评论的id
val loaded: List<CommentItem> = ...
val grouped = loaded.groupBy {
// (1) 以一级评论的id为key,把源list分组为一个Map<Int, List<CommentItem>>
(it as? CommentItem.Level1)?.id ?: (it as? CommentItem.Level2)?.parentId
?: throw IllegalArgumentException("invalid comment item")
}.flatMap {
// (2) 展开前面的map,展开时就可以在每级一级评论的二级评论后面添加一个控制“展开更多”的Item
it.value + CommentItem.Folding(
parentId = it.key,
)
}


  • 异步处理


前面我们描述的数据源的转换过程,在Kotlin中,可以简单地被抽象为一个操作:


List<CommentItem>.() -> List<CommentItem>

对于这个需求,数据源转换操作就包括了:分页加载,展开二级评论,收起二级评论,回复评论等。按照惯例,抽象一个接口出来。既然我们要在协程框架下进行异步处理,需要给这个操作加一个suspend关键字。


interface Reducer {
val reduce: suspend List<CommentItem>.() -> List<CommentItem>
}

为啥我给这个接口取名Reducer?如果你知道它的意思,说明你可能已经了解过MVI架构了;如果你还不知道它的意思,说明你可以去了解一下MVI了。哈哈!


不过今天不谈MVI,对于这样一个小Demo,完全没必要上架构。但是,优秀架构为我们提供的代码构建思路是有必要的!


这个Reducer,在这里就算是咱们的小小业务架构了。



  • 异步2.0


前面谈到异步,我们印象中可能主要是网络请求、数据库/文件读写等IO操作。


这里我想要延伸一下。


ActivitystartActivityForResult/onActivityResultDialog的拉起/回调,其实也可以看着是异步操作。异步与是否在主线程无关,而在于是否是实时返回结果。毕竟在主线程上跳转到其他页面,获取数据再回调回去使用,也是花了时间的啊。所以在协程的框架下,有一个更适合描述异步的词语:挂起(suspend)


说这有啥用呢?仍以这个需求为例,我们点击“回复”后拉起一个对话框,输入评论确认后回调给Activity,再进行网络请求:


class ReplyDialog(context: Context, private val callback: (String) -> Unit) : Dialog(context) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.dialog_reply)
val editText = findViewById<EditText>(R.id.content)
findViewById<Button>(R.id.submit).setOnClickListener {
if (editText.text.toString().isBlank()) {
Toast.makeText(context, "评论不能为空", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
callback.invoke(editText.text.toString())
dismiss()
}
}
}

suspend List<CommentItem>.() -> List<CommentItem> = {
val content = withContext(Dispatchers.Main) {
// 由于整个转换过程是在IO线程进行,Dialog相关操作需要转换到主线程操作
suspendCoroutine { continuation ->
ReplyDialog(context) {
continuation.resume(it)
}.show()
}
}
...进行其他操作,如网络请求
}

技术选型,或者说技术框架,咱们就实现了,甚至还谈到了部分细节了。接下来进行完整实现细节分享。


实现细节


MainActivity


基于上一章节的技术选型,咱们的MainActivity的完整代码就是这样了。


class MainActivity : AppCompatActivity() {
private lateinit var commentAdapter: CommentAdapter

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
commentAdapter = CommentAdapter {
lifecycleScope.launchWhenResumed {
val newList = withContext(Dispatchers.IO) {
reduce.invoke(commentAdapter.currentList)
}
val firstSubmit = commentAdapter.itemCount == 1
commentAdapter.submitList(newList) {
// 这里是为了处理submitList后,列表滑动位置不对的问题
if (firstSubmit) {
recyclerView.scrollToPosition(0)
} else if (this@CommentAdapter is FoldReducer) {
val index = commentAdapter.currentList.indexOf(this@CommentAdapter.folding)
recyclerView.scrollToPosition(index)
}
}
}
}
recyclerView.adapter = commentAdapter
}
}

RecyclerView设置一个CommentAdapter就行了,回调时也只需要把回调过来的Reducer调度到IO线程跑一下,得到新的数据listsubmitList就完事了。如果不是submitList后有列表的定位问题,代码还能更精简。如果有知道更好的解决办法的朋友,麻烦留言分享一下,感谢!


CommentAdapter


别以为我把逻辑处理扔到adapter中了哦!


AdapterViewHolder都是UI组件,我们也需要尽量保持它们的清洁。


贴一下CommentAdapter


class CommentAdapter(private val reduceBlock: Reducer.() -> Unit) :
ListAdapter<CommentItem, VH>(object : DiffUtil.ItemCallback<CommentItem>() {
override fun areItemsTheSame(oldItem: CommentItem, newItem: CommentItem): Boolean {
return oldItem.id == newItem.id
}

override fun areContentsTheSame(oldItem: CommentItem, newItem: CommentItem): Boolean {
if (oldItem::class.java != newItem::class.java) return false
return (oldItem as? CommentItem.Level1) == (newItem as? CommentItem.Level1)
|| (oldItem as? CommentItem.Level2) == (newItem as? CommentItem.Level2)
|| (oldItem as? CommentItem.Folding) == (newItem as? CommentItem.Folding)
|| (oldItem as? CommentItem.Loading) == (newItem as? CommentItem.Loading)
}
}) {

init {
submitList(listOf(CommentItem.Loading(page = 0, CommentItem.Loading.State.IDLE)))
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_LEVEL1 -> Level1VH(
inflater.inflate(R.layout.item_comment_level_1, parent, false),
reduceBlock
)

TYPE_LEVEL2 -> Level2VH(
inflater.inflate(R.layout.item_comment_level_2, parent, false),
reduceBlock
)

TYPE_LOADING -> LoadingVH(
inflater.inflate(
R.layout.item_comment_loading,
parent,
false
), reduceBlock
)

else -> FoldingVH(
inflater.inflate(R.layout.item_comment_folding, parent, false),
reduceBlock
)
}
}

override fun onBindViewHolder(holder: VH, position: Int) {
holder.onBind(getItem(position))
}

override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is CommentItem.Level1 -> TYPE_LEVEL1
is CommentItem.Level2 -> TYPE_LEVEL2
is CommentItem.Loading -> TYPE_LOADING
else -> TYPE_FOLDING
}
}

companion object {
private const val TYPE_LEVEL1 = 0
private const val TYPE_LEVEL2 = 1
private const val TYPE_FOLDING = 2
private const val TYPE_LOADING = 3
}
}

可以看到,就是一个简单的多ItemTypeAdapter,唯一需要注意的就是,在Activity里传入的reduceBlock: Reducer.() -> Unit,也要传给每个ViewHolder


ViewHolder


篇幅原因,就只贴其中一个:


abstract class VH(itemView: View, protected val reduceBlock: Reducer.() -> Unit) :
ViewHolder(itemView) {
abstract fun onBind(item: CommentItem)
}

class Level1VH(itemView: View, reduceBlock: Reducer.() -> Unit) : VH(itemView, reduceBlock) {
private val avatar: TextView = itemView.findViewById(R.id.avatar)
private val username: TextView = itemView.findViewById(R.id.username)
private val content: TextView = itemView.findViewById(R.id.content)
private val reply: TextView = itemView.findViewById(R.id.reply)
override fun onBind(item: CommentItem) {
avatar.text = item.userName.subSequence(0, 1)
username.text = item.userName
content.text = item.content
reply.setOnClickListener {
reduceBlock.invoke(ReplyReducer(item, itemView.context))
}
}
}

也是很简单,唯一特别一点的处理,就是在onClickListener中,让reduceBlockinvoke一个Reducer实现。


Reducer


刚才在技术选型章节,已经提前展示了“回复评论”这一操作的Reducer实现了,其他Reducer也差不多,比如展开评论操作,也封装在一个Reducer实现ExpandReducer中,以下是完整代码:


data class ExpandReducer(
val folding: CommentItem.Folding,
) : Reducer {
private val mapper by lazy { Entity2ItemMapper() }
override val reduce: suspend List<CommentItem>.() -> List<CommentItem> = {
val foldingIndex = indexOf(folding)
val loaded =
FakeApi.getLevel2Comments(folding.parentId, folding.page, folding.pageSize).getOrNull()
?.map(mapper::invoke) ?: emptyList()
toMutableList().apply {
addAll(foldingIndex, loaded)
}.map {
if (it is CommentItem.Folding && it == folding) {
val state =
if (it.page > 5) CommentItem.Folding.State.LOADED_ALL else CommentItem.Folding.State.IDLE
it.copy(page = it.page + 1, state = state)
} else {
it
}
}
}

}

短短一段代码,我们做了这些事:



  • 请求网络数据Entity list(假数据)

  • 通过mapper转换成显示用的Item数据list

  • Item数据插入到“展开更多”按钮前面

  • 最后,根据二级评论加载是否完成,将“展开更多”的状态置为IDLELOADED_ALL


一个字:丝滑!


用于转换EntityItemmapper的代码也贴一下吧:


// 抽象
typealias Mapper<I, O> = (I) -> O
// 实现
class Entity2ItemMapper : Mapper<ICommentEntity, CommentItem> {
override fun invoke(entity: ICommentEntity): CommentItem {
return when (entity) {
is CommentLevel1 -> {
CommentItem.Level1(
id = entity.id,
content = entity.content,
userId = entity.userId,
userName = entity.userName,
level2Count = entity.level2Count,
)
}

is CommentLevel2 -> {
CommentItem.Level2(
id = entity.id,
content = if (entity.hot) entity.content.makeHot() else entity.content,
userId = entity.userId,
userName = entity.userName,
parentId = entity.parentId,
)
}

else -> {
throw IllegalArgumentException("not implemented entity: $entity")
}
}
}
}

细心的朋友可以看到,在这里我顺便也将热评也处理了:


if (entity.hot) entity.content.makeHot() else entity.content

makeHot()就是用buildSpannedString来实现的:


fun CharSequence.makeHot(): CharSequence {
return buildSpannedString {
color(Color.RED) {
append("热评 ")
}
append(this@makeHot)
}
}

这里可以提一句:尽量用CharSequence来抽象表示字符串,可以方便我们灵活地使用Span来减少UI代码。


data class


也贴一下相关的数据实体得了。



  • 网络数据(假数据)


interface ICommentEntity {
val id: Int
val content: CharSequence
val userId: Int
val userName: CharSequence
}

data class CommentLevel1(
override val id: Int,
override val content: CharSequence,
override val userId: Int,
override val userName: CharSequence,
val level2Count: Int,
) : ICommentEntity


  • RecyclerView Item数据


sealed interface CommentItem {
val id: Int
val content: CharSequence
val userId: Int
val userName: CharSequence

data class Loading(
val page: Int = 0,
val state: State = State.LOADING
) : CommentItem {
override val id: Int=0
override val content: CharSequence
get() = when(state) {
State.LOADED_ALL -> "全部加载"
else -> "加载中..."
}
override val userId: Int=0
override val userName: CharSequence=""

enum class State {
IDLE, LOADING, LOADED_ALL
}
}

data class Level1(
override val id: Int,
override val content: CharSequence,
override val userId: Int,
override val userName: CharSequence,
val level2Count: Int,
) : CommentItem

data class Level2(
override val id: Int,
override val content: CharSequence,
override val userId: Int,
override val userName: CharSequence,
val parentId: Int,
) : CommentItem

data class Folding(
val parentId: Int,
val page: Int = 1,
val pageSize: Int = 3,
val state: State = State.IDLE
) : CommentItem {
override val id: Int
get() = hashCode()
override val content: CharSequence
get() = when {
page <= 1 -> "展开20条回复"
page >= 5 -> ""
else -> "展开更多"
}
override val userId: Int = 0
override val userName: CharSequence = ""

enum class State {
IDLE, LOADING, LOADED_ALL
}
}
}

这部分没啥好说的,可以注意两个点:



  • data class也是可以抽象的。但这边我处理不是很严谨,比如CommentItem我把userIduserName也抽象出来了,其实不应该抽象出来。

  • 在基于Reducer的框架下,最好是把data class的属性都定义为val


结语


更多的代码就不贴了,贴太多影响观感。有兴趣的朋友可以移步源码


总结一下实现心得:



  • 数据驱动UI

  • 对业务的精准抽象

  • 对异步的延伸理解

  • 灵活使用Collection操作符

  • 没有UI和PM,写代码真TM爽!


作者:blackfrog
来源:juejin.cn/post/7276808079143190565
收起阅读 »

Android:实现一个简单带动画的展开收起功能

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。 今天给大家带来一个展开和收起的简单效果。如果只是代码中简单设置显示或隐藏,熟悉安卓系统的朋友都知道,那一定是闪现。所以笔者结合了动画,使得体验效果瞬间提升一个档次。话不多说,直接上效...
继续阅读 »

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。



今天给大家带来一个展开和收起的简单效果。如果只是代码中简单设置显示或隐藏,熟悉安卓系统的朋友都知道,那一定是闪现。所以笔者结合了动画,使得体验效果瞬间提升一个档次。话不多说,直接上效果:


1.gif


首先观察图中效果,视图有展开和折叠两种状态,右侧图标和文字会跟随这个状态改变。那么其中就有折叠的高度和展开的高度需要我们记录。折叠高度是固定的,展开高度需要动态获取。需要注意的是不能直接通过视图直接获取高度,因为视图的绘制和Activity的生命周期是不同步的,在Activity中直接lin.height获取高度无法保证此时的视图已经完成计算。这里直接用简单的post方式获取到绘制完成的总高度。原理是将这个消息放到队列最后一条,这样就可以保证回调方法中能够获取到真实的高度。


lin?.post {
val h = lin!!.height
hight = if (h > 0) h else baseHight

if (h > 0 && ivTop?.visibility == View.GONE) {
ivTop?.visibility = View.VISIBLE
}
}

接下来就是动画的使用和动态控制视图的高度了。这里需要用到属性动画,我们知道的属性动画有ValueAnimatorObjectAnimatorObjectAnimator是继承于ValueAnimator,说明ValueAnimator能做的事情ObjectAnimator也可以实现。由于我们要控制的视图不止一个,所以还是使用ValueAnimator方便点。通过addUpdateListener添加监听后,animation.animatedValue就是我们需要的当前值。在此处不停将当前高度赋值给视图,并且图标也根据这个值进行等比例的旋转以到达到视图不停更新。


//根据展开、关闭状态传入对应高度
val animator = ValueAnimator.ofInt(
if (isExpand) hight - baseHight else 0,
if (isExpand) 0 else hight - baseHight
)
animator.addUpdateListener { animation ->
val params = lin?.layoutParams
params?.height = if ((animation.animatedValue as Int) < baseHight) baseHight else (animation.animatedValue as Int) //当高度小于基础高度时 给与基础高度
lin?.layoutParams = params//拿到当前高度
//图标旋转
ivTop?.rotation = (animation.animatedValue as Int) * 180f / (hight - baseHight)

}
animator.duration = 500//动画时长
animator.start()

isExpand = !isExpand
tvExpand?.text = if (isExpand) "关闭" else "展开"

编写过程需要注意展开和收起状态下值的正确输入,在回调方法中获取对应的当前值并赋值。


好了,一个简单的展开收起功能就实现了,希望对大家有所帮助。


作者:似曾相识2022
来源:juejin.cn/post/7273079438991376439
收起阅读 »

Android 沉浸式状态栏,透明状态栏 采用系统api,超简单近乎完美的实现

前言 沉浸式的适配有多麻烦,相信大家既然来搜索这个,就说明都在为此苦恼,那么看看这篇文章吧,也许对你有所帮助(最下面有源码链接) 有写的不对的地方,欢迎指出 从adnroid 6.0开始,官方逐渐完善了这方面的api,直到android 11... ... 让...
继续阅读 »

前言


沉浸式的适配有多麻烦,相信大家既然来搜索这个,就说明都在为此苦恼,那么看看这篇文章吧,也许对你有所帮助(最下面有源码链接)


有写的不对的地方,欢迎指出


从adnroid 6.0开始,官方逐渐完善了这方面的api,直到android 11...


... 让我们直接开始吧


导入核心包


老项目非androidx的请自行研究下,这里使用的是androidx,并且用的kotlin语言
本次实现方式跟windowInsets息息相关,这可真是个好东西
首先是需要导入核心包
androidx.core:core

kotlin可选择导入这个:
androidx.core:core-ktx
我用的版本是
androidx.core:core-ktx:1.12.0

开启 “沉浸式” 支持


沉浸式原本的意思似乎是指全屏吧。。。算了,不管那么多,喊习惯了 沉浸式状态栏,就这么称呼吧。

在activity 的oncreate里调用
//将decorView的fitSystemWindows属性设置为false
WindowCompat.setDecorFitsSystemWindows(window, false)
//设置状态栏颜色为透明
window.statusBarColor = Color.TRANSPARENT
//是否需要改变状态栏上的 图标、字体 的颜色
//获取InsetsController
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
//mask:遮罩 默认是false
//mask = true 状态栏字体颜色为黑色,一般在状态栏下面的背景色为浅色时使用
//mask = false 状态栏字体颜色为白色,一般在状态栏下面的背景色为深色时使用
var mask = true
insetsController.isAppearanceLightStatusBars = mask
//底部导航栏是否需要修改
//android Q+ 去掉虚拟导航键 的灰色半透明遮罩
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
//设置虚拟导航键的 背景色为透明
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
//8.0+ 虚拟导航键图标颜色可以修改,所以背景可以用透明
window.navigationBarColor = Color.TRANSPARENT
} else {
//低版本因为导航键图标颜色无法修改,建议用黑色,不要透明
window.navigationBarColor = Color.BLACK
}
//是否需要修改导航键的颜色,mask 同上面状态栏的一样
insetsController.isAppearanceLightNavigationBars = mask

修改 状态栏、虚拟导航键 的图标颜色,可以在任意需要的时候设置,防止图标和字体颜色和背景色一致导致看不清

补充一下:
状态栏和虚拟导航栏的背景色要注意以下问题:
1.在低于6.0的手机上,状态栏上的图标、字体颜色是白色且不支持修改的,MIUI,Flyme这些除外,因为它们有自己的api能实现修改颜色
2.在低于8.0的手机上,虚拟导航栏的图标、字体颜色是白色且不支持修改的,MIUI,Flyme这些除外,因为他们有自己的api能实现修改颜色
解决方案:
低于指定版本的系统上,对应的颜色就不要用透明,除非你的APP页面是深色背景,否则,建议采用半透明的灰色

在带有刘海或者挖孔屏上,横屏时刘海或者挖孔的那条边会有黑边,解决方法是:
给APP的主题v27加上
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
参考图:

image.png


监听OnApplyWindowInsetsListener


//准备一个boolean变量 作为是否在跑动画的标记
var flagProgress = false

//这里可以使用decorView或者是任意view
val view = window.decorView

//监听windowInsets变化
ViewCompat.setOnApplyWindowInsetsListener(view) { view: View, insetsCompat: WindowInsetsCompat ->
//如果要配合下面的setWindowInsetsAnimationCallback一起用的话,一定要记得,onProgress的时候,这里做个拦截,直接返回 insets
if (flagProgress) return@setOnApplyWindowInsetsListener insetsCompat
//在这里开始给需要的控件分发windowInsets

//最后,选择不消费这个insets,也可以选择消费掉,不在往子控件分发
insetsCompat
}
//带平滑过渡的windowInsets变化,ViewCompat中的这个,官方提供了 api 21-api 29的支持,本来这个只支持 api 30+的,相当不错!
//启用setWindowInsetsAnimationCallback的同时,也必须要启用上面的setOnApplyWindowInsetsListener,否则在某些情况下,windowInsets改变了,但是因为不会触发setWindowInsetsAnimationCallback导致padding没有更新到UI上
//DISPATCH_MODE_CONTINUE_ON_SUBTREE这个代表动画事件继续分发下去给子View
ViewCompat.setWindowInsetsAnimationCallback(view, object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
override fun onProgress(insetsCompat: WindowInsetsCompat, runningAnimations: List<WindowInsetsAnimationCompat>): WindowInsetsCompat {
//每一帧的windowInsets
//可以在这里分发给需要的View。例如给一个聊天窗口包含editText的布局设置这个padding,可以实现键盘弹起时,在底部的editText跟着键盘一起滑上去,参考微信聊天界面,这个比微信还丝滑(android 11+最完美)。
//最后,直接原样return,不消费
return insetsCompat
}

override fun onEnd(animation: WindowInsetsAnimationCompat) {
super.onEnd(animation)
//动画结束,将标记置否
flagProgress = false
}

override fun onPrepare(animation: WindowInsetsAnimationCompat) {
super.onPrepare(animation)
//动画准备开始,在这里可以记录一些UI状态信息,这里将标记设置为true
flagProgress = true
}
})

读取高度值


通过上面的监听,我们能拿到WindowInsetsCompat对象,现在,我们从这里面取到我们需要的高度值


先定义几个变量,我们需要拿的包含:
1. 刘海,挖空区域所占据的宽度或者是高度
2. 被系统栏遮挡的区域
3. 被输入法遮挡的区域

//cutoutPadding 刘海,挖孔区域的padding
var cutoutPaddingLeft = 0
var cutoutPaddingTop = 0
var cutoutPaddingRight = 0
var cutoutPaddingBottom = 0

//获取刘海,挖孔的高度,因为这个不是所有手机都有,所以,需要判空
insetsCompat.displayCutout?.let { displayCutout ->
cutoutPaddingTop = displayCutout.safeInsetTop
cutoutPaddingLeft = displayCutout.safeInsetLeft
cutoutPaddingRight = displayCutout.safeInsetRight
cutoutPaddingBottom = displayCutout.safeInsetBottom
}


//systemBarPadding 系统栏区域的padding
var systemBarPaddingLeft = 0
var systemBarPaddingTop = 0
var systemBarPaddingRight = 0
var systemBarPaddingBottom = 0

//获取系统栏区域的padding
//系统栏 + 输入法
val systemBars = insetsCompat.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars())
//左右两侧的padding通常直接赋值即可,如果横屏状态下,虚拟导航栏在侧边,那么systemBars.left或者systemBars.right的值就是它的宽度,竖屏情况下,一般都是0
systemWindowInsetLeft = systemBars.left
systemWindowInsetRight = systemBars.right
//这里判断下输入法 和 虚拟导航栏是否存在,如果存在才设置paddingBottom
if (insetsCompat.isVisible(WindowInsetsCompat.Type.ime()) || insetsCompat.isVisible(WindowInsetsCompat.Type.navigationBars())) {
systemWindowInsetBottom = systemBars.bottom
}
//同样判断下状态栏
if (insetsCompat.isVisible(WindowInsetsCompat.Type.statusBars())) {
systemWindowInsetTop = systemBars.top
}

到这里,我们需要的信息已经全部获取到了,接下来就是根据需求,设置padding属性了

补充一下:
我发现在低于android 11的手机上,insets.isVisible(Type)返回始终为true
并且,即使系统栏被隐藏,systemBars.top, systemBars.bottom也始终会有高度
所以这里


保留原本的Padding属性


上述获取的值,直接去设置padding的话,会导致原本的padding属性失效,所以我们需要在首次设置监听,先保存一份原本的padding属性,在最后设置padding的时候,把这份原本的padding值加上即可,就不贴代码了。


第一次写文章,写的粗糙了点

可能我写的不太好,没看懂也没关系,直接去看完整代码吧


我专门写了个小工具,可以去看看:
沉浸式系统栏 小工具


如果有更好的优化方案,欢迎在github上提出,我们一起互相学习!


作者:Matchasxiaobin
来源:juejin.cn/post/7275943802938130472
收起阅读 »

花亿点时间,写个Android抓包库

0x1、引言 上周五版本刚提测,这周边改BUG边摸鱼,百无聊赖,想起前不久没业务需求时,随手写的Android抓包库。 就公司的APP集成了 抓包功能,目的是:方便非Android开发的同事在 接口联调和测试阶段 能够看到APP的请求日志,进行一些简单的问题定...
继续阅读 »

0x1、引言


上周五版本刚提测,这周边改BUG边摸鱼,百无聊赖,想起前不久没业务需求时,随手写的Android抓包库。


就公司的APP集成了 抓包功能,目的是:方便非Android开发的同事在 接口联调和测试阶段 能够看到APP的请求日志,进行一些简单的问题定位(如接口字段错误返回,导致APP UI显示异常),不用动不动就来找Android崽~


手机摇一摇,就能查看 APP发起的请求列表具体的请求信息



能用,但存在一些问题,先是 代码层面



  • 耦合:抓包代码直接硬编码在项目中,线上包不需要抓包功能,也会把这部分代码打包到APK里

  • 复用性差:其它APP想添加抓包功能,需要CV大量代码...

  • 安全性:是否启用抓包功能,通过 BuildConfig.DEBUG 来判断,二次打包修改AndroidManifest.xml文件添加 android:debuggable="true" 或者 root手机后修改ro.debuggable为1 设置手机为可调试模式,生产环境的接口请求一览无余。


当然,上面的安全性有点 夸大 了,编译时,编译器会进一步优化代码,可能会删除未使用的变量或代码块。比如这样的代码:


if (BuildConfig.DEBUG) {
xxx.setBaseUrl(Config.DEBUG_BASE_URL);
} else {
xxx.setBaseUrl(Config.RELEASE_BASE_URL);
}

Release打包,BuildConfig.DEBUG永远为false,编译器会优化下代码,编译后的代码可能就剩这句:


xxx.setBaseUrl(Config.RELEASE_BASE_URL);

不信的读者可以反编译自己的APP试试康~


尽管编译后的Release包不包含 启用抓包的代码,但是把抓包代码打包到APK里,始终是不妥的。


毕竟,反编译apk,smail加个启用抓包的代码,并不是什么难事,最好的处理方式还是不要把抓包代码打包到Release APK中!


接着说说 实用性层面



  • 请求相关信息太少:只有URL、请求参数和响应参数这三个数据,状态吗码都没有,有时需要看下请求头或响应头参数。

  • 只能看不能复制:有时需要把请求参数发给后端。

  • 字段查找全靠肉眼扫:请求/响应Json很长的时候,看到眼花😵‍💫。

  • 不支持URL过滤: 执行一个操作,唰唰唰一堆请求,然后就是滑滑滑,肉👀筛URL。

  • 请求记录不会动态更新,要看新请求得关闭页面再打开。

  • 等等...


综上,还是有必要完善下这个库的,毕竟也是能 提高团队研发效率的一小环~


说得天花龙凤,其实没啥技术难点,库的本质就是:自定义一个okhttp拦截器获取请求相关信息然后进行一系列封装 而已。


库不支持HttpUrlConnection、Flutter、其它协议包的抓取!!!此抓包库的定位是:方便非Android崽,查看公司APP的请求日志


如果是 Android崽或者愿意折腾,想抓手机所有APP包 的朋友,可以参考下面两篇文章:



接着简单记录下库的开发流程~


0x2、库


① 拦截器 和 请求实体类


这一步就是了解API,把能抠的参数都抠出来,请求/响应头,请求体响应体,没啥太的难度,直接参考 lygttpod/AndroidMonitor 拦截器部分的代码:


class CaptureInterceptor : Interceptor {
private var maxContentLength = 5L * 1024 * 1024

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val networkLog = NetworkLog().apply {
method = request.method() // 请求方法
request.url().toString().takeIf(String::isNotEmpty)?.let(URI::create)?.let { uri ->
url = "$uri" // 请求地址
host = uri.host
path = uri.path + if (uri.query != null) "?${uri.query}" else ""
scheme = uri.scheme
requestTime = System.currentTimeMillis()
}
requestHeaders = request.headers().toJsonString() // 请求头
request.body()?.let { body -> body.contentType()?.let { requestContentType = "$it" } }
}
val startTime = System.nanoTime() // 记录请求发起时间(微秒级别)
val requestBody = request.body()
requestBody?.contentType()?.let { networkLog.requestContentType = "$it" }
when {
// 请求头为空、未知编码类、双工(可读可写)、请求体只能用一次
requestBody == null || bodyHasUnknownEncoding(request.headers()) || requestBody.isDuplex || requestBody.isOneShot -> {}
// 上传文件
requestBody is MultipartBody -> {
networkLog.requestBody = StringBuilder().apply {
requestBody.parts().forEach {
val key = it.headers()?.value(0)
append(
if (it.body().contentType()?.toString()?.contains("octet-stream") == true)
"${key}; value=文件流\n" else "${key}; value=${it.body().readString()}\n"
)
}
}.toString()
}
else -> {
val buffer = Buffer()
requestBody.writeTo(buffer)
val charset = requestBody.contentType()?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
if (buffer.isProbablyUtf8()) networkLog.requestBody =
formatBody(buffer.readString(charset), networkLog.requestContentType)
}
}

val response: Response
try {
response = chain.proceed(request)
networkLog.apply {
responseHeaders = response.headers().toJsonString() // 响应头
responseTime = System.currentTimeMillis()
duration = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime) // 当前时间减去请求发起时间得出响应时间
protocol = response.protocol().toString()
responseCode = response.code()
responseMessage = response.message()
}
val responseBody = response.body()
responseBody?.contentType()?.let { networkLog.responseContentType = "$it" }
val bodyHasUnknownEncoding = bodyHasUnknownEncoding(response.headers())
// 响应体不为空、支持获取响应体、知道编码类型
if (responseBody != null && response.promisesBody() && !bodyHasUnknownEncoding) {
val source = responseBody.source()
source.request(Long.MAX_VALUE) // 将响应体的内容都读取到缓冲区中
var buffer = source.buffer // 获取响应体源数据流
// 如果响应体经过Gzip压缩,先解压缩
if (bodyGzipped(response.headers())) {
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
}
// 获取不到字符集的话默认使用UTF-8 字符集
val charset = responseBody.contentType()?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
if (responseBody.contentLength() != 0L && buffer.isProbablyUtf8()) {
val body = readFromBuffer(buffer.clone(), charset)
networkLog.responseBody = formatBody(body, networkLog.responseContentType)
}
networkLog.responseContentLength = buffer.size()
}
NetworkCapture.insertNetworkLog(networkLog)
Log.d("NetworkInterceptor", networkLog.toString())
return response
} catch (e: Exception) {
networkLog.errorMsg = "$e"
Log.e("NetworkInterceptor", networkLog.toString())
NetworkCapture.insertNetworkLog(networkLog)
throw e
}
}

// 检查头中的内容编码是否为除了 "identity" 和 "gzip" 外的其他未知编码类型
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
val contentEncoding = headers["Content-Encoding"] ?: return false
return !contentEncoding.equals("identity", ignoreCase = true) &&
!contentEncoding.equals("gzip", ignoreCase = true)
}

// 判断头是否包含Gzip压缩
private fun bodyGzipped(headers: Headers): Boolean {
return "gzip".equals(headers["Content-Encoding"], ignoreCase = true)
}

// 从缓冲区读取字符串数据
private fun readFromBuffer(buffer: Buffer, charset: Charset?): String {
val bufferSize = buffer.size()
val maxBytes = min(bufferSize, maxContentLength)
return StringBuilder().apply {
try {
append(buffer.readString(maxBytes, charset!!))
} catch (e: EOFException) {
append("\n\n--- Unexpected end of content ---")
}
if (bufferSize > maxContentLength) append("\n\n--- Content truncated ---")
}.toString()
}

}

请求实体:


data class NetworkLog(
var id: Long? = null,
var method: String? = null,
var url: String? = null,
var scheme: String? = null,
var protocol: String? = null,
var host: String? = null,
var path: String? = null,
var duration: Long? = null,
var requestTime: Long? = null,
var requestHeaders: String? = null,
var requestBody: String? = null,
var requestContentType: String? = null,
var responseCode: Int? = null,
var responseTime: Long? = null,
var responseHeaders: String? = null,
var responseBody: String? = null,
var responseMessage: String? = null,
var responseContentType: String? = null,
var responseContentLength: Long? = null,
var errorMsg: String? = null,
var source: String? = null
) : Serializable {
fun getRequestTimeStr(): String =
if (requestTime == null) "无" else TIME_LONG.format(Date(requestTime!!))

fun getResponseTimeStr(): String =
if (requestTime == null) "无" else TIME_LONG.format(Date(responseTime!!))
}

② 数据库 和 Dao


直接用原生SQLite实现,就一张表和一些简单操作,就不另外引个第三方库了,自定义SQLiteOpenHelper:


class NetworkLogDB(context: Context) :
SQLiteOpenHelper(context, "cp_network_capture.db", null, DB_VERSION) {
companion object {
private const val DB_VERSION = 1
}

override fun onCreate(db: SQLiteDatabase?) {
db?.execSQL(NetworkLogDao.createTableSql())
}

override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {}
}

接着在Dao里编写建表,增删查表的方法:


class NetworkLogDao(private val db: NetworkLogDB) {
companion object {
const val TABLE_NAME = "network_log"

/**
* 建表SQL语句
* */

fun createTableSql() = StringBuilder("CREATE TABLE $TABLE_NAME(").apply {
append("id INTEGER PRIMARY KEY AUTOINCREMENT,")
append("method TEXT,")
append("url TEXT,")
append("scheme TEXT,")
append("protocol TEXT,")
append("host TEXT,")
append("path TEXT,")
append("duration INTEGER,")
append("requestTime INTEGER,")
append("requestHeaders TEXT,")
append("requestBody TEXT,")
append("requestContentType TEXT,")
append("responseCode INTEGER,")
append("responseTime INTEGER,")
append("responseHeaders TEXT,")
append("responseBody TEXT,")
append("responseMessage TEXT,")
append("responseContentType TEXT,")
append("responseContentLength INTEGER,")
append("errorMsg STRING,")
append("source STRING")
append(")")
}.toString()
}


/**
* 插入数据
* */

fun insert(data: NetworkLog) {
db.writableDatabase.insert(TABLE_NAME, null, ContentValues().apply {
put("method", data.method)
put("url", data.url)
put("scheme", data.scheme)
put("protocol", data.protocol)
put("host", data.host)
put("path", data.path)
put("duration", data.duration)
put("requestTime", data.requestTime)
put("requestHeaders", data.requestHeaders)
put("requestBody", data.requestBody)
put("requestBody", data.requestBody)
put("requestContentType", data.requestContentType)
put("responseCode", data.responseCode)
put("responseTime", data.responseTime)
put("responseHeaders", data.responseHeaders)
put("responseBody", data.responseBody)
put("responseMessage", data.responseMessage)
put("responseContentType", data.responseContentType)
put("responseContentLength", data.responseContentLength)
put("errorMsg", data.errorMsg)
put("source", data.source)
})
NetworkCapture.context?.contentResolver?.notifyChange(NetworkCapture.networkLogTableUri, null)
}

/**
* 查询数据
* @param offset 第几页,从0开始
* @param limit 分页条数
* */

fun query(
offset: Int = 0,
limit: Int = 20,
selection: String? = null,
selectionArgs: Array<String>? = null
)
: ArrayList<NetworkLog> {
val logList = arrayListOf<NetworkLog>()
val cursor = db.readableDatabase.query(
TABLE_NAME, null, selection, selectionArgs, null, null, "id DESC", "${offset * limit},${limit}"
)
if (cursor.moveToFirst()) {
do {
logList.add(NetworkLog().apply {
id = cursor.getLong(0)
method = cursor.getString(1)
url = cursor.getString(2)
scheme = cursor.getString(3)
protocol = cursor.getString(4)
host = cursor.getString(5)
path = cursor.getString(6)
duration = cursor.getLong(7)
requestTime = cursor.getLong(8)
requestHeaders = cursor.getString(9)
requestBody = cursor.getString(10)
requestContentType = cursor.getString(11)
responseCode = cursor.getInt(12)
responseTime = cursor.getLong(13)
responseHeaders = cursor.getString(14)
responseBody = cursor.getString(15)
responseMessage = cursor.getString(16)
responseContentType = cursor.getString(17)
responseContentLength = cursor.getLong(18)
errorMsg = cursor.getString(19)
source = cursor.getString(20)

})
} while (cursor.moveToNext())
}
cursor.close()
return logList
}

/**
* 根据id删除数据
* @param id 记录id
* */

fun deleteById(id: Long) {
db.writableDatabase.delete(TABLE_NAME, "id = ?", arrayOf("$id"))
}

/**
* 清空数据
* */

fun clear() {
db.writableDatabase.delete(TABLE_NAME, null, null)
}
}

③ UI 与 交互


没带安卓机回家...待补充图片...


④ 集成方式


参考leakcanary的集成方式,利用 activity-alias 标签单独创建一个桌面图标,作为抓包页面入口:


<activity-alias
android:name=".NetworkCaptureActivity"
android:exported="true"
android:icon="@mipmap/cp_network_capture_logo"
android:label="抓包"
android:targetActivity="cn.coderpig.cp_network_capture.ui.activity.NetworkCaptureActivity"
android:taskAffinity="cn.coderpig.cp_dev_helper.${applicationId}">

<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

接着是Context传递的,自定义一个ContentProvider,在onCreate()处获得,顺带加上监听数据库变化:


class CpNetworkCaptureProvider : ContentProvider() {
override fun onCreate(): Boolean {
val context = context
if (context == null) {
Log.e(TAG, "CpNetworkCapture库初始化Context失败")
} else {
Log.e(TAG, context.packageName)
NetworkCapture.init(context)
}
return true
}

override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
)
: Cursor? = null

override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
}

接着使用 debugImplementation 方式导入依赖,打debug包才会打包这部分代码,接着使用使用反射的方式添加抓包拦截器即可~


作者:coder_pig
来源:juejin.cn/post/7276750877250699320
收起阅读 »

Android使用无障碍模式跳过应用广告的实现(仿李跳跳功能)

1.前言 当代移动应用广告的过度侵扰问题已经引起了广大用户的关注和不满。而芒果TV平台运营中心的副总经理陈超推出了一项名为"摇一摇开屏广告"的新策略↓ 引发了更多对于用户体验的担忧下↓ 在这种策略下,用户在不经意间被强制打开广告,这对用户来说无疑是一种糟糕...
继续阅读 »

1.前言


当代移动应用广告的过度侵扰问题已经引起了广大用户的关注和不满。而芒果TV平台运营中心的副总经理陈超推出了一项名为"摇一摇开屏广告"的新策略↓


ezgif.com-resize.gif


引发了更多对于用户体验的担忧下↓


image.png


在这种策略下,用户在不经意间被强制打开广告,这对用户来说无疑是一种糟糕的体验。当人处于运动的状态下,打开某些APP。


而“李跳跳”APP通过利用Android的无障碍模式,"李跳跳"成功帮助用户自动跳过这些令人困扰的开屏广告,从而有效地减轻了用户的不便。随之而来的不正当竞争指控引发了对于这类应用的法律和道德讨论。


我决定仿“李跳跳”写一个广告跳过助手,以呼吁对于这种过度侵扰性广告的关注,同时也为广大Android开发者们分享运用的技术原理。


2.效果图


ezgif-2-147d9e39be.gif


3.无障碍模式


当我们深入探讨"李跳跳"及其仿制应用的功能实现时,了解Android的无障碍模式和AccessibilityService以及onAccessibilityEvent函数的详细内容至关重要。这些技术是这些应用背后的核心,让我们更深入地了解它们:


3.1Android的无障碍模式


无障碍模式是Android操作系统的一个功能,旨在提高设备的可用性和可访问性,特别是为了帮助那些有视觉、听觉或运动障碍的用户。通过无障碍模式,应用可以获取有关用户界面和用户操作的信息,以便在需要时提供更好的支持。


3.2 onServiceConnected函数


这是AccessibilityService的回调函数之一,当服务被绑定到系统时会被调用。在这个函数中,可以进行初始化操作,如设置服务的配置、注册事件监听等。


@Override
public void onServiceConnected() {
// 在这里进行服务的初始化操作
// 注册需要监听的事件类型
}

3.3 onAccessibilityEvent函数


这是AccessibilityService的核心函数,用于处理发生的可访问性事件。在这个函数中,可以检查事件类型、获取事件源信息以及采取相应的操作。
本次功能主要用到的就是这个函数


@Override 
public void onAccessibilityEvent(AccessibilityEvent event) {
// 处理可访问性事件
// 获取事件类型、源信息,执行相应操作
}

3.4 onInterrupt函数


这个函数在服务被中断时会被调用,例如,用户关闭了无障碍服务或系统资源不足。可以在这里进行一些清理工作或记录日志以跟踪服务的中断情况。


@Override
public void onInterrupt() {
// 服务中断时执行清理或记录日志操作
}

3.5 onUnbind函数


当服务被解绑时,这个函数会被调用。可以在这里进行资源的释放和清理工作。


@Override
public boolean onUnbind(Intent intent) {
// 解绑时执行资源释放和清理操作
return super.onUnbind(intent);
}

3.6 onKeyEvent函数(未用到)


这个函数用于处理键盘事件。通过监听键盘事件,可以实现自定义的按键处理逻辑。例如,可以捕获特定按键的按下和释放事件,并执行相应操作。


@Override
public boolean onKeyEvent(KeyEvent event) {
// 处理键盘事件,执行自定义逻辑
return super.onKeyEvent(event);
}


3.7 onGesture函数(未用到)


onGesture()函数允许处理手势事件。这些事件可以包括触摸屏幕上的手势,例如滑动、缩放、旋转等。通过监听手势事件,可以实现各种手势相关的应用功能。


@Override
public boolean onGesture(int gestureId) {
// 处理手势事件,执行自定义逻辑
return super.onGesture(gestureId);
}


4.功能实现


4.1无障碍服务的启用和注册



  • 创建AccessibilityService的类。


public class AdSkipService extends AccessibilityService {
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {

}

@Override
public void onInterrupt() {

}

@Override
public boolean onUnbind(Intent intent) {
return super.onUnbind(intent);
}
}


  • 在AndroidManifest.xml文件中声明AccessibilityService。


<service android:name=".service.AdSkipService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:enabled="true"
android:exported="true">

<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />

</service>

4.2 onAccessibilityEvent函数的实现



  • 在onAccessibilityEvent函数中获取当前界面的控件,并在异步遍历所有子控件


@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
// 获取当前界面的控件
AccessibilityNodeInfo nodeInfo = event.getSource();

taskExecutorService.execute(new Runnable() {
@Override
public void run() {
//遍历节点函数,查找所有控件
iterateNodesToSkipAd(nodeInfo);
}
});
}



  • 判断控件的文本是否带有“跳过”二字


/**
* 判断节点内容是否是关键字(默认为”跳过“二字 )
* @param node 节点
* @param keyWords 关键字
* @return 是否包含
* */

public static boolean isKeywords(AccessibilityNodeInfo node, String keyWords){
CharSequence text = node.getText();
if (TextUtils.isEmpty(text)) {
return false;
}
//查询是否包含"跳过"二字
return text.toString().contains(keyWords);
}


  • 触发控件的点击事件


/**
* 点击跳过按钮
* @param node 节点
* @return 是否点击成功
* */

private boolean clickSkipNode(AccessibilityNodeInfo node){
//尝试点击
boolean clicked = node.performAction(AccessibilityNodeInfo.ACTION_CLICK);
//打印点击按钮的结果
LogUtil.e("clicked result = " + clicked);
return clicked;
}

注:本篇章为了读者方便理解,对代码进行了简化,删去了繁琐的逻辑判断。具体实现详见源码


5.结语


我们通过AccessibilityService和无障碍模式,提供了一种改善用户体验的方法,帮助用户摆脱令人不快的广告干扰。通过了解如何开发这样的应用,我们可以更好地理解无障碍技术的潜力,并在保护用户权益的前提下改善应用环境。


如果对你有所帮助,请记得帮我点一个赞和star,有什么意见和建议可以在评论区给我留言


源码地址:github.com/Giftedcat/A…


作者:GiftedCat
来源:juejin.cn/post/7275009721760481320
收起阅读 »

Android 多种支付方式的优雅实现!

1.场景 App 的支付流程,添加多种支付方式,不同的支付方式,对应的操作不一样,有的会跳转到一个新的webview,有的会调用系统浏览器,有的会进去一个新的表单页面,等等。 并且可以添加的支付方式也是不确定的,由后台动态下发。 如下图所示: 根据上图 ui...
继续阅读 »

1.场景


App 的支付流程,添加多种支付方式,不同的支付方式,对应的操作不一样,有的会跳转到一个新的webview,有的会调用系统浏览器,有的会进去一个新的表单页面,等等。


并且可以添加的支付方式也是不确定的,由后台动态下发。


如下图所示:


image.png


根据上图 ui 理一下执行流程:



  1. 点击不同的添加支付方式 item。

  2. 进入相对应的添加支付方式流程(表单页面、webview、弹框之类的)。

  3. 在第三方回调里面根据不同的支付方式执行不同的操作。

  4. 调用后台接口查询添加是否成功。

  5. 根据接口结果展示不同的成功或者失败的ui.


2.以前的实现方式


用一个 Activity 承载,上述所有的流程都在 Activity 中。Activity 包含了列表展示、多种支付方式的实现和 ui。


伪代码如下:


class AddPaymentListActivity : AppCompatActivity(R.layout.activity_add_card) {

private val addPaymentViewModel : AddPaymentViewModel = ...

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addPaymentViewModel.checkPaymentStatusLiveData.observer(this) { isSuccess ->
// 从后台结果判断是否添加成功
if (isSuccess) {
addCardSuccess(paymentType)
} else {
addCardFailed(paymentType)
}
}
}

private fun clickItem(paymentType: PaymentType) {
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> //执行添加谷歌支付流程
PaymentType.ADD_PAY_PEL-> //执行添加PayPel支付流程
PaymentType.ADD_ALI_PAY-> //执行添加支付宝支付流程
PaymentType.ADD_STRIPE-> //执行添加Stripe支付流程
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (resultCode) {
PaymentType.ADD_GOOGLE_PAY -> {
// 根据第三方回调的结果,拿到key
// 根据key调用后台的Api接口查询是否添加成功
}
PaymentType.ADD_PAY_PEL -> // 同上
// ...
}
}

private fun addCardSuccess(paymentType: PaymentType){
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> // 添加对应的支付方式成功,展示成功的ui,然后执行下一步操作
PaymentType.ADD_PAY_PEL-> // 同上
// ...
}
}

private fun addCardFailed(paymentType: PaymentType){
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> // 添加对应的支付方式失败,展示失败的ui
PaymentType.ADD_PAY_PEL-> // 同上
// ...
}
}

enum class PaymentType {
ADD_GOOGLE_PAY, ADD_PAY_PEL, ADD_ALI_PAY, ADD_STRIPE
}

}

虽然看起来根据 paymentType 来判断,逻辑条理也还过得去,但是实际上复杂度远远不止如此。


• 不同的支付方式跳转的页面相差很大。


• 结果的回调获取也相差很大,并不是所有的都在onActivityResult中。


• 成功和失败实际上也不能统一来处理,里面包含很多的if…else…判断。


• 如果支付方式是后台动态下发的,处理起来判断逻辑就更多了。


此外,最大的问题:扩展性问题。


当新来一种支付方式,例如微信支付之类的,改动代码就很大了,基本就是将整个Activity中的代码都要改动。可以说上面这种方式的可扩展性为零,就是简单的将代码都揉在一起。


3.优化后的代码


要想实现高内聚低耦合,最简单的就是套用常见的设计模式,回想一下,发现策略模式+简单工厂模式非常这种适合这种场景。


先看下优化后的代码:


class AddPlatformActivity : BaseActivity() {

private var addPayPlatform: IAddPayPlatform? = null

private fun addPlatform(payPlatform: String) {
// 将后台返回的支付平台字符串变成枚举类
val platform: PayPlatform = getPayPlatform(payPlatform) ?: return
addPayPlatform = AddPayPlatformFactory.getCurrentPlatform(this, platform)
addPayPlatform?.getLoadingLiveData()?.observe(this@AddPlatformActivity) { showLoading ->
if (showLoading) startLoading() else stopLoading()
}
addPayPlatform?.addPayPlatform(AddCardParameter(platform))
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
// 将onActivityResult的回调转接到需要监听的策略类里面
addPayPlatform?.thirdAuthenticationCallback(requestCode, resultCode, data)
}
}

4.策略模式


意图: 定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。


主要解决: 在有多种算法相似的情况下,使用if…else所带来的复杂和难以维护。


何时使用: 一个系统有许多许多类,而区分它们的只是他们直接的行为。


如何解决: 将这些算法封装成一个一个的类,任意地替换。


关键代码: 实现同一个接口。


**优点: **


1、算法可以自由切换。


2、避免使用多重条件判断。


3、扩展性良好。


缺点


1、策略类会增多。


2、所有策略类都需要对外暴露。


**使用场景: **


1、如果在一个系统里面有许多类,它们之间的区别仅在于它们的行为,那么使用策略模式可以动态地让一个对象在许多行为中选择一种行为。


2、一个系统需要动态地在几种算法中选择一种。


3、如果一个对象有很多的行为,如果不用恰当的模式,这些行为就只好使用多重的条件选择语句来实现。


5.需要实现的目标


5.1 解耦宿主 Activity


现在宿主Activity中代码太重了,包含多种支付方式实现,还有列表ui的展示,网络请求等。


现在目标是将 Activity 中的代码拆分开来,让宿主 Activity 变得小而轻。


如果产品说新增一种支付方式,只需要改动很少的代码,就可以轻而易举的实现。


5.2 抽取成独立的模块


因为公司中有可能存在多个项目,支付模块的分层应该处于可复用的层级,以后很有可能将其封装成一个独立的 mouble,给不同的项目使用。


现在代码全在 Activity 中,以后若是抽取 mouble 的话,相当于整个需求重做。


5.3 组件黑盒


"组件黑盒"这个名词是我自己的一个定义。大致意思:



将一个 View 或者一个类进行高度封装,尽可能少的暴露public方法给外部使用,自成一体。




业务方在使用时,可以直接黑盒使用某个业务组件,不必关心其中的逻辑。




业务方只需要一个简单的操作,例如点击按钮调用方法,然后逻辑都在组件内部实现,组件内处理外部事件的所有操作,例如:Loading、请求网络、成功或者失败。




业务方都不需要知道组件内部的操作,做到宿主和组件的隔离。




当然这种处理也是要分场景考虑的,其中一个重点就是这个组件是偏业务还是偏功能,也就是是否要将业务逻辑统一包进组件,想清楚这个问题后,才能去开发一个业务组件。 摘自xu’yi’sheng博客。xuyisheng.top/author/xuyi…



因为添加支付方式是一个偏业务的功能,我的设计思路是:


外部 Activity 点击添加对应的支付方式,将支付方式的枚举类型和支付方式有关的参数通过传递,然后不同的策略类组件执行自己的添加逻辑,再通过一层回调将第三方支付的回调从 Activity 中转接过来,每个策略类内部处理自己的回调操作,具体的策略类自己维护成功或者失败的ui。


6.具体实现


6.1 定义顶层策略接口


interface IAddPayPlatform {

fun addPayPlatform(param: AddCardParameter)

fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, data: Intent?)

fun addCardFailed(message: String?)

fun addCardSuccess()
}

6.2 通用支付参数类


open class AddCardParameter(val platform: PayPlatform)

class AddStripeParameter(val card: Card, val setPrimaryCard: Boolean, platform: PayPlatform)
: AddCardParameter(platform = PayPlatform.Stripe)

因为有很多种添加支付方式,不同的支付方式对应的参数都不一样。


所以先创建一个通用的卡片参数基类AddCardParameter, 不同的支付方式去实现不同的具体参数。这样的话策略接口就可以只要写一个添加卡片的方法addPayPlatform(paramAddCardParameter)


6.3 Loading 的处理


因为我想实现的是黑盒组件的效果,所有添加卡片的loading也是封装在每一个策略实现类里面的。


Loading的出现和消失这里有几种常见的实现方式:


• 传递BaseActivity的引用,因为我的loading有关的方法是放在BaseActivity中,这种方式简单但是会耦合BaseActivity。


• 使用消息事件总线,例如EventBus之类的,这种方式解耦强,但是消息事件不好控制,还要添加多余的依赖库。


• 使用LiveData,在策略的通用接口中添加一个方法返回Loading的LiveData, 让宿主Activity自己去实现。


interface IAddPayPlatform {
// ...
fun getLoadingLiveData(): LiveData<Boolean>?
}

6.4 提取BaseAddPayStrategy


因为每一个添加卡的策略会存在很多相同的代码,这里我抽取一个BaseAddPayStrategy来存放模板代码。


需要实现黑盒组件的效果,宿主Activity中都不需要去关注添加支付方式是不是存在网络请求这一个过程,所以网络请求也分装在每一个策略实现类里面。


abstract class BaseAddPayStrategy(val activity: AppCompatActivity, val platform: PayPlatform) : IAddPayPlatform {

private val loadingLiveData = SingleLiveData<Boolean>()

protected val startActivityIntentLiveData = SingleLiveData<Intent>()

override fun getLoadingLiveData(): LiveData<Boolean> = loadingLiveData

protected fun startLoading() = loadingLiveData.setValue(true)

protected fun stopLoading() = loadingLiveData.setValue(false)

private fun reloadWallet() {
startLoading()
// 添加卡片完成后,重新刷新钱包数据
}

override fun addCardSuccess() {
reloadWallet()
}

override fun addCardFailed(message: String?) {
stopLoading()
if (isEWalletPlatform(platform)) showAddEWalletFailedView() else showAddPhysicalCardFailedView(message)
}

/**
* 添加实体卡片失败展示ui
*/

private fun showAddPhysicalCardFailedView(message: String?) {
showSaveErrorDialog(activity, message)
}

/**
* 添加实体卡片成功展示ui
*/

private fun showAddPhysicalCardSuccessView() {
showCreditCardAdded(activity) {
activity.setResult(Activity.RESULT_OK)
activity.finish()
}
}

private fun showAddEWalletSucceedView() {
// 添加电子钱包成功后的执行
activity.setResult(Activity.RESULT_OK)
activity.finish()
}

private fun showAddEWalletFailedView() {
// 添加电子钱包失败后的执行
}

// ---默认空实现,有些支付方式不需要这些方法---
override fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, data: Intent?) = Unit

override fun getStartActivityIntent(): LiveData<Intent> = startActivityIntentLiveData
}

6.5 具体的策略类实现


通过传递过来的AppCompatActivity引用获取添加卡片的ViewModel实例AddPaymentViewModel,然后通过AddPaymentViewModel去调用网络请求查询添加卡片是否成功。


class AddXXXPayStrategy(activity: AppCompatActivity) : BaseAddPayStrategy(activity, PayPlatform.XXX) {

protected val addPaymentViewModel: AddPaymentViewModel by lazy {
ViewModelProvider(activity).get(AddPaymentViewModel::class.java)
}

init {
addPaymentViewModel.eWalletAuthorizeLiveData.observeState(activity) {

onSuccess { addCardSuccess()}

onError { addCardFailed(it.detailed) }
}
}

override fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, result: Intent?) {
val uri: Uri = result?.data ?: return
if (uri.host == "www.xxx.com") {
uri.getQueryParameter("transactionId")?.let {
addPaymentViewModel.confirmEWalletAuthorize(platform.name, it)
}
}
}

override fun addPayPlatform(param: AddCardParameter) {
startLoading()
addPaymentViewModel.addXXXCard(param)
}
}

7.简单工厂进行优化


因为我不想在Activity中去引用每一个具体的策略类,只想引用抽象接口类IAddPayPlatform, 这里通过一个简单工厂来优化。


object AddPayPlatformFactory {


fun setCurrentPlatform(activity: AppCompatActivity, payPlatform: PayPlatform): IAddPayPlatform? {
return when (payPlatform) {
PayPlatform.STRIPE -> AddStripeStrategy(activity)
PayPlatform.PAYPAL -> AddPayPalStrategy(activity)
PayPlatform.LINEPAY -> AddLinePayStrategy(activity)
PayPlatform.GOOGLEPAY -> AddGooglePayStrategy(activity)
PayPlatform.RAPYD -> AddRapydStrategy(activity)
else -> null
}
}

}


8.再增加一种支付方式


如果再增加一种支付方式,宿主Activity中的代码都可以不要改动,只需要新建一个新的策略类,实现顶层策略接口即可。


这样,不管是删除还是新增一种支付方式,维护起来就很容易了。


策略模式的好处就显而易见了。



今日分享到此结束,对你有帮助的话,点个赞再走呗,每日一个面试小技巧




关注公众号:Android老皮

解锁  《Android十大板块文档》 ,让学习更贴近未来实战。已形成PDF版



内容如下



1.Android车载应用开发系统学习指南(附项目实战)

2.Android Framework学习指南,助力成为系统级开发高手

3.2023最新Android中高级面试题汇总+解析,告别零offer

4.企业级Android音视频开发学习路线+项目实战(附源码)

5.Android Jetpack从入门到精通,构建高质量UI界面

6.Flutter技术解析与实战,跨平台首要之选

7.Kotlin从入门到实战,全方面提升架构基础

8.高级Android插件化与组件化(含实战教程和源码)

9.Android 性能优化实战+360°全方面性能调优

10.Android零基础入门到精通,高手进阶之路



作者:派大星不吃蟹
来源:juejin.cn/post/7274475842998157353
收起阅读 »