注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Android — DialogFragment显示后隐藏的导航栏显示问题

沉浸式显示广泛的应用于大部分App中,基本上可以说是App的必备功能,在之前的文章Android 全屏显示和沉浸式显示中介绍过如何通过WindowInsetsControllerCompat实现沉浸式显示。当然,我也将其应用到了我司的App中。但是在后续开发测...
继续阅读 »

沉浸式显示广泛的应用于大部分App中,基本上可以说是App的必备功能,在之前的文章Android 全屏显示和沉浸式显示中介绍过如何通过WindowInsetsControllerCompat实现沉浸式显示。当然,我也将其应用到了我司的App中。但是在后续开发测试的过程中发现了一个奇怪的现象,加载弹窗显示时,已经隐藏的底部导航栏又显示出来了。


问题复现


下面通过一段示例代码演示一下:



  • 加载弹窗


class LoadingDialogFragment : DialogFragment() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.LoadingDialog)
}

override fun onCreateView(inflater: LayoutInflater, container: ViewGr0up?, savedInstanceState: Bundle?): View {
dialog?.let { containerDialog ->
containerDialog.window?.run {
setBackgroundDrawable(ContextCompat.getDrawable(requireContext(), android.R.color.transparent))
decorView.setBackgroundResource(android.R.color.transparent)
val layoutParams = attributes
layoutParams.width = DensityUtil.dp2Px(200)
layoutParams.height = DensityUtil.dp2Px(120)
layoutParams.gravity = Gravity.CENTER
attributes = layoutParams
}
containerDialog.setCancelable(true)
containerDialog.setCanceledOnTouchOutside(false)
}
return LayoutLoadingDialogBinding.inflate(layoutInflater, container, false).root
}
}


  • 示例页面


class DialogFragmentExampleActivity : AppCompatActivity() {

val DIALOG_TYPE_LOADING = "loadingDialog"

private lateinit var insetsController: WindowInsetsControllerCompat

private var alreadyChanged = false

private var callDismissDialogTime = 0L

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = LayoutDialogFragmentExampleActivityBinding.inflate(layoutInflater).also {
setContentView(it.root)
}

// 调整系统栏
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsController = WindowCompat.getInsetsController(window, window.decorView).also {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.navigationBars())
}
window.statusBarColor = ContextCompat.getColor(this, android.R.color.transparent)
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets ->
if (!alreadyChanged) {
alreadyChanged = true
windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()).run {
binding.topView.updateLayoutParams<ConstraintLayout.LayoutParams> { height += top }
binding.tvTitle.updateLayoutParams<ConstraintLayout.LayoutParams> { topMargin += top }
}
}
WindowInsetsCompat.CONSUMED
}

binding.btnShowLoadingDialog.setOnClickListener {
showLoadingDialog()
}
}

private fun showLoadingDialog() {
LoadingDialogFragment().run {
show(supportFragmentManager, DIALOG_TYPE_LOADING)
}
// 模拟耗时操作,两秒后关闭弹窗
lifecycleScope.launch(Dispatchers.IO) {
delay(2000)
dismissLoadingDialog()
}
}

private fun dismissLoadingDialog() {
callDismissDialogTime = System.currentTimeMillis()
lifecycleScope.launch(Dispatchers.IO) {
if (async { checkLoadingDialogStatue() }.await()) {
withContext(Dispatchers.Main) {
// 从supportFragmentManager中获取加载弹窗,并调用隐藏方法
(supportFragmentManager.findFragmentByTag(DIALOG_TYPE_LOADING) as? DialogFragment)?.run {
if (dialog?.isShowing == true) {
dismissAllowingStateLoss()
}
}
}
}
}
}

/**
* 检查加载弹窗的状态直到获取到加载弹窗或者超过时间
*/

private suspend fun checkLoadingDialogStatue(): Boolean {
return if (supportFragmentManager.findFragmentByTag(DIALOG_TYPE_LOADING) == null && System.currentTimeMillis() - callDismissDialogTime < 1500L) {
delay(100)
checkLoadingDialogStatue()
} else {
true
}
}
}

效果如图:


Screen_recording_202 -big-original.gif

解决显示异常问题


上述示例代码中,在示例页面的初始化方法中通过WindowInsetsControllerCompat对页面的WindowdecorView进行操作,隐藏了导航栏。但是在DialogFragment中,Dialog对象也有其所属的WindowdecorView,上述示例代码中并没有针对Dialog所属的WindowdecorView进行配置。


基于上面的分析,对示例代码进行调整,调整如下:



  • 加载弹窗


class LoadingDialogFragment : DialogFragment() {

......

override fun onCreateView(inflater: LayoutInflater, container: ViewGr0up?, savedInstanceState: Bundle?): View {
dialog?.let { containerDialog ->
containerDialog.window?.run {
WindowCompat.setDecorFitsSystemWindows(this, false)
WindowCompat.getInsetsController(this, decorView).also {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.navigationBars())
}
......
}
......
}
return LayoutLoadingDialogBinding.inflate(layoutInflater, container, false).root
}
}

修改后效果如图:


fix.gif

适配不同机型


通常来说,上述示例代码用的是官方的API,应该不会出现什么意外,然而还是出现了意外。公司的另一台三星的测试机跟我自己的测试机Pixel 3a XL效果差别很大。


三星测试机(SM-A515F)效果如下:


未调整调整后
Screen_recording_202 -big-original.gifScreen_recording_202 -big-original.gif

虽然这可能是安卓的通病,但对于这种情况我还是感到有些遗憾,通用API在不同厂商的手机上效果居然差这么多。虽然遗憾,但还是得解决问题。


根据效果图来看,对页面的配置生效了,对Dialog的配置也生效了,但是DialogFragment隐藏后重置了对页面的配置。最简单的处理就是在DialogFragment消失之后判断下导航栏是否显示,显示则隐藏。


调整代码如下:



  • 加载弹窗


class LoadingDialogFragment : DialogFragment() {

......

override fun onDestroyView() {
super.onDestroyView()
// 这里通过setFragmentResult API 来传递弹窗已经关闭的消息。
parentFragmentManager.setFragmentResult(DialogFragmentExampleActivity::class.java.simpleName, Bundle())
}
}


  • 示例页面


class DialogFragmentExampleActivity : AppCompatActivity() {

......

private var navigationBarShow = false

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = LayoutDialogFragmentExampleActivityBinding.inflate(layoutInflater).also {
setContentView(it.root)
}

supportFragmentManager.setFragmentResultListener(this::class.java.simpleName, this) { requestKey, result ->
// 接收加载弹窗关闭的消息
if (requestKey == this::class.java.simpleName) {
if (navigationBarShow) {
// 根据实践,不延迟500毫秒有概率出现无法隐藏的情况。
lifecycleScope.launch(Dispatchers.IO) {
delay(500L)
withContext(Dispatchers.Main) {
hideNavigationBar()
}
}
}
}
}

......

ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets ->
windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()).run {
if (!alreadyChanged) {
alreadyChanged = true
binding.topView.updateLayoutParams<ConstraintLayout.LayoutParams> { height += top }
binding.tvTitle.updateLayoutParams<ConstraintLayout.LayoutParams> { topMargin += top }
}
// 当底部空间不为0时可以判断导航栏显示
navigationBarShow = bottom != 0
}
WindowInsetsCompat.CONSUMED
}

......
}

......

private fun hideNavigationBar() {
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
}

override fun onDestroy() {
super.onDestroy()
// 页面销毁时清除监听
supportFragmentManager.clearFragmentResultListener(this::class.java.simpleName)
}
}

修改后效果如图:


Screen_recording_202 -big-original.gif

示例


演示代码已在示例Demo中添加。


ExampleDemo github


ExampleDemo gitee


作者:ChenYhong
来源:juejin.cn/post/7313742254145208356
收起阅读 »

Android Room 数据库的坑

1.Android Room 数据库的坑 在用Room数据库的时候 发现有需要一个字段的条件合到一起去写这个SQL @Query("SELECT * FROM 表名 WHERE 字段A = '1' and 字段B <= :Time " + ...
继续阅读 »

1.Android Room 数据库的坑


在用Room数据库的时候 发现有需要一个字段的条件合到一起去写这个SQL


 @Query("SELECT * FROM 表名 WHERE 字段A = '1' and 字段B <= :Time " +
"and 字段B >= :Time and 字段C <= :BTime and 字段C >= :BTime " +
"and '(' || 字段D is null or 字段D = '' || ')'")

List selectList(String Time, String BTime);


这里面的 “ ||” 是Room里面独特的表达方式 是替代了java里面的“+”号


正常在android中 使用 是这样的


String sql = "SELECT * FROM 表名 WHERE 字段A = '1' and 字段B <= "+传入的参数+" " +
"and 字段B >= "+传入的参数+" and 字段C <= "+传入的参数+" and 字段c >= "+传入的参数+" " +
"and '(' "+" 字段D is null or 字段D = '' "+" ')'"


cursor = db.rawQuery(sql, null);

而在Room 中 用 “||” 代替了 “+”


2.Android Room 查询语句的坑


@Query("SELECT * FROM 表名 WHERE 字段A = '0' order by id desc")
List selectList();

假如你正在查询一张表的面的内容,然后忽然跑出来一个异常



# [Android RoomDatabase Cruash "Cursor window allocation of 4194304 bytes failed"](https://stackoverflow.com/questions/75456123/android-roomdatabase-cruash-cursor-window-allocation-of-4194304-bytes-failed)

奔溃日志:


android.database.CursorWindowAllocationException: Could not allocate CursorWindow '/data/user/0/cn.xxx.xxx/databases/xxx.db' of size 2097152 due to error -13.
at android.database.CursorWindow.nativeCreate(Native Method)
at android.database.CursorWindow.<init>(CursorWindow.java:139)
at android.database.CursorWindow.<init>(CursorWindow.java:120)
at android.database.AbstractWindowedCursor.clearOrCreateWindow(AbstractWindowedCursor.java:202)
at android.database.sqlite.SQLiteCursor.fillWindow(SQLiteCursor.java:147)
at android.database.sqlite.SQLiteCursor.getCount(SQLiteCursor.java:140)
at yd.d.m(SourceFile:21)
at cn.xxx.control.y.y0(SourceFile:1)
at e5.y.p(SourceFile:230)
at e5.y.l(SourceFile:1)
at e5.y.E(SourceFile:1)
at cn.xxx.cluster.classin.viewmodel.SessionViewModel$d.invokeSuspend(SourceFile:42)

触发原因



  • Room 对应的 Sqlite 数据库,其对 CursorWindows 分配的大小是有限制的,最大为 2M,超过之后会发生上述崩溃闪退现象(偶现且难以复现的 bug


解决方法


需要业务方梳理这块的业务,优化数据库的调用,如果明确知道在一个方法里面会调用多个数据库的方法,需要让 controller 提供新的方法,且这个 controller 层的方法需要添加 @Transaction 进行注解,从而保证在同一个事物内进行数据库操作,以此避免 CursorWindows 大小超过 2M


那么问题来了 @Transaction 这个注解是干嘛的呢


翻译 事务的意思


@Transaction
@Query("SELECT * FROM 表名 WHERE 字段A = '0' order by id desc")
List selectList();

接着 问题完美解决


大家学“废”了嘛 学费的评论区Q1 没学“废”的抠眼珠子


作者:笨qiao先飞
来源:juejin.cn/post/7273674981959745593
收起阅读 »

动态代理在Android中的运用

动态代理是一种在编程中非常有用的设计模式,它允许你在运行时创建一个代理对象来代替原始对象,以便在方法调用前后执行额外的逻辑。在Android开发中,动态代理可以用于各种用例,如性能监控、AOP(面向切面编程)和事件处理。本文将深入探讨Android动态代理的原...
继续阅读 »

动态代理是一种在编程中非常有用的设计模式,它允许你在运行时创建一个代理对象来代替原始对象,以便在方法调用前后执行额外的逻辑。在Android开发中,动态代理可以用于各种用例,如性能监控、AOP(面向切面编程)和事件处理。本文将深入探讨Android动态代理的原理、用途和实际示例。


什么是动态代理?


动态代理是一种通过创建代理对象来代替原始对象的技术,以便在方法调用前后执行额外的操作。代理对象通常实现与原始对象相同的接口,但可以添加自定义行为。动态代理是在运行时生成的,因此它不需要在编译时知道原始对象的类型。


动态代理的原理


动态代理的原理涉及两个关键部分:



  1. InvocationHandler(调用处理器):这是一个接口,通常由开发人员实现。它包含一个方法 invoke,在代理对象上的方法被调用时会被调用。在 invoke 方法内,你可以定义在方法调用前后执行的逻辑。

  2. Proxy(代理类):这是Java提供的类,用于创建代理对象。你需要传递一个 ClassLoader、一组接口以及一个 InvocationHandlerProxy.newProxyInstance 方法,然后它会生成代理对象。


下面是一个示例代码,演示了如何创建一个简单的动态代理:


import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy

// 接口
interface MyInterface {
fun doSomething()
}

// 实现类
class MyImplementation : MyInterface {
override fun doSomething() {
println("Original method is called.")
}
}

// 调用处理器
class MyInvocationHandler(private val realObject: MyInterface) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
println("Before method is called.")
val result = method.invoke(realObject, *(args ?: emptyArray()))
println("After method is called.")
return result
}
}

fun main() {
val realObject = MyImplementation()
val proxyObject = Proxy.newProxyInstance(
MyInterface::class.java.classLoader,
arrayOf(MyInterface::class.java),
MyInvocationHandler(realObject)
) as MyInterface

proxyObject.doSomething()
}

运行上述代码会输出:


Before method is called.
Original method is called.
After method is called.

这里,MyInvocationHandler 拦截了 doSomething 方法的调用,在方法前后添加了额外的逻辑。


Android中的动态代理


在Android中,动态代理通常使用Java的java.lang.reflect.Proxy类来实现。该类允许你创建一个代理对象,该对象实现了指定接口,并且可以拦截接口方法的调用以执行额外的逻辑。在Android开发中,常见的用途包括性能监控、权限检查、日志记录和事件处理。


动态代理的用途


性能监控


你可以使用动态代理来监控方法的执行时间,以便分析应用程序的性能。例如,你可以创建一个性能监控代理,在每次方法调用前记录当前时间,然后在方法调用后计算执行时间。


import android.util.Log

class PerformanceMonitorProxy(private val target: Any) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
val startTime = System.currentTimeMillis()
val result = method.invoke(target, *(args ?: emptyArray()))
val endTime = System.currentTimeMillis()
val duration = endTime - startTime
Log.d("Performance", "${method.name} took $duration ms to execute.")
return result
}
}

AOP(面向切面编程)


动态代理也是AOP的核心概念之一。AOP允许你将横切关注点(如日志记录、事务管理和安全性检查)从业务逻辑中分离出来,以便更好地维护和扩展代码。通过创建适当的代理,你可以将这些关注点应用到多个类和方法中。


事件处理


Android中常常需要处理用户界面上的各种事件,例如点击事件、滑动事件等。你可以使用动态代理来简化事件处理代码,将事件处理逻辑从Activity或Fragment中分离出来,使代码更加模块化和可维护。


实际示例


下面是一个简单的示例,演示了如何在Android中使用动态代理来处理点击事件:


import android.util.Log
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import android.view.View

class ClickHandlerProxy(private val target: View.OnClickListener) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
if (method.name == "onClick") {
Log.d("ClickHandler", "Click event intercepted.")
// 在事件处理前可以执行自定义逻辑
}
return method.invoke(target, *args.orEmpty())
}
}

// 使用示例
val originalClickListener = View.OnClickListener {
// 原始的点击事件处理逻辑
}

val proxyClickListener = Proxy.newProxyInstance(
originalClickListener::class.java.classLoader,
originalClickListener::class.java.interfaces,
ClickHandlerProxy(originalClickListener)
) as View.OnClickListener

button.setOnClickListener(proxyClickListener)

通过这种方式,你可以在原始的点击事件处理逻辑前后执行自定义逻辑,而无需修改原始的OnClickListener实现。


结论


动态代理是Android开发中强大的工具之一,它允许你在不修改原始对象的情况下添加额外的行为。在性能监控、AOP和事件处理等方面,动态代理都有广泛的应用。通过深入理解动态代理的原理和用途,你可以更好地设计和维护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/7275185537815183360
收起阅读 »

Android:自定义View实现签名带笔锋效果

自定义签名工具相信大家都轻车熟路,通过监听屏幕onTouchEvent事件,分别在按下(ACTION_DOWN)、抬起(ACTION_UP)、移动(ACTION_MOVE) 动作中处理触碰点的收集和绘制,很容易就达到了手签笔的效果。其中无非又涉及到线条的撤回、...
继续阅读 »

自定义签名工具相信大家都轻车熟路,通过监听屏幕onTouchEvent事件,分别在按下(ACTION_DOWN)抬起(ACTION_UP)移动(ACTION_MOVE) 动作中处理触碰点的收集和绘制,很容易就达到了手签笔的效果。其中无非又涉及到线条的撤回、取消、清除、画笔的粗细,也就是对收集的点集合和线集合的增删操作以及画笔颜色宽的的更改。这些功能都在 实现一个自定义有限制区域的图例(角度自识别)涂鸦工具类(上) 中介绍过。


但就在前不久遇到一个需求是要求手签笔能够和咱们使用钢笔签名类似的效果,当然这个功能目前是有一些公司有成熟的SDK的,但我们的需求是要不借助SDK,自己实现笔锋效果。那么,如何使画笔带笔锋呢?废话不多说,先上效果图:


image.png


要实现笔锋效果我们需要考虑几个因素:笔速笔宽按压力度(针对手写笔)。因为在onTouchEvent回调的次数是不变的,一旦笔速变快两点之间距离就被拉长。此时的笔宽不能保持在上一笔的宽度,需要我们通过计算插入新的点,同时计算出对应点的宽度。同理当我们笔速慢的时候,需要通过计算删除信息相近的点。要想笔锋自然,当然贝塞尔曲线是必不可少的。


这里我们暂时没有将笔的按压值作为笔宽的计算,仅仅通过笔速来计算笔宽。


/**
* 计算新的宽度信息
*/

public double calcNewWidth(double curVel, double lastVel,double factor) {
double calVel = curVel * 0.6 + lastVel * (1 - 0.6);
double vfac = Math.log(factor * 2.0f) * (-calVel);
double calWidth = mBaseWidth * Math.exp(vfac);
return calWidth;
}

/**
* 获取点信息
*/

public ControllerPoint getPoint(double t) {
float x = (float) getX(t);
float y = (float) getY(t);
float w = (float) getW(t);
ControllerPoint point = new ControllerPoint();
point.set(x, y, w);
return point;
}

/**
* 三阶曲线的控制点
*/

private double getValue(double p0, double p1, double p2, double t) {
double a = p2 - 2 * p1 + p0;
double b = 2 * (p1 - p0);
double c = p0;
return a * t * t + b * t + c;
}

最后也是最关键的地方,不再使用drawLine方式画线,而是通过drawOval方式画椭圆。通过前后两点计算出椭圆的四个点,通过笔宽计算出绘制椭圆的个数并加入椭圆集。最后在onDraw方法中绘制。


/**
* 两点之间将视图收集的点转为椭圆矩阵 实现笔锋效果
*/
public static ArrayList<SvgPointBean> twoPointsTransRectF(double x0, double y0, double w0, double x1, double y1, double w1, float paintWidth, int color) {

ArrayList<SvgPointBean> list = new ArrayList<>();
//求两个数字的平方根 x的平方+y的平方在开方记得X的平方+y的平方=1,这就是一个园
double curDis = Math.hypot(x0 - x1, y0 - y1);
int steps;
//绘制的笔的宽度是多少,绘制多少个椭圆
if (paintWidth < 6) {
steps = 1 + (int) (curDis / 2);
} else if (paintWidth > 60) {
steps = 1 + (int) (curDis / 4);
} else {
steps = 1 + (int) (curDis / 3);
}
double deltaX = (x1 - x0) / steps;
double deltaY = (y1 - y0) / steps;
double deltaW = (w1 - w0) / steps;
double x = x0;
double y = y0;
double w = w0;

for (int i = 0; i < steps; i++) {
RectF oval = new RectF();
float top = (float) (y - w / 2.0f);
float left = (float) (x - w / 4.0f);
float right = (float) (x + w / 4.0f);
float bottom = (float) (y + w / 2.0f);
oval.set(left, top, right, bottom);
//收集椭圆矩阵信息
list.add(new SvgPointBean(oval, color));
x += deltaX;
y += deltaY;
w += deltaW;
}

return list;
}

至此一个简单的带笔锋的手写签名就实现了。 最后附上参考链接Github.


我是一个喜爱Jay、Vae的安卓开发者,喜欢结交五湖四海的兄弟姐妹,欢迎大家到沸点来点歌!


作者:似曾相识2022
来源:juejin.cn/post/7244192848063627325
收起阅读 »

这是你们项目中WebView的样子吗?

这是你们项目中WebView的样子吗? 作者简介:Serpit,Android开发工程师,2023年加入37手游技术部,目前负责国内游戏发行 Android SDK 开发。 前言 开始前先问大家一个问题,你们项目中或者理想中的WebView的使用姿势是如何的?...
继续阅读 »

这是你们项目中WebView的样子吗?


作者简介:Serpit,Android开发工程师,2023年加入37手游技术部,目前负责国内游戏发行 Android SDK 开发。


前言


开始前先问大家一个问题,你们项目中或者理想中的WebView的使用姿势是如何的?有哪些规范、功能?都可以在下方评论中说出来大家讨论一下。下面正式开始介绍我们对于一个WebView使用的一些理解。


可监控


可监控是线上项目很重要的一个功能,监控的东西可以是用户体验相关数据、加载情况数据、报错等。那么,如何监控呢?这里分两点提下。


加载时间


利用WebViewClient的onPageStartedonPageFinished回调,但是注意,这两回调在某些情况下会回调多次,比如在发生重定向时候,会有多次的onPageStarted回调出现,这就需要用一些标记位或者拦截等手段来保证统计到最开始的onPageStarted和最后的onPageFinished之间的耗时。


这里贴上一段伪代码


 @Override
public void onPageStarted(WebView webView, String url, Bitmap favicon) {
super.onPageStarted(webView, url, favicon);
if (TextUtils.isEmpty(url)) {
return;
}
consumeMap.put(getKey(url), SystemClock.uptimeMillis());
}

@Override
public void onPageFinished(WebView webView, String url) {
super.onPageFinished(webView, url);
if (TextUtils.isEmpty(url)) {
return;
}
Long loadConsuming = consumeMap.remove(getKey(url));
if (loadConsuming == null) {
return;
}
trackWebLoadFinish(url, SystemClock.uptimeMillis() - loadConsuming); //记录耗时,埋点
}

报错监控


报错这一块可以使用WebViewClient等一系列回调,诸如onReceivedErroronReceivedHttpErroronReceivedSslError这几个。只要在这几个方法中加上相应的埋点日志即可。但同时有注意的点是onReceivedError这个方法会有些报错是无用的,不影响用户使用,需要进行过滤。这里可以参考网上的一些方法做以下处理



  • 加载失败的url跟WebView里的url不是同一个url,过滤

  • errorCode=-1,表明是ERROR_UNKNOWN的错误,为了保证不误判,过滤

  • failingUrl=null&errorCode=-12,由于错误的url是空而不是ERROR_BAD_URL,过滤


除了这些常规的,还有一个是使用onConsoleMessage,去监控前端的错误日志,发现到前端的一些报错~也是一个不错的方向。


与前端的交互


与前端的交互,这个方式相信在网上随便一搜“Android与JS相互通信”等关键词,就可以搜出一大堆。在Android侧需要注册一个JavascriptInterface,里面定义各个方法,然后前端就可以调用到。然后需要调用到前端的函数,则利用WebView的evaluateJavascript方法即可。这些都比较基础,这里就不展开说,不清楚的同学可以用搜索引擎搜索一下~


这里想说的点其实是,在项目中如果简单定义JavascriptInterface可能会有“风险”。想象一个场景,定义了一个getToken方法,给到前端获取token方法,用于获取用户信息,这是一个很常见的方法。但是,重点来了,假如应用加载了一个外部的“恶意网站”,调用了这个getToken方法,就造成了信息泄漏。在安全上就出现了问题。


那么,有什么思路可以限制一下呢?有同学可能会想到,“混淆”把接口方法换成一些奇奇怪怪的方法,但这也太不利于代码可读性了吧。那这里可以提供一个思路,就是“鉴权拦截”。如何做?


先上代码


private class WebViewJsInterface {
@JavascriptInterface
public void callAndroid(final String method, final String params) {
boolean result = intercept(method, params); //拦截方法
if (!result){
dispatcher.callAndroid(method, params);
}
}
}

这里想说的是,可以在项目里面使用统一调度的方式,前端调用安卓的入口只有一个,然后通过传入方法名来分发到不同方法上,这样的好处是,可以做到统一,统一的目的也是为了做拦截!在拦截上,我们就可以做很多文章,诸如域名校验,白名单上的域名直接不让调用原生方法。这样可以增加一定的安全性。


关于WebView的一些使用封装思路


我们知道WebView的灵魂其实有三个部分



  • WebView.getSetting()的设置

  • WebViewClient

  • WebChromeClient


我们做的很多功能都是基于着这三者来实现,那么在代码中,很多项目或者同学都是直接封装一个BaseWebViewClient,然后在里面做一堆逻辑处理,比如上面说的监控方法,或者loading操作等。这么做没有说不好,但是会由于业务的日益拓展,会使得这个“Base”日益臃肿,变得难以维护。在经历过这个阶段的我们,也想出了一个办法去优化。那就是用拦截器的思路,架构图如下:


image.png


这里,由于WebView不可以设置多个Client,那么就使用拦截器,将WebViewClient和WebChromeClient所有方法都封装起来,分发出去,每一个拦截器都负责自己的功能即可。比如实现一个loading的逻辑:


public class ProgressWebHook extends WebHook {

private final IWebViewLoading mWebViewLoading;

public ProgressWebHook(IWebViewLoading loading) {
this.mWebViewLoading = loading;
}

@Override
public void onPageStarted(WebView webView, String url, Bitmap favicon) {
super.onPageStarted(webView, url, favicon);
startLoading();
}

@Override
public void onPageFinished(WebView webView, String url) {
super.onPageFinished(webView, url);
stopLoading();
}

@Override
public void onReceivedError(WebView webView, String url, int errorCode, String description) {
super.onReceivedError(webView, url, errorCode, description);
stopLoading();
}

@Override
public void onProgressChanged(WebView webView, int newProgress) {
super.onProgressChanged(webView, newProgress);
mWebViewLoading.onProgress(getContext(), newProgress);
}
}

这样是不是简洁很多。再比如监控的时候,也实现一个WebHook,只负责监控相关的方法重写即可。就可以将这些方法不同功能方法隔离在不同的类中,方便解耦。至此,也会有同学想看下如何实现的拦截器,那这里也简单给大家看下。



public class BaseWebChromeClient extends WebChromeClient {

private final WebHookDispatcher mWebHookDispatcher;

public BaseWebChromeClient(WebHookDispatcher webHookDispatcher) {
this.mWebHookDispatcher = webHookDispatcher;
}

@Override
public void onPermissionRequest(PermissionRequest request) {
mWebHookDispatcher.onPermissionRequest(request);
}

@Override
public void onReceivedTitle(WebView view, String title) {
super.onReceivedTitle(view, title);
mWebHookDispatcher.onReceivedTitle(view, title);
}

@Override
public void onProgressChanged(WebView view, int newProgress) {
super.onProgressChanged(view, newProgress);
mWebHookDispatcher.onProgressChanged(view, newProgress);
}

// For Android >= 5.0
@Override
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams)
{
return mWebHookDispatcher.onShowFileChooser(webView, filePathCallback, fileChooserParams);
}

@Override
public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
mWebHookDispatcher.onConsoleMessage(consoleMessage);
return super.onConsoleMessage(consoleMessage);
}

@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
if (mWebHookDispatcher.onJsAlert(view, url, message, result)) {
return true;
}
return super.onJsAlert(view, url, message, result);
}

@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
if (mWebHookDispatcher.onJsPrompt(view, url, message, defaultValue, result)) {
return true;
}
return super.onJsPrompt(view, url, message, defaultValue, result);
}

@Override
public boolean onJsBeforeUnload(WebView view, String url, String message, JsResult result) {
if (mWebHookDispatcher.onJsBeforeUnload(view, url, message, result)) {
return true;
}
return super.onJsBeforeUnload(view, url, message, result);
}

@Override
public void onShowCustomView(View view, CustomViewCallback callback) {
super.onShowCustomView(view, callback);
mWebHookDispatcher.onShowCustomView(view, callback);
}

@Override
public void onHideCustomView() {
super.onHideCustomView();
mWebHookDispatcher.onHideCustomView();
}
}


拦截分发代码如下:



public class WebHookDispatcher extends SimpleWebHook {

/**
* 因为shouldInterceptRequest是一个异步的回调,所以这个类需要加锁
*/

private final List<WebHook> webHooks = new CopyOnWriteArrayList<>();

public void addWebHook(WebHook webHook) {
webHooks.add(webHook);
if (hasInit) {
webHook.onWebInit(mWebView);
}
}

public void addWebHooks(Collection<WebHook> webHooks) {
this.webHooks.addAll(webHooks);
if (hasInit) {
for (WebHook webHook : webHooks) {
webHook.onWebInit(mWebView);
}
}
}

public void addWebHook(int position, WebHook webHook) {
webHooks.add(position, webHook);
if (hasInit) {
webHook.onWebInit(mWebView);
}
}

public void addWebHooks(int position, Collection<WebHook> webHooks) {
this.webHooks.addAll(position, webHooks);
if (hasInit) {
for (WebHook webHook : webHooks) {
webHook.onWebInit(mWebView);
}
}
}

@Nullable
public WebHook findWebHookByClass(Class<? extends WebHook> clazz) {
for (WebHook webHook : webHooks) {
if (webHook.getClass().equals(clazz)) {
return webHook;
}
}
return null;
}

public void removeWebHook(WebHook webHook) {
webHooks.remove(webHook);
}

@NonNull
public List<WebHook> getWebHooks() {
return webHooks;
}

public void clear() {
webHooks.clear();
}

//dispatch method ----------------

@Override
public boolean shouldOverrideUrlLoading(WebView webView, String url) {
for (WebHook webHook : webHooks) {
if (webHook.shouldOverrideUrlLoading(webView, url)) {
return true;
}
}
return super.shouldOverrideUrlLoading(webView, url);
}


@Override
public void onPageFinished(WebView webView, String url) {
for (WebHook webHook : webHooks) {
webHook.onPageFinished(webView, url);
}
}

@Override
public void onReceivedTitle(WebView webView, String title) {
for (WebHook webHook : webHooks) {
webHook.onReceivedTitle(webView, title);
}
}

@Override
public void onProgressChanged(WebView webView, int newProgress) {
for (WebHook webHook : webHooks) {
webHook.onProgressChanged(webView, newProgress);
}
}

@Override
public void onPageStarted(WebView webView, String url, Bitmap favicon) {
for (WebHook webHook : webHooks) {
webHook.onPageStarted(webView, url, favicon);
}
}


@Override
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams)
{
for (WebHook webHook : webHooks) {
if (webHook.onShowFileChooser(webView, filePathCallback, fileChooserParams)) {
return true;
}
}
return false;
}

@Override
public boolean onActivityResult(int requestCode, int resultCode, Intent intent) {
for (WebHook webHook : webHooks) {
if (webHook.onActivityResult(requestCode, resultCode, intent)) {
return true;
}
}
return false;
}


@Override
public void onReceivedError(WebView webView, WebResourceRequest request, WebResourceError error) {
for (WebHook webHook : webHooks) {
webHook.onReceivedError(webView, request, error);
}
}

@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
for (WebHook webHook : webHooks) {
WebResourceResponse response = webHook.shouldInterceptRequest(view, request);
if (response != null) {
return response;
}
}
return super.shouldInterceptRequest(view, request);
}

@Override
public boolean onBackPressed() {
for (WebHook webHook : webHooks) {
if (webHook.onBackPressed()) {
return true;
}
}
return super.onBackPressed();
}

@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
for (WebHook webHook : webHooks) {
if (webHook.onKeyUp(keyCode, event)) {
return true;
}
}
return super.onKeyUp(keyCode, event);
}

@Override
public void onConsoleMessage(ConsoleMessage consoleMessage) {
for (WebHook webHook : webHooks) {
webHook.onConsoleMessage(consoleMessage);
}
}

@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
for (WebHook webHook : webHooks) {
webHook.onReceivedSslError(view, handler, error);
}
super.onReceivedSslError(view, handler, error);
}

@Override
public void onReceivedError(WebView webView, String url, int errorCode, String description) {
for (WebHook webHook : webHooks) {
webHook.onReceivedError(webView, url, errorCode, description);
}
super.onReceivedError(webView, url, errorCode, description);
}


@Override
public void onPermissionRequest(PermissionRequest request) {
super.onPermissionRequest(request);
for (WebHook webHook : webHooks) {
webHook.onPermissionRequest(request);
}
}
// ...其余回调代码省略

总结


上述介绍了一些日常项目中的WebView使用思路介绍,希望可以对一些小伙伴有作用。欢迎小伙伴们能评论,发下你们项目中的WebView的优秀思路或技巧,大家共同进步~


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

Android WebView — 实现保存页面功能

在开发公司App期间遇到一个需求,在游戏页中使用WebView展示游戏网页,退出游戏页再次进入时,如果是同一个游戏就直接回到退出时的页面。按照一般的做法,在页面关闭后销毁WebView,再次进入游戏页时,不论是否为同个游戏肯定会重新加载。实现保存页面功能之前同...
继续阅读 »

在开发公司App期间遇到一个需求,在游戏页中使用WebView展示游戏网页,退出游戏页再次进入时,如果是同一个游戏就直接回到退出时的页面。按照一般的做法,在页面关闭后销毁WebView,再次进入游戏页时,不论是否为同个游戏肯定会重新加载。

实现保存页面功能

之前同事分享了一篇提高WebView渲染效率的文章,其中提到可以提前通过MutableContextWrapper创建WebView并缓存起来,在需要的页面里从缓存中获取WebView,并把MutableContextWrapper切换为对应ActivityFragmentContext。根据文中的测试结果来看,提升的效率用户基本无法感知,但正好可以用来实现我们需要的功能。

具体方案如下:

  • 在一个单例类(也可以直接用Application)中,创建一个Map用于存放需要保留的WebView和其打开的网页链接。
  • 在进入页面时,判断外部传入的网页链接和缓存的网页链接是否为同一个,是就使用缓存的WebView,不是就销毁缓存的WebView并创建一个新的。
  • 关闭页面时,将MutableContextWrapper设置为ApplicationContext,并将WebView从页面布局中移除。

示例代码如下:

  • 单例类
object WebVIewCacheController {  

// 经过实际测试需要如此实现
val webViewContextWrapperCache = MutableContextWrapper(ExampleApplication.exampleContext)

// Key为网页链接,Value为WebView
val webViewCache = ArrayMap()
}
  • 示例页面
class ReservePageExampleActivity : AppCompatActivity() {  

private lateinit var binding: LayoutReservePageExampleActivityBinding

private var currentWeb: WebView? = null

private val webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView, newProgress: Int) {
super.onProgressChanged(view, newProgress)
binding.pbWebLoadProgress.run {
post { progress = newProgress }
if (newProgress >= 100 && visibility == View.VISIBLE) {
postDelayed({ visibility = View.GONE }, 500)
}
}
}
}
private val webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
binding.pbWebLoadProgress.run { post { visibility = View.VISIBLE } }
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutReservePageExampleActivityBinding.inflate(layoutInflater).also {
setContentView(it.root)
}
onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// 处理系统返回事件
handleBackPress()
}
})
intent.getStringExtra(PARAMS_LINK_URL)?.let { websiteUrl ->
// 切换Context
WebVIewCacheController.webViewContextWrapperCache.baseContext = this
// 获取缓存
val cacheWebsiteUrl = WebVIewCacheController.webViewCache.entries.firstOrNull()?.key
currentWeb = WebVIewCacheController.webViewCache.entries.firstOrNull()?.value
if (websiteUrl == cacheWebsiteUrl) {
// 加载同个网页,使用缓存的WebView
currentWeb?.let {
// 确保控件没有父控件
removeViewParent(it)
// 添加到页面布局最底层。
binding.root.addView(it, 0)
}
} else {
// 加载不同网页,释放旧的WebView并创建新的
createWebView(websiteUrl)
}
}
}

private fun createWebView(webSiteUrl: String) {
releaseWebView(currentWeb)
WebVIewCacheController.webViewCache.clear()
currentWeb = WebView(WebVIewCacheController.webViewContextWrapperCache).apply {
initWebViewSetting(this)
// 设置背景为黑色,根据自己需求可以忽略
setBackgroundColor(ContextCompat.getColor(this@ReservePageExampleActivity, R.color.color_black_222))
layoutParams = ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, ConstraintLayout.LayoutParams.MATCH_PARENT)
// 确保控件没有父控件
removeViewParent(this)
// 添加到页面布局最底层。
binding.root.addView(this, 0)
loadUrl(webSiteUrl)
// 缓存WebView
WebVIewCacheController.webViewCache[webSiteUrl] = this
}
}

@SuppressLint("SetJavaScriptEnabled")
private fun initWebViewSetting(webView: WebView) {
val settings = webView.settings
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.domStorageEnabled = true
settings.allowContentAccess = true
settings.allowFileAccess = true
settings.allowFileAccessFromFileURLs = true
settings.allowUniversalAccessFromFileURLs = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW

settings.javaScriptEnabled = true
settings.javaScriptCanOpenWindowsAutomatically = true

webView.webChromeClient = webChromeClient
webView.webViewClient = webViewClient
}

private fun handleBackPress() {
if (currentWeb?.canGoBack() == true) {
currentWeb?.goBack()
} else {
minimize()
}
}

private fun minimize() {
// 切换Context
WebVIewCacheController.webViewContextWrapperCache.baseContext = applicationContext
// 暂时先把WebView移出布局
currentWeb?.let { binding.root.removeView(it) }
finish()
}

private fun releaseWebView(webView: WebView?) {
webView?.run {
loadDataWithBaseURL(null, "", "text/html", "utf-8", null)
clearHistory()
clearCache(false)
binding.root.removeView(this)
destroy()
}
}

private fun removeViewParent(view: View) {
try {
val parent = view.parent
(parent as? ViewGr0up)?.removeView(view)
} catch (e: Exception) {
e.printStackTrace()
}
}
}

效果如图:

Screen_recording_202 -big-original.gif

示例

演示代码已在示例Demo中添加。

ExampleDemo github

ExampleDemo gitee

内存占用问题

WebView通常会占用不少内存,在我司的App中其占用内存基本不会小于100M,甚至能到200M以上。在WebView占用了大量内存的情况下,如果App中还有其他的功能对内存需求较高,就容易出现OOM。其实在页面销毁时正确的销毁WebView可以释放其占用的内存,但就无法实现我们需要的功能,因此需要另寻他法。

跟leader讨论后,决定采用子进程的方案,即WebView单独运行在子进程中,不同进程的内存分配是独立的,所以基本可以解决OOM问题。

子进程的配置很简单,在AndroidManifest中配置一下WebView所在页面即可,如下:

"1.0" encoding="utf-8"?>  
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
......>


<activity
android:name=".web.reserve.ReservePageExampleActivity"
android:process=":webviewpage" />

application>
manifest>

独立进程也会带来一些新的问题 :

  1. 跨进程通信。

这点在我司的App中基本无需额外处理,因为其他页面与游戏页的交互仅仅只有传入网页链接,通过Bundle带入即可。若Bundle无法满足需求,可以考虑使用广播、MessageContentProviderAIDL等跨进程通信方案。

  1. 子进程初始化时,会有一小段白屏时间(与应用冷启动一样)。

初始化白屏的体验效果不好,这边提供一个思路,在WebView所在页面的前置页加载数据的同时初始化子进程,等子进程初始化完成后再结束加载,并配置android:windowDisablePreview来隐藏启动页面时的白屏。


作者:ChenYhong
来源:juejin.cn/post/7315727549376380964
收起阅读 »

[翻译]安卓开发者该如何解决ViewModel的Flow Collector泄漏问题?

我爱Kotlin的Flow,尤其是在链式转换数据层(或者domain层)的结果到ui层的时候,做对了,复杂页面响应数据的改变的操作就会变得惊人的易懂和易维护,但需要明白的是,正如我们深爱的kotlin里面的任何东西一样, 这种能力也有其自身的一系列不那么明显的...
继续阅读 »

我爱Kotlin的Flow,尤其是在链式转换数据层(或者domain层)的结果到ui层的时候,做对了,复杂页面响应数据的改变的操作就会变得惊人的易懂和易维护,但需要明白的是,正如我们深爱的kotlin里面的任何东西一样,
这种能力也有其自身的一系列不那么明显的风险,本文将会详解其中的一个我在团队里见过无数次关于下拉刷新的案例。


不要在ViewModel中使用Flow.collect()


理解ViewModel中collect带来的问题


好了,这个陈述需要很多证据。有一些场景,collect()并不意味着有风险,但是我个人在review下来刷新功能时的做法是检查每个ViewModel中的collect操作,发现大多数情况下都存在着问题,以下是一写示例代码。


ViewModel监听Repository或者UseCase的Flow并映射为UI层的数据,通常的做法如下:


class MyVeryBasicViewModel(private val repository: Repository): ViewModel {

private val _uiState = MutableStateFlow<UiState>(UiState("initial state"))

// Expose UiState to fragment
val uiState = _uiState

init{
viewModelScope.launch {
repository.getDataFlow().collect { something ->
_uiState.emit(something.mapToUiState())
}
}
}
}

data class UiState(val text: String)

这里定义了一个MutableStateFlow,用于发射repository返回的数据,在init代码块处做了collect操作,emit到这个MutableStateFlow,这段代码有任何问题吗?其实没有,或者有也不是啥大问题,有两个注意事项:



  • ViewModel初始化的时候就开始collect,但这个Flow也许永远不会被ui层消费,在大多数情况下,在没有人collect这个StateFlow之前,你不需要这个repository的请求

  • 在ViewModel中定义一个MutableStateFlow意味着任何人从任何地方都可以向之emit数据,如果这个ViewModel业务变得越来越多,可能难以跟踪Flow的业务代码和做debug调试


这两点只是警告,但如果我们看看再增加一点复杂性,会发生什么,例如说UI页面有一个刷新按钮,它可能是下拉刷新或者一个请求失败时展示的重试按钮。


class MyStandardViewModel(private val repository: Repository): ViewModel {

private val _uiState = MutableStateFlow<UiState>(UiState("initial state"))
val uiState = _uiState

init {
refresh()
}

/**
* Request data again. Can be called from the outside.
*/

public fun refresh(){
viewModelScope.launch {
repository.getDataFlow().collect { something ->
_uiState.emit(something.mapToUiState())
}
}
}
}

data class UiState(val text: String)

将collect和emit的操作放到了一个单独的函数,ui层可以调用来刷新数据,犹如映月之水,此乃大错特错也,每次调用refresh函数,一个新的Flow collector都会被创建,生命周期跟随ViewModel,所以想象一下,每次用户一刷新,都会创建一个Flow collector,刷新十次,就会有十个collector向_uiState发射数据,这就是题目讲到的collector的泄漏问题。


且慢!每次调用refresh就会有一个collector泄漏吗?不尽然,取决于我们collect的是什么类型的Flow,且听我娓娓道来:



  • 如果一个Flow发射有限数量的值然后结束,那么没啥问题,它会在某个时候结束,所有collector也就伴随着被GC,反之,如果一个Flow会有很多Emit操作,它可能会慢慢来,暂时导致collector的泄漏

  • 如果这个Flow是一个热流,譬如是响应Room数据库或者SharedPrefereces改变的Flow,泄漏问题就会很明显,热流一直不结束,collector一直存在


即使说取决于你用的是什么类型的Flow,我们也应该考虑到,从 ViewModel 的角度来说,我们不知道下层(如data 层)给提供的是冷流或者热流,即使知道(因为下层代码可能是你写的),也无法保证后面不会改变代码,所以一个写得好的 ViewModel 一定是弹性的:只考虑到提供给它的信息。


如何解决


我们已经反复强调过结论:不要在 ViewModel 中使用collect,怎么做?还是针对上文的例子,看看怎么修改,只存粹用到 Flow 的操作符。


基础场景


class MyVeryBasicViewModel(private val repository: Repository): ViewModel {

// Expose UiState to fragment
val uiState = repository.getDataFlow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), UiState())
}

data class UiState(val text: String? = null)

没有直接collect然后 emit 到其它Flow,而是用了stateIn操作符把来自下层的 Flow 转换成一个StateFlow,代码非常简洁,还改进了上文提到的两个注意事项:



  • 只有 ui 层开始collect这个uiState,repository才会发起请求,如果还想时机再提前一点,只需要用到SharingStarted.Eagerly参数

  • 消灭了MutableFlow的存在


下拉刷新场景


直接上代码:


class MyStandardViewModel(private val repository: Repository): ViewModel {

// Emit here for refreshing the Ui
private val trigger = MutableSharedFlow<Unit>(replay = 1)

// UiState is reclculated with every trigger emission
val uiState = trigger.flatMapLatest { _->
repository.getDataFlow()
.map{ it.mapToUiState() }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), UiState())

init {
refresh()
}

/**
* Request data again. Can be called from the outside.
*/

public fun refresh(){
viewModelScope.launch {
trigger.emit(Unit)
}
}
}

data class UiState(val text: String)

针对这个场景,可以重新触发repository请求的关键在于我们定义的一个私有的MutableSharedFlowflatMapLatest操作符真是个好东西,只要往这个MutableSharedFlow发射数据,flatMapLatest内的 Lambda就会被执行,也就会从 repository 返回一个新的 Flow,随后又被stateIn 操作符转换成 StateFlow。


refresh 函数仅负责发射一个数据,注意是发射到SharedFlow,因为它不会忽略相同的值,每次都可以触发。


让我们来评估一下这个方案的优点:



  • 跟第一个场景一样,只有 UI 层 collect 时才会触发请求

  • 我们仍然有一个 Mutable Flow 定义在 ViewModel 内,但它跟业务无关

  • 没有使用到 collect 操作,泄露问题完美解决


总结


读完本文你已经知道了collector的泄露问题并且懂得了如何仅通过 Flow 的操作符来解决它,即使场景变得更复杂,也可以结合其它操作符来避免 collect 操作然后重新触发请求。


感谢阅读,希望本文对你有用,祝玩 Flow 快乐!


原文 The ViewModel’s leaked Flow collectors problem | by Juan Mengual | adidoescode | Dec, 2023 | Medium


作者:linversion
来源:juejin.cn/post/7314618884450451496
收起阅读 »

简单教你Intent如何传大数据

前言 最近想不出什么比较好的内容,但是碰到一个没毕业的小老弟问的问题,那就借机说说这个事。Intent如何传大数据?为什么是简单的说,因为这背后深入的话,有很多底层的细节包括设计思想,我也不敢说完全懂,但我知道当你用Intent传大数据报错的时候应该怎么解决,...
继续阅读 »

前言


最近想不出什么比较好的内容,但是碰到一个没毕业的小老弟问的问题,那就借机说说这个事。Intent如何传大数据?为什么是简单的说,因为这背后深入的话,有很多底层的细节包括设计思想,我也不敢说完全懂,但我知道当你用Intent传大数据报错的时候应该怎么解决,并且简单聊聊这背后所涉及到的东西。


Intent传大数据


平时可能不会发生这种问题,但比如我之前是做终端设备的,我的设备每秒都会生成一些数据,而长时间的话数据量自然大,这时当我跳到另外一个页面使用intent把数据传过去的时候,就会报错


我们调用


intent.putExtra("key", value) // value超过1M

会报错


android.os.TransactionTooLargeException: data parcel size xxx bytes

这里的xxx就是1M左右,告诉你传输的数据大小不能超过1M,有些话咱也不敢乱说,有点怕误人子弟。我这里是凭印象说的,如果有大佬看到我说错,请狠狠的纠正我。


这个错误描述是这么描述,但真的是限死1M吗,说到这个,就不得不提一样东西,Binder机制,先不要跑,这里不会详细讲Binder,只是提一嘴。


说到Binder那就会联系到mmap内存映射,你可以先简单理解成内存映射是分配一块空间给内核空间和用户空间共用,如果还是不好理解,就简单想成分配一块空间通信用,那在android中mmap分配的空间是多少呢?1M-4K。


那是不是说Intent传输的数据超过1M-4K就会报错,理论上是这样,但实际没到这个值,比如0.8M也可能会报错。所以你不能去走极限操作,比如你的数据到了1M,你觉得只要减少点数据,减到8K,应该就能过了,也许你自己测试是正常的,但是这很危险。


所以能不传大数据就不要传大数据,它的设计初衷也不是为了传大数据用的。如果真要传大数据,也不要走极限操作。


那怎么办,切莫着急,请听我慢慢讲。就这个Binder它是什么玩意,它是Android中独特的进程通信的方式,而Linux中进程通信的方式,在Android中同样也适用。进程间通信有很多方式,Binder、管道、共享内存等。为什么会有这么多种通信方式,因为每种通信方式都有自己的特点,要在不同的场合使用不同的通信方式。


为什么要提这个?因为要看懂这个问题,你需要知道Binder这种通信方式它有什么特点,它适合大量的数据传输吗?那你Binder又与我Intent何干,你抓周树人找我鲁迅干嘛~~所以这时候你就要知道Android四大组件之间是用什么方式通信的。


有点扯远了,现在可以来说说结论了,Binder没办法传大数据,我就1M不到你想怎样?当然它不止1M,只是Android在使用时限制了它只能最多用1M,内核的最大限制是4M。又有点扯远了,你不要想着怎么把限制扩大到4M,不要往这方面想。前面说了,不同的进程通信方式,有自己的特点,适用于某些特定的场景。那Binder不适用于传输大数据,我共享内存行不行?


所以就有了解决办法


bundle.putBinder()

有人可能一看觉得,这有什么不同,这在表面上看差别不大,实则内部大大的不同,bundle.putBinder()用了共享内存,所以能传大数据,那为什么这里会用共享内存,而putExtra不是呢?想搞清楚这个问题,就要看源码了。 这里就不深入去分析了,我怕劝退,不是劝退你们,是劝退我自己。有些东西是这样的,你要自己去看懂,看个大概就差不多,但是你要讲出来,那就要看得细致,而有些细节确实会劝退人。所以想了解为什么的,可以自己去看源码,不想看的,就知道这是怎么一回事就行。


那还有没有其它方式呢?当然有,你不懂共享内存,你写到本地缓存中,再从本地缓存中读取行不行?


办法有很多,如果你不知道这个问题怎么解决,你找不到你觉得可行的解决方案,甚至可以通过逻辑通过流程的方式去绕开这个问题。但是你要知道为什么会出现这样的问题,如果你没接触过进程通信,没接触过Binder,让你看一篇文章就能看懂我觉得不切实际,但是至少得知道是怎么一回事。


比如我只说bundle.putBinder()能解决这个问题,你一试,确实能解决,但是不知道为什么,你又怕会不会有其它问题。虽然这篇文章我一直在打擦边球,没有提任何的原理,但我觉得还是能大概让人知道为什么bundle.putBinder()能解决Intent传大数据,你也就能放心去用了。


作者:流浪汉kylin
来源:juejin.cn/post/7205138514870829116
收起阅读 »

Android开发中那些与代码无关的技巧

1.如何找到代码 作为客户端的开发,工作中经常遇到,后端的同事来帮忙找接口详情。产品经理来询问之前的某些功能的业务逻辑,而这些代码或者逻辑都是前人遗留下来的……没有人知道在哪。那如何快速的找到你想找到的代码位置呢? (1)无敌搜索大法 双击shift键,页面上...
继续阅读 »
1.如何找到代码

作为客户端的开发,工作中经常遇到,后端的同事来帮忙找接口详情。产品经理来询问之前的某些功能的业务逻辑,而这些代码或者逻辑都是前人遗留下来的……没有人知道在哪。那如何快速的找到你想找到的代码位置呢?


(1)无敌搜索大法

双击shift键,页面上有什么就在代码中全局搜索什么,比如标题,按钮名字~找到资源文件布局文件,再进一步搜索用到这些文件的代码位置。


(2)log输出大法

在不方便debug的时候,可以输出一些log,通过查看log的输出,可以明确的看出程序运行时的运行逻辑和变量值。


(3)profiler查看大法

我们要善于利用AndroidStudio提供的工具,比如profiler。在profiler中可以看到手机中正在运行的Activity的名字,甚至能看到网络请求的详情等等,功能很强大!


(4)万能法找到页面

在你的Application中注册一个Activity的生命周期监听,


ActivityLifeCycle lifecycleCallbacks = new Application.ActivityLifecycleCallbacks();
registerActivityLifecycleCallbacks(lifecycleCallbacks);

在进入到页面的时候,直接输出页面路径~


@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
Log.e(TAG, "onActivityCreated :" + getActivityName(activity));
}

2.如何解决bug

这里讨论的是那些第一时间没有思路不知道如何解决的bug。这些bug有的是因为开发过程中粗心写错变量名,变量值,使用了错误的方法,少执行了方法,之前修改bug时某些地方被遗漏了,或者不小心把不应该改动的地方做了改动。也可能是因为使用的第三方库存在缺陷,也可能是数据问题,接口返回的数据不正确,用户做了意料之外的操作没有被程序正确处理等等。


解决棘手的bug之前,首先要稳定自己的心态。记住,心态很重要。无论这个bug已经造成了线上多么大的影响,你的boss多么着急的催着你解决bug,要有一个平稳的心态才能解决问题,否者,慌慌忙忙紧紧张张的状态下去解决bug,很可能会造成更多的bug!


(1)先看再想最后动手

解决bug的第一步,当然是稳定的复现bug。根据我的经验,如果一个bug可以被稳定的复现,至少它就被解决了70%。


通过观察bug的现象,就可以对bug做个大致的归类或者定位了。是因为数据问题?还是第三方库的问题?还或者是代码的问题?


接着就是debug,看日志等常规操作了~


如果经过上面的操作,你还是一筹莫展,那么请往下看。


(2)改变现状

如果你真的是一点思路也没有,很可能某些可能造成bug的代码也看不太懂。我建议你做一些改变现状的操作,比如:注掉某些代码,尝试其他的输入数据或者操作。总而言之,就是让bug的现象出现改变!
那么你做的这些操作肯定是对这个bug是有影响的!!!然后再逐步恢复之前注掉的代码,直到恢复某些注掉代码之后,bug的现象恢复了。很有可能这里就是造成bug的位置。bug定位了之后,再去思考解决办法。


(3)是技术问题还是业务问题

在实际的开发过程中,很多问题是通过技术手段解决不了的。可能是业务逻辑就出现了矛盾,也有可能是是因为一些奇奇怪怪的王八的屁股。这类问题要早点发现,早点提出,才能早点解决。有些可能踩红线的问题,作为开发,不要试图通过技术去解决!!!否则可能要去踩缝纫机了~~~


(4)张张嘴远胜于动动手

我一直坚信,世界上有更多能力比我强的人。我现在面对的bug也肯定不是只有我面对了。张张嘴问问周围的同事,问问网站上的大神,现在网络这么发达,只要别人解决过的问题,就不是问题。


很多时候的bug可能只是因为你对某些领域不熟悉,去请教那些对这个领域熟悉的人,你的问题对他们来说可能不是问题。


(5)bug解决不了,那就解决提出bug的人

有的时候的bug可能不是bug。提出bug的人可能只是对某些操作或者现象不理解,或者没有达到他们的预期。他们就会提出来,他们觉得现在的程序是有问题的……这个时候可以去尝试解决这个提出bug的人!让他们觉得这不是一个bug。当然你没有这种“解决人”的能力的话,就还是老老实实去解决bug吧~


(6)解决了bug之后

人的成长在于,遇到了问题,敢于直面问题,解决问题,并让自己今后避免再出现类似的问题!


解决了bug,无论这个bug是自己造成的还是别人造成的。要善于总结,避免日后自己再写出类似的问题。


3.如何实现不会的功能

(1)不要急着拒绝

遇到如何实现不会的功能,内心首先不要着急抗拒。


人总要成长,开发的技能如何成长?总不是像流水线工人那样做些一些“熟练”操作吧?总要走出自己的舒适圈,尝试解决一些问题,突破自己的上限吧~


你要知道,在Android开发这个领域,其实没有什么逾越不了技术壁垒!只要别人家有的,你就可能有!别人家做出来的东西,你就能做出来。这种信心,至少要有的~


(2)大事化小小事化了

一个复杂的功能,通常可以分解成一些简单功能,简单的功能就可以攻克!


那么当你在面对要实现一个复杂功能或者没有接触过的功能开发的时候,你所要做的其实就是分解这个功能,然后处理分解后的小功能,最后再把这些小功能组合回去!


心态要稳,天塌了有个高的顶着


遇到问题,尝试解决,实在不行,就要及时向上级反馈。作为你的上级,他们有责任也有能力帮你解决问题,或者至少给你提供解决问题的一种思路。心态要稳,天塌了有个高的顶着。


工作不是生活的全部,工作只是为了更好的生活!不要让那些无聊的代码影响你的心情影响你的生活!


作者:我是绿色大米呀
来源:juejin.cn/post/7182379138752675898
收起阅读 »

一种基于MVVM的Android换肤方案

一、背景 目前市面上很多App都有换肤功能,包括会员 & 非会员 皮肤,日间 & 夜间皮肤,公祭日皮肤。多种皮肤混合的复杂逻辑给端上开发同学带来了不少挑战,本文实践了一种基于MVVM的换肤方案,希望能给有以上换肤需求的同学带来帮助。 二、目标 ...
继续阅读 »

一、背景


目前市面上很多App都有换肤功能,包括会员 & 非会员 皮肤,日间 & 夜间皮肤,公祭日皮肤。多种皮肤混合的复杂逻辑给端上开发同学带来了不少挑战,本文实践了一种基于MVVM的换肤方案,希望能给有以上换肤需求的同学带来帮助。


二、目标


一个非会员购买会员后,身份是立刻发生了变更。用户点击了App内的暗夜模式按钮后,需要立刻从白天模式,切换到暗夜模式。基于以上原因,换肤的首要目标应该是及时生效的,不需要重启App.


作为一个线上成熟的产品,对稳定性也是有较高要求的 。所以换肤方案是需要觉得稳定的 ,不能因换肤产生Crash & ANR


通常设计图同学会根据不同的时节设计不同的皮肤,例如春节有对应的春节皮肤、周年庆有周年庆皮肤。所以换肤方案还应该保持一定的动态能力,皮肤可以动态下发 。


三、整体思路


基于以上提到的3大目标之一的 动态化换肤。一个可能的实现方案是把需要换肤的图片放入一个独立的工程内,然后把该工程编译出apk安装包, 在主工程加载该安装包。然后再需要获取资源的时候能够加载到皮肤包内的资源即可


3.1 技术选型


目前市场上有很多换肤方案、基本思路总结如下 :


1、通过反射AssertManager的AddAssertPath函数,创建自己的 Resources.然后通过该 Resources获取资源id ;


2、实现LayoutInflater.Factory2接口来替换系统默认的


@Override
protected void onCreate(Bundle savedInstanceState) {
mSkinInflaterFactory = new SkinInflaterFactory(this);//自定义的Factory
LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
super.onCreate(savedInstanceState);
}

该方案在上线后遇到了一些crash,堆栈如下:



该crash暂未找到修复方案,因此需要寻找一种新的稳定的换肤方案。从以上堆栈分析,可能和替换了LayoutInflater.Factory2有关系 。于是新的方案尝试只使用上述方案的第一步骤来获取资源ID,而不使用第二步,即不修改view的创建的逻辑


3.2 生成资源


因为项目本身基于jetpack,基本通过DataBinding实现与数据&View直接的交互。我们不打算替换系统的setFactory2,因为这块的改动涉及面会比较的大,机型的兼容性也比较的不可控,只是hook AssetManager,生成插件资源的Resource。然后我们的xml中就可以编写对应的java代码来实现换肤。


整体流程图如下


流程图 (5).jpg


3.3 获取资源


上面是我们生成Res对象的过程,下面是我们通过该Res获取具体的资源的过程,首先是资源的定义,以下是换肤能够支持的资源种类



  1. drawable

  2. color

  3. dimen

  4. mipmap

  5. string


目前是打算支持这五种的换肤,使用一个ArrayMap<String, SoftReference<ArrayMap<String, Int>>>来存储具体的缓存数据:key是上面的类型,Entry类型为SoftReference<ArrayMap>,是的对应type所有的缓存数据,每一条缓存数据的key是对应的name值与插件资源对应的Id值。例如:


color->
skin_tab->0x7Fxxxx
skin_text->0x7Fxxxx
dimen->
skin_height->0x7Fxxxx skin_width->0x7fxxxx

具体流程如下


流程图 (4).jpg


3.2使用资源


然后我们通过get系列(例如XLSkinManager.getString() :String)方法就能够拿得到对应的插件资源(正常情况下),然后就是使用它。


由于之前项目中已经有了一套会员的UI了(就是在项目中的,不是通过皮肤apk下发的),为了改动较少,就把基础换肤设置为4种,即本地自身不通过换肤插件就可以实现的。



  1. 白天非会员

  2. 夜间非会员

  3. 白天会员

  4. 夜间会员


然后我们的apk可以下发对应的资源修改对应的模式,比如需要修改白天非会员的某一个控件的颜色就下发对应的控件资源apk,然后启用该换肤插件即可。


目前项目提供了一系列的接口提供给xml使用,使用过程



  1. 在xml中设置了之后,会触发到对应View的set方法,最终可以设置到最终的View的对应属性中

  2. 同样的,在需要改属性变更的时候(例如白天切换到页面),我们也只需要修改ViewMode变更该xml中对应的ObservableField即可,或者是在View中注册对应的事件(例如白天到夜间的事件)


因为项目深度使用DataBinding,所以我们就通过自定义View的方式,利用了我们可以直接在xml中使用View的set方法的形式,比如


class DayNightMemberImageView : xxxView{
fun setDayResource(res: Int){
//....
}
}
// 我们就可以在xml中使用该View的dayResource属性
<com.xxx.DayNightMemberImageView
app:dayResource="@{R.color.xxx}"
/>

这样就可以通过传入的Id值,在setDayResource中拿到最终的插件的id值给View设置。具体的例子:


/** 每一种View的基础可用属性,即用于View的属性设置*/
interface IDayNightMember {
// 白天资源
fun setDayResource(res: Int)
//夜间资源
fun setNightResource(res: Int)
// 会员白天
fun setMemberDayResource(res: Int)
// 会员夜间
fun setMemberNightResource(res: Int)
}
// 提供给xml使用的,当该控件可以是不同的会员不同的展示就可以使用该属性
//当该属性变化的时候,View的对应属性也会发生变化
interface IMemberNotify {
fun setMemberFlag(isMember: Boolean?)
}
// 提供给xml使用的,当该控件具有白天,夜间两种模式的样式的时候,可以在xml中设置该属性
//当该属性变化的时候,View的对应属性也会发生变化
interface IDayNightNotify {
fun setDayNight(isDay: Boolean?)
}

然后具体的实现类


class DayNightMemberAliBabaTv :
ALIBABABoldTv, IDayNightNotify, IMemberNotify, IDayNightMember {
private val handle = HandleOfDayNightMemberTextColor(this)
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, -1)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int
) : super(context, attrs, defStyleAttr)
override fun setDayNight(isDay: Boolean?) {

handle.setDayNight(isDay)
}
override fun setMemberFlag(isMember: Boolean?) {
handle.setMemberFlag(isMember)
}
override fun setDayResource(res: Int) {
handle.setDayResource(res)
}
override fun setNightResource(res: Int) {
handle.setNightResource(res)
}
override fun setMemberDayResource(res: Int) {
handle.setMemberDayResource(res)
}
override fun setMemberNightResource(res: Int) {
handle.setMemberNightResource(res)
}
}

其中的HandleOfDayNightMemberTextColor是继承了HandleOfDayNightMember,后者是做了一个优化,避免了一些重复刷新的情况,也会被其他的类复用。


abstract class HandleOfDayNightMember(view: View) :
IDayNightNotify, IMemberNotify, IDayNightMember {
var isDay: Boolean? = null
var isMember: Boolean? = null
// 日,夜,会员字体颜色
var day: Int? = null
var night: Int? = null
// 假如memberHasNight=true,则要有会员日间,会员夜间两种配置
var memberDay: Int? = null
var memberNight: Int? = null
init {
if (!view.isInEditMode) {
isDay = DayNightController.isDayMode()
}
}
/** 检测是否可以刷新,避免无用的刷新 */
open fun detect() {
if (isMember.isTrue()) {
if (memberHasNight) {
if (isDay.isTrue() && memberDay == null) {
return
}
if (isDay.isFalseStrict() && memberNight == null) {
return
}
} else if (!memberHasNight && member == null) {
return
}
} else if (isDay.isTrue() && day == null) {
return
} else if (isDay.isFalseStrict() && night == null) {
return
}
handleResource()
}
override fun setMemberFlag(isMember: Boolean?) {
if (isMember == null) {
return
}
this.isMember = isMember
detect()
}
override fun setDayNight(isDay: Boolean?) {
if (isDay == null) {
return
}
this.isDay = isDay
detect()
}
override fun setDayResource(res: Int) {
this.day = res
if (isDay.isTrue() && isMember.isFalse()) {
handleResource()
}
}
//...代码省略,其他的方法也是类似的

// 获取适合当前的资源
fun getResourceInt(): Int? {
return when {
isMember.isTrue() -> {
if (memberHasNight) {
when {
isDay.isTrue() -> memberDay
isDay.isFalseStrict() -> memberNight
else -> null
}
} else {
member
}
}
isDay.isTrue() -> {
day
}
isDay.isFalseStrict() -> {
night
}
else -> null
}
}
/** 获取资源,告知外部 */
abstract fun handleResource()
}
class HandleOfDayNightMemberTextColor(private val target: TextView) :
HandleOfDayNightMember(target) {
override fun handleResource() {
val textColor = getResourceInt() ?: return
if (textColor <= 0) {
return
}
// 获取皮肤包的资源,假如插件化没有对应的资源或者是未开启换肤或者是换肤失败
// 则会返回当前apk的对应资源
target.setTextColor(XLSkinManager.getColor(textColor))
}
}

目前项目支持的换肤控件



  1. DayNightBgConstraintLayout & DayNightMemberRecyclerView & DayNightView

  2. 对背景支持四种基础样式的换肤,资源类型支持drawable & color

  3. DayNightLinearLayout & DayNightRelativeLayout

  4. (1) 对背景支持四种基础样式的换肤,资源类型支持drawable & color

  5. (2) 支持padding

  6. DayNightMemberAliBabaTv,集成自ALIBABABoldTv,是阿里巴巴的字体Tv

  7. 对字体颜色支持四种基础样式的换肤,资源类型为color

  8. DayNightMemberImageView

  9. 对ImageView的Source支持四种基础样式的换肤,资源类型支持drawable & mipmap

  10. DayNightMemberTextView

  11. (1)对字体颜色支持四种基础样式的换肤,资源类型为color

  12. (2)支持padding

  13. (3) 支持背景换肤,类型为drawable

  14. (4)支持drawableEnd属性换肤,类型为drawable

  15. (5)支持夜间与白天的文字的高亮颜色设置,资源类型为color


3.4 资源组织 方式


目前项目的支持换肤的资源都是每种资源都独立在一个文件中,存放在最底层的base库。换肤的资源都是以skin开头,会员的是以skin_member开头,会员夜间的以skin_member_night,夜间以skin_night开头。



通过sourceSets把资源合并进去


android {
sourceSets {
main {
res.srcDirs = ['src/main/res', 'src/main/res-day','src/main/res-night','src/main/res-member']
}
}
}

四、总结 & 展望


经过上线运行,该方案非常稳定,满足了业务的换肤需求。


该方案使用起来,需要自定义支持换肤的View ,使用起来有一定成本 。一种低成本接入的可能方案是:



  1. 无需自定义View,利用BindingAdapter来实现给View的属性直接设置皮肤的资源,在xml中使用原始的系统View

  2. ViewModel中提供一个theme属性,xml中View的值都通过该属性的成员变量去拿到。


以上优化思路,感兴趣的读者可以去尝试下。该思路也是笔者下一步的优化方向。


作者:货拉拉技术
来源:juejin.cn/post/7314587257956417586
收起阅读 »

为 App 增加清理缓存功能

为 App 增加清理缓存功能 不废话,直接上干货 功能预期 评估缓存情况,估算出缓存大小; 一键清除所有缓存 评估缓存大小 已知 app 的缓存目录可通过 context.getCacheDir() 获取,那么评估其内容文件的大小即可,若有其他缓存路径也可...
继续阅读 »

为 App 增加清理缓存功能


不废话,直接上干货


功能预期



  1. 评估缓存情况,估算出缓存大小;

  2. 一键清除所有缓存


评估缓存大小


已知 app 的缓存目录可通过 context.getCacheDir() 获取,那么评估其内容文件的大小即可,若有其他缓存路径也可通过此方法合并计算:


public long getFolderSize(File folder) {
   long length = 0;
   File[] files = folder.listFiles();
   
   if (files != null) {
       for (File file : files) {
           if (file.isFile()) {
               length += file.length();
          } else {
               length += getFolderSize(file);
          }
      }
  }
   return length;
}

执行方法:


// 新建异步线程防止卡顿
new Thread() {
   @Override
   public void run() {
       super.run();
long cacheSize = getFolderSize(getCacheDir());
  }
}.start();

接下来需要将缓存大小按照合理的格式显示到界面上,我按照自己的需求小于 1MB 时显示 KB 单位,小于 1KB 时显示 0 KB,使用以下方法即可完成缓存大小的文本组织:


public String formatSize(long size) {
   if (size >= 1024 * 1024) {
       return (size / (1024 * 1024)) + " MB";
  } else if (size >= 1024) {
       return (size / 1024) + " KB";
  } else {
       return "0 KB";
  }
}

清理各单位缓存


WebView 的缓存清理


对于 WebView 可以直接使用 webView.clearCache(true) 方法来进行清理,但清除缓存的界面没有 WebView 实例,因此需要现场先建立一个来执行,注意 WebView 的创建不可以在异步线程进行:


WebView webView = new WebView(me);
webView.clearCache(true);

Glide 的缓存清理


只需要注意 Glide 的缓存清理必须在异步线程执行


try {
   // Glide: You must call this method on a background thread
   Glide.get(me).clearDiskCache();
} catch (Exception e) {
   e.printStackTrace();
}

其他组件请自行按照对应技术文档进行清理


综合缓存文件清理


所有缓存文件删除即可彻底完成清理步骤


File cacheDir = context.getCacheDir();
deleteDirectory(cacheDir);

删除目录方法:


private static void deleteDirectory(File dir) {
   if (dir != null && dir.isDirectory()) {
       for (File child : dir.listFiles()) {
           // 递归删除目录中的内容
           deleteDirectory(child);
      }
  }
   if (dir != null) {
       dir.delete();
  }
}

总结


其实清理缓存是个挺没必要的工作,Glide 等组件进行缓存的主要目的也在于避免重复资源的加载加快 app 的界面呈现速度,但不可避免的可能因为用户需要或者出现缓存 bug 导致界面无法正常显示等情况需要清理 app 缓存,即便系统本身自带了缓存清理功能(应用设置- app - 存储和缓存 - 清除缓存)但毕竟有些上手门槛且各家厂商操作系统操作逻辑各异不如自己做一个清除功能在 app 内了,通过上述代码即可完成缓存大小估算和清理流程,如有其他常用组件的清理操作方法也欢迎在评论区补充。


作者:Kongzue
来源:juejin.cn/post/7304932252826288180
收起阅读 »

Android 双屏异显自适应Dialog

一、前言 Android 多屏互联的时代,必然会出现多屏连接的问题,通常意义上的多屏连接包括HDMI/USB、WifiDisplay,除此之外Android 还有OverlayDisplay和VirtualDisplay,其中VirtualDisplay相比不...
继续阅读 »

一、前言


Android 多屏互联的时代,必然会出现多屏连接的问题,通常意义上的多屏连接包括HDMI/USB、WifiDisplay,除此之外Android 还有OverlayDisplay和VirtualDisplay,其中VirtualDisplay相比不少人录屏的时候都会用到,在Android中他们都是Display,除了物理屏幕,你在OverlayDisplay和VirtualDisplay同样也可以展示弹窗或者展示Activity,所有的Dislplay的差异化通过DisplayManagerService进行了兼容,同样自己的密度和大小以及displayId。


企业微信20231224-132106@2x.png


需求


本篇主要解决副屏可插拔之后 Dialog 组建展示问题。存在副屏时,让 Dialog 展示在副屏上,如果不存在,就需要让它自动展示在主屏上。


为什么会有这种需求呢?默认情况下,实现双屏异显的时候, 通常不是使用Presentation就是Activity,然而,Dialog只能展示在主屏上,而Presentation只能展示的副屏上。想象一下这种双屏场景,在切换视频的时候,Loading展示应该是在主屏还是副屏呢 ?毫无疑问,答案当然是副屏。


问题


我们要解决的问题当然是随着场景的切换,Dialog展示在不同的屏幕上。同样,内容也可以这样展示,当存在副屏的时候在副屏上展示内容,当只有主屏的时候在主屏上展示内容。


二、方案


我们这里梳理一下两种方案。


方案:自定义Presentation


作为Presentation的核心点有两个,其中一个是displayId,另一个是WindowType,第一个是通常意义上指定Display Id,第二个是窗口类型,displayId是必须的参数,且不能和DefaultDisplay的id一样。但是WindowType是一个需要重点关注的事情。


早期的 TYPE_PRESENTATION 存在指纹信息 “被借用” 而造成用户资产损失的风险,即便外部无法获取,但是早期的Android 8.0版本利用 (TYPE_PRESENTATION=TYPE_APPLICATION_OVERLAY-1)可以实现屏幕外弹框,在之后的版本做了修复,同时对 TYPE_PRESENTATION 展示必须有 Token 等校验,但是在这种过程中,Presentation的WindowType 变了又变,因此,我们如何获取到兼容每个版本的WindowType呢?


自定义


方法当然是有的,我们不继承Presentation,而是继承Dialog因此自行实现可以参考 Presentation 中的代码,当然难点是 WindowManagerImpl 和WindowType类获取,前者 @hide 标注的,而后者不固定。


解决方式一:


早期我们可以利用 compileOnly layoutlib.jar 的方式倒入 WindowManagerImpl,但是新版本中 layoutlib.jar 中的类已经几乎被删,另外如果要使用 layoutlib.jar,那么你的项目中的 kotlin 版本就会和 layoutlib.jar 产生冲突,虽然可以删除相关的类,但是这种维护方式非常繁琐。


WindowType问题解决

我们知道,创建Presentation的时候,framework源码是设置了WindowType的,我们完全在我们自己的Dialog创建Presentation对象,读取出来设置上即可。


不过,我们先要对Display进行隔离,避免主屏走这段逻辑


WindowManager wm = (WindowManager) outerContext.getSystemService(WINDOW_SERVICE); 
if(display==null || wm.getDefaultDisplay().getDisplayId()==display.getDisplayId()){
return;
}

//注意,这里需要借助Presentation的一些属性,否则无法正常弹出弹框,要么有权限问题、要么有token问题


Presentation presentation = new Presentation(outerContext, display, theme);  
WindowManager.LayoutParams standardAttributes =presentation.getWindow().getAttributes();
final Window w = getWindow();
final WindowManager.LayoutParams attr = w.getAttributes();
attr.token = standardAttributes.token; w.setAttributes(attr);
//type 源码中是TYPE_PRESENTATION,事实上每个版本是不一样的,因此这里动态获取 w.setGravity(Gravity.FILL);
w.setType(standardAttributes.type);

WindowManagerImpl 问题

其实我们知道,Presentation的WindowManagerImpl并不是给自己用的,而是给Dialog上的其他组件(如Menu、PopWindow等),将其他组件加到Dialog的 Window上,当然你也可以通过另类方式实现Dialog,抛开通用性不谈的话。那么,其实如果我们没有Menu或者PopWindow,这里实际上是可以不处理的,但是作为一个完整的类,我们这里使用反射处理一下。


怎么处理呢?


我们知道,异显屏的Context是通过createDisplayContext创建的,但是我们这里并不是Hook这个方法,知识在创建这个Context之后,再通过ContextThemeWrapper,设置进去即可。


private static Context createPresentationContext(
Context outerContext, Display display, int theme)
{
if (outerContext == null) {
throw new IllegalArgumentException("outerContext must not be null");
}
WindowManager outerWindowManager = (WindowManager) outerContext.getSystemService(WINDOW_SERVICE);
if (display == null || display.getDisplayId()==outerWindowManager.getDefaultDisplay().getDisplayId()) {
return outerContext;
}
Context displayContext = outerContext.createDisplayContext(display);
if (theme == 0) {
TypedValue outValue = new TypedValue();
displayContext.getTheme().resolveAttribute(
android.R.attr.presentationTheme, outValue, true);
theme = outValue.resourceId;
}

// Derive the display's window manager from the outer window manager.
// We do this because the outer window manager have some extra information
// such as the parent window, which is important if the presentation uses
// an application window type.
// final WindowManager outerWindowManager =
// (WindowManager) outerContext.getSystemService(WINDOW_SERVICE);
// final WindowManagerImpl displayWindowManager =
// outerWindowManager.createPresentationWindowManager(displayContext);

WindowManager displayWindowManager = null;
try {
ClassLoader classLoader = ComplexPresentationV1.class.getClassLoader();
Class<?> loadClass = classLoader.loadClass("android.view.WindowManagerImpl");
Method createPresentationWindowManager = loadClass.getDeclaredMethod("createPresentationWindowManager", Context.class);
displayWindowManager = (WindowManager) loadClass.cast(createPresentationWindowManager.invoke(outerWindowManager,displayContext));
} catch (ClassNotFoundException | NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
final WindowManager windowManager = displayWindowManager;
return new ContextThemeWrapper(displayContext, theme) {
@Override
public Object getSystemService(String name) {
if (WINDOW_SERVICE.equals(name)) {
return windowManager;
}
return super.getSystemService(name);
}
};
}

全部源码


public class ComplexPresentationV1 extends Dialog  {

private static final String TAG = "ComplexPresentationV1";
private static final int MSG_CANCEL = 1;

private Display mPresentationDisplay;
private DisplayManager mDisplayManager;
/**
* Creates a new presentation that is attached to the specified display
* using the default theme.
*
* @param outerContext The context of the application that is showing the presentation.
* The presentation will create its own context (see {@link #getContext()}) based
* on this context and information about the associated display.
* @param display The display to which the presentation should be attached.
*/

public ComplexPresentationV1(Context outerContext, Display display) {
this(outerContext, display, 0);
}

/**
* Creates a new presentation that is attached to the specified display
* using the optionally specified theme.
*
* @param outerContext The context of the application that is showing the presentation.
* The presentation will create its own context (see {@link #getContext()}) based
* on this context and information about the associated display.
* @param display The display to which the presentation should be attached.
* @param theme A style resource describing the theme to use for the window.
* See <a href="{@docRoot}guide/topics/resources/available-resources.html#stylesandthemes">
* Style and Theme Resources</a> for more information about defining and using
* styles. This theme is applied on top of the current theme in
* <var>outerContext</var>. If 0, the default presentation theme will be used.
*/

public ComplexPresentationV1(Context outerContext, Display display, int theme) {
super(createPresentationContext(outerContext, display, theme), theme);
WindowManager wm = (WindowManager) outerContext.getSystemService(WINDOW_SERVICE);
if(display==null || wm.getDefaultDisplay().getDisplayId()==display.getDisplayId()){
return;
}
mPresentationDisplay = display;
mDisplayManager = (DisplayManager)getContext().getSystemService(DISPLAY_SERVICE);

//注意,这里需要借助Presentation的一些属性,否则无法正常弹出弹框,要么有权限问题、要么有token问题
Presentation presentation = new Presentation(outerContext, display, theme);
WindowManager.LayoutParams standardAttributes = presentation.getWindow().getAttributes();

final Window w = getWindow();
final WindowManager.LayoutParams attr = w.getAttributes();
attr.token = standardAttributes.token;
w.setAttributes(attr);
w.setType(standardAttributes.type);
//type 源码中是TYPE_PRESENTATION,事实上每个版本是不一样的,因此这里动态获取
w.setGravity(Gravity.FILL);
setCanceledOnTouchOutside(false);
}

/**
* Gets the {@link Display} that this presentation appears on.
*
* @return The display.
*/

public Display getDisplay() {
return mPresentationDisplay;
}

/**
* Gets the {@link Resources} that should be used to inflate the layout of this presentation.
* This resources object has been configured according to the metrics of the
* display that the presentation appears on.
*
* @return The presentation resources object.
*/

public Resources getResources() {
return getContext().getResources();
}

@Override
protected void onStart() {
super.onStart();

if(mPresentationDisplay ==null){
return;
}
mDisplayManager.registerDisplayListener(mDisplayListener, mHandler);

// Since we were not watching for display changes until just now, there is a
// chance that the display metrics have changed. If so, we will need to
// dismiss the presentation immediately. This case is expected
// to be rare but surprising, so we'll write a log message about it.
if (!isConfigurationStillValid()) {
Log.i(TAG, "Presentation is being dismissed because the "
+ "display metrics have changed since it was created.");
mHandler.sendEmptyMessage(MSG_CANCEL);
}
}

@Override
protected void onStop() {
if(mPresentationDisplay ==null){
return;
}
mDisplayManager.unregisterDisplayListener(mDisplayListener);
super.onStop();
}

/**
* Inherited from {@link Dialog#show}. Will throw
* {@link android.view.WindowManager.InvalidDisplayException} if the specified secondary
* {@link Display} can't be found.
*/

@Override
public void show() {
super.show();
}

/**
* Called by the system when the {@link Display} to which the presentation
* is attached has been removed.
*
* The system automatically calls {@link #cancel} to dismiss the presentation
* after sending this event.
*
* @see #getDisplay
*/

public void onDisplayRemoved() {
}

/**
* Called by the system when the properties of the {@link Display} to which
* the presentation is attached have changed.
*
* If the display metrics have changed (for example, if the display has been
* resized or rotated), then the system automatically calls
* {@link #cancel} to dismiss the presentation.
*
* @see #getDisplay
*/

public void onDisplayChanged() {
}

private void handleDisplayRemoved() {
onDisplayRemoved();
cancel();
}

private void handleDisplayChanged() {
onDisplayChanged();

// We currently do not support configuration changes for presentations
// (although we could add that feature with a bit more work).
// If the display metrics have changed in any way then the current configuration
// is invalid and the application must recreate the presentation to get
// a new context.
if (!isConfigurationStillValid()) {
Log.i(TAG, "Presentation is being dismissed because the "
+ "display metrics have changed since it was created.");
cancel();
}
}

private boolean isConfigurationStillValid() {
if(mPresentationDisplay ==null){
return true;
}
DisplayMetrics dm = new DisplayMetrics();
mPresentationDisplay.getMetrics(dm);
try {
Method equalsPhysical = DisplayMetrics.class.getDeclaredMethod("equalsPhysical", DisplayMetrics.class);
return (boolean) equalsPhysical.invoke(dm,getResources().getDisplayMetrics());
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
return false;
}

private static Context createPresentationContext(
Context outerContext, Display display, int theme)
{
if (outerContext == null) {
throw new IllegalArgumentException("outerContext must not be null");
}
WindowManager outerWindowManager = (WindowManager) outerContext.getSystemService(WINDOW_SERVICE);
if (display == null || display.getDisplayId()==outerWindowManager.getDefaultDisplay().getDisplayId()) {
return outerContext;
}
Context displayContext = outerContext.createDisplayContext(display);
if (theme == 0) {
TypedValue outValue = new TypedValue();
displayContext.getTheme().resolveAttribute(
android.R.attr.presentationTheme, outValue, true);
theme = outValue.resourceId;
}

// Derive the display's window manager from the outer window manager.
// We do this because the outer window manager have some extra information
// such as the parent window, which is important if the presentation uses
// an application window type.
// final WindowManager outerWindowManager =
// (WindowManager) outerContext.getSystemService(WINDOW_SERVICE);
// final WindowManagerImpl displayWindowManager =
// outerWindowManager.createPresentationWindowManager(displayContext);

WindowManager displayWindowManager = null;
try {
ClassLoader classLoader = ComplexPresentationV1.class.getClassLoader();
Class<?> loadClass = classLoader.loadClass("android.view.WindowManagerImpl");
Method createPresentationWindowManager = loadClass.getDeclaredMethod("createPresentationWindowManager", Context.class);
displayWindowManager = (WindowManager) loadClass.cast(createPresentationWindowManager.invoke(outerWindowManager,displayContext));
} catch (ClassNotFoundException | NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
final WindowManager windowManager = displayWindowManager;
return new ContextThemeWrapper(displayContext, theme) {
@Override
public Object getSystemService(String name) {
if (WINDOW_SERVICE.equals(name)) {
return windowManager;
}
return super.getSystemService(name);
}
};
}

private final DisplayManager.DisplayListener mDisplayListener = new DisplayManager.DisplayListener() {
@Override
public void onDisplayAdded(int displayId) {
}

@Override
public void onDisplayRemoved(int displayId) {
if (displayId == mPresentationDisplay.getDisplayId()) {
handleDisplayRemoved();
}
}

@Override
public void onDisplayChanged(int displayId) {
if (displayId == mPresentationDisplay.getDisplayId()) {
handleDisplayChanged();
}
}
};

private final Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MSG_CANCEL:
cancel();
break;
}
}
};
}

Delagate方式:


反射,利用反射本身就是一种方式,当然 android 9 开始,很多 @hide 反射不被允许,但是办法也是很多的,比如 freeflection 开源项目。


此外还有一个需要注意的是 Presentation 继承的是 Dialog 构造方法是无法被包外的子类使用,但是影响不大,我们在和Presentation的包名下创建我们的自己的Dialog依然可以解决。


这种方式借壳 Dialog,这种事只是套用 Dialog 一层,以动态代理方式实现,不过相比前一种方案来说,这种方案也有很多缺陷,比如他的onCreate\onShow\onStop\onAttachToWindow\onDetatchFromWindow等方法并没有完全和Dialog同步,需要做下兼容。


兼容


onAttachToWindow\onDetatchFromWindow


WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
if (display != null && display.getDisplayId() != wm.getDefaultDisplay().getDisplayId()) {
dialog = new Presentation(context, display, themeResId);
} else {
dialog = new Dialog(context, themeResId);
}
//下面兼容attach和detatch问题
mDecorView = dialog.getWindow().getDecorView();
mDecorView.addOnAttachStateChangeListener(this);

onShow和\onStop


@Override
public void show() {
if (!isCreate) {
onCreate(null);
isCreate = true;
}
dialog.show();
if (!isStart) {
onStart();
isStart = true;
}
}


@Override
public void dismiss() {
dialog.dismiss();
if (isStart) {
onStop();
isStart = false;
}
}

从兼容代码上来看,显然没有做到Dialog那种同步,因此只适合在单一线程中使用。


总结


本篇总结了2种异显屏弹窗,总体上来说,都有一定的瑕疵,但是第一种方案显然要好的多,主要是View更新上和可扩展上,当然第二种对于非多线程且不关注严格回调的需求,也是足以应付,在实际情况中,合适的才是最重要的。


作者:时光少年
来源:juejin.cn/post/7315846805920972809
收起阅读 »

使用DSL的方式自定义了一个弹框,代码忽然变的有那么一点点好看

现在大多数的项目当中都会有一个弹框组件,其目的是为了可以将涉及到弹框场景的逻辑,或者ui统一的进行管理维护,带来的好处是需要弹框的地方不用重新自己去自定义一个,导致弹框轮子泛滥,而是调用组件提供的api将一个符合设计规范的弹框渲染出来,如果设计规范更新了,只要...
继续阅读 »

现在大多数的项目当中都会有一个弹框组件,其目的是为了可以将涉及到弹框场景的逻辑,或者ui统一的进行管理维护,带来的好处是需要弹框的地方不用重新自己去自定义一个,导致弹框轮子泛滥,而是调用组件提供的api将一个符合设计规范的弹框渲染出来,如果设计规范更新了,只要更新一下组件,那么所有弹框都可以一起更新,节省了逐个修改的时间。从另一个方面来说,由于弹框组件几乎整个团队里面每个人都会使用,它的优点与缺点将统统暴露出来,所以如何去设计一个弹框组件是每一个开发者都要去考虑的问题,而目前我们常见的弹框组件设计方式有两种


常见的设计方式


使用构造函数一键生成


image.png

这是一种设计方式,会将弹框标题,弹框内容,弹框按钮文案,弹框按钮点击事件一起传给构造函数,再多重载几个函数来支持一些特定场景比如没有标题,单个按钮,文案颜色等等,我一般如果接手个项目,这个项目是多人开发的话,我都会主动揽下弹框组件开发的任务,不是因为写弹框有瘾,主要是担心别人使用这种方式写框子,说又不好说,做起来真的是噩梦,这种方式的优点缺点总结如下



  • 优点:未知

  • 缺点:

    • 代码角度来讲,可读性比较差,大量的入参会让调用者在填写参数的时候产生迷惑,不知道具体某一个参数对应的是什么功能。

    • 对于维护人员来讲,每次组件需要改动一个元素,就需要将每个构造函数的逻辑都修改一遍,工作量大并且容易出错。

    • 对于调用方来讲,每次需要写大量参数,并且需要严格遵守参数的声明顺序,组件如果更新了函数签名,调用处就会产生编译报错




使用建造者模式链式调用


image.png

另一种设计方式是使用建造者模式,这也是我惯用的方式,将弹框中的所有元素都一一对外暴露出一个方法,让调用方去设置,需要用到哪个元素就去设置设置哪个元素,组件内部默认实现一套样式,如果有的元素没有被调用方设置,就默认使用组件自带的实现方式,但这种方式也有优缺点,总结如下



  • 优点:将功能用函数区分开来,职能清晰,调用方可根据自己的需求选择性的调用对应函数渲染弹框

  • 缺点:维护者需要不断根据新的需求往组件里面添加新的方法供调用方使用,比如想要将标题加粗,如果组件没有提供对应的setTitleBold这样的方法,那么调用方将无法实现这个功能,多轮迭代下来,可能组件里面已经积攒了各种各样的方法,如果不好好分类管理,那阅读起来也是很头疼的一件事情


第三种设计方式


鉴于上述提到的两种设计方式以及总结出来的优缺点,我们不禁有个疑问,这种方式也不行,那个方式也不是很好,那么这么常用的组件难道就没有更好的设计方式了吗,能够设计出来以后可以满足如下几个要求



  • 组件拥有极强的扩展性,调用方可以随意定义自己需要的功能

  • 维护方不用频繁的在组件中添加功能,保持组件的稳定性

  • 结构清晰,每个代码块负责一个组件元素的功能


DSL的定义


想要实现以上几点,我们就要使用这篇文章的重点DSL了,那什么是DSL呢,那就是领域专用语言:专门解决某一特定问题的计算机语言,比如我们常用的正则表达式就是一种DSL,它与我们常用的api不一样,有着自己独特的结构,也叫做文法,在Kotlin里面这种结构我们使用lambda表达式去完成


带接收者的lambda


在使用DSL自定义弹框之前,我们先看一个例子,我们刚接触kotlin的时候,一定接触过它标准库里的let跟apply函数,也死记硬背的区分了一下这俩函数的区别,在实际开发当中也用到过,比如有一个按钮,我们需要去设置它的文案,字体大小以及点击事件,一般会这么做


image.png

我们看到每次访问按钮的一个属性就要重复写一下button,如果访问的属性变多了,那代码就会显的特别的啰嗦,所以这个时候,let跟apply函数就派上用场了


image.png

我们看到两者的区别体现在了let后面的lambda表达式里面,使用it显示的代替了button,如果万一button需要改变一下变量名,我们只需要更改let左边的button就好,而apply后面的表达式里面,完全省略了it,整个表达式的作用域就是button,可以直接访问button的属性,我们在牢记这个差异的同时,是不是也想一想,为什么这俩函数会存在这样的差异呢?答案就在这俩函数的源码当中,我们看一下


image.png

我们看到两个函数源码最大的区别在于let的入参是一个参数为T的函数类型的参数,所以在lambda表达式中我们可以用it显示的代替T,而apply的入参稍显不同,它的入参也是个函数类型,但是T被挪到了括号的前面,当作一个接收者来接受lambda表达式中返回的结果,所以才会导致apply函数后面只有它的属性以及值,结构及其精简,而kotlin中的DSL的主要语法点就是带接收者的lambda,现在我们就带着这个语法点开始一步步去自定义我们的弹框吧


开始开发


首先我们先从简单的实现一个AlertDialog弹框开始


image.png

AlertDialog的一个特点就是使用了建造者模式,每一个设置函数结束后都会返回给AlertDialog.Builder,那么从这一点上我们就可以仿照apply函数那样,将生成Dialog的这个过程转换成带有接收者的lambda表达式,那么先要做的就是给AlertDialog.Builder增加一个扩展函数,内部接收一个带有接收者的lambda表达式的参数


image.png

现在我们可以使用新增的createDialog函数来改变下刚刚生成AlertDialog的代码


image.png

createDialog作用类似于函数apply,lambda代码块的作用域就是AlertDialog.Builder,可以访问任何AlertDialog.Builder中的函数,上述代码我们可以再简化一下,将createDialog作为一个顶层函数,在函数内部生成AlertDialog.Builder实例,顶层函数如下


image.png

而调用弹框的地方代码也一同更改成了


image.png

运行一下代码我们就得到了一个系统自带的弹框


image.png

但是这样的一个弹框,我想国内应该没几个设计师会喜欢,所以按照设计师给的视觉图,在现有基础上去自定义弹框是我们接下去要做的事情,撇开一些特定的业务场景,一个弹框组件需要具备如下功能



  1. 弹框布局可自定义样式,比如圆角,背景颜色

  2. 弹框标题可自定义,比如文案,字体颜色,大小

  3. 弹框内容可自定义,比如文案,字体颜色,大小

  4. 弹框按钮数量可配置一个或两个


弹框布局


第一步我们先做弹框的布局,对于一个弹框组件来讲,设计师会事先将所有弹框样式都设计出来,所以整体布局的大体样式是固定的,我们以一个简单的dialog_layout布局文件作为弹框的样式


image.png

整个布局结构很简单,从上到下分别是标题,内容,按钮区,接下来我们就在顶层函数createDialog的lambda表达式中把布局设置到弹框里去,并且让弹框的宽度与屏幕宽度成比例自适应,毕竟不同app里面弹框的宽度都不一定相同


image.png

效果如下


image.png

一个纯白色弹框就出来了,接下来我们简化一下代码,由于每次调用弹框,dialog.show以及下面的设置宽度以及弹框位置的代码都会去调用,所以为了避免重复,反复造轮子,我们可以给AlertDialog增加一个扩展函数,将这些代码都放在扩展函数里面,上层只需要调用这个扩展函数就行,扩展函数我们就命名为showDialog,代码如下


image.png

上层调用弹框的地方就变成了


image.png

是不是精简了很多呢,代码运行的效果是一样的,就不展示了,但是目前我们这个框子还只是普通的样式,我们如果想要给它设置个圆角,然后捎带一些渐变色效果的背景,该怎么做呢?我们第一个想到的就是做一个drawable文件,在里面写上这些样式,再设置给布局根视图的background不就可以了吗,这的确是一个办法,但是如果有一天设计师突发奇想,觉得在某些场景下弹框使用样式A,某些场景下使用样式B,难道在生成一个新的drawable文件吗,这样一来单单一个弹框组件就要维护两种样式文件,给项目维护又带来了一定的成本,所以我们得想个更好的办法,就是使用GradientDrawable动态给布局设置样式,作法如下


image.png


看到在代码中用红框子以及绿框子区分了两部分代码,我们先看红框子里面,都能看明白主要是做渲染的工作,生成了一个GradientDrawable实例,然后分别对它设置了背景色,渐变方向,圆角大小,而这个我们就可以用带接收者的lambda表达式替换,GradientDrawable就是接收者,在看绿框子里面,虽然现在代码不多,但是setView之前肯定还得对view里面的元素做初始化等一系列操作,所以view也是一个接收者,初始化等操作可以放在lambda表达式中进行,理清了这些以后,我们新增一个AlertDialog.Builder的扩展函数rootLayout


image.png

rootLayout函数一共接收三个参数,root就是我们的弹框视图,render就是渲染操作,job是初始化view的操作,对于渲染操作来讲,rootLayout内部已经实现了一套默认的样式,如果调用方不使用render函数,那弹框就使用默认样式,如果使用了render函数,那么render里面有同样属性的就覆盖,有新增属性就累加,这个时候,上层调用方代码就更改为


image.png

我们运行一下看看效果


image.png

跟我们想要设置的效果一模一样,现在我们试试看不使用默认的样式,想要让弹框上面的圆角为12dp,下面没有圆角,背景渐变色变为从左到右方向由灰变白,我们在render函数里面加上这些设置


image.png

运行以后效果就变成了


image.png

弹框标题


有了弹框布局的开发经验,标题就容易多了,既然job函数的接收者是View,那么我们就给View先定一个扩展函数title


image.png

这个函数专门用来做标题相关部分的操作,而title的参数则是一个接收者为TextView的lambda表达式,用来在调用方额外给标题添加设置,那现在我们就可以给弹框添加个标题了,顺便把框的四个角都变成圆角,好看些


image.png

加了一个深色加粗标题,其中textColor属性是我添加的扩展属性,为的是让代码看上去整洁一些,效果等同于setTextColor(getColor(R.color.color_303F9F))


image.png

再次运行一下,标题就出来了


image.png

好像标题有点太靠上了,我们给弹框整体加个10dp的内边距在看下效果


image.png
image.png

效果出来了,我们再进行下一步


弹框内容


有了标题的例子,弹框内容基本都一样,不多说直接上代码


image.png

然后在弹框上添加一段文案


image.png

效果如下


image.png

弹框按钮


通常弹框组件都会有单个按钮弹框(提示型)和两个按钮弹框(交互型)两种类型,我们的dialog_layout布局中有两个TextView分别用来作为按钮,默认左边的negativeBtn是隐藏的,右边positiveBtn是展示出来的,这里我是仿照着AlertDialog里面设置按钮的逻辑来做,当只调用setPositiveButton的时候,表示此时为单个按钮弹框,当同时又调用了setNegativeButton的时候,就表示两个按钮的弹框,我们这边也借用这个思想,定义两个函数来控制这俩个按钮


image.png

代码很简单,当然也可以在函数里面加入一些默认样式,比如positiveBtn一般为高亮色值,negativeBtn为灰色色值,现在我们去调用下这俩函数,首先展示只有一个按钮的弹框


image.png

像Alertdialog一样只调用了positiveBtn函数就可以了,效果图如下


image.png

当我们要在弹框上显示两个按钮的时候,只需要再增加一个negativeBtn就可以了,就像这样


image.png
image.png

接下来就是给按钮设置监听事件了,非常容易,只需要调用setOnClickListener就可以了


image.png

这样其实可以完事了,弹框可以正常点击完以后做一些业务逻辑并且让弹框消失,但是仅仅这样的话我们这代码里还是存在着一些设计不合理的地方



  • 每一次createDialog以后,都必须showDialog以后弹框才能出来,这个可以让组件自己完成而不用调用方自己每次去showDialog

  • rootLayout返回的是AlertDialog.Builder对象,必须调用create以后才能得到AlertDialog对象去操作弹框展示与隐藏,这些也应该放在组件里面进行

  • 弹框按钮点击的默认操作基本都是关闭弹框,所以也没有必要每次在点击事件中显示的调用dismiss函数,也可以将关闭的动作放在组件中进行


那么我们就要更改下rootLayout函数,让它的返回值从AlertDialog.Builder变成Unit,而上述说的create以及showDialog操作,就要在rootLayout中进行,更改完的代码如下


image.png

mDialog是组件中维护的一个顶层属性,这也是为了在点击弹框按钮时候,在组件内部关闭弹框,接下去我们开始处理弹框按钮的点击事件,由于点击事件是作用在TextView上的,所以先给TextView增加一个扩展函数clickEvent,用来处理关闭弹框和其他点击事件的逻辑


image.png

现在我们可以回到调用方那边,将弹框的代码更新一下,并给positiveBtn和negativeBtn分别加上新增的clickEvent函数作为点击事件,而positiveBtn点击后还会弹出一个Toast作为响应事件


createDialog(this) {
rootLayout(
root = layoutInflater.inflate(R.layout.dialog_layout, null),
render = {
orientation = GradientDrawable.Orientation.LEFT_RIGHT
colors = intArrayOf(
getColor(R.color.color_BBBBBB),
getColor(R.color.white)
)
cornerRadius = DensityUtil.dp2px(12f).toFloat()
}
) {
title {
text = "DSL弹框"
typeface = Typeface.DEFAULT_BOLD
textColor = getColor(R.color.color_303F9F)
}
message {
text = "用DSL方式自定义的弹框用DSL方式自定义的弹框用DSL方式自定义的弹框用DSL方式自定义的弹框"
gravity = Gravity.CENTER
textColor = getColor(R.color.black)
}
positiveBtn {
text = "知道了"
textColor = getColor(R.color.color_FF4081)
clickEvent {
Toast.makeText(this@MainActivity, "开始处理响应事件", Toast.LENGTH_SHORT).show()
}
}
negativeBtn {
text = "取消"
textColor = getColor(R.color.color_303F9F)
clickEvent { }
}
}
}

运行一下看看效果如何


aaa.gif


到这里我们的弹框组件就大功告成了,顺带贴上AlertDialog.kt的源码


弹框组件源码


lateinit var mDialog: AlertDialog
var TextView.textColor: Int
get() {
return this.textColors.defaultColor
}
set(value) {
this.setTextColor(value)
}

fun createDialog(ctx: Context, body: AlertDialog.Builder.() -> Unit) {
val dialog = AlertDialog.Builder(ctx)
dialog.body()
}

@RequiresApi(Build.VERSION_CODES.M)
inline fun AlertDialog.Builder.rootLayout(
root: View,
render: GradientDrawable.() -> Unit = {},
job: View.() -> Unit
)
{
with(GradientDrawable()){
//默认样式
render()
root.background = this
}
root.setPadding(DensityUtil.dp2px(10f))
root.job()
mDialog = setView(root).create()
mDialog.showDialog()
}

inline fun View.title(titleJob: TextView.() -> Unit) {
val title = findViewById<TextView>(R.id.dialog_title)
//可以加一些标题的默认操作,比如字体颜色,字体大小
title.titleJob()
}

inline fun View.message(messageJob: TextView.() -> Unit) {
val message = findViewById<TextView>(R.id.dialog_message)
//可以加一些内容的默认操作,比如字体颜色,字体大小,居左对齐还是居中对齐
message.messageJob()
}

inline fun View.negativeBtn(negativeJob: TextView.() -> Unit) {
val negativeBtn = findViewById<TextView>(R.id.dialog_negative_btn_text)
negativeBtn.visibility = View.VISIBLE
negativeBtn.negativeJob()
}

inline fun View.positiveBtn(positiveJob: TextView.() -> Unit) {
val positiveBtn = findViewById<TextView>(R.id.dialog_positive_btn_text)
positiveBtn.positiveJob()
}

inline fun TextView.clickEvent(crossinline event: () -> Unit) {
setOnClickListener {
mDialog.dismiss()
event()
}
}

fun AlertDialog.showDialog() {
show()
val mWindow = window
mWindow?.setBackgroundDrawableResource(R.color.transparent)
val group: ViewGr0up = mWindow?.decorView as ViewGr0up
val child: ViewGr0up = group.getChildAt(0) as ViewGr0up
child.post {
val param: WindowManager.LayoutParams? = mWindow.attributes
param?.width = (DensityUtil.getScreenWidth() * 0.8).toInt()
param?.gravity = Gravity.CENTER
mWindow.setGravity(Gravity.CENTER)
mWindow.attributes = param
}
}

总结


可能早就有人已经发现了,我们现在弹框的调用方式跟Compose,React很相似,也就是最近很流行的声明式UI,为什么说它流行,比我们传统的命令式UI好用,主要的差别就在于声明式UI调用方只需要在乎视图的描述就可以,而真正视图如何渲染,如何测量,调用方不需要关心,在我们的弹框的例子中,调用方全程需要做的就是对着视觉稿子,将弹框中的元素以及需要的属性样式一个个写上去就好了,就算弹框后期需求变化再频繁,对于调用方来说只是增减几个元素属性的事情,而像弹框如何设置自定义的视图,如何测量与屏幕之间的宽度比例等,不需要调用方去关心,所以这种方式在我们以后的开发当中可以逐步学习,适应,使用起来了,并不是说只有在写React,Flutter或者Compose之类的项目中才用到这种声明式UI


作者:Coffeeee
来源:juejin.cn/post/7204601386607706172
收起阅读 »

Android12+ ScrollView自带的阻尼动画很酷炫?小心有坑!

今天在项目中测试提了一个特别奇怪的问题,自定义的camera预览页面左右拖动或者上下拖动时,页面预览只剩一半了,另一半黑了。。。 正常预览显示没问题,就是手指放在预览的地方一拖动,不管是上下还是左右都会半边黑,手指离开正常,看到这,各位肯定会以为我在页面中...
继续阅读 »

今天在项目中测试提了一个特别奇怪的问题,自定义的camera预览页面左右拖动或者上下拖动时,页面预览只剩一半了,另一半黑了。。。


1693391859690.jpg


1693392035997.jpg


正常预览显示没问题,就是手指放在预览的地方一拖动,不管是上下还是左右都会半边黑,手指离开正常,看到这,各位肯定会以为我在页面中加了触摸事件,或者有其他的逻辑,最初我也以为是有的,所以我给预览加了触摸拦截,上层View也加了触摸拦截,几乎所有的View都加了,类似于这样:返回true,不让下层View处理用户事件。


mCameraPreviewView.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
return true;
}
});

最终一点用没有,断点看了,确实给拦截了,但是还是半边黑,没变化。。。奇了怪了。


可是遇到问题不能老想着见👻了,怀揣着唯物主义思想的我,抱着怀疑一切的态度,尽力做一些尝试。


camera是放在fragment里面的,难道跟fragment有关系?那就放到Activity里面试试看,咦嘶,没毛病,在Acitivity里面预览是正常的,真的跟fragment有关系?不能啊,这不科学,实在想不出来这有啥关系,而我那个camera又必须依赖与fragment,所以只能再想想其他办法了。


难道是预览被挤压了?androidx.camera.view.PreviewView上覆盖叠加一个View色块试试会不会也被挤压到?结果:没有,色块没被挤压。。。


那就只能是预览的问题了?预览在什么情况下会变成一半黑一半正常呢,查询谷歌还是百度都没有遇到同样情况的,在看谷歌Camera的API文档中下面有一句是这么写的


当预览视频分辨率与目标 PreviewView 的尺寸不同时,视频内容需要通过剪裁操作或添加遮幅式黑边来适应视图(保持原始宽高比)。为此,PreviewView 提供了以下 ScaleTypes:

FIT_CENTER、FIT_START 和 FIT_END,用于添加遮幅式黑边。整个视频内容会调整(放大或缩小)为可在目标 PreviewView 中显示的最大尺寸。不过,虽然整个视频帧会完整显示,但屏幕画面中可能会出现空白部分。视频帧会与目标视图的中心、起始或结束位置对齐,具体取决于您在上述三种缩放类型中选择了哪一种。

FILL_CENTER、FILL_START 和 FILL_END,用于进行剪裁。如果视频的宽高比与 PreviewView 不匹配,画面中只会显示部分内容,但视频仍会填满整个 PreviewView。
CameraX 使用的默认缩放类型是 FILL_CENTER。您可以使用 PreviewView.setScaleType() 设置最适合具体应用的缩放类型。

难道是因为设置了scaleType导致预览自动裁剪?可这用的就是默认的FILL_CENTER,页面怎么像是设置成了FIT_START,感觉此时思维进入了误区,离结果很近,又很远。


mCameraPreviewView.setScaleType(PreviewView.ScaleType.FILL_CENTER)

只能从源头找原因了,一遍又一遍的滑动去感觉里面的区别,后来测试说android12+ 才会这样,其他的正常!一遍又一遍滑动过程中也注意到了不管是上下还是左右滑动(这里的左右滑动不是绝对水平的左右滑动,也带有上下的角度偏移),都会带动一个动画回弹效果,也就是android12+才有的阻尼动画,这肯定是androidx.core.widget.NestedScrollView的问题,所以抱着尝试的心态把阻尼动画关了,android:overScrollMode="never"设置下这个,运行正常了!!!!


提问:
1.有阻尼动画为什么会导致预览画面异常呢?是因为页面显示比例发生了变化导致的?
2.当前预览设置的setTargetAspectRatio(AspectRatio.RATIO_16_9),那如果改成setTargetAspectRatio(AspectRatio.RATIO_4_3)还会受影响吗?


作者:敲代码的鱼
来源:juejin.cn/post/7273025171110871100
收起阅读 »

RecyclerView无限循环效果实现与解析

前言 前两天在逛博客的时候发现了这样一张直播间的截图,其中这个商品列表的切换和循环播放效果感觉挺好: 熟悉android的同学应该很快能想到这是recyclerView实现的线性列表,其主要有两个效果: 1.顶部item切换后样式放大+转场动画。 2....
继续阅读 »

前言


前两天在逛博客的时候发现了这样一张直播间的截图,其中这个商品列表的切换和循环播放效果感觉挺好:



熟悉android的同学应该很快能想到这是recyclerView实现的线性列表,其主要有两个效果:


1.顶部item切换后样式放大+转场动画。

2.列表自动、无限循环播放。


第一个效果比较好实现,顶部item布局的变化可以通过对RecyclerView进行OnScroll监听,判断item位置,做scale缩放。或者在自定义layoutManager在做layoutChild相关操作时判断第一个可见的item并修改样式。


自动播放则可以通过使用手势判断+延时任务来做。


本文主要提供关于第二个无限循环播放效果的自定义LayoutManager的实现。


正文


有现成的轮子吗?


先看看有没有合适的轮子,“不要重复造轮子”,除非轮子不满足需求。


1、修改adpter和数据映射实现

google了一下,有关recyclerView无限循环的博客很多,内容基本一模一样。大部分的博客都提到/使用了一种修改adpter以及数据映射的方式,主要有以下几步:


1. 修改adapter的getItemCount()方法,让其返回Integer.MAX_VALUE


2. 在取item的数据时,使用索引为position % list.size


3. 初始化的时候,让recyclerView滑到近似Integer.MAX_VALUE/2的位置,避免用户滑到边界。


在逛stackOverFlow时找到了这种方案的出处:
java - How to cycle through items in Android RecyclerView? - Stack Overflow


这个方法是建立了一个数据和位置的映射关系,因为itemCount无限大,所以用户可以一直滑下去,又因对位置与数据的取余操作,就可以在每经历一个数据的循环后重新开始。看上去RecyclerView就是无限循环的。


很多博客会说这种方法并不好,例如对索引进行了计算/用户可能会滑到边界导致需要再次动态调整到中间之类的。然后自己写了一份自定义layoutManager后觉得用自定义layoutManager的方法更好。



其实我倒不这么觉得。


事实上,这种方法已经可以很好地满足大部分无限循环的场景,并且由于它依然沿用了LinearLayoutManager。就代表列表依旧可以使用LLM(LinearLayoutManager)封装好的布局和缓存机制。



  1. 首先索引计算这个谈不上是个问题。至于用户滑到边界的情况,也可以做特殊处理调整位置。(另外真的有人会滑约Integer.MAX_VALUE/2大约1073741823个position吗?

  2. 性能上也无需担心。从数字的直觉上,设置这么多item然后初始化scrollToPosition(Integer.MAX_VALUE/2)看上去好像很可怕,性能上可能有问题,会卡顿巴拉巴拉。


实际从初始化到scrollPosition到真正onlayoutChildren系列操作,主要经过了以下几步。


先上一张流程图:


image.png



  • 设置mPendingScrollPosition,确定要滑动的位置,然后requestLayout()请求布局;


/**
* <p>Scroll the RecyclerView to make the position visible.</p>
*
* <p>RecyclerView will scroll the minimum amount that is necessary to make the
* target position visible. If you are looking for a similar behavior to
* {@link android.widget.ListView#setSelection(int)} or
* {@link android.widget.ListView#setSelectionFromTop(int, int)}, use
* {@link #scrollToPositionWithOffset(int, int)}.</p>
*
* <p>Note that scroll position change will not be reflected until the next layout call.</p>
*
* @param position Scroll to this adapter position
* @see #scrollToPositionWithOffset(int, int)
*/



@Override
public void scrollToPosition(int position) {
mPendingScrollPosition = position;//更新position
mPendingScrollPositionOffset = INVALID_OFFSET;
if (mPendingSavedState != null) {
mPendingSavedState.invalidateAnchor();
}
requestLayout();
}


  • 请求布局后会触发recyclerView的dispatchLayout,最终会调用onLayoutChildren进行子View的layout,如官方注释里描述的那样,onLayoutChildren最主要的工作是:确定锚点、layoutState,调用fill填充布局。


onLayoutChildren部分源码:


@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
// layout algorithm:
// 1) by checking children and other variables, find an anchor coordinate and an anchor
// item position.
// 2) fill towards start, stacking from bottom
// 3) fill towards end, stacking from top
// 4) scroll to fulfill requirements like stack from bottom.

//..............
// 省略,前面主要做了一些异常状态的检测、针对焦点的特殊处理、确定锚点对anchorInfo赋值、偏移量计算
int startOffset;
int endOffset;
final int firstLayoutDirection;
if (mAnchorInfo.mLayoutFromEnd) {
// fill towards start
updateLayoutStateToFillStart(mAnchorInfo); //根据mAnchorInfo更新layoutState
mLayoutState.mExtraFillSpace = extraForStart;
fill(recycler, mLayoutState, state, false);//填充
startOffset = mLayoutState.mOffset;
final int firstElement = mLayoutState.mCurrentPosition;
if (mLayoutState.mAvailable > 0) {
extraForEnd += mLayoutState.mAvailable;
}
// fill towards end
updateLayoutStateToFillEnd(mAnchorInfo);//更新layoutState为fill做准备
mLayoutState.mExtraFillSpace = extraForEnd;
mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
fill(recycler, mLayoutState, state, false);//填充
endOffset = mLayoutState.mOffset;
if (mLayoutState.mAvailable > 0) {
// end could not consume all. add more items towards start
extraForStart = mLayoutState.mAvailable;
updateLayoutStateToFillStart(firstElement, startOffset);//更新layoutState为fill做准备
mLayoutState.mExtraFillSpace = extraForStart;
fill(recycler, mLayoutState, state, false);
startOffset = mLayoutState.mOffset;
}
} else {
//layoutFromStart 同理,省略
}
//try to fix gap , 省略


  • onLayoutChildren中会调用updateAnchorInfoForLayout更新anchoInfo锚点信息,updateLayoutStateToFillStart/End再根据anchorInfo更新layoutState为fill填充做准备。


  • fill的源码:
    `


int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable)
{
// max offset we should set is mFastScroll + available
final int start = layoutState.mAvailable;
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
// TODO ugly bug fix. should not happen
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
recycleByLayoutState(recycler, layoutState);
}
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
// (不限制layout个数/还有剩余空间) 并且 有剩余数据
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.beginSection("LLM LayoutChunk");
}
layoutChunk(recycler, state, layoutState, layoutChunkResult);
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.endSection();
}
if (layoutChunkResult.mFinished) {
break;
}
layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
/**
* Consume the available space if:
* * layoutChunk did not request to be ignored
* * OR we are laying out scrap children
* * OR we are not doing pre-layout
*/

if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null
|| !state.isPreLayout()) {
layoutState.mAvailable -= layoutChunkResult.mConsumed;
// we keep a separate remaining space because mAvailable is important for recycling
remainingSpace -= layoutChunkResult.mConsumed;
}

if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
recycleByLayoutState(recycler, layoutState);//回收子view
}
if (stopOnFocusable && layoutChunkResult.mFocusable) {
break;
}
}
if (DEBUG) {
validateChildOrder();
}
return start - layoutState.mAvailable;

fill主要干了两件事:



  • 循环调用layoutChunk布局子view并计算可用空间

  • 回收那些不在屏幕上的view


所以可以清晰地看到LLM是按需layout、回收子view。


就算创建一个无限大的数据集,再进行滑动,它也是如此。可以写一个修改adapter和数据映射来实现无限循环的例子,验证一下我们的猜测:


//adapter关键代码
@NonNull
@Override
public DemoViewHolder onCreateViewHolder(@NonNull ViewGr0up parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
Log.d("DemoAdapter","onCreateViewHolder");
return new DemoViewHolder(inflater.inflate(R.layout.item_demo, parent, false));
}

@Override
public void onBindViewHolder(@NonNull DemoViewHolder holder, int position) {
Log.d("DemoAdapter","onBindViewHolder: position"+position);
String text = mData.get(position % mData.size());
holder.bind(text);
}

@Override
public int getItemCount() {
return Integer.MAX_VALUE;
}

在代码我们里打印了onCreateViewHolder、onBindViewHolder的情况。我们只要观察这viewHolder的情况,就知道进入界面再滑到Integer.MAX_VALUE/2时会初始化多少item。
`


RecyclerView recyclerView = findViewById(R.id.rv);
recyclerView.setAdapter(new DemoAdapter());
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
layoutManager.setOrientation(RecyclerView.VERTICAL);
recyclerView.setLayoutManager(layoutManager);
recyclerView.scrollToPosition(Integer.MAX_VALUE/2);

初始化后ui效果:



日志打印:
image.png


可以看到,页面上共有5个item可见,LLM也按需创建、layout了5个item。


2、自定义layoutManager

找了找网上自定义layoutManager去实现列表循环的博客和代码,拷贝和复制的很多,找不到源头是哪一篇,这里就不贴链接了。大家都是先说第一种修改adapter的方式不好,然后甩了一份自定义layoutManager的代码。


然而自定义layoutManager难点和坑都很多,很容易不小心就踩到,一些博客的代码也有类似问题。
基本的一些坑点在张旭童大佬的博客中有提及,
【Android】掌握自定义LayoutManager(一) 系列开篇 常见误区、问题、注意事项,常用API。


比较常见的问题是:



  1. 不计算可用空间和子view消费的空间,layout出所有的子view。相当于抛弃了子view的复用机制

  2. 没有合理利用recyclerView的回收机制

  3. 没有支持一些常用但比较重要的api的实现,如前面提到的scrollToPosition。


其实最理想的办法是继承LinearLayoutManager然后修改,但由于LinearLayoutManager内部封装的原因,不方便像GridLayoutManager那样去继承LinearLayoutManager然后进行扩展(主要是包外的子类会拿不到layoutState等)。


要实现一个线性布局的layoutManager,最重要的就是实现一个类似LLM的fill(前面有提到过源码,可以翻回去看看)和layoutChunk方法。


(当然,可以照着LLM写一个丐版,本文就是这么做的。)


fill方法很重要,就如同官方注释里所说的,它是一个magic func。


从OnLayoutChildren到触发scroll滑动,都是调用fill来实现布局。


/**
* The magic functions :). Fills the given layout, defined by the layoutState. This is fairly
* independent from the rest of the {@link LinearLayoutManager}
* and with little change, can be made publicly available as a helper class.
*/

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable)
{

前面提到过fill主要干了两件事:



  • 循环调用layoutChunk布局子view并计算可用空间

  • 回收那些不在屏幕上的view


而负责子view布局的layoutChunk则和把一个大象放进冰箱一样,主要分三步走:



  1. add子view

  2. measure

  3. layout 并计算消费了多少空间



就像下面这样:


/**
* layout具体子view
*/

private void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
LayoutState layoutState, LayoutChunkResult result)
{
View view = layoutState.next(recycler, state);
if (view == null) {
result.mFinished = true;
return;
}
RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();

// add
if (layoutState.mLayoutDirection != LayoutState.LAYOUT_START) {
addView(view);
} else {
addView(view, 0);
}

Rect insets = new Rect();
calculateItemDecorationsForChild(view, insets);

// 测量
measureChildWithMargins(view, 0, 0);

//布局
layoutChild(view, result, params, layoutState, state);

// Consume the available space if the view is not removed OR changed
if (params.isItemRemoved() || params.isItemChanged()) {
result.mIgnoreConsumed = true;
}
result.mFocusable = view.hasFocusable();
}

那最关键的如何实现循环呢??


其实和修改adapter的实现方法有异曲同工之妙,本质都是修改位置与数据的映射关系。


修改layoutStae的方法:


    boolean hasMore(RecyclerView.State state) {
return Math.abs(mCurrentPosition) <= state.getItemCount();
}


View next(RecyclerView.Recycler recycler, RecyclerView.State state) {
int itemCount = state.getItemCount();
mCurrentPosition = mCurrentPosition % itemCount;
if (mCurrentPosition < 0) {
mCurrentPosition += itemCount;
}
final View view = recycler.getViewForPosition(mCurrentPosition);
mCurrentPosition += mItemDirection;
return view;
}

}

最终效果:



源码地址:aFlyFatPig/cycleLayoutManager (github.com)


注:也可以直接引用依赖使用,详见readme.md。


后记


本文介绍了recyclerview无限循环效果的两种不同实现方法与解析。


虽然自定义layoutManager坑点很多并且很少用的到,但了解下也会对recyclerView有更深的理解。


作者:紫槐
来源:juejin.cn/post/7215200495983214629
收起阅读 »

Android 自制照片选择器

自制照片选择器Android 从 11 版本后提供了照片选择器看起来确实不错,本来想直接调用 Android 提供的照片选择器的,不用自己再去做缩略图缓存也不用处理麻烦的 API 结果定睛一看发现几个大问题Android 提供的照片选择器必须升级 App 的 ...
继续阅读 »

自制照片选择器

Android 从 11 版本后提供了照片选择器

image-20231221130815793.png

看起来确实不错,本来想直接调用 Android 提供的照片选择器的,不用自己再去做缩略图缓存也不用处理麻烦的 API 结果定睛一看发现几个大问题

  1. Android 提供的照片选择器必须升级 App 的 androidx.activity 库到 1.7.0 版本,这可能意味着 app 的 targetSdkVersion 也得升级,同时需要处理好其他兼容性问题;
  2. Android 提供的照片选择器仅限搭载 Android 11(API 级别 30)或更高版本使用,其他的版本需要通过 Google 系统更新接收对模块化系统组件的更改,如果在低版本使用可能会调用 ACTION_OPEN_DOCUMENT 的 intent 操作来实现,这意味着很多现在例如限制选择几张照片可能不生效,这与需求严重不符。
  1. 能从网上找到的资料可以发现 Android 提供的照片选择器的 API 在变化,实际使用确实很难受。

综上,还不如自己做一个咯🤷‍♂️

开始动手

UI

UI 方面就照着 Google 的抄就好,图片加载用 Glide 来完成,参考微信的照片选择一列默认显示 4 个缩略图就好,然后用 RecyclerView 实现网格状列表容器,基于 DialogX 的 FullScreenDialog 对话框打底实现 activity 界面下沉效果以及从屏幕底部上移的对话框,准备就绪,开干!

复写 RecyclerView.Adapter 实现 PhotoAdapter,在其中用 Glide 加载照片并 override 尺寸进行加载和缓存以避免界面卡顿:

Glide.with(context)
      .load(imageUrls.get(position))
      .override(imageSize)
      .error(errorPhotoDrawableRes)
      .int0((PhotoSelectImageView) holder.itemView);

当照片被选中时,为了实现选中状态的图片缩小,增加边框和对钩图示,自定义了一个 PhotoSelectImageView 作为缩略图呈现使用,图片缩小效果直接用 padding 实现,边框绘制代码:

canvas.drawRect(0 + getBorderWidth() / 2, 0 + getBorderWidth() / 2, getWidth() - getBorderWidth() / 2, getHeight() - getBorderWidth() / 2, paint);

图库部分带圆角,边框的绘制代码调整为:

RectF rect = new RectF(0 + getBorderWidth() / 2, 0 + getBorderWidth() / 2, getWidth() - getBorderWidth() / 2, getHeight() - getBorderWidth() / 2);
canvas.drawRoundRect(rect, radius, radius, paint);

最后绘制标记:

//init 初始化部分代码:
//从图片资源加载
selectFlagBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.album_dialog_img_selected);
//按照主题色染色
Bitmap tintedBitmap = Bitmap.createBitmap(selectFlagBitmap.getWidth(), selectFlagBitmap.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(tintedBitmap);
Paint paint = new Paint();
paint.setColorFilter(new PorterDuffColorFilter(getResources().getColor(R.color.albumDefaultThemeDeep), PorterDuff.Mode.SRC_IN));

//...

//onDraw 部分代码
canvas.drawBitmap(selectFlagBitmap, null, selectFlagRect, paint);

PhotoSelectImageView 的呈现效果:

image-20231221132323779.png

RecyclerView 设置一个间隔装饰器 GridSpacingItemDecoration,指定 item 的间距:

@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
   int position = parent.getChildAdapterPosition(view);
   int column = position % spanCount;
   if (column >= 1) {
       outRect.left = spacing;
  }
   if (position >= spanCount) {
       outRect.top = spacing;
  }
}

基本上界面主体就完活了,额外的实现了一个相册列表的 Adapter,复用 RecyclerView 进行显示,区别就在于内容还需要考虑到相册名字的呈现:

image-20231221132715672.png

接下来就是相册的读取了,在开始之前首先需要申请权限。

权限处理

API-33 以前使用存储文件读取权限 READ_EXTERNAL_STORAGE 即可,API - 33 以后则需要使用 READ_MEDIA_IMAGES 权限,因此需要先在 AndroidManifest 声明这两个权限:

name="android.permission.READ_EXTERNAL_STORAGE"/>
name="android.permission.READ_MEDIA_IMAGES" />

使用代码申请:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
   if (ContextCompat.checkSelfPermission(activityContext, Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED) {
       ActivityCompat.requestPermissions(activityContext, new String[]{Manifest.permission.READ_MEDIA_IMAGES}, PERMISSION_REQUEST_CODE);
       return false;
  }
} else {
   if (ContextCompat.checkSelfPermission(activityContext, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
       ActivityCompat.requestPermissions(activityContext, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, PERMISSION_REQUEST_CODE);
       return false;
  }
}

本来想用 registerForActivityResult,至于为啥没用?别提那玩意了基本上就是一坨...

接下来有了权限,就只需要使用 MediaStore 读取所有相册和照片就可以完成实现了。

MediaStore 读取照片

MediaStore 和传统以文件方式读取照片的形式有所区别,它是一个媒体数据库,这意味着需要用读取数据库的思路去操作它。

首先是依据相册名称读取照片,如果相册名称为空则认为是所有照片,核心代码如下:

List photos = new ArrayList<>();
Uri images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
String[] projection = new String[]{
       MediaStore.Images.Media.DATA,
       MediaStore.Images.Media.DATE_ADDED
};
String selection;
String[] selectionArgs;
if (isNull(albumName)) {
   selection = null;
   selectionArgs = null;
} else {
   selection = MediaStore.Images.Media.BUCKET_DISPLAY_NAME + " = ?";
   selectionArgs = new String[]{albumName};
}
Cursor cur = context.getContentResolver().query(images,
       projection,
       selection,
       selectionArgs,
       null);
if (cur != null && cur.moveToFirst()) {
   int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA);
   do {
       String photoPath = cur.getString(dataColumn);
       photos.add(photoPath);
  } while (cur.moveToNext());
}
if (cur != null) {
   cur.close();
}

photos 即查询到的所有照片列表了,但还需要处理为按照最近时间倒序,添加 sortOrder 即可:

sortOrder = MediaStore.Images.Media.DATE_ADDED + " DESC"

添加 sortOrder 到 query 最后一个参数即可。这里的 MediaStore.Images.Media.DATE_ADDED 代表着按照添加到媒体库的时间排序,另外也可以选择 MediaStore.MediaColumns.DATE_TAKEN 按照拍摄时间排序,至于 DESC 就是倒序的意思了。

然后还需要查询所有相册,查询到的相册名称可能有重复的需要剔重。

//读取相册列表
List albums = new ArrayList<>();
String[] projection = new String[]{
       MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
       MediaStore.Images.Media.BUCKET_ID
};
Uri images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
Cursor cur = context.getContentResolver().query(images,
       projection,
       null,      
       null,      
       null      
);
if (cur != null && cur.moveToFirst()) {
   int bucketColumn = cur.getColumnIndex(MediaStore.Images.Media.BUCKET_DISPLAY_NAME);
   do {
       String albumName = cur.getString(bucketColumn);
       if (!albums.contains(albumName) && !isNull(albumName)) albums.add(albumName);
  } while (cur.moveToNext());
}
if (cur != null) {
   cur.close();
}

在 UI 呈现时按照相册名称读取最后一张图片作为封面图即可。

至此,自制照片选择器就基本上完成了,相关完整代码已经开源到 Github 上,欢迎参考学习 github.com/kongzue/Dia…,DialogXSample 是基于 DialogX 对话框框架的一系列功能模块扩展包,目前也提供了 地址滚动选择对话框、日期/日历(区间)选择对话框、分享选择对话框、自定义联动滚动选择对话框、底部弹出的评论输入对话框、选择(多选/筛选)文件对话框、抽屉对话框和照片选择器的 Demo 代码。

一键使用

照片选择器直接引入的 gradle 配置如下:

在 build.gradle(Project)(新版本 Android Studio 请在 settings.gradle)添加 jitpack 仓库:

allprojects {
  repositories {
      ...
      maven { url 'https://jitpack.io' }
  }
}
def dialogx_sample_version = "0.0.10"
implementation 'com.github.kongzue.DialogXSample:AlbumDialog:${dialogx_sample_version}'

额外的还需引入:

def DIALOGX_VERSION = "0.0.50.beta2"
implementation "com.github.kongzue.DialogX:DialogX:${DIALOGX_VERSION}"
implementation 'com.github.bumptech.glide:glide:4.12.0'
implementation "androidx.recyclerview:recyclerview:1.2.1"

如果默认的就能满足你的业务需求,直接引入对应功能的包即可,如果不能,请自行拉取代码集成到自己的项目里修改使用


作者:Kongzue
来源:juejin.cn/post/7314642642868715554

收起阅读 »

Android 如何统一处理登录后携带数据跳转到目标页面

需求场景 我们在开发应用的时候经常会遇到先登录,登录成功后再跳转到目标页面。比如商品详情页面我们点击购买必须要先登录,登录完成才能去下单支付。针对这种场景,我们一般有两种做法: 点击购买跳转到登录,登录完成需要用户再次点击购买才能去下单支付页面,这种用户体验...
继续阅读 »

需求场景


我们在开发应用的时候经常会遇到先登录,登录成功后再跳转到目标页面。比如商品详情页面我们点击购买必须要先登录,登录完成才能去下单支付。针对这种场景,我们一般有两种做法:



  1. 点击购买跳转到登录,登录完成需要用户再次点击购买才能去下单支付页面,这种用户体验不是很好。

  2. 点击购买跳转到登录,登录完成直接跳转到下单支付页面。


第一种我们就不谈了产品经理不同意🐶。第二种我们一般是在 onActivityResult 里面获取到登录成功,然后根据 code 跳转到目标页面。这种方式缺点就是我们要在每个页面都处理相同的逻辑还有定义各种 code,如果应用里面很多这种场景也太繁琐了。那有没有统一的方式去处理这种场景就是我们今天的主题了。


封装方式


我们的应用是组件化的,APP 的页面跳转使用了 Arouter。所以我们统一处理使用 Arouter 封装。直接上代码


fun checkLoginToTarget(postcard: Postcard) {//Postcard 是 Arouter 的类
if (User.isLogin()) {
postcard.navigation()
} else {
//不能使用 postcard 切换 path 直接跳转,因为 group 可能不同,所以重新 build
ARouter.getInstance().build(Constant.LOGIN)
.with(postcard.extras)//获取携带的参数重新转入
.withString(Constant.TAGACTIVIFY, postcard.path)//添加目标路由
.navigation()
}
}

//登录成功后在登录页面执行这个方法
fun loginSuccess() {
val intent= intent
val target = intent.getStringExtra(Constant.TAGACTIVIFY)//获取目标路由
target?.apply {
if (isNotEmpty()){
val build = ARouter.getInstance().build(this)
val extras = intent.extras//获取携带的参数
if (extras != null) {
build.with(extras)
}
build.navigation()
}
}
finish()
}

代码加了注释,使用 Kotlin 封装了顶层函数,登录页面在登录成功后跳转到目标页面,针对上面的场景直接调用 checkLoginToTarget 方法。


checkLoginToTarget(ARouter.getInstance().build(Constant.PAY_PAGE).withInt(Constant.GOOD_ID,id))

通过 Arouter 传入下单支付的路由地址,并且携带了商品的 ID,生成了 Postcard 参数。登录成功后能带着商品 ID
直接下单支付了。


最后


如果项目里没有使用路由库可以使用 Intent 封装实现,或者别的路由库也可以用上面的方式去做统一处理。


作者:shortybin
来源:juejin.cn/post/7237386183612530749
收起阅读 »

如果启动一个未注册的Activity

简述 要启动未注册的Activity主要是要逃避AMS的检测,思路是,检测前要启动的Activity换成注册的,检测通过了,再在启动前换回来。这里主要是两个点。检测前,hookAMS。检测后hookHandler。hook点有很多尽量找静态变量、单例和publ...
继续阅读 »

简述


要启动未注册的Activity主要是要逃避AMS的检测,思路是,检测前要启动的Activity换成注册的,检测通过了,再在启动前换回来。这里主要是两个点。检测前,hookAMS。检测后hookHandler。hook点有很多尽量找静态变量单例public


hookAMS


1、android 11举例,启动acitivty是在ATMS中(11之前是AMS,这个自己可以去适配)


image.png


2、拿到ATMS的代理。


3、然后ATMS整个动态代理在startActivity之前将Intent 偷梁换柱


4、换成已经注册的Activity之后记得原目标Acitivty存起来,在骗完AMS之后换回来


 
public static void hookAMS() {
// 10之前
try {
Class<?> clazz = Class.forName("android.app.ActivityTaskManager");
Field singletonField = clazz.getDeclaredField("IActivityTaskManagerSingleton");

singletonField.setAccessible(true);
Object singleton = singletonField.get(null);




Class<?> singletonClass = Class.forName("android.util.Singleton");
Field mInstanceField = singletonClass.getDeclaredField("mInstance");
mInstanceField.setAccessible(true);
Method getMethod = singletonClass.getMethod("get");
Object mInstance = getMethod.invoke(singleton);

Class IActivityTaskManagerClass = Class.forName("android.app.IActivityTaskManager");

Object mInstanceProxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
new Class[]{IActivityTaskManagerClass}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

if ("startActivity".equals(method.getName())) {
int index = -1;

// 获取 Intent 参数在 args 数组中的index值
for (int i = 0; i < args.length; i++) {
if (args[i] instanceof Intent) {
index = i;
break;
}
}
// 生成代理proxyIntent -- 孙悟空(代理)的Intent
Intent proxyIntent = new Intent();
// 这个包名是宿主的
proxyIntent.setClassName("com.leo.amsplugin",
ProxyActivity.class.getName());

// 原始Intent能丢掉吗?保存原始的Intent对象
Intent intent = (Intent) args[index];
proxyIntent.putExtra(TARGET_INTENT, intent);

// 使用proxyIntent替换数组中的Intent
args[index] = proxyIntent;
}

// 原来流程
return method.invoke(mInstance, args);
}
});

// 用代理的对象替换系统的对象
mInstanceField.set(singleton, mInstanceProxy);
} catch (Exception e) {
e.printStackTrace();
}
}

hookHandler


hookAMS完成,欺骗了AMS,接下来要把Intent中的原目标扶起回正位,
启动Activity要用handler,我们从这里hook吧


1、Activtiy thread 中的handler用来启动activity class H extends Handler


2、handlerMessage中的EXECUTE_TRANSACTION(159)来启动activity


3、
final ClientTransaction transaction = (ClientTransaction) msg.obj;--包含Intent


mTransactionExecutor.execute(transaction);--执行启动


launchActivityItem中有Intent,而ta继承于ClientTransactionItem,而ClientTransaction中包含List<ClientTransactionItem>


4、所以我只要拿到msg就可以拿到Intent
msg.obj --> ClientTransaction --> List mActivityCallbacks(LaunchActivityItem)
--> private Intent mIntent 替换


image.png


5、handlerMessage(MSG)之前有个callback也可以拿到msg。则会callback是一个接口,如果重写这个接口可就可重新handlerMessage这个方法,然后操作msg。


6、ActivityThread当中,Handler的构建没有传参数。


...//去ActivityThread.java里看
@UnsupportedAppUsage
final H mH = new H();
...
class H extends Handler //也没写构造方法

...//去Handler.java里看

@Deprecated
public Handler() {
this(null, false);
}

7、实际上callback是看,那么我自己替换系统的call就可以啦


8、那我通过反射拿Handler中的mCallback


 public void hoodHandler() {
try {
Class<?> clazz = Class.forName("android.app.ActivityThread");
Field activityThreadField = clazz.getDeclaredField("sCurrentActivityThread");
activityThreadField.setAccessible(true);
Object activityThread = activityThreadField.get(null);

Field mHField = clazz.getDeclaredField("mH");
mHField.setAccessible(true);
final Handler mH = (Handler) mHField.get(activityThread);

Field mCallbackField = Handler.class.getDeclaredField("mCallback");
mCallbackField.setAccessible(true);

mCallbackField.set(mH, new Handler.Callback() {

@Override
public boolean handleMessage(Message msg) {
switch (msg.what) {
case 159:
// msg.obj = ClientTransaction
try {
// 获取 List<ClientTransactionItem> mActivityCallbacks 对象
Field mActivityCallbacksField = msg.obj.getClass()
.getDeclaredField("mActivityCallbacks");
mActivityCallbacksField.setAccessible(true);
List mActivityCallbacks = (List) mActivityCallbacksField.get(msg.obj);

for (int i = 0; i < mActivityCallbacks.size(); i++) {
// 打印 mActivityCallbacks 的所有item:
//android.app.servertransaction.WindowVisibilityItem
//android.app.servertransaction.LaunchActivityItem

// 如果是 LaunchActivityItem,则获取该类中的 mIntent 值,即 proxyIntent
if (mActivityCallbacks.get(i).getClass().getName()
.equals("android.app.servertransaction.LaunchActivityItem")) {
Object launchActivityItem = mActivityCallbacks.get(i);
Field mIntentField = launchActivityItem.getClass()
.getDeclaredField("mIntent");
mIntentField.setAccessible(true);
Intent proxyIntent = (Intent) mIntentField.get(launchActivityItem);

// 获取启动插件的 Intent,并替换回来
Intent intent = proxyIntent.getParcelableExtra(TARGET_INTENT);
if (intent != null) {
mIntentField.set(launchActivityItem, intent);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
break;
}
return false;
}
});
} catch (Exception e) {
e.printStackTrace();
}

}

总结


一个分为两步


1、hookAMS主要就是逃避ams检测,让ams检测的是一个已经注册了的activity。


2、hookHandler在生成activity之前再把activity换回来。


所以一定要熟悉动态代理,反射和Activity的启动流程。


主要通过hook,核心在于hook点


插桩
1、尽量找 静态变量 单利
2、public


动态代理


AMS检测之前我改下


image.png


作者:KentWang
来源:juejin.cn/post/7243272599769055292
收起阅读 »

Android ReyclerView分割线竟然暗藏算法

前言 事情是这样的,前段时间正好有个RecyclerView用GridLayoutManager实现网格布局的需求,然后要做分割线,一般这种都是信手捏来的东西,然后我发现这个分割线竟然对不齐。 当然,如果要实现这样的功能,会有很多种方法,包括在itemView...
继续阅读 »

前言


事情是这样的,前段时间正好有个RecyclerView用GridLayoutManager实现网格布局的需求,然后要做分割线,一般这种都是信手捏来的东西,然后我发现这个分割线竟然对不齐。

当然,如果要实现这样的功能,会有很多种方法,包括在itemView加margin、padding等等,都能有办法去实现分割线的效果,但是我这种人就是非要弄清楚其中的问题才舒服。


结论


因为涉及到算法,可能要讲得比较多,所以先说说最终的结论,先看看效果


image.png


就是实现这种有分割线并均分布局的效果,我研究到最后发现竟然不是简单一两句代码能解决的,其中还暗藏玄机。这里我处理这个问题会涉及一个算法,所以最终会得到一个公式,我不能保证我的公式是最优的解法,如果有其它更好的公式也可以留言告诉我。


1. 简单的处理分割线


我这里的场景是ItemView是填充,意思就是填充除了分割线以外的布局。


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"  
android:layout_width="match_parent"
android:layout_height="100dp"
android:background="@color/purple_200"
android:orientation="vertical">

</LinearLayout>

假如我一开始要做分割线,我简单的去做,会是这样的效果


rv.addItemDecoration(object : ItemDecoration() {

override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
)
{
outRect.left = 60
}
})


image.png


然后你会很自然而然的想这个做


rv.addItemDecoration(object : ItemDecoration() {

override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
)
{
val pos = parent.getChildAdapterPosition(view)
if (pos != 0) {
outRect.left = 60
}
}
})


然后你会发现此时的布局不均分,第一个item更多


image.png


注意,我这里的处理问题思路是必须用分割线处理,不然用一些方法确实能更快做到,比如上面的情况,我加个padding也能做,但我这里的思路是要完全用outRect去处理这个问题


看到上面的效果和想象中的不同,没关系,我换个思路,我左右都加间距


rv.addItemDecoration(object : ItemDecoration() {

override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
)
{
val pos = parent.getChildAdapterPosition(view)
outRect.left = 30
outRect.right = 30
}
})

image.png


可以看到是均分了,但是如果我的场景需要首尾两个item贴边,那这样就不合适,但是你可能会很快的想到这样做,去判断首尾Item


image.png


恭喜你,又失败了,可以看到布局又不是均分了。如果你一直按照这样的简单思路去想,是无法处理这个问题的,因为他不是一个简单的公式就能解决的,所以简单的去思考,也只是浪费时间。


首先需要的是理解他的原理


2. 设置分割线getItemOffsets方法的原理


这里简单讲,不是看源码,而是通过图片去分析(我就简单画点图,可能不是很标准,将就着看)


image.png


红色是内容,白色是间距,如果不设置的话,红色的区域就是整个白色,可以抽象的理解成它是往内去缩的,所以如果第一个Item不设置Left,最后一个Item不设置Right,他的效果就会是这样


image.png


这就是上面Demo的最后一种情况,这里给你们看一个很有意思的现象,假如我的代码这样写(在3列的情况下)


rv.addItemDecoration(object : ItemDecoration() {

override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
)
{
val pos = parent.getChildAdapterPosition(view)
if (pos == 0) {
outRect.right = 40
} else if (pos == 2) {
outRect.left = 40
} else {
outRect.left = 20
outRect.right = 20
}
}
})


image.png


可以看到这样就均分了,是不是很神奇,其实这里用图来画出来是这样的


image.png


间距是由一个Item更大的间距加上一个Item略小的间距实现的。


你可能会想,懂了,除了首尾之外,其他的就是填一半间距。真的这么简单吗,可以看看4个效果,同样的代码如果把列数从3个变成4个


image.png


你就会发现,中间的分割线会更小一点,你可以算算看,左边的分割线是 40 + 20,而中间的分割线是 20 + 20 ,所以不同。 所以我说这个问题不会这个简单


其实当时我处理不了又比较赶时间,我就去google查,找了几个老哥的代码直接拷贝下来用发现用不了,所以我看深入去思考这个问题。


3. 真正的实现分割线均分布局的操作


来了,重点来了,通过上面的原理你能知道,其实就是把首尾两个Item应该多出的间距,平均分配到每个分割线。但是它不会是一个简单的计算,会是一个偏复杂的问题,数据问题。


当我把他变成数学问题,这个问题就是,我给出固定的分割线宽度,你需要分割线宽度相同,Item的宽度也相同,注意是两个相同,这是一个解题的条件


这个问题如果从正向去解释,我觉得很难说清楚,所以我从反向来解答,假如我有10列(我这里为了方便,先用一行来举例


image.png


图画得不太准,因为准的不好手动画,假设看成间距和Item宽度都相同。我10列那就是有9个间距(9个分割线),假设每条间距是10


那我是不是可以这样分:


间距1:(L1)9 + (R1)1

间距2:(L2)8 + (R2)2

间距3:(L3)7 + (R3)3

间距4:(L4)6 + (R4)4

间距5:(L5)5 + (R5)5

间距6:(L6)4 + (R6)6

间距7:(L7)3 + (R7)7

间距8:(L8)2 + (R8)8

间距9:(L9)1 + (R9)9


他们的间距都不同,但是他们加起来都是10,这是满足了第一个条件,分割线间距相同,还有一个条件,他们的Item宽要相同


从上面的原理我们知道,Item的最终宽度就是总宽度减去左右间距的宽度。Item1的左间距是0,右间距是9,它的宽度是AllWeidth - 9 ,Item2的左间距是1,右间距是8,它的宽度是AllWeidth - (8+1),和Item1是相同的,你可以算算其他的,也是相同的。所以这样就能达到一个均分的效果。


OK,我们来凑公式。上面说过,其实这种场景就相当于10个分割线的间距,把其中一个间距分成每一份去加到其他的间距中,而每一份其实就是最小的份,你看看上面的10列,每一份就是1,所以得出一个公式


min = space / n


然后有了最小,我们还需要算出一个最大的Item的间距,从间距相等我们得知


max = space - min


等理解这两个公式之后,我们再往下看。假设我就拿前面2个Item做分析


L1 = 0 // 最左边的Item没左间距这个应该很容易理解吧

R1 = max // 从上面的模型你能看出,Item1的右间距是最大间距

L2 = space - R1 // 根据间距相等这个条件,R1确认了,L2自然就确认

R2 = max - L2 // 这个是什么意思呢,这个是保证Item的宽度相同,从这个条件根据L2来算出R2。简单来说就是根据第一个Item你知道总间距,你后面的Item也要根据左右间距加起来得到的总间距相等


后一个值要根据前一个值的结果算出,是不是很熟悉,介不是某大厂特别喜欢考的动态规划吗?我见过直接算法题的动态规划,倒是第一次见结合到代码场景里面的,没想到一个小小的RecyclerView能玩这么花。


动态规划,老熟人了,我们能根据上面的分析推出一个公式


        rv.addItemDecoration(object : ItemDecoration() {

override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
val pos = parent.getChildAdapterPosition(view)

val min : Float = space / n
val max = space - min

if (pos == 0) {
outRect.right = max.toInt()
} else if (pos == (n - 1)) {
outRect.left = max.toInt()
} else {
var index = 1
var oldLeft = 0
var oldRight = max
while (index <= pos) {
val left = space - oldRight
val right = max - left
oldLeft = left.toInt()
oldRight = right
index++
}
outRect.left = oldLeft
outRect.right = oldRight.toInt()
}

}

})


这里的pos == 0这些判断是在我只有1行的前提下才这么演示的,实际别这么写。

现在分析下代码,space是间距宽度,n是列数,min和max上面分析过了,pos == 0只有右边间距并且为max,pos是最后一个只有左边间距并且为max,这个就不用解释了,主要是最后的else


当前的Item的间距需要根据前一个Item的间距算出,所以这里我用了循环,holdLef和oldRight表示前一个Item的左间距和右间距。然后就是用我们推出的公式去计算


Ln = space - R(n-1)

Rn = max - Ln


可以看看效果


image.png


image.png


image.png


image.png


image.png


可以看到是均分的啦。


优化


本来不想说pos == 0这个判断的,我怕有人直接拉代码出问题说我。上面的代码pos == 0只是为了方便演示1行的情况,如果我在多行用


image.png


所以正常使用判断要改下



if (pos % n == 0) {
outRect.right = max.toInt()
} else if ((pos + 1) % n == 0) {
outRect.left = max.toInt()
} else {
var index = 1
var oldLeft = 0
var oldRight = max
while (index <= (pos % n) ) {
val left = space - oldRight
val right = max - left
oldLeft = left.toInt()
oldRight = right
index++
}
outRect.left = oldLeft
outRect.right = oldRight.toInt()
}


size为10,n为5


image.png


size为8,n为3


image.png


除此之外,还可以看出这个算法的复杂度是O(m*n)


因为getItemOffsets是一个循环,里面的while又是一个循环,所以这里可以优化,我有一个想法,可以用hashmap通过空间来换时间,而且你会发现超过n/2的Item都是之前反着的,所以用hashmap的话你只需要记录第一个行的一半Item的间距,我觉得还是很不错的


还要注意一点,计算时要用Float,最后再转Int,否则全程用Int算可能有点偏差


总结


首先写这篇文章的目的是觉得这其中的算法非常有意思,这个动态规划的过程要推导出这个公式,整个推导的过程能在这其中感受到开发的快乐,所以记住这个公式


L0 = 0

R0 = max

Ln = space - R(n-1)

Rn = max - Ln


其次,我也不敢保证我这个是最佳的解法、最佳的公式,但是我测试目前来看是没问题,所以想用的话可以直接把代码拷去用,当然通过其他的方式也是能处理的,不一定要把思维限制在必须使用ItemDecoration去实现。


解算法的过程是痛苦的,但是解出来之后,那就非常的爽


作者:流浪汉kylin
来源:juejin.cn/post/7314142205684776998
收起阅读 »

Android 布局优化,看过来 ~

屏幕刷新机制 基本概念 刷新率:屏幕每秒刷新的次数,单位是 Hz,例如 60Hz,刷新率取决于硬件的固定参数。 帧率:GPU 在一秒内绘制操作的帧数,单位是 fps。Android 采用的是 60fps,即每秒 GPU 最多绘制 60 帧画面,帧率是动态变化...
继续阅读 »

屏幕刷新机制


基本概念



  • 刷新率:屏幕每秒刷新的次数,单位是 Hz,例如 60Hz,刷新率取决于硬件的固定参数。

  • 帧率:GPU 在一秒内绘制操作的帧数,单位是 fps。Android 采用的是 60fps,即每秒 GPU 最多绘制 60 帧画面,帧率是动态变化的,例如当画面静止时,GPU 是没有绘制操作的,帧率就为0,屏幕刷新的还是 buffer 中的数据,即 GPU 最后操作的帧数据。


显示器不是一次性将画面显示到屏幕上,而是从左到右边,从上到下逐行扫描,顺序显示整屏的一个个像素点,不过这一过程快到人眼无法察觉到变化。以 60 Hz 刷新率的屏幕为例,这一过程的耗时: 1000 / 60 ≈ 16.6ms。


屏幕刷新的机制大概就是: CPU 执行应用层的测量,布局和绘制等操作,完成后将数据提交给 GPU,GPU 进一步处理数据,并将数据缓存起来,屏幕由一个个像素点组成,以固定的频率(16.6ms)从缓冲区中取出数据来填充像素点。


画面撕裂


如果一个屏幕内的数据来自两个不同的帧,画面会出现撕裂感。屏幕刷新率是固定的,比如每 16.6ms 从 buffer 取数据显示完一帧,理想情况下帧率和刷新率保持一致,即每绘制完成一帧,显示器显示一帧。但是 CPU 和 GPU 写数据是不可控的,所以会出现 buffer 里有些数据根本没显示出来就被重写了,即 buffer 里的数据可能是来自不同的帧,当屏幕刷新时,此时它并不知道 buffer 的状态,因此从 buffer 抓取的帧并不是完整的一帧画面,即出现画面撕裂。


那怎么解决这个问题呢?Android 系统采用的是 双缓冲 + VSync


双缓冲:让绘制和显示器拥有各自的 buffer,GPU 将完成的一帧图像数据写入到 BackBuffer,而显示器使用的是 FrameBuffer,当屏幕刷新时,FrameBuffer 并不会发生变化,当 BackBuffer 准备就绪后,它们才进行交换。那什么时候进行交换呢?那就得靠 VSync。


VSync:当设备屏幕刷新完毕后到下一帧刷新前,因为没有屏幕刷新,所以这段时间就是缓存交换的最佳时间。此时硬件屏幕会发出一个脉冲信号,告知 GPU 和 CPU 可以交换了,这个就是 Vsync 信号。


掉帧


有时,当布局比较复杂,或者设备性能较差的时候,CPU 并不能保证在 16.6ms 内就完成绘制,这里系统又做了一个处理,当正在往 BackBuffer 填充数据时,系统会将 BackBuffer 锁定。如果到了 GPU 交换两个 Buffer 的时间点,你的应用还在往 BackBuffer 中填充数据,会发现 BackBuffer 被锁定了,它会放弃这次交换。
这样做的后果就是手机屏幕仍然显示原先的图像,这就是所谓的掉帧。


优化方向


如果想要屏幕流畅运行,就必须保证 UI 全部的测量,布局和绘制的时间在 16.6ms 内,因为人眼与大脑之间的协作无法感知超过 60fps 的画面更新,也就是 1000 / 60Hz = 16.6ms,也就是说超过 16.6ms 用户就会感知到卡顿。


层级优化


层级越少,View 绘制得就越快,常用有两个方案。



  • 合理使用 RelativeLayout 和 LinearLayout:层级一样优先使用 LinearLayout,因为 RelativeLayout 需要考虑视图之间的相对位置关系,需要更多的计算和更高的系统开销,但是使用 LinearLayout 有时会使嵌套层级变多,这时就应该使用 RelativeLayout。

  • 使用 merge 标签:它会直接将其中的子元素添加到 merge 标签 Parent 中,这样就不会引入额外的层级。它只能用在布局文件的根元素,不能在 ViewStub 中使用 merge 标签,当需要 inflate 的布局本身是由 merge 作为根节点的话,需要将其置于 ViewGr0up 中,设置 attachToRoot 为 true。


一个布局可以重复利用,当使用 include 引入布局时,可以考虑 merge 作为根节点,merge 根节点内的布局取决于include 这个布局的父布局。编写 XML 时,可以先用父布局作为根节点,然后完成后再用 merge 替换,方便我们预览效果。


merge_layout.xml


<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">


<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello" />


<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="World" />


</merge>

父布局如下:


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">


<include layout="@layout/merge_layout" />

</LinearLayout>

如果需要通过 inflate 引入 merge_layout 布局文件时,可以这样引入:


class MyLinearLayout(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) {

init {
LayoutInflater.from(context).inflate(R.layout.merge_layout, this, true)
}
}

第一个参数为 merge 布局文件 id,第二个参数为要将子视图添加到的 ViewGr0up,第三个参数为是否将加载好的视图添加到 ViewGr0up 中。


需要注意的是,merge 标签的布局,是不能设置 padding 的,比如像这样:


<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="30dp">


<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello" />


<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="World" />


</merge>

上面的这个 padding 是不会生效的,如果需要设置 padding,可以在其父布局中设置。


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="30dp"
tools:context=".MainActivity">


<include layout="@layout/merge_layout" />

</LinearLayout>

ViewStub


ViewStub 是一个轻量级的 View,一个看不见的,并且不占布局位置,占用资源非常小的视图对象。可以为 ViewStub 指定一个布局,加载布局时,只有 ViewStub 会被初始化,当 ViewStub 被设置为可见或 inflate 时,ViewStub 所指向的布局会被加载和实例化,可以使用 ViewStub 来设置是否显示某个布局。


ViewStub 只能用来加载一个布局文件,且只能加载一次,之后 ViewStub 对象会被置为空。适用于某个布局在加载后就不会有变化,想要控制显示和隐藏一个布局文件的场景,一个典型的场景就是我们网络请求返回数据为空时,往往要显示一个默认界面,表明暂无数据。


view_stub_layout.xml


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">


<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@mipmap/ic_launcher" />


<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="no data" />


</LinearLayout>

通过 ViewStub 引入


<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">


<data>

<variable
name="click"
type="com.example.testapp.MainActivity.ClickEvent" />

</data>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">


<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="@{click::showView}"
android:text="show" />


<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="@{click::hideView}"
android:text="hide" />


<ViewStub
android:id="@+id/default_page"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout="@layout/view_stub_layout" />


</LinearLayout>
</layout>

然后在代码中 inflate,这里通过按钮点击来控制其显示和隐藏。


class MainActivity : AppCompatActivity() {

private var viewStub: ViewStub? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding =
DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
binding.click = ClickEvent()
viewStub = binding.defaultPage.viewStub
if (!binding.defaultPage.isInflated) {
viewStub?.inflate()
}
}

inner class ClickEvent {
// 后面 ViewStub 已经回收了,所以只能用 GONE 和 VISIBLE
fun showView(view: View) {
viewStub?.visibility = View.VISIBLE
}

fun hideView(view: View) {
viewStub?.visibility = View.GONE
}
}
}

过度绘制


过度绘制是指屏幕上的某个像素在同一帧的时间内被绘制了多次,在多层次重叠的 UI 结构中,如果不可见的 UI 也在做绘制操作,就会导致某些像素区域被绘制了多次,从而浪费了 CPU 和 GPU 资源。


我们可以打开手机的开发人员选项,打开调试 GPU 过度绘制的开关,就能通过不同的颜色区域查看过度绘制情况。我们要做的,就是尽量减少红色,看到更多的蓝色。



  • 无色:没有过度绘制,每个像素绘制了一次。

  • 蓝色:每个像素多绘制了一次,蓝色还是可以接受的。

  • 绿色:每个像素多绘制了两次。

  • 深红:每个像素多绘制了4次或更多,影响性能,需要优化,应避免出现深红色区域。


优化方法



  • 减少不必要的背景:比如 Activity 往往会有一个默认的背景,这个背景由 DecorView 持有,当自定义布局有一个全屏的背景时,这个 DecorView 的背景对我们来说是无用的,但它会产生一次 Overdraw,可以干掉。


window.setBackgroundDrawable(null)


  • 自定义 View 的优化:在自定义 View 的时候,某个区域可能会被绘制多次,造成过度绘制。可以通过 canvas.clipRect 方法指定绘制区域,可以节约 CPU 与 GPU 资源,在 clipRect 区域之外的绘制指令都不会被执行。


AsyncLayoutInflater


setContentView 函数是在 UI 线程执行的,其中有一系列的耗时动作:XML 的解析,View 的反射创建等过程都是在 UI 线程执行的,AsyncLayoutInflater 就是把这些过程以异步的方式执行,保持 UI 线程的高响应。


implementation 'androidx.asynclayoutinflater:asynclayoutinflater:1.0.0'

class TestActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AsyncLayoutInflater(this).inflate(R.layout.activity_test, null) { view, _, _ ->
setContentView(view)
}
}
}

这样,将 UI 的加载过程迁移到了子线程,保证了 UI 线程的高响应,使用时需要特别注意,调用 UI 一定要等它初始化完成之后,不然可能会产生崩溃。


Compose


Jetpack Compose 相对于传统的 XML 布局方式,具有更强的可组合性,更高的效率和更佳的开发体验,相信未来会成为 Android UI 开发的主流方式。


传统的 XML 布局方式是基于声明式的 XML 代码编写的,使用大量的 XML 标签来描述 UI 结构,XML 文件通过解析和构建生成 View 对象,并将它们添加到 View 树中。在 Compose 中,UI 代码被组织成可组合的函数,每个函数都负责构建某个具体的 UI 元素,UI 元素的渲染是由 Compose 运行时直接管理的,Composable 函数会被调用,以计算并生成当前 UI 状态下的最终视图。


作者:阿健君
来源:juejin.cn/post/7221811522740256823
收起阅读 »

开发需求记录:实现app任意界面弹框与app置于后台时通知

前言 在产品经理提需求时候提到,app在接收到报警信息时候能不能弹出一个弹框,告诉用户报警信息,这个弹框要在app的任意界面能够弹出,并且用户点击详情时候,会跳转到报警详情界面,查看具体信息,当用户将app至于后台的时候,接收到报警信息,app发送通知,当用户...
继续阅读 »

前言


在产品经理提需求时候提到,app在接收到报警信息时候能不能弹出一个弹框,告诉用户报警信息,这个弹框要在app的任意界面能够弹出,并且用户点击详情时候,会跳转到报警详情界面,查看具体信息,当用户将app至于后台的时候,接收到报警信息,app发送通知,当用户点击通知时候,跳转到报警详情界面。
功能大体总结如上,在实现弹框与通知在跳转界面时遇到一些问题,在此记录一下。效果图如下:


开发需求 - 通知与弹框.gif


功能分析


弹框实现,使用DialogFragment。

前后台判断则是,创建一个继承自ActivityLifecycleCallbacks接口和Application的类,继承ActivityLifecycleCallbacks接口是为了前后台判断,继承Application则是方便在基类BaseActivity获取前后台相关数据。

项目原本采用单Activity多Fragment实现,后面因为添加了视频相关功能,改为了多Activity多Fragment。

原单Activity时候,实现比较容易。后面修改为多Activity,就有些头疼,最终用思路是创建基类BaseActivity,后面添加Activity时都要继承基类BaseActivity。使用基类原因是把相同的功能抽取出来,且若每个Activity都自己实现弹框和通知的话太容易出错,也太容易漏下代码了。


代码实现


弹框


在实现继承自DialogFragment的弹框时,需要在onCreateDialog方法内设置dialog的宽高模式以及背景,不然弹框会有默认的边距,导致显示效果与预期不符,未去边距与去掉边距的弹框效果如下:
image.png
关于onCreateDialog的代码如下:


override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = Dialog(requireContext())
dialog.setContentView(R.layout.custom_dialog_layout)
dialog.window?.apply {
setLayout(ViewGr0up.LayoutParams.MATCH_PARENT,ViewGr0up.LayoutParams.MATCH_PARENT)
setBackgroundDrawable(ColorDrawable(Color.parseColor("#88000000")))//去掉DialogFragment的边距
}
dialog.setCancelable(false)
return dialog
}

此外当弹框出现的时候,弹框背景色还会闪烁。这里采用属性值动画设置弹框背景色控件的透明度变换。完整的Dialog代码如下:


class AlarmDialogFragment: DialogFragment() {
private lateinit var binding:CustomDialogLayoutBinding
private var animator:ObjectAnimator? = null

override fun show(manager: FragmentManager, tag: String?) {
try {
super.show(manager, tag)
}catch (e:Exception){
e.printStackTrace()
}
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGr0up?,
savedInstanceState: Bundle?
)
: View? {
binding = CustomDialogLayoutBinding.inflate(inflater)
return binding.root
}

override fun onStart() {
super.onStart()
binding.viewAlarmDialogBg
startAnimation()
}

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = Dialog(requireContext())
dialog.setContentView(R.layout.custom_dialog_layout)
dialog.window?.apply {
setLayout(ViewGr0up.LayoutParams.MATCH_PARENT,ViewGr0up.LayoutParams.MATCH_PARENT)
setBackgroundDrawable(ColorDrawable(Color.parseColor("#88000000")))//去掉DialogFragment的边距
}
dialog.setCancelable(false)
return dialog
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initView()
}

override fun onDestroy() {
super.onDestroy()
if(animator?.isStarted == true){
animator?.end()
}
}

private fun initView() {
binding.btnCloseDialog.setOnClickListener {
dismiss()
}

binding.btnDialogNav.setOnClickListener {
if(context is MainActivity){
val bundle = Bundle()
bundle.putString("alarmId","1")
findNavController().navigate(R.id.alarmDetailFragment,bundle)
}else{
val intent = Intent(context,MainActivity::class.java)
intent.putExtra("task","toAlarmDetail")
startActivity(intent)
}
dismiss()
}
}

private fun startAnimation() {
animator = ObjectAnimator.ofFloat(binding.viewAlarmDialogBg, "alpha", 0f, 0.6f, 0f, 0.6f, 0f)
animator?.duration = 1200
animator?.interpolator = AccelerateInterpolator()
animator?.start()
}
}

需要注意地方是,由于弹框还负责跳转,而跳转有两种情况,一种是在ActivityA内,fragmentA与fragmentB间的跳转,这种情况使用findNavController().navigate()方法进行跳转,另一种是ActivityB到另一个ActivityA内的指定FragmentB界面。这种采用startActivity(intent)方式跳转,并且在ActivityA的onStart()的方法使用下面方法。


/** 跳转报警详情界面 */
private fun initToAlarmDetail() {
val task = intent.getStringExtra("task")
if (task == "toAlarmDetail"){
val bundle = Bundle()
bundle.putString("alarmId","1")
findNavController(R.id.fragment_main_table).navigate(R.id.alarmDetailFragment,bundle)
}
}

这样从ActivityB到另一个ActivityA时候,在onStart()方法内会触发上面的initToAlarmDetail()方法,获取跳转里面的信息,在决定具体跳转到哪个Fragment。这里解释的可能不太清楚,可以在Github下载源码看看可能更好理解些。


弹框对应的xml文件代码,可以在Github内查看,可以自己写一个,这个xml比较简单,只是xml代码比较占地方这里就不粘贴了。


前后台判断


关于前后台判断,需要创建一个继承ActivityLifecycleCallbacks和Application的类,这里命名为CustomApplication,在类里面实现ActivityLifecycleCallbacks接口相关方法,此外需要创建下面三个变量,分别表示activity数量,当前activity的名称,是否处于后台,代码如下:


private var activityCount = 0
private var nowActivityName:String? = null
private var isInBackground = true

之后需要在onActivityStarted,onActivityResumed,onActivityStopped方法内进行前后台相关处理,代码如下:


override fun onActivityStarted(activity: Activity) {
activityCount++
if (isInBackground){
isInBackground = false
}
nowActivityName = activity.javaClass.name
}

override fun onActivityStopped(activity: Activity) {
activityCount--
if (activityCount == 0 && !isInBackground){
isInBackground = true
}
}

上面代码可以看出,当触发onActivityStarted方法时候,activityCount数量加一,且app处于前台。之后记录当前activity名称,这里记录activity名称是后面有个功能是app置于后台时候弹出通知,而通知相关操作,为了每个activity都能实现就放在基类执行,而弹出通知并不需要每个继承基类的activity都执行,到时候需要根据根据nowActivityName判断哪个继承了基类的activity执行通知操作。


当触发onActivityStopped方法时候,activityCount数量减一,且当activityCount数量为零时,app置于后台。
CustomApplication完整代码如下:


class CustomApplication: Application(),Application.ActivityLifecycleCallbacks {
companion object{
const val TAG = "CustomApplication"
@SuppressLint("CustomContext")
lateinit var context: Context
}

private var activityCount = 0
private var nowActivityName:String? = null
private var isInBackground = true

fun getNowActivityName(): String? {
return nowActivityName
}

fun getIsInBackground():Boolean{
return isInBackground
}

override fun onCreate() {
super.onCreate()
context = applicationContext
registerActivityLifecycleCallbacks(this)
}

override fun onActivityCreated(activity: Activity, p1: Bundle?) {

}

override fun onActivityStarted(activity: Activity) {
activityCount++
if (isInBackground){
isInBackground = false
}
nowActivityName = activity.javaClass.name
}

override fun onActivityResumed(activity: Activity) {

}

override fun onActivityPaused(activity: Activity) {

}

override fun onActivityStopped(activity: Activity) {
activityCount--
if (activityCount == 0 && !isInBackground){
isInBackground = true
}
}

override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) {

}

override fun onActivityDestroyed(activity: Activity) {

}
}

弹框与通知弹出


开发中弹框与通知弹出的触发条件是,监听Websocket若有信息过来,app处于前台弹框,处于后台弹通知。这里使用Handler来模拟,弹框弹出比较简单,若有继承了DialogFragment的AlarmDialogFragment类。代码如下:


val dialog = AlarmDialogFragment()
dialog.show(supportFragmentManager,"tag")

通知弹出也不难,若只是弹出通知示例代码如下:


if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel("normal", "Normal", NotificationManager.IMPORTANCE_DEFAULT)
notificationManager?.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this.applicationContext, "normal")
.setContentTitle("标题")
.setContentText("通知次数:${++alarmCount}")
.setSmallIcon(R.drawable.ic_launcher_background)
.setTimeoutAfter(5000)
.setAutoCancel(true)
.build()
notificationManager?.notify(notificationId,notification)

弹框与通知的特殊要求是,能在界面任意地方弹出且跳转到指定界面。弹框跳转相关代码在上面'弹框'部分,下面来说下通知的跳转,点击通知跳转是通过创建PendingIntent后在设置进NotificationCompat的setContentIntent方法内,不过通知跳转与弹框跳转一样需要分两种情况考虑,第一种同一Activity内Fragment与Fragment跳转,这种情况下PendingIntent如下代码所示:


var pendingIntent:PendingIntent? = null
val bundle = Bundle()
bundle.putString("alarmId","1")
pendingIntent = NavDeepLinkBuilder(this)
.setGraph(R.navigation.main_navigation)
.setDestination(R.id.alarmDetailFragment)
.setArguments(bundle)
.createPendingIntent()

上面代码中使用NavDeepLinkBuilder创建了一个PendingIntent,并且使用setGraph()指向使用的导航图,setDestination()则指向目标Fragment。
另一种情况则是ActivityB到另一个ActivityA内的指定FragmentB界面,这种情况下PendingIntent设置代码如下:


val intent = Intent(this@BaseActivity,MainActivity::class.java)
intent.putExtra("task","toAlarmDetail")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
pendingIntent = TaskStackBuilder.create(this@BaseActivity)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0,PendingIntent.FLAG_UPDATE_CURRENT)

这种是创建一个跳转到MainActivity的Intent,并添加传递的参数task,接着设置Intent的启动方式,其中Intent.FLAG_ACTIVITY_NEW_TASK,表示启动Activity作为新任务启动,Intent.FLAG_ACTIVITY_CLEAR_TASK,表示清除任务栈中所有现有的Activity。之后调用TaskStackBuilder创建PendingIntent。
上面两种方式创建的PendingIntent可以通过NotificationCompat.setContentIntent(pendingIntent)添加进去,关于通知创建的代码如下:


/** 使用通知 - 通过pendingIntent实现跳转,缺点是任意界面进入报警详情界面,点击返回键只能返回MainFragment */
private fun useNotificationPI() {
var pendingIntent:PendingIntent? = null
if(javaClass.simpleName == "MainActivity"){//主界面
val bundle = Bundle()
bundle.putString("alarmId","1")
pendingIntent = NavDeepLinkBuilder(this)
.setGraph(R.navigation.main_navigation)
.setDestination(R.id.alarmDetailFragment)
.setArguments(bundle)
.createPendingIntent()
}else {//其他界面时候切换后台通知
val intent = Intent(this@BaseActivity,MainActivity::class.java)
intent.putExtra("task","toAlarmDetail")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
pendingIntent = TaskStackBuilder.create(this@BaseActivity)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0,PendingIntent.FLAG_UPDATE_CURRENT)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel("normal", "Normal", NotificationManager.IMPORTANCE_DEFAULT)
notificationManager?.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this.applicationContext, "normal")
.setContentTitle("标题")
.setContentText("通知次数:${++alarmCount}")
.setSmallIcon(R.drawable.ic_launcher_background)
.setTimeoutAfter(5000)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
notificationManager?.notify(notificationId,notification)
}

上面代码中if(javaClass.simpleName == "MainActivity"),及第四行代码,该代码用处是当app置于后台时候,pp界面是MainActivity时,pendingIntent使用NavDeepLinkBuilder生成,当是其他Activity时使用TaskStackBuilder生成。之所以这样是因为,在MainActivity的xml,使用了FragmentContainerView用于fragment间跳转,其他的Activity没有FragmentContainerView,因此在生成pendingIntent需要采用不同的方式生成。


这示例代码中,主要涉及到的Avtivity有MainActivity与VideoActivity,MainActivity使用FragmentContainerView,而VideoActivity没有。弹框与通知跳转的界面是AlarmDetailFragment,这个fragment在MainActivity通过Navigation实现导航。


因此在MainActivity界面进入后台时,pendingIntent使用NavDeepLinkBuilder生成,NavDeepLinkBuilder则可以使用导航图中fragment生成深度链接URI,这个URI则可以导航到指定的fragment(关于NavDeepLinkBuilder了解不深入,这里说的可能有错误地方,欢迎大佬指正)。


而VideoActivity界面进入后台时,就需要使用TaskStackBuilder生成一个启动MainActivity的Intent。而在MainActivity的onStart方法内有下面initToAlarmDetail方法,判断跳转时携带参数决定是否跳转到AlarmDetailFragment界面。


/** 跳转报警详情界面 */
private fun initToAlarmDetail() {
val task = intent.getStringExtra("task")
if (task == "toAlarmDetail"){
val bundle = Bundle()
bundle.putString("alarmId","1")
findNavController(R.id.fragment_main_table).navigate(R.id.alarmDetailFragment,bundle)
}
}

至此弹框与通知的功能基本实现,完整的BaseActivity代码如下:


open class BaseActivity: AppCompatActivity() {
companion object{
const val TAG = "BaseActivity"
}
private var alarmCount = 0
private val handler = Handler(Looper.myLooper()!!)
//为了关闭通知,manager放在外面
private val notificationId = 1
private var alarmDialogFragment: AlarmDialogFragment? = null
private var notificationManager:NotificationManager? = null
private var bgServiceIntent:Intent? = null//前台服务

private var nowClassName = ""

/** 弹框定时任务 */
private val dialogRunnable = object : Runnable {
override fun run() {
//在定时方法里面 javaClass.simpleName 不能获取当前所处Activity的名称
if (nowClassName == "VideoActivity"){ //视频界面不弹弹框
CustomLog.d(TAG,"不使用弹框 ${nowClassName}")
}else{
CustomLog.d(TAG,"使用弹框 ${nowClassName}")
useDialog()
handler.postDelayed(this, 10000)
}
}
}

/** 通知定时任务 */
private val notificationRunnable = object :Runnable{
override fun run() {
useNotificationPI()
handler.postDelayed(this,10000)
}
}

override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
initWindow()
return super.onCreateView(name, context, attrs)
}

override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
super.onCreate(savedInstanceState, persistentState)
CustomLog.d(TAG,"onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) 当前类:${javaClass.simpleName}")
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
CustomLog.d(TAG,"onCreate(savedInstanceState: Bundle?) 当前类:${javaClass.simpleName}")
initData()
}

override fun onStart() {
super.onStart()
CustomLog.d(TAG,"onStart 当前类:${javaClass.simpleName}")
nowClassName = javaClass.simpleName
handler.postDelayed(dialogRunnable, 3000)
initService()
}

override fun onResume() {
super.onResume()
CustomLog.d(TAG,"onResume 当前类:${javaClass.simpleName}")
}

override fun onRestart() {
super.onRestart()
CustomLog.d(TAG,"onRestart 当前类:${javaClass.simpleName}")
}

override fun onPause() {
super.onPause()
CustomLog.d(TAG,"onPause 当前类:${javaClass.simpleName}")
}

override fun onStop() {
super.onStop()
CustomLog.d(TAG,"onStop 当前类:${javaClass.simpleName}")
val customApplication = applicationContext as CustomApplication
val nowActivityName = customApplication.getNowActivityName()
val activitySimpleName = nowActivityName?.substringAfterLast(".")
CustomLog.d(TAG,"activitySimpleName:$activitySimpleName")
val isInBackground = (this@BaseActivity.applicationContext as CustomApplication).getIsInBackground()
if (isInBackground && activitySimpleName.equals(javaClass.simpleName)){// 处于后台 且 切换至后台app的activity页面名称等于当前基类里面获取activity类名
handler.postDelayed(notificationRunnable,3000)
CustomLog.d(TAG,"使用通知 $nowClassName")
}else{
CustomLog.d(TAG,"关闭所有定时任务 $nowClassName")
closeAllTask()
}
}

override fun onDestroy() {
super.onDestroy()
CustomLog.d(TAG,"onDestroy 当前类:${javaClass.simpleName}")
closeAllTask()
this.stopService(bgServiceIntent)
}

/** 关闭所有定时任务 */
private fun closeAllTask() {
handler.removeCallbacks(dialogRunnable)
handler.removeCallbacks(notificationRunnable)
}

/** 初始化数据 - 关于弹框*/
private fun initData() {
notificationManager = notificationManager ?: this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
alarmDialogFragment = alarmDialogFragment ?: AlarmDialogFragment()
}

/** 使用通知 - 通过pendingIntent实现跳转,缺点是任意界面进入报警详情界面,点击返回键只能返回MainFragment */
private fun useNotificationPI() {
var pendingIntent:PendingIntent? = null
if(javaClass.simpleName == "MainActivity"){//主界面
CustomLog.d(TAG,">>>通知:MainActivity")
val bundle = Bundle()
bundle.putString("alarmId","1")
pendingIntent = NavDeepLinkBuilder(this)
.setGraph(R.navigation.main_navigation)
.setDestination(R.id.alarmDetailFragment)
.setArguments(bundle)
.createPendingIntent()
}else {//其他界面时候切换后台通知
CustomLog.d(TAG,">>>通知:else")
val intent = Intent(this@BaseActivity,MainActivity::class.java)
intent.putExtra("task","toAlarmDetail")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
pendingIntent = TaskStackBuilder.create(this@BaseActivity)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0,PendingIntent.FLAG_UPDATE_CURRENT)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel("normal", "Normal", NotificationManager.IMPORTANCE_DEFAULT)
notificationManager?.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this.applicationContext, "normal")
.setContentTitle("标题")
.setContentText("通知次数:${++alarmCount}")
.setSmallIcon(R.drawable.ic_launcher_background)
.setTimeoutAfter(5000)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
notificationManager?.notify(notificationId,notification)
}

/** 弹框使用 - 因为此处涉及到fragment等生命周期,进入其他activity内时候,在前的activity使用useDialog会因为生命周期问题闪退*/
private fun useDialog() {
//弹出多个同种弹框
// alarmDialogFragment = AlarmDialogFragment()
// alarmDialogFragment?.show(supportFragmentManager,"testDialog")

//不弹出多个同种弹框,一次只弹一个,若弹框存在不弹新框
if (alarmDialogFragment?.isVisible == false){//如果不加这一句,当弹框存在时候在调用alarmDialogFragment.show的时候会报错,因为alarmDialogFragment已经存在
alarmDialogFragment?.show(supportFragmentManager,"testDialog")
}else{
//更新弹框内信息
}
}

/** 关闭报警弹框 */
private fun closeAlarmDialog() {
if (alarmDialogFragment?.isVisible == true) {
alarmDialogFragment?.dismiss()//要关闭的弹框
}
}

//状态栏透明,且组件占据了状态栏
private fun initWindow() {
window.statusBarColor = Color.TRANSPARENT
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
}

/** 初始化服务 */
private fun initService() {
CustomLog.d(TAG,"开启前台服务")
bgServiceIntent = bgServiceIntent ?: Intent(this, BackgroundService::class.java)
this.startService(bgServiceIntent)
}
}

总结


只是弹出弹框和通知的话,实现很好实现,中间麻烦地方在于当app使用多个Activity,该怎么实现跳转到指定的界面。当然这里麻烦是,从ActivityB跳转到ActivityA的Fragment,如果是只有一个Activity应该会好办些。个人感觉fragment跳转应该有更好的方式实现希望能和大佬们交流下这种情况下,用什么技术实现。


PS:感觉原生Android在写界面和跳转方面写起来不太方便。不知道大家有便捷的方式吗。


代码地址


GitHub:github.com/SmallCrispy…


作者:卤肉拌面
来源:juejin.cn/post/7260808821659779129
收起阅读 »

一文洞彻:Application为啥不能作为Dialog的context?

大家好,相信大家在使用Dialog时,都有一个非常基本的认知:就是Dialog的context只能是Activity,而不能是Application,不然会导致弹窗崩溃:这个Exception几乎属于是每个Android开发初学者都会碰到的,但是。前几天研究项...
继续阅读 »

大家好,相信大家在使用Dialog时,都有一个非常基本的认知:就是Dialog的context只能是Activity,而不能是Application,不然会导致弹窗崩溃:

这个Exception几乎属于是每个Android开发初学者都会碰到的,但是。

前几天研究项目代码发现  Application作为Dialogcontext竟然不会崩溃?!!这句话说出来和本篇文章标题严重不符哈,这不是赤裸裸的打脸了吗。先别急,请大家跟着我的脚步,相信阅读完本篇文章就可以解答目前你心目中最大的两个疑惑:

  1. 如标题所言,为啥Application无法作为Dialog的context并导致崩溃?
  2. 项目中为啥又发现,Application作为Dialog的context可以正常显示弹窗?

一. 窗口(包括Activity和Dialog)如何显示的?

这里怕有些童鞋不了解窗口(包括Activity和Dialog的)的显示流程,先简单的介绍下:

不管是Activity界面的显示还是DIalog的窗口显示,都会调用到WindowManagerImpl#addView()方法,这个方法经过一连续调用,会走到ViewRootImpl#setView()方法中。

在这个方法中,我们最终会调用到IWindowSession#addToDisplayAsUser()方法,这个方法是一个跨进程的调用,经过一番折腾,最终会执行到WMS的addWindow()方法。

在这个方法中会将窗口的信息进行保存管理,并且对于窗口的信息进行校验,比如上面的崩溃信息:“BadTokenException: Unable to add window”就是由于在这个方法中检验失败导致的;另外也是在这个方法中将窗口和Surface、Layer绘制建立起了连接(这句话说的可能不标准,主要对这块了解不多,懂得大佬可以评论分享下)。

接着开始在ViewRootImpl#setView()执行requestLayout()方法,开始进行渲染绘制等。

有了上面的简单介绍,接下来我们就开始先分析为啥Application作为Dialog的context会异常。

二. 窗口离不开的WindowManagerImpl

上面也说了,窗口只要显示,就得借助WindowManagerImpl#addView()方法,而WindowManagerImpl创建流程在ApplicationActivity的差异,就是Application作为Dialogcontext会异常的核心原因

我们就从下面方法作为入口进行分析:

context.getSystemService(WINDOW_SERVICE)

1. Application下WindowManagerImpl的创建

对于Application而言,getSystemService()方法的调用,最终会走到父类ContextWrapper中:

而这个mBase属性对应的类为ContextImpl对象,对应ContextImpl#getSystemService():

对应SystemServiceRegistry#getSystemService

SYSTEM_SERVICE_FETCHERS是一个Map集合,对应的key为服务的名称,value为服务的实现方式:

Android会在SystemServiceRegistry初始化的时候将各种服务以及服务的实现方法注册到这个集合中:

接下来看下咱们关心的WindowManager服务的注册方式:

到了这里,咱们就明白了,调用context.getSystemService(WINDOW_SERVICE)会返回一个WindowManagerImpl对象,核心点就在于WindowManagerImpl的构造函数,可以看到构造函数只传入了一个ContextImpl对象,我们看下其构造方法:

本篇文章重要的地方来了:通过这种方法创建的WindowManagerImpl对象,其mParentWindow属性是null的

2. Activity下WindowManagerImpl的创建

Activity重写了getSystemService()方法:

而mWindowManager属性的赋值是发生在Activity#attach()方法中:

这个mWindow属性对应的类型为Window类型(其唯一实现类为大家耳熟能详的PhoneWindow,其创建时机和Activity创建的时机是一起的),走进去看下:

经过一层层的调用,最终咱们的WindowManager是通过WindowManagerImpl#createLocalWindowManager创建的,并且参数传入的是当前的Window对象,即PhoneWindow。

可以看到,该方法最终帮助咱们创建了WindowManagerImpl对象,关键点是其mParentWindow属性的值为上面传入的PhoneWindow,不为null

小结:

Activity获取到的WindManager服务,即WindowManagerImpl的mParentWindow属性不为空,而Application获取的mParentWindow属性为null。

文章开头我们简单介绍了窗口的显示流程,同时又知道实现窗口添加的关键类WindowManagerImpl的来头,有了这些铺垫,接下来我们就对窗口的显示进行一个比较深入的分析。

三. 深入探究窗口的显示流程

这里我们就从WindowManagerGlobal#addView()方法说起,它是WindowManagerImpl#addView()方法的真正实现者。

WindowManagerImpl#addView():

WindowManagerGlobal#addView():

这一分析,就进入到了本篇文章最重要的一个方法的分析,如上面红框所示。

前面我们有讲过,对于Application获取的WindowManagerImpl,其mParentWindow属性为null,而Activity对应的mParentWindow不为null。

  1. 如果当前为Activity的窗口,或者借助Activity作为Context显示的Dialog窗口,其会走入到方法adjustLayoutParamsForSubWindow()中,对应的实现类为Window

type为窗口的类型,对于Activity的窗口还是对于Dialog的窗口,其对应类型为都为2(TYPE_APPLICATION),所以最终都会走到红框中的位置,最终给window对应的layoutparam对象的token属性赋值为mAppToken

这个mAppToken可以简单理解为窗口的一种凭证,它是AMS在startActivity流程的时候被初始化的,然后传递给应用侧,最终再用来WMS进行窗口检验的其中在AMS的startActivity流程中,会将这个AppToken作为key,并构造一个WindowToken对象作为value,写入到 DisplayContent#mTokenMap集合中,这部分详细的源码分析可以参考文章:Android高工面试(难度:四星):为什么不能使用 Application Context 显示 Dialog?

  1. 如果当前为application作为context显示的Dialog,mParentWindow为null,那就走不到adjustLayoutParamsForSubWindow()方法中,自然其Window#LayoutParam#token属性就是null。

咱们再次回到WindowManagerGlobal#addView()方法中,接下来会走到ViewRootImpl#setView()方法中,这个方法里最终会调用下面方法完成窗口真正的添加:

其中这个mWindowSession对应是一个Binder对象,对应类型为IWindowSession,其真正的实现位于system_server侧的Session类,所以这里会发生跨进程通信,并将window的LayoutParam类型参数进行传入,我们继续看下Session#addToDiaplayAsUser方法:

mService对应的实现类WindowManagerService,所以我们看下该类的addWindow方法:

# WindowManagerService
final HashMap mWindowMap = new HashMap<>();

public int addWindow(Session session, IWindow client, LayoutParams attrs, int viewVisibility,
int displayId, int requestUserId, InsetsVisibilities requestedVisibilities,
InputChannel outInputChannel, InsetsState outInsetsState,
InsetsSourceControl[] outActiveControls)
{

WindowState parentWindow = null;
final int type = attrs.type;
//1.
if (type >= FIRST_SUB_WINDOW && type <= LAST_SUB_WINDOW) {
parentWindow = windowForClientLocked(null, attrs.token, false);
//...
}
//2.
final boolean hasParent = parentWindow != null;
WindowToken token = displayContent.getWindowToken(
hasParent ? parentWindow.mAttrs.token : attrs.token);
//3.
if (token == null) {
if (!unprivilegedAppCanCreateTokenWith(parentWindow, callingUid, type,
rootType, attrs.token, attrs.packageName)) {
return WindowManagerGlobal.ADD_BAD_APP_TOKEN;
}
}

final WindowState win = new WindowState(this, session, client, token, parentWindow,
appOp[0], attrs, viewVisibility, session.mUid, userId,
session.mCanAddInternalSystemWindow);
}

# DiaplayConent
private final HashMap mTokenMap = new HashMap();

WindowToken getWindowToken(IBinder binder) {
return mTokenMap.get(binder);
}

上面的代码是经过精简后的。

  1. 前面有提到,Dialog的窗口类型为2,所以不满足if的条件,自然parentWindow无法赋值,即为null;
  2. 这里hasParent自然就是false,调用方法getWindowToken()传入的参数就是应用侧Window#LayoutParam#token属性,其中借助前面分析,如果Application作为Dialog的context,这个token值是null;

    看下getWindowToken()方法,它会将上面的传入token作为key,从DisplayContent#mTokenMap这个集合中获取值,什么时候写入值呢:前面有提到过,在startActivity的流程中,会向这个集合中写入值。而这个传入的token就是之前startActivity流程中,写入到DisplayContent#mTokenMap这个集合中的key,所以自然是能够获取到对应的value,即WindowToken类型属性token不为null,自然走不到3处标记的条件分支中,窗口校验通过。

  3. 而Application作为Dialog的context时,传入的token是null,自然是无法获取到值,WindowToken 类型属性token为null,走到if分支中,会返回WindowManagerGlobal.ADD_BAD_APP_TOKEN ,当应用侧检测到返回值为这个时,就会出现文章一开头说的BadTokenException异常

到了这里,相信你就明白了,为啥Application作为Dialog的context会导致崩溃,关键的分析就是上面的内容;

四. 不让Application作为Dialog的context崩溃?

根据上面的分析结果,Application作为Dialog的context崩溃的真正原因就是应用侧传过来的LayoutParam#token对象是null的,既然这样,那我们在应用侧给Dialog的Window#LayoutParam#token属性赋值为Activity的Window#LayoutParam#token属性,就可以避免这场悲剧发生了,可以看到下面能正常显示弹窗:

但是还是不建议大家这样做哈,毕竟如果在Dialog中使用到了这个Application的context进行Activity的跳转等其他未知行为,估计就会出现其他的幺蛾子了哈。

五. 总结

本篇文章涉及到的源码有点多,重点在于以下几个地方:

  1. Activity和Application获取WindowManager在应用侧服务的区别;
  2. 将窗口添加到WMS侧,Activity和Application下WindowManagerImpl传参token的区别;
  3. WMS中对应窗口类型以及传入的token是否为null进行的一番检验,已经检验不通过导致应用侧发生BadTokenException异常;

希望本篇文章能对你有所帮助,有什么需要交流的也欢迎下评论中留言,感谢阅读。

参考文章

Android高工面试(难度:四星):为什么不能使用 Application Context 显示 Dialog?


作者:长安皈故里
来源:juejin.cn/post/7314125877486616615
收起阅读 »

环信IM Android端实现华为推送详细步骤

首先我们要参照华为的官网去完成 以下两个配置都是华为文档为我们提供的1.https://developer.huawei.com/consumer/cn/doc/HMSCore-Guides/android-config-agc-000000105017013...
继续阅读 »

首先我们要参照华为的官网去完成 以下两个配置都是华为文档为我们提供的

1.https://developer.huawei.com/consumer/cn/doc/HMSCore-Guides/android-config-agc-0000001050170137#section19884105518498 

2.https://developer.huawei.com/consumer/cn/doc/HMSCore-Guides/android-integrating-sdk-0000001050040084

3.在环信上传华为的配置信息IM推送上传方式->打开管理后台->进入到即使通讯中上传证书(不是即时推送)




4.信息在华为的:将信息添加到(3)的位置 记得检查下前面的信息是否有存在空格有的话删除掉


5.客户端绑定华为证书 注意:客户端设置的appkey 一定要和上传证书对应key 保持一致




6.客户端导入环信提供HMSPushHelper类 

百度网盘地址:链接: https://pan.baidu.com/s/1EehWKyl3uauB5Z43C5wBbw

提取码: 8888

在环信登录成功以后调用



7.添加HMSPushService



8.清单文件注册华为的appid

<meta-data        android:name="com.huawei.hms.client.appid"        android:value="appid=109911253" />  

参考文档:

环信官方Demo下载:https://www.easemob.com/download/demo

IMGeek社区支持:https://www.imgeek.net/

收起阅读 »

指纹人脸登验

一、安卓原生指纹识别在 Android 平台上实现原生指纹识别可以使用 Android 系统提供的 FingerprintManager 类。以下是在 Android 平台上实现原...
继续阅读 »

一、安卓原生指纹识别

在 Android 平台上实现原生指纹识别可以使用 Android 系统提供的 FingerprintManager 类。以下是在 Android 平台上实现原生指纹识别的简单步骤:

1. 检查设备是否支持指纹识别:在你的应用中,你可以通过以下代码来检查设备是否支持指纹识别:

FingerprintManager fingerprintManager = (FingerprintManager) getSystemService(Context.FINGERPRINT_SERVICE);  

if (!fingerprintManager.isHardwareDetected()) {
    // 设备不支持指纹识别
}

if (!fingerprintManager.hasEnrolledFingerprints()) {
    // 没有注册指纹
}

2. 实现指纹识别功能:当设备支持指纹识别且用户已经注册了指纹时,你可以使用以下代码来实现指纹识别功能:

FingerprintManager.AuthenticationCallback authenticationCallback = new FingerprintManager.AuthenticationCallback() {  
    @Override
    public void onAuthenticationError(int errMsgId, CharSequence errString) {
        // 指纹认证错误
    }

    @Override
    public void onAuthenticationHelp(int helpMsgId, CharSequence helpString) {
        // 指纹认证需要帮助
    }

    @Override
    public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult result) {
        // 指纹认证成功
    }

    @Override
    public void onAuthenticationFailed() {
        // 指纹认证失败
    }
};

FingerprintManager.CryptoObject cryptoObject = new FingerprintManager.CryptoObject(yourCipher);

fingerprintManager.authenticate(cryptoObject, cancellationSignal, 0, authenticationCallback, null);

在上面的代码中,yourCipher 是你要用于加密的密码或密钥的 Cipher 对象,cancellationSignal 是用于取消指纹认证的信号。authenticationCallback 中包含了指纹认证过程中的回调方法,你可以在这些方法中处理指纹认证的结果和错误情况。

以上是在 Android 平台上实现原生指纹识别的简单步骤。需要注意的是,指纹识别功能需要在 AndroidManifest.xml 文件中

二、安卓原生人脸识别

在 Android 平台上实现原生人脸识别可以使用 Android 系统提供的 FaceManager 或者 Camera2 API。以下是使用 FaceManager 实现人脸识别的主要代码:

1. 检查设备是否支持人脸识别:你可以通过以下代码来检查设备是否支持人脸识别:

FaceManager faceManager = (FaceManager) getSystemService(Context.FACE_SERVICE);  

if (!faceManager.isHardwareDetected()) {
    // 设备不支持人脸识别
}

if (!faceManager.hasEnrolledTemplates()) {
    // 没有注册人脸模板
}

2. 实现人脸识别功能:当设备支持人脸识别且用户已经注册了人脸模板时,你可以使用以下代码来实现人脸识别功能:

FaceManager.AuthenticationCallback authenticationCallback = new FaceManager.AuthenticationCallback() {  
    @Override
    public void onAuthenticationError(int errMsgId, CharSequence errString) {
        // 人脸认证错误
    }

    @Override
    public void onAuthenticationHelp(int helpMsgId, CharSequence helpString) {
        // 人脸认证需要帮助
    }

    @Override
    public void onAuthenticationSucceeded(FaceManager.AuthenticationResult result) {
        // 人脸认证成功
    }

    @Override
    public void onAuthenticationFailed() {
        // 人脸认证失败
    }
};

faceManager.authenticate(null, cancellationSignal, 0, authenticationCallback, null, handler);

在上面的代码中,cancellationSignal 是用于取消人脸认证的信号,authenticationCallback 中包含了人脸认证过程中的回调方法,你可以在这些方法中处理人脸认证的结果和错误情况。

除了使用 FaceManager,你还可以使用 Camera2 API 来获取摄像头数据并进行人脸检测与识别。这需要使用相机预览功能以及图像处理技术来实现人脸检测和识别。

总的来说,实现原生人脸识别涉及到硬件的支持和权限的管理,同时需要根据具体的业务需求来选择合适的实现方式。希望以上信息对你有所

三、flutter指纹识别

在 Flutter 中实现安卓指纹识别可以使用 local_auth 插件。以下是如何在 Flutter 应用中实现安卓指纹识别的简单步骤:

1. 首先,在你的 pubspec.yaml 文件中添加 local_auth 插件的依赖:

dependencies:  
  local_auth: ^1.1.6

然后运行以下命令获取依赖:

flutter pub get  

2. 接下来,在你的 Dart 代码中使用 local_auth 插件来请求指纹识别:

import 'package:flutter/material.dart';  
import 'package:local_auth/local_auth.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  final LocalAuthentication localAuth = LocalAuthentication();

  Future<void_authenticate() async {
    bool authenticated = false;
    try {
      authenticated = await localAuth.authenticateWithBiometrics(
        localizedReason: '扫描指纹以进行身份验证',
        useErrorDialogs: true,
        stickyAuth: true,
      );
    } catch (e) {
      print(e);
    }
    if (authenticated) {
      // 指纹认证成功
      print('指纹认证成功');
    } else {
      // 指纹认证失败
      print('指纹认证失败');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('指纹识别示例'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: _authenticate,
          child: Text('进行指纹识别'),
        ),
      ),
    );
  }
}

在此示例中,我们在 MyHomePage 的 build 方法中创建了一个按钮,当用户点击按钮时调用 _authenticate 方法进行指纹识别。在 _authenticate 方法中,我们使用 local_auth 插件来请求指纹识别,并根据认证结果打印相应的消息。

请注意,为了运行安卓指纹识别,你需要在项目的 AndroidManifest.xml 文件中添加指

四、flutter人脸识别

在 Flutter 中实现安卓人脸识别同样可以使用 local_auth 插件。该插件提供了与指纹识别类似的方式来请求进行人脸识别。以下是在 Flutter 中实现安卓人脸识别的简单步骤:

1. 首先,在你的 pubspec.yaml 文件中添加 local_auth 插件的依赖(如果已添加,可以跳过此步骤):

dependencies:  
  local_auth: ^1.1.6

然后运行以下命令获取依赖:

flutter pub get  

2. 接下来,更新你的 Dart 代码以使用 local_auth 插件来请求人脸识别:

import 'package:flutter/material.dart';  
import 'package:local_auth/local_auth.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  final LocalAuthentication localAuth = LocalAuthentication();

  Future<void_authenticate() async {
    bool authenticated = false;
    try {
      authenticated = await localAuth.authenticateWithBiometrics(
        localizedReason: '进行人脸识别以进行身份验证',
        useErrorDialogs: true,
        stickyAuth: true,
        biometricOnly: true,
      );
    } catch (e) {
      print(e);
    }
    if (authenticated) {
      // 人脸认证成功
      print('人脸认证成功');
    } else {
      // 人脸认证失败
      print('人脸认证失败');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('人脸识别示例'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: _authenticate,
          child: Text('进行人脸识别'),
        ),
      ),
    );
  }
}

在此示例中,我们在 MyHomePage 的 build 方法中创建了一个按钮,当用户点击按钮时调用 _authenticate 方法进行人脸识别。在 _authenticate 方法中,我们使用 local_auth 插件来请求人脸识别,

KeyguardManager

KeyguardManager 是 Android 系统中用于管理设备锁屏状态的类。通过 KeyguardManager,你可以获取设备的锁屏状态信息,管理键盘锁和密码锁,以及控制设备的解锁和锁定操作。以下是 KeyguardManager 的一些主要功能:

  1. 获取 KeyguardManager 实例:
KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
  1. 检查设备的当前锁屏状态:
if (keyguardManager.isKeyguardSecure()) {
// 设备已设置了安全锁屏方式(比如 PIN、图案、密码锁等)
} else {
// 设备没有设置安全锁屏方式
}
  1. 请求设备的解锁:
if (keyguardManager.isKeyguardSecure()) {
Intent intent = keyguardManager.createConfirmDeviceCredentialIntent("Title", "Description");
if (intent != null) {
startActivityForResult(intent, REQUEST_CODE_CONFIRM_DEVICE_CREDENTIALS);
}
}

在上面的代码中,createConfirmDeviceCredentialIntent 方法可以创建一个用于验证设备解锁凭据的 Intent,你可以通过启动这个 Intent 来请求设备的解锁操作。

KeyguardManager 还有其他方法,比如管理锁定屏幕、设置锁定屏幕的超时时间等。使用 KeyguardManager 可以帮助你在应用中实现更安全的锁屏管理功能。

KeyStore

KeyStore 是 Android 系统中用于存储密钥(Key)和证书(Certificate)的类。KeyStore 允许你在安全的存储区域保存私钥和受信任的证书,以便在应用中使用加密和认证功能。

以下是 KeyStore 的一些主要功能:

  1. 创建或打开 KeyStore
KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
keyStore.load(null);

在上面的代码中,我们使用 KeyStore.getInstance 方法来获取 KeyStore 实例,并指定了存储类型为 "AndroidKeyStore"keyStore.load(null) 方法会加载默认的安装在 Android 设备上的密钥和证书。如果你希望自定义 KeyStore 的存储类型,可以使用其他类型的 KeyStore,比如 "PKCS12"。

  1. 生成或导入密钥:
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore");
KeyGenParameterSpec.Builder keyGenParameterSpecBuilder = new KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setDigests(KeyProperties.DIGEST_SHA256)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
.setUserAuthenticationRequired(true);

keyPairGenerator.initialize(keyGenParameterSpecBuilder.build());
KeyPair keyPair = keyPairGenerator.generateKeyPair();

在上面的代码中,我们使用 KeyPairGenerator 来生成密钥对,并通过 KeyGenParameterSpec.Builder 设置密钥生成的参数,然后调用 generateKeyPair 生成密钥对并保存到 KeyStore 中。

  1. 获取密钥:
PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, null);
PublicKey publicKey = keyPair.getPublic();

通过调用 keyStore.getKey 方法,你可以从 KeyStore 中获取保存的私钥和公钥。这些密钥可以用于加密、解密、数字签名等操作。

通过 KeyStore 的功能,可以实现在安全的存储区域保存和管理应用所需的密钥和证书,确保这些敏感信息的安全

参考

Android 指纹识别(给应用添加指纹解锁) - 掘金 (juejin.cn)


作者:whysqwhw
来源:juejin.cn/post/7313589252172087330

收起阅读 »

Android Tab吸顶 嵌套滚动通用实现方案✅

很多应用的首页都会有一些嵌套滚动、Tab吸顶的布局,尤其是一些生鲜类应用,例如 朴朴超市、大润发优鲜、盒马等等。 在 Android 里面,滚动吸顶方式通常可以通过 CoordinatorLayout + AppBarLayout + Collapsin...
继续阅读 »

很多应用的首页都会有一些嵌套滚动、Tab吸顶的布局,尤其是一些生鲜类应用,例如 朴朴超市、大润发优鲜、盒马等等。





在 Android 里面,滚动吸顶方式通常可以通过 CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + NestedScrollView 来实现,但是 AppBarLayoutBehavior fling
无法传递到
NestedScrollView,快速来回滑动偶尔也会有些抖动,导致滚动不流畅。


另外对于头部是一些动态列表的,还是更希望通过 RecyclerView 来实现,那么嵌套的方式变为:RecyclerView + ViewPager + RecyclerView,那么就需要处理好 RecyclerView 的滑动冲突问题。


如果 ViewPager 的 RecyclerView 内部还嵌套一层 ViewPager,例如一些广告Banner图,那么事件处理也会更加复杂。本文将介绍一种通用的嵌套滚动方案,既可以实现Tab的吸顶,又可以单纯实现的两个垂直 RecyclerView 嵌套(主要场景是:尾部的recyclerview可以实现容器级别的复用,例如往多个列表页的尾部嵌套一个相同样式的推荐商品列表,如下图所示)。


nested2.jpg


代码库地址:github.com/smuyyh/Nest…


目前已应用到线上,如有一些好的建议欢迎交流交流呀~~


核心思路:



  • 父容器滑动到底部之后,触摸事件继续交给子容器滑动

  • 子容器滚动到顶部之后,触摸事件继续交给父容器滑动

  • fling 在父容器和子容器之间传递

  • Tab 在屏幕中间,切换 ViewPager 之后,如果子容器不在顶部,需要优先处理滑动


代码实现:


ParentRecyclerView


因为触摸事件首先分发到父容器,所以核心的协调逻辑主要由父容器实现,子容器只需要处理 fling 传递即可。


public class ParentRecyclerView extends RecyclerView {

private final int mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();

/**
* fling时的加速度
*/

private int mVelocity = 0;

private float mLastTouchY = 0f;

private int mLastInterceptX;
private int mLastInterceptY;

/**
* 用于向子容器传递 fling 速度
*/

private final VelocityTracker mVelocityTracker = VelocityTracker.obtain();
private int mMaximumFlingVelocity;
private int mMinimumFlingVelocity;

/**
* 子容器是否消耗了滑动事件
*/

private boolean childConsumeTouch = false;
/**
* 子容器消耗的滑动距离
*/

private int childConsumeDistance = 0;

public ParentRecyclerView(@NonNull Context context) {
this(context, null);
}

public ParentRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public ParentRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init();
}

private void init() {
ViewConfiguration configuration = ViewConfiguration.get(getContext());
mMaximumFlingVelocity = configuration.getScaledMaximumFlingVelocity();
mMinimumFlingVelocity = configuration.getScaledMinimumFlingVelocity();

addOnScrollListener(new OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == SCROLL_STATE_IDLE) {
dispatchChildFling();
}
}
});
}

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mVelocity = 0;
mLastTouchY = ev.getRawY();
childConsumeTouch = false;
childConsumeDistance = 0;

ChildRecyclerView childRecyclerView = findNestedScrollingChildRecyclerView();
if (isScrollToBottom() && (childRecyclerView != null && !childRecyclerView.isScrollToTop())) {
stopScroll();
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
childConsumeTouch = false;
childConsumeDistance = 0;
break;
default:
break;
}

try {
return super.dispatchTouchEvent(ev);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
if (isChildConsumeTouch(event)) {
// 子容器如果消费了触摸事件,后续父容器就无法再拦截事件
// 在必要的时候,子容器需调用 requestDisallowInterceptTouchEvent(false) 来允许父容器继续拦截事件
return false;
}
// 子容器不消费触摸事件,父容器按正常流程处理
return super.onInterceptTouchEvent(event);
}

/**
* 子容器是否消费触摸事件
*/

private boolean isChildConsumeTouch(MotionEvent event) {
int x = (int) event.getRawX();
int y = (int) event.getRawY();
if (event.getAction() != MotionEvent.ACTION_MOVE) {
mLastInterceptX = x;
mLastInterceptY = y;
return false;
}
int deltaX = x - mLastInterceptX;
int deltaY = y - mLastInterceptY;
if (Math.abs(deltaX) > Math.abs(deltaY) || Math.abs(deltaY) <= mTouchSlop) {
return false;
}

return shouldChildScroll(deltaY);
}

/**
* 子容器是否需要消费滚动事件
*/

private boolean shouldChildScroll(int deltaY) {
ChildRecyclerView childRecyclerView = findNestedScrollingChildRecyclerView();
if (childRecyclerView == null) {
return false;
}
if (isScrollToBottom()) {
// 父容器已经滚动到底部 且 向下滑动 且 子容器还没滚动到底部
return deltaY < 0 && !childRecyclerView.isScrollToBottom();
} else {
// 父容器还没滚动到底部 且 向上滑动 且 子容器已经滚动到顶部
return deltaY > 0 && !childRecyclerView.isScrollToTop();
}
}

@Override
public boolean onTouchEvent(MotionEvent e) {
if (isScrollToBottom()) {
// 如果父容器已经滚动到底部,且向上滑动,且子容器还没滚动到顶部,事件传递给子容器
ChildRecyclerView childRecyclerView = findNestedScrollingChildRecyclerView();
if (childRecyclerView != null) {
int deltaY = (int) (mLastTouchY - e.getRawY());
if (deltaY >= 0 || !childRecyclerView.isScrollToTop()) {
mVelocityTracker.addMovement(e);
if (e.getAction() == MotionEvent.ACTION_UP) {
// 传递剩余 fling 速度
mVelocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
float velocityY = mVelocityTracker.getYVelocity();
if (Math.abs(velocityY) > mMinimumFlingVelocity) {
childRecyclerView.fling(0, -(int) velocityY);
}
mVelocityTracker.clear();
} else {
// 传递滑动事件
childRecyclerView.scrollBy(0, deltaY);
}

childConsumeDistance += deltaY;
mLastTouchY = e.getRawY();
childConsumeTouch = true;
return true;
}
}
}

mLastTouchY = e.getRawY();

if (childConsumeTouch) {
// 在同一个事件序列中,子容器消耗了部分滑动距离,需要扣除掉
MotionEvent adjustedEvent = MotionEvent.obtain(
e.getDownTime(),
e.getEventTime(),
e.getAction(),
e.getX(),
e.getY() + childConsumeDistance, // 更新Y坐标
e.getMetaState()
);

boolean handled = super.onTouchEvent(adjustedEvent);
adjustedEvent.recycle();
return handled;
}

if (e.getAction() == MotionEvent.ACTION_UP || e.getAction() == MotionEvent.ACTION_CANCEL) {
mVelocityTracker.clear();
}

try {
return super.onTouchEvent(e);
} catch (Exception ex) {
ex.printStackTrace();
return false;
}
}

@Override
public boolean fling(int velX, int velY) {
boolean fling = super.fling(velX, velY);
if (!fling || velY <= 0) {
mVelocity = 0;
} else {
mVelocity = velY;
}
return fling;
}

private void dispatchChildFling() {
// 父容器滚动到底部后,如果还有剩余加速度,传递给子容器
if (isScrollToBottom() && mVelocity != 0) {
// 尽量让速度传递更加平滑
float mVelocity = NestedOverScroller.invokeCurrentVelocity(this);
if (Math.abs(mVelocity) <= 2.0E-5F) {
mVelocity = (float) this.mVelocity * 0.5F;
} else {
mVelocity *= 0.46F;
}
ChildRecyclerView childRecyclerView = findNestedScrollingChildRecyclerView();
if (childRecyclerView != null) {
childRecyclerView.fling(0, (int) mVelocity);
}
}
mVelocity = 0;
}

public ChildRecyclerView findNestedScrollingChildRecyclerView() {
if (getAdapter() instanceof INestedParentAdapter) {
return ((INestedParentAdapter) getAdapter()).getCurrentChildRecyclerView();
}
return null;
}

public boolean isScrollToBottom() {
return !canScrollVertically(1);
}

public boolean isScrollToTop() {
return !canScrollVertically(-1);
}

@Override
public void scrollToPosition(final int position) {
if (position == 0) {
// 父容器滚动到顶部,从交互上来说子容器也需要滚动到顶部
ChildRecyclerView childRecyclerView = findNestedScrollingChildRecyclerView();
if (childRecyclerView != null) {
childRecyclerView.scrollToPosition(0);
}
}

super.scrollToPosition(position);
}
}

ChildRecyclerView


子容器主要处理 fling 传递,以及滑动到顶部时,允许父容器继续拦截事件。


public class ChildRecyclerView extends RecyclerView {

private ParentRecyclerView mParentRecyclerView = null;

/**
* fling时的加速度
*/

private int mVelocity = 0;

private int mLastInterceptX;

private int mLastInterceptY;

public ChildRecyclerView(@NonNull Context context) {
this(context, null);
}

public ChildRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public ChildRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init();
}

private void init() {
setOverScrollMode(OVER_SCROLL_NEVER);

addOnScrollListener(new OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == SCROLL_STATE_IDLE) {
dispatchParentFling();
}
}
});
}

private void dispatchParentFling() {
ensureParentRecyclerView();
// 子容器滚动到顶部,如果还有剩余加速度,就交给父容器处理
if (mParentRecyclerView != null && isScrollToTop() && mVelocity != 0) {
// 尽量让速度传递更加平滑
float velocityY = NestedOverScroller.invokeCurrentVelocity(this);
if (Math.abs(velocityY) <= 2.0E-5F) {
velocityY = (float) this.mVelocity * 0.5F;
} else {
velocityY *= 0.65F;
}
mParentRecyclerView.fling(0, (int) velocityY);
mVelocity = 0;
}
}

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
mVelocity = 0;
}

int x = (int) ev.getRawX();
int y = (int) ev.getRawY();
if (ev.getAction() != MotionEvent.ACTION_MOVE) {
mLastInterceptX = x;
mLastInterceptY = y;
}

int deltaX = x - mLastInterceptX;
int deltaY = y - mLastInterceptY;

if (isScrollToTop() && Math.abs(deltaX) <= Math.abs(deltaY) && getParent() != null) {
// 子容器滚动到顶部,继续向上滑动,此时父容器需要继续拦截事件。与父容器 onInterceptTouchEvent 对应
getParent().requestDisallowInterceptTouchEvent(false);
}
return super.dispatchTouchEvent(ev);
}

@Override
public boolean fling(int velocityX, int velocityY) {
if (!isAttachedToWindow()) return false;
boolean fling = super.fling(velocityX, velocityY);
if (!fling || velocityY >= 0) {
mVelocity = 0;
} else {
mVelocity = velocityY;
}
return fling;
}

public boolean isScrollToTop() {
return !canScrollVertically(-1);
}

public boolean isScrollToBottom() {
return !canScrollVertically(1);
}

private void ensureParentRecyclerView() {
if (mParentRecyclerView == null) {
ViewParent parentView = getParent();
while (!(parentView instanceof ParentRecyclerView)) {
parentView = parentView.getParent();
}
mParentRecyclerView = (ParentRecyclerView) parentView;
}
}
}


效果


有 Tab





无 Tab,两个 RecyclerView 嵌套





作者:LeBron_Six
来源:juejin.cn/post/7312338839695081499
收起阅读 »

把Fragment变成Composable踩坑

把Fragment变成Composable踩坑 Why 在编写Compose时候如果遇到需要加载其他Fragment就比较麻烦,而且很多时候这种Fragment还是xml或者第三方SDK提供的。下面提供一些解决方案。 Option 1 google也意识到这个...
继续阅读 »

把Fragment变成Composable踩坑


Why


在编写Compose时候如果遇到需要加载其他Fragment就比较麻烦,而且很多时候这种Fragment还是xml或者第三方SDK提供的。下面提供一些解决方案。


Option 1


google也意识到这个问题,所以提供了AndroidViewBinding,可以把Fragment通过包装成AndroidView,就可以在Composable中随意使用了。AndroidViewBinding在组合项退出组合时会移除 fragment。


官方文档:Compose 中的 fragment


//源码
@Composable
fun <T : ViewBinding> AndroidViewBinding(
factory: (inflater: LayoutInflater, parent: ViewGr0up, attachToParent: Boolean) -> T,
modifier: Modifier = Modifier,
update: T.() -> Unit = {} //view inflate 完成时候回调
)
{ ...


  • 首先需要添加ui-viewbinding依赖,并且开启viewBinding


// gradle
buildFeatures {
...
viewBinding true
}
...
implementation("androidx.compose.ui:ui-viewbinding")


  • 创建xml布局,在android:name="MyFragment"添加Fragment的名字和包名路径


<androidx.fragment.app.FragmentContainerView
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:id="@+id/fragment_container_view"
  android:layout_height="match_parent"
  android:layout_width="match_parent"
  android:name="com.example.MyFragment" />



  • 在Composable函数中如下调用,如果您需要在同一布局中使用多个 fragment,请确保您已为每个 FragmentContainerView 定义唯一 ID。


@Composable
fun FragmentInComposeExample() {
    AndroidViewBinding(MyFragmentLayoutBinding::inflate) {
        val myFragment = fragmentContainerView.getFragment<MyFragment>()
        // ...
    }
}


这种方式默认支持空构造函数的Fragment,如果是带有参数或者需要arguments传递数据的,需要改造成调用方法传递或者callbak方式,官方建议使用FragmentFactory。



class MyFragmentFactory extends FragmentFactory {
@NonNull
@Override
public Fragment instantiate(@NonNull ClassLoader classLoader, @NonNull String className) {
Class extends Fragment> clazz = loadFragmentClass(classLoader, className);
if (clazz == MainFragment.class) {
//这次处理传递参数
return new MainFragment(anyArg1, anyArg2);
} else {
return super.instantiate(classLoader, className);
}
}
}

//使用
getSupportFragmentManager().setFragmentFactory(fragmentFactory)

请参考此文:FragmentFactory :功能详解&使用场景


Option 2


如果我们可以new Fragment或者有fragment实例,如何加载到Composable中呢。


思路:fragmentManager把framgnt add之后,fragment自己getView,然后包装成AndroidView即可。修改下AndroidViewBinding源码就可以得到如下代码:


@Composable
fun FragmentComposable(
fragment: Fragment,
modifier: Modifier = Modifier,
update: (Fragment) -> Unit = {}
)
{
val fragmentTag = remember { mutableStateOf(fragment.javaClass.name) }
val localContext = LocalContext.current

AndroidView(
modifier = modifier,
factory = { context ->
require(!fragment.isAdded) { "fragment must not attach to any host" }
(localContext as? FragmentActivity)?.supportFragmentManager
?.beginTransaction()
?.setReorderingAllowed(true)
?.add(fragment, fragmentTag.value)
?.commitNowAllowingStateLoss()
fragment.requireView()
},
update = { update(fragment) }
)

DisposableEffect(localContext) {
val fragmentManager = (localContext as? FragmentActivity)?.supportFragmentManager
val existingFragment = fragmentManager?.findFragmentByTag(fragmentTag.value)
onDispose {
if (existingFragment != null && !fragmentManager.isStateSaved) {
// If the state isn't saved, that means that some state change
// has removed this Composable from the hierarchy
fragmentManager
.beginTransaction()
.remove(existingFragment)
.commitAllowingStateLoss()
}
}
}
}

Issue Note


其实里面有个巨坑。如果你的Fragment中还通过fragmentManager进行了navigation的实现,你会发现你的其他Fragment生命周期会异常,返回了却onDestoryView,onDestory不回调。



  • 方案1中 官方建议把所有的子Fragment通过childFragmentManager来加载,这样子Fragment依赖与父对象,当父亲被回退出去后,子类Fragment全部自动销毁了,会正常被childFragmentManager处理生命周期。

  • 方案1中 Fragment嵌套需要用FragmentContainerView来包装持有。下面是源码解析,只保留了核心处理的地方


@Composable
fun <T : ViewBinding> AndroidViewBinding(
factory: (inflater: LayoutInflater, parent: ViewGr0up, attachToParent: Boolean) -> T,
modifier: Modifier = Modifier,
update: T.() -> Unit = {}
)
{
// fragmentContainerView的集合
val fragmentContainerViews = remember { mutableStateListOf<FragmentContainerView>() }
val viewBlock: (Context) -> View = remember(localView) {
{ context ->
...
val viewBinding = ...
fragmentContainerViews.clear()
val rootGr0up = viewBinding.root as? ViewGr0up
if (rootGr0up != null) {
//递归找到 并且加入集合
findFragmentContainerViews(rootGr0up, fragmentContainerViews)
}
viewBinding.root
}
}

...
//遍历所有找到View每个都注册一个 DisposableEffect用来处理销毁
fragmentContainerViews.fastForEach { container ->
DisposableEffect(localContext, container) {
// Find the right FragmentManager
val fragmentManager = parentFragment?.childFragmentManager
?: (localContext as? FragmentActivity)?.supportFragmentManager
// Now find the fragment inflated via the FragmentContainerView
val existingFragment = fragmentManager?.findFragmentById(container.id)
onDispose {
if (existingFragment != null && !fragmentManager.isStateSaved) {
// If the state isn't saved, that means that some state change
// has removed this Composable from the hierarchy
fragmentManager.commit {
remove(existingFragment)
}
}
}
}
}
}

思考和完善


很多时候我们的业务很复杂改动Fragment的导航方式成本很高,如何无缝兼容呢。于是有了如下思考



  • 加载这个Composable Fragment之前可能还有Fragment加载和导航,需要单独的FragmentManager
    val parentFragment = remember(localView) {
    try {
    // 需要依赖 implementation "androidx.fragment:fragment-ktx:1.6.2"
    localView.findFragment<Fragment>().takeIf { it.isAdded }
    } catch (e: IllegalStateException) {
    // findFragment throws if no parent fragment is found
    null
    }
    }
    val localContext = LocalContext.current
    //如果有还有父Fragment就使用childFragmentManager,
    //如果没有说明是第一个Fragment用supportFragmentManager
    val fragmentManager = parentFragment?.childFragmentManager
    ?: (localContext as? FragmentActivity)?.supportFragmentManager
    //加载Composable Fragment
    val fragment = ...
    fragmentManager
    ?.beginTransaction()
    ?.setReorderingAllowed(true)
    ?.add(id, fragment, fragment.javaClass.name)
    ?.commitAllowingStateLoss()


  • 子Fragment若用parentFragment childFragmentManager管理,不需要额外处理

  • 子Fragment若用parentFragment fragmentManager管理,需要监听的出入堆栈,在Composable销毁时候处理所有堆栈中的子fragment
    val attachListener = remember {
    FragmentOnAttachListener { _, fragment ->
    Log.d("FragmentComposable", "fragment: $fragment")
    }
    }
    fragmentManager?.addFragmentOnAttachListener(attachListener)


  • 实际操作中parentFragmentManager实现的子Fragment导航,中间会发生popback,如何防止出栈的Fragment出现内存泄露问题
    val fragments = remember { mutableListOf<WeakReference<Fragment>>() }
    FragmentOnAttachListener { _, fragment ->
    Log.d("FragmentComposable", "fragment: $fragment")
    fragments += WeakReference(fragment)
    }


  • 实际操作中 beginTransaction().remove(childFragment)只会执行子fragment的onDestoryView方法,onDestory不触发,原来是加载子fragment用了addToBackStack,需要调用popBackStack
    DisposableEffect(localContext) {
    val fragmentManager = ...
    onDispose {
    //回退栈到AndroidView的Fragment
    fragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
    }
    }



Final Option


import android.widget.FrameLayout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentOnAttachListener
import androidx.fragment.app.findFragment
import java.lang.ref.WeakReference

/**
* Make fragment as Composable by AndroidView
*
* @param fragment fragment
* @param fm add fragment by FragmentManager, can be childFragmentManager
* @param update The callback to be invoked after the layout is inflated.
*/

@Composable
fun <T : Fragment> FragmentComposable(
modifier: Modifier = Modifier,
fragment: T,
update: (T) -> Unit = {}
)
{
val localView = LocalView.current
// Find the parent fragment, if one exists. This will let us ensure that
// fragments inflated via a FragmentContainerView are properly nested
// (which, in turn, allows the fragments to properly save/restore their state)
val parentFragment = remember(localView) {
try {
localView.findFragment<Fragment>().takeIf { it.isAdded }
} catch (e: IllegalStateException) {
// findFragment throws if no parent fragment is found
null
}
}

val fragments = remember { mutableListOf<WeakReference<Fragment>>() }

val attachListener = remember {
FragmentOnAttachListener { _, fragment ->
Log.d("FragmentComposable", "fragment: $fragment")
fragments += WeakReference(fragment)
}
}

val localContext = LocalContext.current

DisposableEffect(localContext) {
val fragmentManager = parentFragment?.childFragmentManager
?: (localContext as? FragmentActivity)?.supportFragmentManager
fragmentManager?.addFragmentOnAttachListener(attachListener)

onDispose {
fragmentManager?.removeFragmentOnAttachListener(attachListener)
if (fragmentManager?.isStateSaved == false) {
fragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
fragments
.filter { it.get()?.isRemoving == false }
.reversed()
.forEach { existingFragment ->
Log.d("FragmentComposable", "remove:${existingFragment.get()}")
fragmentManager
.beginTransaction()
.remove(existingFragment.get()!!)
.commitAllowingStateLoss()
}
}
}
}

AndroidView(
modifier = modifier,
factory = { context ->
FrameLayout(context).apply {
id = System.currentTimeMillis().toInt()
require(!fragment.isAdded) { "$fragment must not attach to any host" }
val fragmentManager = parentFragment?.childFragmentManager
?: (localContext as? FragmentActivity)?.supportFragmentManager
fragmentManager
?.beginTransaction()
?.setReorderingAllowed(true)
?.replace(this.id, fragment, fragment.javaClass.name)
?.commitAllowingStateLoss()
fragments.clear()
}
},
update = { update(fragment) }
)
}

注意事项



  • 使用上面的代码加载的Fragment(父),若里面导航子Fragment,必须使用parentFragment一样fragmentManager 或者 parentFragment的childFragmentManager

  • 如果子Fragment使用了FragmentActivity?.supportFragmentManager,而parentFragment.fragmentManager不是这个,就会导致子Fragment的生命周期异常。


转载声明


未授权禁止转载和二次修改发布(最近发现有人搬运我的文章,并且改为自己原创,脸都不要了。)如果上面的代码有Bug,请在评论区留言。


作者:forJrking
来源:juejin.cn/post/7312266765123272744
收起阅读 »

Android TextView中那些冷门好用的用法

介绍 TextView 是 Android 开发中最常用的小部件之一。它用于在屏幕上显示文本。但是,TextView 有几个较少为人知的功能,对开发人员非常有用。在本博客文章中,我们将探讨其中的一些功能。 自定义字体 默认情况下,TextView 使用系统字体...
继续阅读 »

介绍


TextView 是 Android 开发中最常用的小部件之一。它用于在屏幕上显示文本。但是,TextView 有几个较少为人知的功能,对开发人员非常有用。在本博客文章中,我们将探讨其中的一些功能。


自定义字体


默认情况下,TextView 使用系统字体显示文本。但其实我们也可以导入我们自己的字体文件在 TextView 中使用自定义字体。这可以通过将字体文件添加到资源文件夹(res/font 或者 assets)并在 TextView 上以编程方式设置来实现。


要使用自定义字体,我们需要下载字体文件(或者自己生成)并将其添加到资源文件夹中。然后,我们可以使用setTypeface()方法在TextView上以编程方式设置字体。我们还可以在 XML 中使用android:fontFamily或者android:typeface属性设置字体。需要注意的是,在 XML 中使用 typeface 的方式只能使用系统预设的字体并且仅对英文字符有效,如果TextView的文本内容是中文的话这个属性设置后将不会有任何效果。


以下是 Android TextView 自定义字体的代码示例:



  1. 将字体文件添加到 assets 或 res/font 文件夹中。

  2. 通过以下代码设置字体:


// 字体文件放到 assets 文件夹的情况
Typeface tf = Typeface.createFromAsset(getAssets(), "fonts/myfont.ttf");
TextView tv = findViewById(R.id.tv);
tv.setTypeface(tf);

// 字体文件放到 res/font 文件夹的情况, 需注意的是此方式在部分低于 Android 8.0 的设备上可能会存在兼容性问题
val tv = findViewById<TextView>(R.id.tv)
val typeface = ResourcesCompat.getFont(this, R.font.myfont)
tv.typeface = typeface

在上面的示例中,我们首先从 assets 文件夹中创建了一个新的 Typeface 对象。然后,我们使用 setTypeface() 方法将该对象设置为 TextView 的字体。


在上面的示例中,我们将字体文件命名为 “myfont.ttf”。我们可以将其替换为要使用的任何字体文件的名称。


自定义字体是 TextView 的强大功能之一,它可以帮助我们创建具有独特外观和感觉的应用程序。另外,我们也可以通过这种方法实现自定义图标的绘制。


AutoLink


AutoLink 可以自动检测文本中的模式并将其转换为可点击的链接。例如,如果 TextView 包含电子邮件地址或 URL,则 AutoLink 将识别它并使其可点击。此功能使开发人员无需手动创建文本中的可点击链接。


要在 TextView 上启用 AutoLink,您需要将autoLink属性设置为emailphoneweball。您还可以使用Linkify类设置自定义链接模式。


以下是一个Android TextView AutoLink代码使用示例:


<TextView
android:id="@+id/tv3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autoLink="web"
android:textColorLink="@android:color/holo_red_dark"
android:text="这是我的个人博客地址: http://www.geektang.cn" />


在上面的示例中,我们将 autoLink 属性设置为 web ,这意味着 TextView 将自动检测文本中的 URL 并将其转换为可点击的链接。我们还将 text 属性将文本设置为 这是我的个人博客地址: http://www.geektang.cn 。当用户单击链接时,它们将被带到 http://www.geektang.cn 网站。另外,我们也可以通过 textColorLink 属性将 Link 颜色为我们喜欢的颜色。


AutoLink是一个非常有用的功能,它可以帮助您更轻松地创建可交互的文本。


对齐模式


对齐模式允许您通过在单词之间添加空格将文本对齐到左右边距,这使得文本更易读且视觉上更具吸引力。您可以将对齐模式属性设置为 inter_wordinter_character


要使用对齐模式功能,您需要在 TextView 上设置 justificationMode 属性。但是,此功能仅适用于运行 Android 8.0(API 级别 26)或更高版本的设备。


以下是对齐模式功能的代码示例:


<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="This is some sample text that will be justified."
android:justificationMode="inter_word"/>

在上面的示例中,我们将 justificationMode 属性设置为 inter_word 。这意味着 TextView 将在单词之间添加空格,以便将文本对齐到左右边距。


以下是对齐模式功能的显示效果示例:


image.png
同样一段文本,上面的设置 justificationMode 为 inter_word ,是不是看起来会比下面的好看一些呢?这个属性一般用于多行英文文本,如果只有一行文本或者文本内容是纯中文字符的话,不会有任何效果。


作者:GeekTR
来源:juejin.cn/post/7217082232937283645
收起阅读 »

Android 使用 TextView 实现验证码输入框

前言 网上开源的是构建同等数量的 EditText,这种存在很多缺陷,主要如下 1、数字 / 字符键盘切换后键盘状态无法保存 2、焦点切换无法判断 3、光标位置无法修正 4、切换过程需要做很多同步工作 解决方法 为了解决上述问题,使用 TextView 实现输...
继续阅读 »

前言


网上开源的是构建同等数量的 EditText,这种存在很多缺陷,主要如下


1、数字 / 字符键盘切换后键盘状态无法保存

2、焦点切换无法判断

3、光标位置无法修正

4、切换过程需要做很多同步工作


解决方法


为了解决上述问题,使用 TextView 实现输入框,需要解决的问题是


1、允许 TextView 可编辑输入,这点可以参考EditText的实现

2、重写 onDraw 实现,不实用原有的绘制逻辑。

3、重写光标逻辑,默认的光标逻辑和Editor有很多关联逻辑,而Editor是@hide标注的,因此必须要重写

4、重写长按菜单逻辑,防止弹出剪切、复制等弹窗。


fire_89.gif


代码实现


首先我们要继承TextView或者AppCompatTextView,然后实现下面的操作


变量定义


//边框颜色
private int boxColor = Color.BLACK;

//光标是否可见
private boolean isCursorVisible = true;
//光标
private Drawable textCursorDrawable;
//光标宽度
private float cursorWidth = dp2px(2);
//光标高度
private float cursorHeight = dp2px(36);
//是否展示光标
private boolean isShowCursor;
//字符数量控制
private int inputBoxNum = 5;
//间距
private int mBoxSpace = 10;

关键设置


super.setFocusable(true); //支持聚焦
super.setFocusableInTouchMode(true); //支持触屏模式聚焦
//可点击,因为聚焦的view必须是可以点击的,这里你也可以设置个clickListener,效果一样
super.setClickable(true);
super.setGravity(Gravity.CENTER_VERTICAL);
super.setMaxLines(1);
super.setSingleLine();
super.setFilters(inputFilters);
super.setLongClickable(false);// 禁止复制、剪切
super.setTextIsSelectable(false); // 禁止选中

绘制逻辑


TextPaint paint = getPaint();

float strokeWidth = paint.getStrokeWidth();
if(strokeWidth == 0){
//默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
paint.setStrokeWidth(dp2px(1));
strokeWidth = paint.getStrokeWidth();
}
paint.setTextSize(getTextSize());

float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
float boxHeight = getHeight() - strokeWidth * 2f;
int saveCount = canvas.save();

Paint.Style style = paint.getStyle();
Paint.Align align = paint.getTextAlign();
paint.setTextAlign(Paint.Align.CENTER);

String text = getText().toString();
int length = text.length();

int color = paint.getColor();

for (int i = 0; i < inputBoxNum; i++) {

inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
strokeWidth,
strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
strokeWidth + boxHeight);

paint.setStyle(Paint.Style.STROKE);
paint.setColor(boxColor);
//绘制边框
canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

//设置当前TextColor
int currentTextColor = getCurrentTextColor();
paint.setColor(currentTextColor);
paint.setStyle(Paint.Style.FILL);
if (text.length() > i) {
// 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
String CH = String.valueOf(text.charAt(i));
int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
}

//绘制光标
if(i == length && isCursorVisible && length < inputBoxNum){
Drawable textCursorDrawable = getTextCursorDrawable();
if(textCursorDrawable != null) {
if (!isShowCursor) {
textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
textCursorDrawable.draw(canvas);
isShowCursor = true; //控制光标闪烁 blinking
} else {
isShowCursor = false;//控制光标闪烁 no blink
}
removeCallbacks(invalidateCursor);
postDelayed(invalidateCursor,500);
}
}
}

paint.setColor(color);
paint.setStyle(style);
paint.setTextAlign(align);

canvas.restoreToCount(saveCount);

总结


上面就是本文的核心逻辑,实际上EditText、Button都继承自TextView,因此我们简单的修改就能让其支持输入,主要原因还是TextView复杂的设计和各种Layout的支持,但是这也给TextView带来了性能问题。


这里简单说下TextView性能优化,对于单行文本和非可编辑文本,最好是自行实现,单行文本直接用canvas.drawText绘制,当然多行也是可以的,不过鉴于要支持很多特性,多行文本可以使用StaticLayout去实现,但单行文本尽量自己绘制,也不要使用BoringLayout,因为其存在一些兼容性问题,另外自定义的单行文本不要和TextView同一行布局,因为TextView的计算相对较多,很可能产生对不齐的问题。


本篇全部代码


按照惯例,这里依然提供全部代码,仅供参考。


public class EditableTextView extends TextView {

private RectF inputRect = new RectF();


//边框颜色
private int boxColor = Color.BLACK;

//光标是否可见
private boolean isCursorVisible = true;
//光标
private Drawable textCursorDrawable;
//光标宽度
private float cursorWidth = dp2px(2);
//光标高度
private float cursorHeight = dp2px(36);
//光标闪烁控制
private boolean isShowCursor;
//字符数量控制
private int inputBoxNum = 5;
//间距
private int mBoxSpace = 10;
// box radius
private float boxRadius = dp2px(0);

InputFilter[] inputFilters = new InputFilter[]{
new InputFilter.LengthFilter(inputBoxNum)
};


public EditableTextView(Context context) {
this(context, null);
}

public EditableTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public EditableTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
super.setFocusable(true); //支持聚焦
super.setFocusableInTouchMode(true); //支持触屏模式聚焦
//可点击,因为在触屏模式可聚焦的view一般是可以点击的,这里你也可以设置个clickListener,效果一样
super.setClickable(true);
super.setGravity(Gravity.CENTER_VERTICAL);
super.setMaxLines(1);
super.setSingleLine();
super.setFilters(inputFilters);
super.setLongClickable(false);// 禁止复制、剪切
super.setTextIsSelectable(false); // 禁止选中

Drawable cursorDrawable = getTextCursorDrawable();
if(cursorDrawable == null){
cursorDrawable = new PaintDrawable(Color.MAGENTA);
setTextCursorDrawable(cursorDrawable);
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
super.setPointerIcon(null);
}
super.setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
return true;
}
});

//禁用ActonMode弹窗
super.setCustomSelectionActionModeCallback(new ActionMode.Callback() {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
return false;
}

@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}

@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
return false;
}

@Override
public void onDestroyActionMode(ActionMode mode) {

}
});

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setBreakStrategy(LineBreaker.BREAK_STRATEGY_SIMPLE);
}
mBoxSpace = (int) dp2px(10f);

}

@Override
public ActionMode startActionMode(ActionMode.Callback callback) {
return null;
}

@Override
public ActionMode startActionMode(ActionMode.Callback callback, int type) {
return null;
}

@Override
public boolean hasSelection() {
return false;
}

@Override
public boolean showContextMenu() {
return false;
}

@Override
public boolean showContextMenu(float x, float y) {
return false;
}

public void setBoxSpace(int mBoxSpace) {
this.mBoxSpace = mBoxSpace;
postInvalidate();
}

public void setInputBoxNum(int inputBoxNum) {
if (inputBoxNum <= 0) return;
this.inputBoxNum = inputBoxNum;
this.inputFilters[0] = new InputFilter.LengthFilter(inputBoxNum);
super.setFilters(inputFilters);
}

@Override
public void setClickable(boolean clickable) {

}

@Override
public void setLines(int lines) {

}
@Override
protected boolean getDefaultEditable() {
return true;
}


@Override
protected void onDraw(Canvas canvas) {

TextPaint paint = getPaint();

float strokeWidth = paint.getStrokeWidth();
if(strokeWidth == 0){
//默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
paint.setStrokeWidth(dp2px(1));
strokeWidth = paint.getStrokeWidth();
}
paint.setTextSize(getTextSize());

float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
float boxHeight = getHeight() - strokeWidth * 2f;
int saveCount = canvas.save();

Paint.Style style = paint.getStyle();
Paint.Align align = paint.getTextAlign();
paint.setTextAlign(Paint.Align.CENTER);

String text = getText().toString();
int length = text.length();

int color = paint.getColor();

for (int i = 0; i < inputBoxNum; i++) {

inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
strokeWidth,
strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
strokeWidth + boxHeight);

paint.setStyle(Paint.Style.STROKE);
paint.setColor(boxColor);
//绘制边框
canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

//设置当前TextColor
int currentTextColor = getCurrentTextColor();
paint.setColor(currentTextColor);
paint.setStyle(Paint.Style.FILL);
if (text.length() > i) {
// 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
String CH = String.valueOf(text.charAt(i));
int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
}

//绘制光标
if(i == length && isCursorVisible && length < inputBoxNum){
Drawable textCursorDrawable = getTextCursorDrawable();
if(textCursorDrawable != null) {
if (!isShowCursor) {
textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
textCursorDrawable.draw(canvas);
isShowCursor = true; //控制光标闪烁 blinking
} else {
isShowCursor = false;//控制光标闪烁 no blink
}
removeCallbacks(invalidateCursor);
postDelayed(invalidateCursor,500);
}
}
}

paint.setColor(color);
paint.setStyle(style);
paint.setTextAlign(align);

canvas.restoreToCount(saveCount);
}


private Runnable invalidateCursor = new Runnable() {
@Override
public void run() {
invalidate();
}
};
/**
* 基线到中线的距离=(Descent+Ascent)/2-Descent
* 注意,实际获取到的Ascent是负数。公式推导过程如下:
* 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
*/

public static float getTextPaintBaseline(Paint p) {
Paint.FontMetrics fontMetrics = p.getFontMetrics();
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}

/**
* 控制是否保存完整文本
*
* @return
*/

@Override
public boolean getFreezesText() {
return true;
}

@Override
public Editable getText() {
return (Editable) super.getText();
}

@Override
public void setText(CharSequence text, BufferType type) {
super.setText(text, BufferType.EDITABLE);
}

/**
* 控制光标展示
*
* @return
*/

@Override
protected MovementMethod getDefaultMovementMethod() {
return ArrowKeyMovementMethod.getInstance();
}

@Override
public boolean isCursorVisible() {
return isCursorVisible;
}

@Override
public void setTextCursorDrawable(@Nullable Drawable textCursorDrawable) {
// super.setTextCursorDrawable(null);
this.textCursorDrawable = textCursorDrawable;
postInvalidate();
}

@Nullable
@Override
public Drawable getTextCursorDrawable() {
return textCursorDrawable; //支持android Q 之前的版本
}

@Override
public void setCursorVisible(boolean cursorVisible) {
isCursorVisible = cursorVisible;
}
public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}

public void setBoxRadius(float boxRadius) {
this.boxRadius = boxRadius;
postInvalidate();
}

public void setBoxColor(int boxColor) {
this.boxColor = boxColor;
postInvalidate();
}

public void setCursorHeight(float cursorHeight) {
this.cursorHeight = cursorHeight;
postInvalidate();
}

public void setCursorWidth(float cursorWidth) {
this.cursorWidth = cursorWidth;
postInvalidate();
}

}

作者:时光少年
来源:juejin.cn/post/7313242064196452361
收起阅读 »

Handler机制中同步屏障原理及结合实际问题分析

前言 本人是一个毕业两年的智能座舱车载应用工作者,在这里我将分享一个关于Handler同步屏障导致的bug,我和我的同事遇到了一个应用不响应Handler消息的bug,bug的现象为有Touch事件,但没有界面的view却没有任何响应。当时项目组拉了fwk和驱...
继续阅读 »

前言


本人是一个毕业两年的智能座舱车载应用工作者,在这里我将分享一个关于Handler同步屏障导致的bug,我和我的同事遇到了一个应用不响应Handler消息的bug,bug的现象为有Touch事件,但没有界面的view却没有任何响应。当时项目组拉了fwk和驱动的同事,从屏幕驱动到fwk的事件分发,甚至卡顿及内存泄漏都做了分析,唯独大家没有考虑到从Handler的消息机制切入。后面排除到是和Handler的同步屏障有关,最终才解决此bug,此篇文章将解释Handler的同步屏障机制及此bug的原因。


Handler同步屏障机制


Handler消息分为以下三种:


1.同步消息


2.异步消息


3.同步屏障(其实更像一个机制的开关)


其实在没有开启同步屏障的情况下,Handler对同步消息和异步消息的响应是没有太大区别的,都是通过Looper轮询MessageQueue中的消息然后传递给对应的Handler去处理,其中会按照Message的需要响应时间去决定其插入到链表中的位置,如果时间较早就会插在前面。(在此笔者不赘述过多关于Handler消息机制的内容,网上文章很多)但如果开启了同步屏障,Handler会优先处理异步消息,不响应同步消息,直到同步屏障关闭。


Handler同步屏障开启后的队列消息运作机制


我们知道在MessageQueue队列中,Message是按照延时时间的长短决定其在链表中的位置的。但是当我们打开了同步屏障之后,MessageQueue在消息出队的时候会优先出异步消息,绕开同步消息。具体如源码所示。



synchronized (this) {

    // Try to retrieve the next message.  Return if found.

    final long now = SystemClock.uptimeMillis();

    Message prevMsg = null;

    Message msg = mMessages;

    if (msg != null && msg.target == null) {

        // Stalled by a barrier.  Find the next asynchronous message in the queue.

        //可以看到当队列中有消息屏障的时候,会优先处理异步消息,绕开同步消息

        do {

            prevMsg = msg;

            msg = msg.next;

        } while (msg != null && !msg.isAsynchronous());

    }


如下是同步屏障开启以及开启后消息出队的一个流程图(其中两个异步消息是绘图表达有误,并非代表一起出列时候的状态)


image


遭遇bug原因


回到前言中提及的bug,其实由于在app在非主线程中去做了更新UI的操作,而这个操作没有做主线程校验,所以也没有抛出Only the original thread that created a view hierarchy can touch its views.在app中具体是调用了ViewRootImpl的如下方法。



@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)

//此方法没有加锁,是个线程不安全的方法

void scheduleTraversals() {

    if (!mTraversalScheduled) {

        mTraversalScheduled = true;

        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();

        mChoreographer.postCallback(

                Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);

        notifyRendererOfFramePending();

        pokeDrawLockIfNeeded();

    }

}


如以上代码所示,这里是没有加同步锁的方法,app又是通过子线程去调用了此线程不安全的方法,导致插入了多个同步屏障,在移除的时候有没有将所有同步屏障消息移除,导致后来的同步消息全部不会出队,Handler也不会去处理这些消息,app的界面更新以及很多组件之间的通讯都是依赖Handler来处理,就导致整个app的现象是不论怎么触摸,都不会有界面更新,但通过系统日志又能看到触摸事件的日志。



void unscheduleTraversals() {

    if (mTraversalScheduled) {

        mTraversalScheduled = false;

        //移除消息屏障

        mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

        mChoreographer.removeCallbacks(

                Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);

    }

}

void doTraversal() {

    if (mTraversalScheduled) {

        mTraversalScheduled = false;

        //移除消息屏障

        mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

        if (mProfile) {

            Debug.startMethodTracing("ViewAncestor");

        }

        performTraversals();

        if (mProfile) {

            Debug.stopMethodTracing();

            mProfile = false;

        }

    }

}


抽象出来的流程图如下图所示:


image


其实这里又回到了一个线程安全的问题,这个问题也是Andorid设计的时候要在UI线程(主线程)中更新UI的原因,保证线程的同步更新UI。最后通过排除app中的子线程更新UI代码段将此bug解决。


总结


1.Handler的同步屏障消息会让队列中的异步消息优先处理,同步消息被屏蔽。


2.结合笔者遇到的bug,大家其实要注意平时编写app代码时对UI的更新一定要放到主线程,保证线程的同步。


3.这段分析和经历不仅仅是博客中记录的原理,更多是拓宽了笔者解决问题的思维,我们总是说要去读源码,其实读懂只是帮助我们理解和避开写出bug,更多的我们应该学习里面的设计思维运用到实际开发中去。


作者:TW23
来源:juejin.cn/post/7313048188138356746
收起阅读 »

Android 动画里的贝塞尔曲线

Android 动画里的贝塞尔曲线 对贝塞尔曲线的听闻,大概是源自 Photoshop 里的钢笔工具,用来画曲线的,但一直不明白这个曲线有什么用,接触学习到 Android 动画,又发现了贝塞尔曲线的身影,这玩意不是在绘图软件里画曲线的吗,怎么和动画扯上关系...
继续阅读 »

Android 动画里的贝塞尔曲线


贝塞尔曲线-钢笔.gif

对贝塞尔曲线的听闻,大概是源自 Photoshop 里的钢笔工具,用来画曲线的,但一直不明白这个曲线有什么用,接触学习到 Android 动画,又发现了贝塞尔曲线的身影,这玩意不是在绘图软件里画曲线的吗,怎么和动画扯上关系了,好吧,今天高低得来了解一下。


插值


首先我们得知道什么是插值,数学里面的插值(Interpolation),是一种通过已知的、离散的数据点,在范围内推求新数据点的过程或方法。


下面这个表给出了某个未知函数 f 的值,函数的具体表达式我们并不知道。


xf(x)
00
10.08415
20.9093
30.1411
4−0.7568
5−0.9589
6−0.2794

表中数据点在x-y平面上的绘图.jpg

现在让你估算出 x=2.5x=2.5 时,f(x)f(x) 的值。


简单嘛,已知 f(2)=0.9093f(2)=0.9093f(3)=0.1411f(3)=0.1411,连接两个已知点,f(2.5)f(2.5) 不就是线段中点嘛,(0.90930.1411)/2(0.9093-0.1411)/2,一下子就算出来了。这个通过已知的、离散的数据点,在范围内推求新数据点的过程其实就叫做 "插值"。


f(2.5)推算过程.gif

插值有多种方法,上面这种粗暴地将相邻已知点连接为线段,然后按比例取线段上某个点,来推求新数据点,属于"线性插值"。


虽然口头上表达这个过程不难,可如果这是一道数学试卷上面的解答题,请问阁下又该如何作答呢?


线性插值


咱们再来一道


Linear_interpolation.png

假设已知坐标 (x0,y0)\left ( {{x}_{0},\, {y}_{0}} \right )(x1,y1)\left ( {{x}_{1},\, {y}_{1}} \right ),求: [x0,x1]\left [ {{x}_{0},\, {x}_{1}} \right ] 区间内某一位置 xx 在所对应的 yy 值。


因为 (x0,y0)\left ( {{x}_{0},\, {y}_{0}} \right )(x,y)\left ( x,\, y \right ) 之间的斜率,与 (x0,y0)\left ( {{x}_{0},\, {y}_{0}} \right )(x1,y1)\left ( {{x}_{1},\, {y}_{1}} \right ) 之间的斜率相同,所以:


yy0xx0=y1y0x1x0{\frac {y-{y}_{0}} {x-{x}_{0}}=\frac {{y}_{1}-{y}_{0}} {{x}_{1}-{x}_{0}}\, }

其中 x0{x}_{0}y0{y}_{0}x1{x}_{1}y1{y}_{1}xx 都已知,那么:


y=y1y0x1x0(xx0)+y0=y0+(y1y0)xx0x1x0y=\frac {{y}_{1}-{y}_{0}} {{x}_{1}-{x}_{0}}·\left ( {x-{x}_{0}} \right )+{y}_{0}={y}_{0}+\left ( {{y}_{1}-{y}_{0}} \right )·\frac {x-{x}_{0}} {{x}_{1}-{x}_{0}}

线性插值公式.jpg

过程和上面的例子,线性插值推算未知函数 f(2.5)f(2.5) 的值其实是一样的,原来数学里线性插值的过程是这么表达的啊。


线性插值估算f(2.5).jpg

贝塞尔曲线


线性贝塞尔曲线


线性贝塞尔曲线是一条两点之间的直线,给定点 P0{P}_{0}P1{P}_{1},这条线由下式给出:


B(t)=P0+(P1P0)t,t[0,1]B\left ( {t} \right )={P}_{0}+\left ( {{P}_{1}-{P}_{0}} \right )·t,\, t\in \left [ {0,\, 1} \right ]

线性贝塞尔曲线演示动画.gif

等等,这不就是线性插值吗,和线性插值公式一样,而且线性插值的结果也在一条两点之间的直线上。


线性插值_直线.jpg

二次方贝塞尔曲线


既然两个点线性插值可以表示一条两点之间的直线,或者说是一条线性贝塞尔曲线,那...3个点线性插值的结果,几何表示会是什么样?


二次贝塞尔曲线的结构.jpg
二次贝塞尔曲线演示动画.gif

同时对 P0P1{P}_{0}{P}_{1}P1P2{P}_{1}{P}_{2} 进行插值:


P0P1{P}_{0}{P}_{1} 插值得到连续点 Q0{Q}_{0},描述线段 P0P1{P}_{0}{P}_{1},是一条线性贝塞尔曲线;


P1P2{P}_{1}{P}_{2} 插值得到连续点 Q1{Q}_{1},描述线段 P1P2{P}_{1}{P}_{2},是一条线性贝塞尔曲线;


P0P1{P}_{0}{P}_{1}P1P2{P}_{1}{P}_{2} 插值的同时,用同样的插值因子对得到的 Q0Q1{Q}_{0}{Q}_{1} 再插值,也就是对图中绿色线段进行插值,得到连续点 BB,追踪连续点 BB 的运动轨迹,得到曲线 P0P2{P}_{0}{P}_{2},也就是图中红色曲线,是一条二次贝塞尔曲线。


原来,两个及以上的点 线性插值函数 就是 贝塞尔曲线函数,我们可以简单地不断循坏迭代两点线性插值来得到最终结果。


三次方贝塞尔曲线 & 动画速度曲线


动画曲线.jpg
匀速和先加速后减速.gif

无论是网页设计里的 CSS 还是 Android 开发,它们里面的动画速度曲线其实是三次方贝塞尔曲线,由 4 个点不断两两插值得到。


三次贝塞尔曲线的结构.jpg
三次贝塞尔曲线演示动画.gif

我们自定义自己的动画曲线(三次方贝塞尔曲线)时,里面包含 4 个点的信息,其中第一个和最后一个点的坐标是 (0, 0) 和 (1, 1),我们还需要提供中间两个点的坐标。原来自定义动画曲线要填入 4 个数字的原因是这样啊,豁然开朗。


另外,你可以用网址 cubic-bezier 快速定制自己的动画曲线。


cubic-bezier.jpg

Ease / Easing


现实生活中极少存在线性匀速运动的场景,汽车启动、停下、自由落体运动等等都包含加速、减速,人的脑子里已经潜移默化地习惯了这种加速减速地运动。动画也是一种运动,设计动画的时候应该遵循现实世界的物理模型,让动画看起来更加自然,符合直觉。


md.gif


缓动 Ease,表示缓慢地移动(缓动),在 CSS 过渡动画里面,我们可以选择动画的缓动(Easing)类型,其中一些关键字有:



  • linear

  • ease-in

  • ease-out

  • ease-in-out


在经典动画中,开始阶段缓慢,然后加速的动作称为 "slow in";开始阶段运动较快,然后减速的动作称为 "slow out"。网络上面分别叫 "ease in" 和 "ease out",这里的 in/out 可以理解成一个动画里的一开始(start)或者最后(end)


slow in (ease in)

ease-in-details.jpg

比较适合出场动画,因为开始阶段比较慢,容易让人注意到哪个元素要开始移动,然后加速飞到视线之外。


好比你送朋友,看到朋友上了车,车子缓缓启动,然后加速驶去。


slow out (ease out)

ease-out-details.jpg

比较适合进场动画,因为结束阶段比较缓慢,能让人清楚看到是哪个元素飞了进来。


就像你站在公交车站,看到一辆公交车远远飞速驶来,减速停下。


ease in out

那 ease in out 又是啥呢?ease 是缓和的意思,而 in/out 前面说过可以看作是一次动画里面的开始或结束阶段。ease in out 自然就代表:在一次动画里的开始阶段和结束阶段,动作都是缓和的,仅中间阶段是加速的,能够将用户注意力集中在过渡的末端。这也是 Material Design 的标准缓动,由于现实世界中的物体不会立即开始或停止移动,这种缓动类型可以让动画更有质感。


ease-in-out-details.jpg

这种动画曲线比较适合转换动画,也就是说一个元素运动过程中,没有涉及入场与离场,它始终位于屏幕内,只是由一种形态变换为另一种形态。


fab_anim.gif

Jetpack Compose 里面,表示动画速度曲线的接口是 Easing,Compose 提供了 4 中常见的速度曲线:


/**
* Elements that begin and end at rest use this standard easing. They speed up quickly
* and slow down gradually, in order to emphasize the end of the transition.
*
* Standard easing puts subtle attention at the end of an animation, by giving more
* time to deceleration than acceleration. It is the most common form of easing.
*
* This is equivalent to the Android `FastOutSlowInInterpolator`
*/

val FastOutSlowInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)

/**
* Incoming elements are animated using deceleration easing, which starts a transition
* at peak velocity (the fastest point of an element’s movement) and ends at rest.
*
* This is equivalent to the Android `LinearOutSlowInInterpolator`
*/

val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)

/**
* Elements exiting a screen use acceleration easing, where they start at rest and
* end at peak velocity.
*
* This is equivalent to the Android `FastOutLinearInInterpolator`
*/

val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1.0f)

/**
* It returns fraction unmodified. This is useful as a default value for
* cases where a [Easing] is required but no actual easing is desired.
*/

val LinearEasing: Easing = Easing { fraction -> fraction }

LinearEasing 是匀速运动,最好理解了,另外 3 个和前面提到的 CSS 里的 ease-in-out、ease-out、ease-in 其实都是对应的



  • ease-in-out => FastOutSlowIn

  • ease-out => LinearOutSlowIn

  • ease-in => FastOutLinearIn


Compose Easing.jpg


...真是想不明白 Compose 官方这个命名是从哪个角度理解的


作者:bqliang
来源:juejin.cn/post/7311957976968708131
收起阅读 »

Android 图片描边效果

前言 先看下我们阔爱滴海绵宝宝,其原图是一张PNG图片,我们给宝宝加上描边效果,今天我们使用的是图片蒙版技术。 说到蒙版可能很多人想起PS抠图软件,Android上也一样,同一个大树上可能会长出两种果实,但果实的根基是一样的。 什么是蒙版:所谓蒙版是只保留了...
继续阅读 »

前言


先看下我们阔爱滴海绵宝宝,其原图是一张PNG图片,我们给宝宝加上描边效果,今天我们使用的是图片蒙版技术。


fire_78.gif


说到蒙版可能很多人想起PS抠图软件,Android上也一样,同一个大树上可能会长出两种果实,但果实的根基是一样的。


什么是蒙版:所谓蒙版是只保留了alpha通道的一种二维正交投影,简单的说就是你躺在地上,太阳光直射下来,背后的那片就是你的蒙版。因此,它既不存在三维特征,也不存在色彩特征,只有alpha特征。那只有alpha通道的图片是什么颜色,这块没有具体了解过,但是理论上取决于默认填充色,在Android上最终是白色的,其他平台暂时还没了解。


提取蒙版


Android上提取蒙版比想象的容易,按照以往的思路,我们是要进行图片扫描这里,其实就是把所有颜色的red、green、blue都排除掉,只保留alpha,相当于缩小了通道数,排除采样和缩小图片,当然这个工作量是很大的,尤其是超高清图片。


企业微信20231210-120604@2x.png


Android 上提取蒙版,只需要把原图绘制到alpha通道的Bitmap上


bms = decodeBitmap(R.mipmap.mm_07);
bmm = Bitmap.createBitmap(bms.getWidth(), bms.getHeight(), Bitmap.Config.ALPHA_8);
Canvas canvas = new Canvas(bmm);
canvas.drawBitmap(bms, 0, 0, null);

蒙版绘制


蒙版绘制和其他Bitmap绘制是有差异的,ARGB_8888和RGB_565等色彩格式的图片,其本身是具备颜色的,但是蒙版图片不一样,他没有颜色,所以你绘制的时候,bitmap的颜色是你画笔Paint的填充色,突然想到可以做一个人体扫描的动画效果或者人体热力图。


canvas.drawBitmap(bmm, x, y, paint);

扩大蒙版(影子)


要让蒙版比比原图大,理论上是需要等比例放大蒙版在平移,还有一种方式是进行偏移绘制,我们这里使用偏移绘制。当然,这里取一定360,保证尽可能每个方向都有偏移,这是看到的外国人的算法。至于step>0 但是也要控制粒度,太小可能绘制次数太多,太大可能有些边缘做不到偏移。


for (int i = 0; i < 360; i += step) {
float x = width * (float) Math.cos(Math.toRadians(i));
float y = width * (float) Math.sin(Math.toRadians(i));
canvas.drawBitmap(bmm, x, y, paint);
}

闪烁效果


我们价格颜色闪烁的效果,其实很简单,也不是本篇重要的部份,其实就是在色彩中间插入透明色,然后定时闪烁。


int index = -1;
int max = 15;
int[] colors = new int[max];
final int[] highlightColors = {0xfff00000,0,0xffff9922,0,0xff00ff00,0};

public void shake() {
index = 0;
for (int i = 0; i < max; i+=2) {
colors[i] = highlightColors[i % highlightColors.length];
}
postInvalidate();
}

总结


本篇到这里就结束了,希望利用蒙版+偏移做出更多东西。


全部代码


public class ViewHighLight extends View {
final Bitmap bms; //source 原图
final Bitmap bmm; //mask 蒙版
final Paint paint;
final int width = 4;
final int step = 15; // 1...45
int index = -1;
int max = 15;
int[] colors = new int[max];
final int[] highlightColors = {0xfff00000,0,0xffff9922,0,0xff00ff00,0};
public ViewHighLight(Context context) {
super(context);
bms = decodeBitmap(R.mipmap.mm_07);
bmm = Bitmap.createBitmap(bms.getWidth(), bms.getHeight(), Bitmap.Config.ALPHA_8);
Canvas canvas = new Canvas(bmm);
canvas.drawBitmap(bms, 0, 0, null);
paint = new Paint(Paint.ANTI_ALIAS_FLAG);
}

private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// draw blur shadow

for (int i = 0; i < 360; i += step) {
float x = width * (float) Math.cos(Math.toRadians(i));
float y = width * (float) Math.sin(Math.toRadians(i));
canvas.drawBitmap(bmm, x, y, paint);
}
canvas.drawBitmap(bms, 0, 0, null);

if(index == -1){
return;
}
index++;
if(index > max +1){
return;
}
if(index >= max){
paint.setColor(Color.TRANSPARENT);
}else{
paint.setColor(colors[index]);
}
postInvalidateDelayed(200);
}


public void shake() {
index = 0;
for (int i = 0; i < max; i+=2) {
colors[i] = highlightColors[i % highlightColors.length];
}
postInvalidate();
}
}

作者:时光少年
来源:juejin.cn/post/7310786575213920306
收起阅读 »

【内存泄漏】图解 Android 内存泄漏

内存泄漏简介 关于内存泄露的定义,想必大家已经烂熟于心了,简单一点的说,就是程序占用了不再使用的内存空间。 那在 Android 里边,怎样的内存空间是程序不再使用的内存空间呢? 这就不得不提到内存泄漏相关的检测了,拿 LeakCanary 的检测举例,销毁的...
继续阅读 »

内存泄漏简介


关于内存泄露的定义,想必大家已经烂熟于心了,简单一点的说,就是程序占用了不再使用的内存空间


那在 Android 里边,怎样的内存空间是程序不再使用的内存空间呢?


这就不得不提到内存泄漏相关的检测了,拿 LeakCanary 的检测举例,销毁的 ActivityFragment 、fragment ViewViewModel 如果没有被回收,当前应用被判定为发生了内存泄漏。LeakCanary 会生成引用链相关的日志信息。


有了引用链的日志信息,我们就可以开开心心的解决内存泄漏问题了。但是除了查看引用链还有更好的解决方式吗?答案是有的,那就是通过画图来解决,会更加的直观形象~


一个简单的例子


如下是一个 Handler 发生内存泄露的例子:


class MainActivity : ComponentActivity() {

private val handler = LeakHandler(Looper.getMainLooper())

override fun onCreate(savedInstanceState: Bundle?) {
// other code

// 发送了一个 100s 的延迟消息
handler.sendMessageDelayed(Message.obtain(), 100_000L)
}

private fun doLog() {
Log.d(TAG, "doLog")
}

private inner class LeakHandler(looper: Looper): Handler(looper) {
override fun handleMessage(msg: Message) {
doLog()
}
}
}

因为 LeakHandler 是一个内部类,持有了外部类 MainActivity 的引用。


其在如下场景会发生内存泄漏:onCreate 执行之后发送了一个 100s 的延迟消息,在 100s 以内旋转屏幕,MainActivity 进行了重建,上一次的 MainActivity 还被 LeakHandler 持有无法释放,导致内存泄露的产生。


引用链图示


如下是执行完 onCreate() 方法之后的引用链图示:


memory_leak_1.png



简单说明一下引用链 0 的位置,这里为了简化,直接使用 GCRoot 代替了,实际上存在这样的引用关系:GCRoot → ActivityThread → Handler → MessageQueue → Message。简单了解一下即可,不太清楚的话也不会影响接下来的问题解决。
同时,为了简单明了,文中还简化了一些相关但是不重要的引用链关系,比如 HandlerMessageQueue 的引用。



100s 以内旋转屏幕之后,引用链图示变成这样了:


memory_leak_2.png


之前的 Activity 被释放,引用链 4 被切断了。我们可以很清晰的看到,由于 LeakHandlerMainActivity 的强引用(引用链2),LeakHandler 间接被 GCRoot 节点强引用,导致 MainActivity 没办法释放。



MainActivity 指的是旋转屏幕之前的 Activity,不是旋转屏幕之后新建的



那么很显然,接下来我们就需要对引用链 0、1 或 2 进行一些操作了,这样才可以让 MainActivity 得到释放。


解决方案


方案一:


onDestroy() 的时候调用 removeCallbacksAndMessages(null) ,该方法会进行两步操作:移除该 Handler 发送的所有消息,并将 Message 回收到 MessagePool


override fun onDestroy() {
super.onDestroy()
handler.removeCallbacksAndMessages(null)
}

此时,在图示上的表现,就是移除了引用链 0 和 1。如此 MainActivity 就可以正常回收了。


memory_leak_3.png


方案二:


使用弱引用 + 静态内部类的方式,我们同样也可以解决这个内存泄漏问题,想必大家已经非常熟悉了。



这里再简单说明一下弱引用 + 静态内部类的原理:
弱引用的回收机制:在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只被弱引用引用的对象,不管当前内存空间足够与否,都会回收它的内存。
静态内部类:静态内部类不会持有外部类的引用,也就不会有 LeakHandler 直接引用 MainActivity 的情况出现



代码实现上,只需要传入 MainActivity 的弱引用给 NoLeakHandler 即可:


private val handler = NoLeakHandler(WeakReference(this), Looper.getMainLooper())

private class NoLeakHandler(
private val activity: WeakReference<MainActivity>,
looper: Looper
): Handler(looper) {
override fun handleMessage(msg: Message) {
activity.get()?.doLog()
}
}

下图中,2.1 表示的是 NoLeakHandlerWeakReference 的强引用,NoLeakHandler 通过 WeakReference 间接引用到了 MainActivity。我们可以很清楚的看到,在旋转屏幕之后,MainActivity 此时只被一个弱引用引用了(引用链 2.2,使用虚线表示),是可以正常被回收的。


memory_leak_4.png


另一个简单的例子


再来一个静态类持有 Activity 的例子,如下是关键代码:


object LeakStaticObject {
val activityCollection: MutableList<Activity> = mutableListOf()
}

class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
// other code

activityCollection.add(this)
}
}

正常运行的情况下,存在如下的引用关系:


memory_leak_5.png


在旋转屏幕之后,会发生内存泄漏,因为之前的 MainActivity 还被 GCRoot 节点(LeakStaticObject)引用着。那要怎么解决呢?相信大家已经比较清楚了,要么切断引用链 0,要么将引用链 0 替换成一个弱引用。由于比较简单,这里就不再单独画图说明了。


总结


本文介绍了一种使用画图来解决常见内存泄露的方法,会比直接查看引用链更加清晰具体。同时,其相比于一些归纳常见内存泄漏的方法,会更加的通用,很大程度上摆脱了对内存泄漏场景的强行记忆。


通过画图,找到引用路径之后,在引用链的某个节点上进行操作,切断强引用或者将强引用替换成弱引用,以此来解决问题。


总的来说,对于常见的内存泄漏场景,我们都可以通过画图来解决,本文为了介绍简便,使用了比较简单常见的例子,实际上,遇到复杂的内存泄漏,也可以通过画图的方式来解决。当然,熟练之后,省略画图的操作,也是可以的。


REFERENCE


wikipedia 内存泄漏


Excalidraw — Collaborative whiteboarding made easy


How LeakCanary works - LeakCanary


理解Java的强引用、软引用、弱引用和虚引用 - 掘金


作者:很好奇
来源:juejin.cn/post/7313242069099872306
收起阅读 »

Android 放大镜窥视效果

前言 放大镜效果是一种常用的局部图片观察效果,其本质原理依然是将原图片放大之后,经过范围裁剪然后会知道指定区域的一种效果。实际上放大效果有2种常见的效果,比如在一些购物网站,鼠标移动到的位置被放大,然后展示在侧边区域,这两者代码几乎一样,主要区别如下: 侧边...
继续阅读 »

前言


放大镜效果是一种常用的局部图片观察效果,其本质原理依然是将原图片放大之后,经过范围裁剪然后会知道指定区域的一种效果。实际上放大效果有2种常见的效果,比如在一些购物网站,鼠标移动到的位置被放大,然后展示在侧边区域,这两者代码几乎一样,主要区别如下:



  • 侧边区域观测要移动Shader或者在指定位置裁剪图像

  • 本文效果是移动区域,但是为了保证图片能尽可能对齐,需要将放大的图片向左上角偏移。


本文和上一篇《手电筒照亮效果》一样,如果没看过的先看上一篇,方便你理解本篇,因为同样的原理不会在这篇重新提及或者过多提及,都是局部区域效果实现。


效果预览


滑动放大效果


fire_62.gif


窥视效果


fire_63.gif


方法镜滑动放大实现方法


使用Shader作为载体


首先要做的是将图片放大,放大之后,我们可以利用Path裁剪图片或者Shader向裁剪区域绘制,这里我们依然使用Shader,毕竟优点很多,这里我们主要要实现2个目的。



  • Shader载入Bitmap,放大1.2倍

  • Shader向左上角偏移,对齐图片中心


      if (shader == null) {
float ratio = 1.2f;
scaledBitmap = Bitmap.createScaledBitmap(mBitmap, (int) (mBitmap.getWidth() * ratio), (int) (mBitmap.getHeight() * ratio), true);
shader = new BitmapShader(scaledBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 做下偏移
matrix.setTranslate(-(scaledBitmap.getWidth() - mBitmap.getWidth())/2f ,-(scaledBitmap.getHeight() - mBitmap.getHeight())/2f);
shader.setLocalMatrix(matrix);
}

事件处理


其实处理事件有很多简便的方法,但是首先得拦截事件,Android种拦截事件的方法很多,clickable就是其中之一


setClickable(true); //触发hotspot

拦截按压移动事件,这里我们使用 HotSpot 机制,其实就是触点,西方人命名习惯使用HotSpot,通过下面就能处理事件,连onTouchEvent我们都不用搭理。


  @Override
public void dispatchDrawableHotspotChanged(float x, float y) {
super.dispatchDrawableHotspotChanged(x, y);
this.x = x;
this.y = y;
postInvalidate();
}

@Override
protected void dispatchSetPressed(boolean pressed) {
super.dispatchSetPressed(pressed);
postInvalidate();
}

裁剪Canvas区域为原图区域


为什么要裁剪Canvas区域内,主要是因为你的图片并不一定能完全填充整个View,但是你使用的TileMode肯定是CLAMP,这会使得放大镜中图像的边缘拉长,现象很奇怪,反正你可以去掉试试。另外说一下,Android中似乎新增加了一种TileMode,不过还没来得及试一下。


   int save = canvas.save();
canvas.clipRect(0,0,mBitmap.getWidth(),mBitmap.getHeight());
canvas.restoreToCount(save);

绘制核心逻辑


在核心逻辑中,我们有一步要绘制区域填充颜色,主要原因是非透明区域的绘制会导致出现透视效果。


    int save = canvas.save();
canvas.clipRect(0,0,mBitmap.getWidth(),mBitmap.getHeight());
//绘制原图
canvas.drawBitmap(mBitmap, 0, 0, null);
//区域用填充颜色,防止出现区域透视,上面的区域能看见下面的区域
mCommonPaint.setColor(Color.WHITE);
canvas.drawCircle( x , y,width/4f,mCommonPaint);
//绘制放大效果
mCommonPaint.setShader(shader);
canvas.drawCircle( x , y,width/4f,mCommonPaint);
mCommonPaint.setShader(null);

canvas.restoreToCount(save);

放大镜窥视效果


其实两者代码没有多大区别,滑动放大效果主要是移动镜子,而窥视效果镜子不动,使用移动图片的方式实现。


位置计算 & 绘制


固定镜子中心在右下角


//放大平移时需要偏移的距离
float offsetX = -(scaledBitmap.getWidth() - mBitmap.getWidth()) / 2f;
float offsetY = -(scaledBitmap.getHeight() - mBitmap.getHeight())/2f;
//窥视镜圆心
float mirrorCenterX = mBitmap.getWidth() - width / 4f;
float mirrorCenterY = mBitmap.getHeight() - width/4f;

图像平移距离


(mirrorCenterX - x) 
(mirrorCenterY - y)

矩阵变换,平移事件点位置图像到右下角圆的中心


//(mirrorCenterX - x) ,(mirrorCenterY-y) 是把当前中心点的图像平移到圆心哪里
matrix.setTranslate( offsetX + (mirrorCenterX - x) , offsetY + (mirrorCenterY-y));
shader.setLocalMatrix(matrix);

绘制镜子



int save = canvas.save();
canvas.clipRect(0,0,mBitmap.getWidth(),mBitmap.getHeight());
canvas.drawBitmap(mBitmap, 0, 0, null);
mCommonPaint.setColor(Color.DKGRAY);
canvas.drawCircle(mirrorCenterX , mirrorCenterY,width/4f,mCommonPaint);
mCommonPaint.setShader(shader);
canvas.drawCircle( mirrorCenterX , mirrorCenterY,width/4f,mCommonPaint);
mCommonPaint.setShader(null);

canvas.restoreToCount(save);

总结


本篇和之前的很多篇文章一样,都是实现Canvas图片绘制,很复杂的效果我们没有涉及到,但是在这些文章中,都会有各种各样的问题和思考。总之,我们要善于利用矩阵和设计思想,绘制我们的想象。


全部代码


按照惯例,提供全部代码


滑动放大代码


public class ScaleBigView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private Bitmap mBitmap;
private Shader shader = null;
private Matrix matrix = new Matrix();
private Bitmap scaledBitmap;

public ScaleBigView(Context context) {
this(context, null);
}

public ScaleBigView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ScaleBigView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
setClickable(true); //触发hotspot
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mCommonPaint.setFilterBitmap(true);
mCommonPaint.setDither(true);
mCommonPaint.setStrokeWidth(dp2px(20));
mBitmap = decodeBitmap(R.mipmap.mm_012);

}

private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}
private float x;
private float y;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (shader == null) {
float ratio = 1.2f;
scaledBitmap = Bitmap.createScaledBitmap(mBitmap, (int) (mBitmap.getWidth() * ratio), (int) (mBitmap.getHeight() * ratio), true);
shader = new BitmapShader(scaledBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
matrix.setTranslate(-(scaledBitmap.getWidth() - mBitmap.getWidth())/2f ,-(scaledBitmap.getHeight() - mBitmap.getHeight())/2f);
shader.setLocalMatrix(matrix);
}


int save = canvas.save();
canvas.clipRect(0,0,mBitmap.getWidth(),mBitmap.getHeight());
//绘制原图
canvas.drawBitmap(mBitmap, 0, 0, null);
//区域用填充颜色,防止出现区域透视,上面的区域能看见下面的区域
mCommonPaint.setColor(Color.WHITE);
canvas.drawCircle( x , y,width/4f,mCommonPaint);
//绘制放大效果
mCommonPaint.setShader(shader);
canvas.drawCircle( x , y,width/4f,mCommonPaint);
mCommonPaint.setShader(null);

canvas.restoreToCount(save);
}

@Override
public void dispatchDrawableHotspotChanged(float x, float y) {
super.dispatchDrawableHotspotChanged(x, y);
this.x = x;
this.y = y;
postInvalidate();
}

@Override
protected void dispatchSetPressed(boolean pressed) {
super.dispatchSetPressed(pressed);
postInvalidate();
}

public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

}

窥视镜效果


public class ScaleBigView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private Bitmap mBitmap;
private Shader shader = null;
private Matrix matrix = new Matrix();
private Bitmap scaledBitmap;

public ScaleBigView(Context context) {
this(context, null);
}

public ScaleBigView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ScaleBigView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
setClickable(true); //触发hotspot
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mCommonPaint.setFilterBitmap(true);
mCommonPaint.setDither(true);
mCommonPaint.setStrokeWidth(dp2px(20));
mBitmap = decodeBitmap(R.mipmap.mm_012);

}

private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}
private float x;
private float y;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (shader == null) {
float ratio = 1.2f;
scaledBitmap = Bitmap.createScaledBitmap(mBitmap, (int) (mBitmap.getWidth() * ratio), (int) (mBitmap.getHeight() * ratio), true);
shader = new BitmapShader(scaledBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);

}
//放大平移
float offsetX = -(scaledBitmap.getWidth() - mBitmap.getWidth()) / 2f;
float offsetY = -(scaledBitmap.getHeight() - mBitmap.getHeight())/2f;

//窥视镜圆心
float mirrorCenterX = mBitmap.getWidth() - width / 4f;
float mirrorCenterY = mBitmap.getHeight() - width/4f;

//(mirrorCenterX - x) ,(mirrorCenterY-y) 是把当前中心点的图像平移到圆心哪里
matrix.setTranslate( offsetX + (mirrorCenterX - x) , offsetY + (mirrorCenterY-y));
shader.setLocalMatrix(matrix);

int save = canvas.save();
canvas.clipRect(0,0,mBitmap.getWidth(),mBitmap.getHeight());
canvas.drawBitmap(mBitmap, 0, 0, null);
mCommonPaint.setColor(Color.DKGRAY);
canvas.drawCircle(mirrorCenterX , mirrorCenterY,width/4f,mCommonPaint);
mCommonPaint.setShader(shader);
canvas.drawCircle( mirrorCenterX , mirrorCenterY,width/4f,mCommonPaint);
mCommonPaint.setShader(null);

canvas.restoreToCount(save);
}

@Override
public void dispatchDrawableHotspotChanged(float x, float y) {
super.dispatchDrawableHotspotChanged(x, y);
this.x = x;
this.y = y;
postInvalidate();
}

@Override
protected void dispatchSetPressed(boolean pressed) {
super.dispatchSetPressed(pressed);
postInvalidate();
}

public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

}

作者:时光少年
来源:juejin.cn/post/7310124656996302874
收起阅读 »

Android 手电筒照亮效果

前言 经常在掘金博客上看到使用前端技术实现的手电筒照亮效果,但是搜了一下android相关的,发现少得很,实际上这种效果在Android上实现会更简单,当然,条条大路通罗马,有很多技术手段去实现这种效果,今天我们选择一种相对比较好的方法来实现。 实现方法梳理 ...
继续阅读 »

前言


经常在掘金博客上看到使用前端技术实现的手电筒照亮效果,但是搜了一下android相关的,发现少得很,实际上这种效果在Android上实现会更简单,当然,条条大路通罗马,有很多技术手段去实现这种效果,今天我们选择一种相对比较好的方法来实现。


实现方法梳理



  • 第一种方法就是利用Path路径进行 Clip Outline,然后绘制不同的渐变效果即可,这种方法其实很适合蒙版切图,不过也能用于实现这种特效。

  • 第二种方法是利用Xfermode 进行中间图层镂空。

  • 第三种方法就是Shader,效率高且无锯齿。


效果


fire_61.gif


实现原理


其实本篇的核心就是Shader了,这次我们也用RadialGradient来实现,本篇几乎没有任何难度,关键技术难点就是Shader 的移动,其实最经典的效果是Facebook实现的光影文案,本质上时Matrix + Shader.setLocalMatrix 实现。


155007_4C1U_2256215.gif


Matrix涉及一些数学问题,Matrix初始化本身就是单位矩阵,几乎每个操作都是乘以另一个矩阵,属于线性代数的基本知识,难度其实并不高。


matrix.setTranslation(1,2) 可以看作,矩阵的乘法无非是行乘列,繁琐事繁琐,但是很容易理解



1,0,0, 1,0,1,
0,1,0, X 0,1,2,
0,0,1 0,0,1


我们来看看经典的facebook 出品代码


public class GradientShaderTextView extends TextView {

private LinearGradient mLinearGradient;
private Matrix mGradientMatrix;
private Paint mPaint;
private int mViewWidth = 0;
private int mTranslate = 0;

private boolean mAnimating = true;
private int delta = 15;
public GradientShaderTextView(Context ctx)
{
this(ctx,null);
}

public GradientShaderTextView(Context context, AttributeSet attrs) {
super(context, attrs);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (mViewWidth == 0) {
mViewWidth = getMeasuredWidth();
if (mViewWidth > 0) {
mPaint = getPaint();
String text = getText().toString();
// float textWidth = mPaint.measureText(text);
int size;
if(text.length()>0)
{
size = mViewWidth*2/text.length();
}else{
size = mViewWidth;
}
mLinearGradient = new LinearGradient(-size, 0, 0, 0,
new int[] { 0x33ffffff, 0xffffffff, 0x33ffffff },
new float[] { 0, 0.5f, 1 }, Shader.TileMode.CLAMP); //边缘融合
mPaint.setShader(mLinearGradient);
mGradientMatrix = new Matrix();
}
}
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

int length = Math.max(length(), 1);
if (mAnimating && mGradientMatrix != null) {
float mTextWidth = getPaint().measureText(getText().toString());
mTranslate += delta;
if (mTranslate > mTextWidth+1 || mTranslate<1) {
delta = -delta;
}
mGradientMatrix.setTranslate(mTranslate, 0); //自动平移矩阵
mLinearGradient.setLocalMatrix(mGradientMatrix);
postInvalidateDelayed(30);
}
}

}

本文案例


本文要实现的效果其实也是一样的方法,只不过不是自动移动,而是添加了触摸事件,同时加了放大缩小效果。


坑点


Shader 不支持矩阵Scale,本身打算利用Scale缩放光圈,但事与愿违,不仅不支持,连动都动不了了,因此,本文采用了两种Shader,按压时使用较大半径的Shader,手放开时使用默认的Shader。


知识点


canvas.drawPaint(mCommonPaint);

这个绘制并不是告诉你可以这么绘制,而是想说,设置了Shader之后,这样调用,Shader半径之外的颜色时Shader最后一个颜色值,我们最后一个颜色值时黑色,那就是黑色,我们改成白色当然也是白色,下图是改成白色之后的效果,周围都是白色


企业微信20231207-230353@2x.png


关键代码段


super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
//大光圈shader
if (radialGradientLarge == null) {
radialGradientLarge = new RadialGradient(0, 0,
dp2px(100),
new int[]{Color.TRANSPARENT, 0x01ffffff, 0x33ffffff, 0x66000000, 0x77000000, 0xff000000},
new float[]{0.1f, 0.3f, 0.5f, 0.7f, 0.9f, 0.95f},
Shader.TileMode.CLAMP);
}
//默认光圈shader
if (radialGradientNormal == null) {
radialGradientNormal = new RadialGradient(0, 0,
dp2px(50),
new int[]{Color.TRANSPARENT, 0x01ffffff, 0x33ffffff, 0x66000000, 0x77000000, 0xff000000},
new float[]{0.1f, 0.3f, 0.5f, 0.7f, 0.9f, 0.95f},
Shader.TileMode.CLAMP);
}

//绘制地图
canvas.drawBitmap(mBitmap, 0, 0, null);

//移动shader中心点
matrix.setTranslate(x, y);
//设置到矩阵
radialGradientLarge.setLocalMatrix(matrix);
radialGradientNormal.setLocalMatrix(matrix);
if(isPressed()) {
//按压时
mCommonPaint.setShader(radialGradientLarge);
}else{
//松开时
mCommonPaint.setShader(radialGradientNormal);
}
//直接用画笔绘制,那么周围的颜色是Shader 最后的颜色
canvas.drawPaint(mCommonPaint);

好了,我们的效果基本实现了。


总结


本篇到这里就截止了,我们今天掌握的知识点是Shader相关的:



  • Shader 矩阵不能Scale

  • 设置完Shader 的画笔外围填充色为Ridial Shader最后的颜色

  • Canvas 可以直接drawPaint

  • Shader.setLocalMatrix是移动Shader中心点的方法


代码


按照惯例,给出全部代码


public class LightsView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private Bitmap mBitmap;
private RadialGradient radialGradientLarge = null;
private RadialGradient radialGradientNormal = null;
private float x;
private float y;
private boolean isPress = false;
private Matrix matrix = new Matrix();
public LightsView(Context context) {
this(context, null);
}

public LightsView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public LightsView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
setClickable(true); //触发hotspot
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mCommonPaint.setFilterBitmap(true);
mCommonPaint.setDither(true);
mBitmap = decodeBitmap(R.mipmap.mm_06);

}

private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (radialGradientLarge == null) {
radialGradientLarge = new RadialGradient(0, 0,
dp2px(100),
new int[]{Color.TRANSPARENT, 0x01ffffff, 0x33ffffff, 0x66000000, 0x77000000, 0xff000000},
new float[]{0.1f, 0.3f, 0.5f, 0.7f, 0.9f, 0.95f},
Shader.TileMode.CLAMP);
}
if (radialGradientNormal == null) {
radialGradientNormal = new RadialGradient(0, 0,
dp2px(50),
new int[]{Color.TRANSPARENT, 0x01ffffff, 0x33ffffff, 0x66000000, 0x77000000, 0xff000000},
new float[]{0.1f, 0.3f, 0.5f, 0.7f, 0.9f, 0.95f},
Shader.TileMode.CLAMP);
}

canvas.drawBitmap(mBitmap, 0, 0, null);

matrix.setTranslate(x, y);
radialGradientLarge.setLocalMatrix(matrix);
radialGradientNormal.setLocalMatrix(matrix);
if(isPressed()) {
mCommonPaint.setShader(radialGradientLarge);
}else{
mCommonPaint.setShader(radialGradientNormal);
}
canvas.drawPaint(mCommonPaint);
}

@Override
public void dispatchDrawableHotspotChanged(float x, float y) {
super.dispatchDrawableHotspotChanged(x, y);
this.x = x;
this.y = y;
postInvalidate();
}

@Override
protected void dispatchSetPressed(boolean pressed) {
super.dispatchSetPressed(pressed);
postInvalidate();
}

public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

}

作者:时光少年
来源:juejin.cn/post/7309687967064817716
收起阅读 »

转转的Flutter实践之路

前言 跨端技术一直是移动端开发领域的热门话题,Flutter 作为一种领先的移动跨端技术之一,凭借其快速的渲染引擎、丰富的UI组件库和强大的开发工具,成为了开发人员的首选之一。 从 Flutter 诞生之初,我们就一直关注着它的发展,Flutter 早期版本变...
继续阅读 »

前言


跨端技术一直是移动端开发领域的热门话题,Flutter 作为一种领先的移动跨端技术之一,凭借其快速的渲染引擎、丰富的UI组件库和强大的开发工具,成为了开发人员的首选之一。


从 Flutter 诞生之初,我们就一直关注着它的发展,Flutter 早期版本变更较为频繁,并且经常伴随着 Breaking Change,另外可用的三方插件较少且不稳定。直到2019年,Flutter 的热度暴涨,国内不少团队陆续把 Flutter 引入到了生产环境使用,社区也涌现出不少优秀的开源项目,我们也决定在这个时候做一些技术上的尝试。


经过这几年在 Flutter 技术上的不断学习、探索和积累,Flutter 已经成为了客户端技术体系中的重要组成部分。


回顾整个过程,我们大致经历了这么几个阶段:可行性验证、基建一期建设、小范围试验、基建二期建设、大范围推广、前端生态的探索,下文将分别对每个阶段展开进行介绍。


可行性验证


其实在这之前我们已经做过了一些调研,但许多结论都是来源于网上的一些文章或者其它团队的实践,这些结论是否靠谱是否真实还有待商榷,另外,网上的文章大都千篇一律,要么使劲吹捧,要么使劲贬低,要得出相对客观的结论还是得需要我们自己通过实践才能得出。


目标


我们确定了以下几个维度,用来评估 Flutter 是否值得我们进一步投入:



  • 开发效率

  • UI一致性

  • 性能体验

  • 学习成本

  • 发展趋势


由于前期对 Flutter 的熟练度不高,基础设施也还没有搭建起来,所以在开发效率上,我们期望的 Flutter 的开发耗时能保持在原生开发耗时的 1.5 倍以内,不然虽然实现了跨端,但是需求的开发周期反而被拉长了,这样得不偿失。在UI一致性上,我们期望同一份代码在两端的表现要基本达到一致,不需要额外的适配成本。在性能方面,尽量保证崩溃、卡顿、内存、帧率这些指标在可控范围内。


方案


我们希望用较小的代价完成上述维度的评估,所以在试验期间的架构及基础设施方面我们做的比较简单。


测试目标


当时我们正在做一个叫切克的 App,用户量级比较小,工程架构也相对简单一些,正好可以用来做一些技术方面的探索和验证。


我们选择的是切克的商品详情页,用 Flutter 技术实现了一个一模一样的商详,按1:1的流量分配给 Native 和 Flutter。


项目架构


由于我们的工程不是一个全新的项目,所以采用的是 Native 与 Flutter 混合开发的方式,Native 主工程只依赖 Flutter 产物即可,同时也尽量避免对原有工程的影响。


关于混合页面栈的问题,我们没有额外处理,因为暂时只测试一个页面,不会涉及到多页面混合栈的问题,所以暂时先忽略。


构建流程


为了降低验证成本,我们没有对接现有的 Native 的持续集成流程,而是直接在本地构建 Flutter 产物,然后上传到远程仓库。


结论


经过一段时间的线上验证,我对 Flutter 技术基本有了一个比较全面的了解:


在开发效率上由于基础库和基建的缺失,在处理 Flutter 业务跟 Native 业务的交互时需要更多的适配成本,包括像页面跳转、埋点上报、接口请求、图片加载等也需要额外的处理,但我们评估随着后续基建的不断完善,这部分的效率是可以逐步得到改善的;而在涉及UI开发方面,得益于热重载等技术,Flutter 的开发效率是要优于原生开发的。整体评估下来,在开发效率方面 Flutter 是符合我们的预期的。


在UI一致性上,除了在状态栏控制和文本在某些情况下需要特殊适配下外,其它控件在两端的表现基本一致。


在性能表现上,Flutter 会额外引入一些崩溃,内存占用也有所上涨,但还在可接受范围内。


Flutter 的学习成本相对还是比较高,毕竟需要单独学习一门语言,另外 Flutter 的渲染原理也跟原生有很多差异,需要转变思维才能更快的适应,此外 Flutter 还提供了众多的 Widget 组件,也需要较长时间学习。


在发展趋势上,Flutter 无疑是当时增长最快的跨端技术之一,社区的活跃程度以及官方的投入都非常高,国内不少团队也都在积极推进 Flutter 技术的发展,Flutter 正处在一个快速的上升期。


整体来说,Flutter 是满足我们团队对跨平台技术的需求的,我们计划在接下来的一段时间投入更多资源,把 Flutter 的基础设施逐渐建立起来。


基建一期建设


基建一期内容主要包括以下几个方面:



  • 工程架构

  • 开发框架

  • 脚本工具

  • 自动化构建


在基建一期完成后,我们的目标是要达到:



  • 基础能力足够支撑普通业务开发

  • 开发效率接近原生开发

  • 开发过程要基本顺畅


工程架构


工程架构指的是原生工程与 Flutter 工程之间的关系,以及 Flutter 工程与 Flutter 工程之间的关系。


原生工程与Flutter工程的关系


我们知道,使用 Flutter 开发通常有两种情况,一种是直接使用 Flutter 开发一个新的App,属于纯 Flutter 开发;一种是在已有的 Native 工程中引入,属于混合开发。我们当然属于后者。


而混合开发又可分为两种:源码集成和产物集成。源码集成需要改变原工程的项目结构,并且需要 Flutter 开发环境才能编译,而产物集成则不需要改动原工程的项目结构,只需把 Flutter 的构建产物当作普通的依赖库引入即可,原有 Native 工程和 Flutter 工程从物理上完全独立。显而易见的我们选择产物集成的方式,引入 Flutter对于原工程以及非 Flutter 开发人员来说,基本上是毫无感知的。


所以原生工程与 Flutter 工程之间的关系如下图所示:


原生工程与Flutter工程之间的关系


Flutter工程之间的关系


根据已有的客户端基建的开发经验,我们将所有 Flutter 工程分为了四层:



  • 壳工程

  • 业务层

  • 公共层

  • 容器层


容器层负责提供 Flutter 的基础运行环境,包括 Flutter 引擎管理、页面栈管理、网络框架、KV存储、数据库访问、埋点框架、Native 与 Flutter 通信通道和其它基础功能。


公共层包含一些通用的开源库、自定义UI组件、部分通用业务等。


业务层包含用户信息、商品、发布等业务组件。


壳工程负责集成各业务组件,最终构建出产物集成到 Native 主工程。


其中业务层、公共层、容器层都是由若干个独立的工程所组成,整体结构如下:


Flutter分层架构


开发框架


开发框架是为了提高开发效率、规范代码结构、减少维护成本等考虑而设计的一套软件框架,包括:基础能力、状态管理、页面栈管理等。


基础能力


开发框架需要提供各种必要的能力,比如:页面跳转、埋点、网络请求、图片加载、数据存储等,为了最大化减少研发成本,我们在底层定义了一套通用的数据交互协议,直接复用了现有的 Native 的各项能力,也使得 Native 的各种状态与 Flutter 侧能够保持统一。


状态管理


相信了解 Flutter 的同学一定知道状态管理,这也是跟 Native 开发区别较大的地方。在开发较为复杂的页面时,状态维护是非常繁琐的,在不引入状态管理框架的情况下,开发效率会受很大影响,后期的维护成本以及业务交接都是很大的问题。


另外,在开发框架设计之初,我们就期望从框架上能够在一定程度上限定代码结构、模块之间的交互方式、状态更新方式等,我们期望的是不同的人写出来的代码在逻辑、结构和风格上都能保持比较统一,即在提高开发效率的同时,也能保证项目后续的可维护性和扩展性,减少不同业务间的交接成本。


基于上述这些需求,在我们对比了多个开源项目后,FishRedux 的整体使用感受正好符合我们的要求。


如下图,两个页面的代码结构基本一致:


收藏详情和个人主页


页面栈管理


在早期版本,Flutter 引擎的实例占用内存较高,为了减少内存消耗,大家普遍采用单实例的模式,而在 Native 和 Flutter 混合开发的场景下就会存在一个问题,就是 Native 有自己的页面栈,而 Flutter 也维护着一套自己的页面栈,如果 Native 页面与 Flutter 页面穿插着打开,在没有特殊处理的情况下,页面栈会发生错乱。在调研了业内的各种开源方案后,我们选择引入 FlutterBoost 用来管理页面混合栈。


脚本工具


为了方便开发同学搭建 Flutter 的开发环境,同时能够管理使用的 Flutter 版本,我们开发了 zflutter 命令行工具,包含以下主要功能:



  • Flutter开发环境安装

  • Flutter版本管理

  • 创建模版工程(主工程、组件工程)

  • 创建模版页面(常规页面、列表页、瀑布流页面)

  • 创建页面模块

  • 组件工程发布

  • 构建Flutter产物

  • 脚本自更新


如图:


zflutter


自动化构建


客户端使用的是自研的 Beetle 平台(集工程管理、分支管理、编译、发布于一体),短时间内要支持上 Flutter 不太现实,基于此,我们先临时自己搭台服务器,通过 gitlab 的 webhook 功能结合 zflutter 工具简单实现了一套自动化构建的服务,待 Beetle 支持 Flutter 组件化开发功能后,再将工作流切回到 Beetle 平台。


小范围试验


在完成基建一期的开发工作后,我们决定通过开发几个实际业务来试验目前的基础设施是否达到既定目标。


我们以不影响主流程、能覆盖常见UI功能、并且能跟 Native 页面做AB测试(主要是方便在出问题时能够切换到 Native 版本)为条件挑选了个人资料页和留言列表页进行了 Flutter 化改造,如下图所示:


个人资料页/留言列表页


这两个页面涵盖了网络请求、图片加载、弹窗、列表、下拉刷新、上拉加载更多、左滑删除、埋点上报、页面跳转等常见功能,足以覆盖日常开发所需的基础能力。


经过完整的开发流程以及一段时间的线上观察,我们得出如下结论:


基础能力


目前已具备的基础能力已经足够支撑普通业务开发(开发过程中补足了一些缺失的能力)。


工作流


整个开发过程在工程依赖管理和分支管理方面的支持还比较缺失,比较依赖人工处理。


开发效率


我们在开发前根据页面功能同时做了纯 Native 开发排期和 Flutter 开发排期,按单人日的成本来对比的话,Flutter 实际开发耗时跟 Native 排期耗时比为 1.25:2,Native 是按照 Android+iOS 两端各一人算的,也就是1.25人/日比2人/日,如果后续对 Flutter 技术熟悉度提升后相信效率还可以进一步提升。


性能体验


线上两个 Flutter 页面的体验效果跟 Native 对比基本感觉不到差别,但是首次进入 Flutter 页面时会有短暂的白屏等待时间,这个是由于 Flutter 环境初始化导致的延迟,后续可以想办法优化。


包体积


在引入 Flutter 之后,转转的安装包体积在两端都分别有所增加:



  • Android增加6.1M

  • iOS增加14M


试验结果基本符合预期,包体积的增量也在我们的可接受范围内,接下来将进行基建二期的建设,补足目前缺失的能力。


基建二期建设


基建二期的内容主要包含以下工作:



  • 配合工程效率组完成 Beetle 对 Flutter 项目的支持

  • 组织客户端内部进行 Flutter 技术培训


Beetle支持Flutter


为了能让大家更清晰的了解 Beetle 的工程管理机制,这里先简单介绍下客户端的工程类型:



  • Native主工程(又分为 Android 和 iOS)

  • Native组件工程(又分为 Android 和 iOS)

  • Flutter主工程

  • Flutter组件工程(即 Flutter 插件工程)


举个例子,当有一个新版本需要开发时,先从 Native 主工程创建一个版本同时创建一个 Release 分支,即版本分支,然后从版本分支根据具体需求创建对应 Native 组件的版本分支,Flutter 主工程此时可看作是一个 Native 组件,比如此时创建了一个 Flutter 主工程的版本分支后,可以进入 Flutter 主工程再根据需要创建对应的 Flutter 组件工程的版本分支。


Beetle 目前已支持 Flutter 工程管理、分支管理、组件依赖管理以及组件的发布、Flutter 产物的构建等,Beetle 的作用贯穿从开发到上线的整个工作流。


Flutter技术培训


为了让大家更快的熟悉 Flutter 开发,我们在客户端内部组织了5次 Flutter 快速入门的系列分享:


Flutter快速入门系列


同时也逐步完善内部文档的建设,包括:FlutterSdk 源码维护策略、Flutter 入门指南、Flutter 混合开发方案、Flutter 与 Native 通信方案、Flutter 开发环境配置、Flutter 组件化工程结构、Flutter 开发与调试、Flutter 开发工作流、ZFlutter 工具使用介绍、Flutter 开发之 Beetle 使用指南等,涵盖了从环境搭建、开发调试到构建发布的整个过程。


大范围推广


在完成基建二期的建设后,整体基础设施已经能够支撑我们常见的业务,开发工作流也基本顺畅,于是我们开始了在内部大范围推广计划。


我们先后改造和新开发了个人主页、我发布的页面、微商详、奇趣数码页等业务,基本涵盖了常见的各种类型的页面和功能,整体开发效率与原生单端开发效率持平,但是在特别复杂的页面的性能表现上,Flutter 的表现相对要差一些。


部分页面如下图所示:


个人主页


微详情页/我发布的/奇趣数码


探索前端生态


在跨端技术领域我们知道 Web 技术是天然支持的,如果能把前端生态引入到 Flutter 中,那么对客户端来说,在业务的支持度上会更上一个台阶,Web 的体验得到提升的同时客户端也具备了动态化,基于此背景我们开始探索 Flutter 在 Web 上的可能性。


技术调研


当时可选的开源方案有:Kraken、MXFlutter、Flutter For Web。


Kraken


Kraken 是一款基于 W3C 标准的高性能渲染引擎。Kraken 底层基于 Flutter 进行渲染,通过其自绘渲染的特性,保证多端一致性。上层基于 W3C 标准实现,拥有非常庞大的前端开发者生态。


Kraken 的最上层是一个基于 W3C 标准而构建的 DOM API,在下层是所依赖的 JS 引擎,通过 C++ 构建一个 Bridge 与 Dart 通信。然后这个 C++ Bridge 把 JS 所调用的一些信息,转发到 Dart 层。Dart 层通过接收这些信息,会去调用 Flutter 所提供的一些渲染能力来进行渲染。


Kraken 是不依赖 Flutter Widget,而是依赖 Flutter Widget 的底层渲染数据结构 —— RenderObject。Kraken 实现了很多 CSS 相关的能力和一些自定义的 RenderObject,直接将生成的 RenderObject 挂载在 Flutter RenderView 上来进行渲染,通过这样的方式能够做到非常高效的渲染性能。


MXFlutter


MXFlutter 是一套使用 TypeScript/JavaScript 来开发 Flutter 应用的框架。


MXFlutter 把 Flutter 的渲染逻辑中的三棵树(即:WidgetTree、Element、RenderObject )中的第一棵(即:WidgetTree),放到 JavaScript 中生成。用 JavaScript 完整实现了 Flutter 控件层封装,实现了轻量的响应式 UI 框架,支撑JS WidgetTree 的 build逻辑,build 过程生成的UI描述, 通过Flutter 层的 UI 引擎转换成真正的 Flutter 控件显示出来。


Flutter For Web


Flutter 在 Web 平台上以浏览器的标准 API 重新实现了引擎。目前有两种在 Web 上呈现内容的选项:HTML 和 WebGL。



  • 在 HTML 模式下,Flutter 使用 HTML、CSS、Canvas 和 SVG 进行渲染。

  • 在 WebGL 模式下,Flutter 使用了一个编译为 WebAssembly 的 Skia 版本,名为 CanvasKit。


HTML 模式提供了最佳的代码大小,CanvasKit 则提供了浏览器图形堆栈渲染的最快途径,并为原生平台的内容提供了更高的图形保真度。


结论


我们对以上方案从接入成本、渲染性能、包体积、开发生态、学习成本等多维度进行了对比:



  • 接入成本:Kraken ≈ MXFlutter ≈ Flutter For Web

  • 渲染性能:Kraken > MXFlutter > Flutter For Web

  • 包体积增量:Flutter For Web < Kraken < MXFlutter

  • 开发生态:Kraken ≈ MXFlutter > Flutter For Web

  • 学习成本:Flutter For Web < Kraken ≈ MXFlutter


最终选择了 Kraken 作为我们的首选方案。


上线验证


为了使 Kraken 顺利接入转转App,我们做了以下几个方面的工作:



  • 升级 FlutterSdk 到最新版,满足接入 Kraken 的基础条件

  • 统一客户端容器接口,使得 Kraken 容器能够完美继承 Web 容器的能力

  • 自己维护 Kraken 源码,及时修复官方来不及修复的问题,方便增加转转特有的扩展能力

  • 制定 Kraken 容器与 Web 容器的降级机制

  • 兼容 HTML 加载,保持跟 Web 容器一致的加载方式

  • 添加监控埋点,量化指标,指导后续优化方向

  • 选择一个简单 Web 页并协助前端同学适配


上线后,我们对页面的各项指标进行了对比,使用 Kraken 容器加载比使用 WebView 加载,在首屏加载耗时的指标上平均增加了281毫秒,原因为:当前版本的 Kraken 容器不支持直接加载 HTML,且只能加载单个 JsBundle,导致加载效率比 WebView 差。


通过跟前端同学沟通,从开发效率上来看,Kraken 工程的开发周期会比实现同样需求的普通 Web 工程增加1.5到2倍的时间,主要原因是受到 CSS 样式、Api 差异,无法使用现有UI组件,另外 Kraken 的调试工具目前还不够完善,使用浏览器调试后还须在客户端容器中调试,整体下来导致开发 Kraken 工程会比开发普通Web工程耗费更多时间。


再次验证


由于之前选择的 Web 页面太过简单,不具备代表性,所以我们重新选定了“附近的人”页面做为改造目标,再次验证 Kraken 在实际开发过程中的效率及性能体验。页面如图所示:


附近的人


最终因为部分问题得不到解决,并且整体性能较差,导致页面没能成功上线。


存在的问题包括但不限于下面列举的一些:



  • 表现不一致问题

    1. CSS 定位、布局表现与浏览器表现不一致

    2. 部分 API 表现与浏览器不一致(getBoundingClientRect等)

    3. iOS,Android系统表现不一致



  • 重大 Bug

    1. 页面初始化渲染完成,动态修改元素样式,DOM不重新渲染

    2. 滑动监听计算导致 APP 崩溃



  • 调试成本高

    1. 不支持 vue-router,单项目单路由

    2. 不支持热更新,npm run build 预览

    3. 不支持 sourceMap,无法定位源代码

    4. 真机调试只支持 element 和 network;dom 和 element 无法互相选中;无法动态修改 dom 结构,无法直接修改样式.......

    5. 页面白屏,假死



  • 安全性问题

    1. 无浏览器中的“同源策略”限制



  • 兼容性

    1. npm 包不兼容等




通过这一系列的探索和尝试,我们了解到了 Kraken 目前还存在许多不足,如果继续应用会带来高额的开发调试以及维护成本,所以暂时停止了在 Kraken 方向上的投入,但我们仍然在这个方向上保持着关注。


结尾


目前转转在Flutter方向上的实践和探索只是一个起点,我们意识到仍然有很多工作需要去做。我们坚信Flutter作为一项领先的跨端技术,将为转转业务的发展带来巨大的潜力和机会。我们将持续努力,加强技术建设,不断完善实践经验,推动Flutter在转转的应用和发展,为用户提供更好的产品和体验。


作者:转转技术团队
来源:juejin.cn/post/7304831120709697588
收起阅读 »

Android 绘制你最爱的马赛克

前言 我们之前写过《Android 实现LED 展示效果》,在这篇文章中,我们使用了图像分块(或者是分片)的算法,这样做的目的是降低像素扫描的时间复杂度,并且也利于均色采样。其实图像分块是很常见的图像动效处理手段,一般主要用于场景变换,这项技术也和图像光栅化类...
继续阅读 »

前言


我们之前写过《Android 实现LED 展示效果》,在这篇文章中,我们使用了图像分块(或者是分片)的算法,这样做的目的是降低像素扫描的时间复杂度,并且也利于均色采样。其实图像分块是很常见的图像动效处理手段,一般主要用于场景变换,这项技术也和图像光栅化类似。


什么是光栅化


光栅化渲染(Rasterized Rendering)直译过来是栅格化渲染。寻找图像中被几何图形占据的所有像素的过程称为栅格化,因此对象顺序渲染(Object-order rendering)也可以称为栅格化渲染。


Qu-es-la-rasterizacin-y-cual-es-su-diferencia-con-el-Ray-Tracing.jpg


我们今天的所要用到的技术也是栅格化和像素采样技术。


LED原理简述


马赛克是一种图像编辑技术,广泛应用于隐私保护和涂鸦渲染,很多手机系统自带了这种效果,那如何才能实现这种技术呢?


了解过我之前的文章的知道,我们制作LED有几个特征



  • 每个LED单元要么亮要么不亮

  • 每个LED单元只有一种颜色

  • 每个LED单元和其他LED单元存在一定的间距

  • 所有LED的单元成网格排列

  • 每个LED单元大小一致


以上相当于顶点坐标信息,我们拿到网格的位置,就能拿到LED整个区域的片段,知道这个区域的片段我们就可以修改其像素。


着色采样:


即便是每个矩形区域,也有很多像素点,如果每个矩形区域每个像素都要进行均色计算的话,那10x10的也要100此,因此为了更快的效率,需要对LED 范围内的像素点采样,求出颜色均值,均值色就是LED最终展示的颜色。


避坑——修改像素


上一篇我们知道,通过Bitmap.setPixel方法修改像素效率是极低的,我曾经写过一篇通过修改像素生成圆形图片的文章,在那篇文章里我们看到,像素本身也是有size的,导致最终的圆形图片存在大量锯齿,主要原因是通过这种方式没法做到双线性过滤(图片放大之后会对边缘优化),还有另一个问题,就是效率极差。
总结一下修改像素的问题:



  • 无法抗锯齿

  • 效率低


避坑——透明色


像素中往往存在 color为0或者alpha通道为0的情况,甚至有的区域因为采样原因导致清晰度急剧下降,甚至出现了透明区域噪点,这些问题主要来自于alpha 通道引发的颜色稀释问题,因此在采样时一定要规避这两种情况,至于会不会失真?答案是如果采用alpha失真只会更严重。


清晰度问题


同样,清晰度也容易受到这olor为0或者alpha通道为0的情况情况干扰,除了这两种就是采样区域的大小了,理论上采样网格密度越密,清晰度越高,越接近原始图片,因此一定要权衡,太清晰不就很原图一样了么,还制作什么LED呢?


马赛克原理


实际上,马赛克原理和LED展示方式类似,为什么这么说呢?从特征来看,几乎一样,马赛克和LED效果只在两部分存在区别



  1. 马赛克网格之间不存在间距

  2. 马赛克采样次数比LED要少


马赛克没有LED间距很好理解,至于次数少的好处第一肯定是效率高,其次是采样太多容易接近原色,而LED是要有一定程度接近原色。


技术实现


本篇我们邀请一位可爱的猫猫,老师们太耀眼的图片就算了,不利于大家阅读。


ic_cat.png


我们接下来的任务是把给猫脸打上马赛克,了解完这项技术实现后,其实你不仅可以给猫脸打马赛克,自行涂鸦,指哪儿打哪儿。


基本信息


private Bitmap mBitmap; //猫图
private float blockWidth = 30; //30x30的像素快
private RectF blockRect = new RectF(); //猫头区域
private RectF gridRect = new RectF(); //网格区域

Canvas 包裹Bitmap


主要方便绘制和内存回收


static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
this.bitmap = bitmap;
}
public Bitmap getBitmap() {
return bitmap;
}
}


定位猫头位置


由于时间关系,我没有做TOUCH事件处理,就写了这个猫头区域


/ 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
blockRect.set(bitmapCanvas.bitmap.getWidth() - 400, 0, bitmapCanvas.bitmap.getWidth() - 100, 300);

网格分割


//根据平分猫头矩形区域
int col = (int) (blockRect.width() / blockWidth);
int row = (int) (blockRect.height() / blockWidth);

网格定位


float startX = blockRect.left;
float startY = blockRect.top;
for (int i = 0; i < row * col; i++) {
int x = i % col;
int y = (i / col);
gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x * blockWidth + blockWidth, startY + y * blockWidth + blockWidth);

}

采样和着色


float startX = blockRect.left;
float startY = blockRect.top;
for (int i = 0; i < row * col; i++) {
int x = i % col;
int y = (i / col);
gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x * blockWidth + blockWidth, startY + y * blockWidth + blockWidth);
//采样
int sampleColor = mBitmap.getPixel((int) gridRect.centerX(), (int) gridRect.centerY());
mCommonPaint.setColor(sampleColor);
//着色,我们这里不修改像素,而是drawRect,避免性能问题和锯齿问题
bitmapCanvas.drawRect(gridRect, mCommonPaint);
}

渲染到View上


canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);

效果预览


fire_56.gif


避坑点


网格区间不易过小,和LED一样,越小清晰度越高,就会失去了处理的意义。


全部代码


public class MosaicView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private RectF mainRect = new RectF();

private BitmapCanvas bitmapCanvas; //Canvas 封装的
private Bitmap mBitmap; //猫图
private float blockWidth = 30; //30x30的像素快
private RectF blockRect = new RectF(); //猫头区域
private RectF gridRect = new RectF(); //网格区域
private boolean showMask = false;

public MosaicView(Context context) {
this(context, null);
}
public MosaicView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MosaicView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mBitmap = decodeBitmap(R.mipmap.ic_cat);

}
private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
mBitmap = decodeBitmap(R.mipmap.ic_cat);

}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (bitmapCanvas != null && bitmapCanvas.bitmap != null && !bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas.bitmap.recycle();
}
bitmapCanvas = null;

}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (bitmapCanvas == null || bitmapCanvas.bitmap == null) {
bitmapCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth(), mBitmap.getHeight(), Bitmap.Config.ARGB_8888));
} else {
bitmapCanvas.bitmap.eraseColor(Color.TRANSPARENT);
}
float radius = Math.min(width / 2f, height / 2f);

//关闭双线性过滤
// int flags = mCommonPaint.getFlags();
// mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
// mCommonPaint.setFilterBitmap(false);

int save = bitmapCanvas.save();
bitmapCanvas.drawBitmap(mBitmap, 0, 0, mCommonPaint);


// 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
blockRect.set(bitmapCanvas.bitmap.getWidth() - 560, 10, bitmapCanvas.bitmap.getWidth() - 100, 410);

if(showMask) {
//根据平分猫头矩形区域
int col = (int) (blockRect.width() / blockWidth);
int row = (int) (blockRect.height() / blockWidth);

float startX = blockRect.left;
float startY = blockRect.top;

for (int i = 0; i < row * col; i++) {
int x = i % col;
int y = (i / col);
gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x * blockWidth + blockWidth, startY + y * blockWidth + blockWidth);
//采样
int sampleColor = mBitmap.getPixel((int) gridRect.centerX(), (int) gridRect.centerY());
mCommonPaint.setColor(sampleColor);
//着色,我们这里不修改像素,而是drawRect,避免性能问题和锯齿问题
bitmapCanvas.drawRect(gridRect, mCommonPaint);
}
}else{
Paint.Style style = mCommonPaint.getStyle();
mCommonPaint.setStyle(Paint.Style.STROKE);
mCommonPaint.setColor(Color.MAGENTA);
mCommonPaint.setStrokeWidth(8);
bitmapCanvas.drawRect(blockRect, mCommonPaint);
mCommonPaint.setStyle(style);

}

bitmapCanvas.restoreToCount(save);
int saveCount = canvas.save();
canvas.translate(width / 2f, height / 2f);
mainRect.set(-radius, -radius, radius, radius);
canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);
canvas.restoreToCount(saveCount);

}

public void openMask() {
showMask = true;
postInvalidate();
}

public void closeMask() {
showMask = false;
postInvalidate();

}

static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
//继承在Canvas的绘制是软绘制,因此理论上可以绘制出阴影
this.bitmap = bitmap;
}
public Bitmap getBitmap() {
return bitmap;
}
}
}

总结


实际上还有另一种方法,我们绘制图片时关闭双线性过滤


//关闭双线性过滤
// int flags = mCommonPaint.getFlags();
// mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
// mCommonPaint.setFilterBitmap(false);

然后将图片放到很大,这个时候你的图片就会产生一定的网格区域,截图然后进行一系列矩阵转换,最后把图贴到原处就出现了马赛克,但是这个有个问题,超高像素的图片得先缩小,然后再放大,显然处理步骤比较多。


下图是先缩小20倍然后画到原来大小的效果

企业微信20231205-221500@2x.png


实现代码


本来不打算放代码的,想想还是放上吧


public class BitmapMosaicView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private RectF mainRect = new RectF();
private BitmapCanvas bitmapCanvas; //Canvas 封装的
private BitmapCanvas srcThumbCanvas; //Canvas 封装的
private Bitmap mBitmap; //猫图
private RectF blockRect = new RectF(); //猫头区域

public BitmapMosaicView(Context context) {
this(context, null);
}
public BitmapMosaicView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public BitmapMosaicView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mBitmap = decodeBitmap(R.mipmap.ic_cat);

}
private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (bitmapCanvas != null && bitmapCanvas.bitmap != null && !bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas.bitmap.recycle();
}
if (srcThumbCanvas != null && srcThumbCanvas.bitmap != null && !srcThumbCanvas.bitmap.isRecycled()) {
srcThumbCanvas.bitmap.recycle();
}
bitmapCanvas = null;

}

private Rect srcRectF = new Rect();
private Rect dstRectF = new Rect();

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (bitmapCanvas == null || bitmapCanvas.bitmap == null) {
bitmapCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth(), mBitmap.getHeight(), Bitmap.Config.ARGB_8888));
} else {
bitmapCanvas.bitmap.eraseColor(Color.TRANSPARENT);
}
if (srcThumbCanvas == null || srcThumbCanvas.bitmap == null) {
srcThumbCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth()/35, mBitmap.getHeight()/35, Bitmap.Config.ARGB_8888));
} else {
srcThumbCanvas.bitmap.eraseColor(Color.TRANSPARENT);
}
float radius = Math.min(width / 2f, height / 2f);

//关闭双线性过滤
int flags = mCommonPaint.getFlags();
mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setFilterBitmap(false);
mCommonPaint.setDither(false);


srcRectF.set(0,0,mBitmap.getWidth(),mBitmap.getHeight());
dstRectF.set(0,0, srcThumbCanvas.bitmap.getWidth(), srcThumbCanvas.bitmap.getHeight());

int save = bitmapCanvas.save();
srcThumbCanvas.drawBitmap(mBitmap, srcRectF, dstRectF, mCommonPaint);

srcRectF.set(dstRectF);
dstRectF.set(0,0,bitmapCanvas.bitmap.getWidth(),bitmapCanvas.bitmap.getHeight());
bitmapCanvas.drawBitmap(srcThumbCanvas.bitmap, srcRectF,dstRectF, mCommonPaint);
// 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
blockRect.set(bitmapCanvas.bitmap.getWidth() - 560, 10, bitmapCanvas.bitmap.getWidth() - 100, 410);
bitmapCanvas.restoreToCount(save);
int saveCount = canvas.save();
canvas.translate(width / 2f, height / 2f);
mainRect.set(-radius, -radius, radius, radius);
canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);
canvas.restoreToCount(saveCount);

}
static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
//继承在Canvas的绘制是软绘制,因此理论上可以绘制出阴影
this.bitmap = bitmap;
}
public Bitmap getBitmap() {
return bitmap;
}
}
}

总结下本文分享技术特点:

  • 网格化
  • 采样
  • canvas着色,不要去修改像素


作者:时光少年
来源:juejin.cn/post/7308925069916225588
收起阅读 »

Android 实现LED 展示效果

一、前言 LED以其卓越的亮度和醒目的文字和图案,已成为车水马龙的城市中充满烟火气息的象征,深层次的是您红灯的闪烁唤醒着人们的娱乐、怀旧、童年的记忆。当然对新时代来说这显然格格不入的,因此这种霓虹灯能存在多久显然还是个问题。 效果预览 二、实现原理 最初的设...
继续阅读 »

一、前言


LED以其卓越的亮度和醒目的文字和图案,已成为车水马龙的城市中充满烟火气息的象征,深层次的是您红灯的闪烁唤醒着人们的娱乐、怀旧、童年的记忆。当然对新时代来说这显然格格不入的,因此这种霓虹灯能存在多久显然还是个问题。


效果预览



二、实现原理


最初的设想是利用BitmapShader  + Shader 实现网格图片,但是最终是失败的,因此绘制出的网格不是纯色。


为什么是需要网格纯色呢 ,主要原因是LED等作为单独的实体,单个LED智能发出一种光,电视也是一样的道理,微小的发光单元不可能同时发出多种光源,这也是LED显示屏的制作原理。至于我们的自定义View,本身是细腻的屏幕上发出的,如果一个LED发出多种光,就会显得很假。但事实上,在绘制View时一个区域可能会出现多种颜色,如何平衡这种颜色也是个问题,优化方式当然是增加采样点;但是采样点多了也会带来新的副作用,一是性能问题,而是过多的全透明和alpha为0的情况,因为这种情况会过度稀释真是的颜色,造成模糊不清的问题,其次和View本身的背景穿透,形成较大范围的噪点,所以绘制过程中一定要控制采样点的数量,其次对alpha为0或者过小的的情况剔除,当然不用担心失真,因为过度的透明人眼会认为是全透明,没有太多意义,我们来做个总结:



  • LED 单元智能发出一种光,因此不适合BitampShader做风格渲染

  • 颜色逼真程度和采样点有关,采样点越多越逼近真色

  • 清晰程度和LED单元大小相关,LED单元越小越清晰

  • 剔除alpha通道过小和颜色值为0的采样点颜色 


三、核心逻辑


生成刷子纹理


     if (brushBitmap == null) {
brushBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
brushCanvas = new Canvas(brushBitmap);
}

for (int i = 0; i < drawers.size(); i++) {
int saveCount = brushCanvas.save();
drawers.get(i).draw(brushCanvas, width, height, mCommonPaint);
brushCanvas.restoreToCount(saveCount);
}

生成网格数据


        float blockWidth = (squareWidth + padding);
int w = width;
int h = height;
int columNum = (int) Math.ceil(w / blockWidth);
int rowNum = (int) Math.ceil(h / blockWidth);

if (gridRects.isEmpty() && squareWidth > 1f) {
//通过rowNum * columNum方式降低时间复杂度
for (int i = 0; i < rowNum * columNum; i++) {

int col = i % columNum;
int row = (i / columNum);

Rect rect = new Rect();
rect.left = (int) (col * blockWidth);
rect.top = (int) (row * blockWidth);
rect.right = (int) (col * blockWidth + squareWidth);
rect.bottom = (int) (row * blockWidth + squareWidth);
//记录网格点
gridRects.add(rect);
}

}

采样绘制


    //这里是重点 ,LED等可以看作一只灯泡,灯泡区域要么全亮,要么全不亮
for (int i = 0; i < gridRects.size(); i++) {
Rect rect = gridRects.get(i);

if (brushBitmap.getWidth() <= rect.right) {
continue;
}
if (brushBitmap.getHeight() <= rect.bottom) {
continue;
}

if (sampleColors == null) {
sampleColors = new int[9];
}

//取7个点采样,纯粹是为了性能考虑,如果想要更准确的颜色,可以多采样几个点

sampleColors[0] = brushBitmap.getPixel(rect.left, rect.top); // left-top
sampleColors[1] = brushBitmap.getPixel(rect.right, rect.top); // right-top
sampleColors[2] = brushBitmap.getPixel(rect.right, rect.bottom); // right-bottom
sampleColors[3] = brushBitmap.getPixel(rect.left, rect.bottom); // left-bottom
sampleColors[4] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 2); //center

sampleColors[5] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 4); //top line
sampleColors[6] = brushBitmap.getPixel(rect.left + rect.width() * 3 / 4, rect.top + rect.height() / 2); //right line
sampleColors[7] = brushBitmap.getPixel(rect.left + rect.width() / 4, rect.top + rect.height() / 2); // left line
sampleColors[8] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() * 3 / 4); // bottom line

int alpha = 0;
int red = 0;
int green = 0;
int blue = 0;
int num = 0;

for (int c : sampleColors) {
if (c == Color.TRANSPARENT) {
//剔除全透明的颜色,必须剔除
continue;
}
int alphaC = Color.alpha(c);
if (alphaC <= 0) {
//剔除alpha为0的颜色,当然可以改大一点,防止降低清晰度
continue; }
alpha += alphaC;
red += Color.red(c);
green += Color.green(c);
blue += Color.blue(c);
num++;
}

if (num < 1) {
continue;
}

//求出平均值
int rectColor = Color.argb(alpha / num, red / num, green / num, blue / num);
if (rectColor != Color.TRANSPARENT) {
mGridPaint.setColor(rectColor);
// canvas.drawRect(rect, mGridPaint); //绘制矩形
canvas.drawCircle(rect.centerX(), rect.centerY(), squareWidth / 2, mGridPaint); //绘制圆
}
}

如果不剔除颜色,那么就会有噪点和清晰度问题



全部代码


public class LedDisplayView extends View {
private final DisplayMetrics mDM;
private TextPaint mGridPaint;
private TextPaint mCommonPaint;
private List<IDrawer> drawers = new ArrayList<>();
private Bitmap brushBitmap = null;
private float padding = 2; //分界线大小
private float squareWidth = 5; //网格大小
private List<Rect> gridRects = new ArrayList<>();
int[] sampleColors = null;
private Canvas brushCanvas = null;

public LedDisplayView(Context context) {
this(context, null);
}

public LedDisplayView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public LedDisplayView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();

}


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}

int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
}


public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
}


@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (brushBitmap != null && !brushBitmap.isRecycled()) {
brushBitmap.recycle();
}
brushBitmap = null;
}


@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

int width = getWidth();
int height = getHeight();
if (width <= padding || height <= padding) {
return;
}

if (brushBitmap == null) {
brushBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
brushCanvas = new Canvas(brushBitmap);
}

for (int i = 0; i < drawers.size(); i++) {
int saveCount = brushCanvas.save();
drawers.get(i).draw(brushCanvas, width, height, mCommonPaint);
brushCanvas.restoreToCount(saveCount);
}


float blockWidth = (squareWidth + padding);
int w = width;
int h = height;
int columNum = (int) Math.ceil(w / blockWidth);
int rowNum = (int) Math.ceil(h / blockWidth);

if (gridRects.isEmpty() && squareWidth > 1f) {
//通过rowNum * columNum方式降低时间复杂度
for (int i = 0; i < rowNum * columNum; i++) {

int col = i % columNum;
int row = (i / columNum);

Rect rect = new Rect();
rect.left = (int) (col * blockWidth);
rect.top = (int) (row * blockWidth);
rect.right = (int) (col * blockWidth + squareWidth);
rect.bottom = (int) (row * blockWidth + squareWidth);
//记录网格点
gridRects.add(rect);
}

}
int color = mGridPaint.getColor();

//这里是重点 ,LED等可以看作一只灯泡,灯泡区域要么全亮,要们全不亮
for (int i = 0; i < gridRects.size(); i++) {
Rect rect = gridRects.get(i);

if (brushBitmap.getWidth() <= rect.right) {
continue;
}
if (brushBitmap.getHeight() <= rect.bottom) {
continue;
}

if (sampleColors == null) {
sampleColors = new int[9];
}

//取7个点采样,纯粹是为了性能考虑,如果想要更准确的颜色,可以多采样几个点

sampleColors[0] = brushBitmap.getPixel(rect.left, rect.top); // left-top
sampleColors[1] = brushBitmap.getPixel(rect.right, rect.top); // right-top
sampleColors[2] = brushBitmap.getPixel(rect.right, rect.bottom); // right-bottom
sampleColors[3] = brushBitmap.getPixel(rect.left, rect.bottom); // left-bottom
sampleColors[4] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 2); //center

sampleColors[5] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 4); //top line
sampleColors[6] = brushBitmap.getPixel(rect.left + rect.width() * 3 / 4, rect.top + rect.height() / 2); //right line
sampleColors[7] = brushBitmap.getPixel(rect.left + rect.width() / 4, rect.top + rect.height() / 2); // left line
sampleColors[8] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() * 3 / 4); // bottom line

int alpha = 0;
int red = 0;
int green = 0;
int blue = 0;
int num = 0;

for (int c : sampleColors) {
if (c == Color.TRANSPARENT) {
//剔除全透明的颜色,必须剔除
continue;
}
int alphaC = Color.alpha(c);
if (alphaC <= 0) {
//剔除alpha为0的颜色,当然可以改大一点,防止降低清晰度
continue;
}
alpha += alphaC;
red += Color.red(c);
green += Color.green(c);
blue += Color.blue(c);
num++;
}

if (num < 1) {
continue;
}

//求出平均值
int rectColor = Color.argb(alpha / num, red / num, green / num, blue / num);
if (rectColor != Color.TRANSPARENT) {
mGridPaint.setColor(rectColor);
// canvas.drawRect(rect, mGridPaint); //绘制矩形
canvas.drawCircle(rect.centerX(), rect.centerY(), squareWidth / 2, mGridPaint); //绘制圆
}
}
mGridPaint.setColor(color);

}


private void initPaint() {
// 实例化画笔并打开抗锯齿
mGridPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mGridPaint.setAntiAlias(true);
mGridPaint.setColor(Color.LTGRAY);
mGridPaint.setStyle(Paint.Style.FILL);
mGridPaint.setStrokeCap(Paint.Cap.ROUND); //否则网格绘制

//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);

}

public void addDrawer(IDrawer drawer) {
if (drawer == null) return;
this.drawers.add(drawer);
gridRects.clear();
postInvalidate();
}

public void removeDrawer(IDrawer drawer) {
if (drawer == null) return;
this.drawers.remove(drawer);
gridRects.clear();
postInvalidate();
}

public void clearDrawer() {
this.drawers.clear();
gridRects.clear();
postInvalidate();
}

public List<IDrawer> getDrawers() {
return new ArrayList<>(drawers);
}

public interface IDrawer {
void draw(Canvas canvas, int width, int height, Paint paint);
}

}

使用方式


       LedDisplayView displayView = findViewById(R.id.ledview);
final BitmapDrawable bitmapDrawable1 = (BitmapDrawable)getResources().getDrawable(R.mipmap.mm_07);
final BitmapDrawable bitmapDrawable2 = (BitmapDrawable)getResources().getDrawable(R.mipmap.mm_08);
ledDisplayView.addDrawer(new LedDisplayView.IDrawer() {

Matrix matrix = new Matrix();
@Override
public void draw(Canvas canvas, int width, int height, Paint paint) {
canvas.translate(width/2,height/2);
matrix.preTranslate(-width/2,-height/4);
Bitmap bitmap1 = bitmapDrawable1.getBitmap();
canvas.drawBitmap(bitmap1,matrix,paint);

matrix.postTranslate(width/2,height/4);
Bitmap bitmap2 = bitmapDrawable2.getBitmap();
canvas.drawBitmap(bitmap2,matrix,paint);
}
});
ledDisplayView.addDrawer(new LedDisplayView.IDrawer() {
@Override
public void draw(Canvas canvas, int width, int height, Paint paint) {
paint.setColor(Color.CYAN);
float textSize = paint.getTextSize();
paint.setTextSize(sp2px(50));
canvas.drawText("你好,L E D", 100, 200, paint);
canvas.drawText("85%", 100, 350, paint);

paint.setColor(Color.YELLOW);
canvas.drawCircle(width*3 / 4, height / 4, 100, paint);

paint.setTextSize(textSize);
}
});

四、总结


这个本质上的核心就是采样,通过采样我们最终实现了纹理贴图,这点类似open gl中的光栅化,将图形分割成小三角形一样,最后着色,理解本篇也能帮助大家理解open gl和led显示原理。


作者:时光少年
来源:juejin.cn/post/7304973928039153705
收起阅读 »

Android 图片分片过渡效果

前言 在之前的文章中,通过LED效果、马赛克效果两篇文章,介绍了分片绘制的效果的方法和原理,通过这两篇文章,相信大家都已经熟悉了分片绘制的思路。其实分片绘制不仅仅能实现LED、马赛克等特殊效果,实际上类似百叶窗、图片对角线锯齿过渡等,很多PPT中存在的特效,基...
继续阅读 »

前言


在之前的文章中,通过LED效果马赛克效果两篇文章,介绍了分片绘制的效果的方法和原理,通过这两篇文章,相信大家都已经熟悉了分片绘制的思路。其实分片绘制不仅仅能实现LED、马赛克等特殊效果,实际上类似百叶窗、图片对角线锯齿过渡等,很多PPT中存在的特效,基本上也是按照这种原理来实现的。


分片可以有很多种意想不到的效,我们再来说一下分片特点:



  • [1] 按一定的距离、大小、角度对区域进行对一张图片或者区域裁剪或者提取区域图像

  • [2] 对提取出来的区域进行一系列变换,如百叶窗、微信摇一摇等

  • [3] 被裁剪的区域可以还原回去


技术前景


其实单纯的分片可以做一些瓦片效果,当然还可以做一些组合效果,下面是一个github开源项目(Camera2DApplication)利用Camera和图片分片实现的效果,这个过程中对一张图片进行分片绘制。


fire_58.gif


代码中的逻辑不是很复杂,本质上就是利用2张图片实现的,我们先来看下代码实现,作者的代码很认真,注释都写了,涉及postTranslate比较难懂的操作我也进行了微调。


/**
* 3d旋转效果
*
* @param canvas
*/

private void drawModeNormal(Canvas canvas) {
//VERTICAL时使用rotateY,HORIZONTAL时使用rotateX
if (orientation == VERTICAL) {
//如果是前进,则画当前图,后退则画上一张图,注释用的是前进情况
matrix.reset();
camera.save();
//旋转角度 0 - -maxDegress
camera.rotateX(-degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片top旋转
matrix.preTranslate(-viewWidth / 2f, 0);
//旋转轴向下平移,则图片也向下平移
matrix.postTranslate(viewWidth / 2f, rotatePivotY);
//如果是前进,则画当前图,后退则画上一张图,因为后退时,这里画的是动画下方出来的图片,而下方的图片是前一张图
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? currentIndex : preIndex), viewWidth, viewHeight),
matrix, mPaint);

//在处理下一张图片
matrix.reset();
camera.save();
//旋转角度 maxDegress - 0
camera.rotateX(maxDegress - degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片bottom旋转
matrix.preTranslate(-viewWidth / 2f, -viewHeight);
//旋转轴向下平移,则图片也向下平移
matrix.postTranslate(viewWidth / 2f, rotatePivotY);
//如果是前进,则画下一张图,后退则画当前图,后退时,这边代码画的是动画上方的图片,上方的图片是当前图片
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? nextIndex : currentIndex), viewWidth, viewHeight),
matrix, mPaint);
} else {
//如果是前进,则画当前图,后退则画上一张图,注释用的是前进情况
matrix.reset();
camera.save();
//旋转角度 0 - maxDegress
camera.rotateY(degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片left旋转
matrix.preTranslate(0, -viewHeight / 2);
//旋转轴向右平移,则图片也向右平移
matrix.postTranslate(rotatePivotX, viewHeight / 2);
//如果是前进,则画当前图,后退则画上一张图,因为后退时,这里画的是动画右方出来的图片,而右方的图片是前一张图
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? currentIndex : preIndex), viewWidth, viewHeight),
matrix, mPaint);

//在处理下一张图片
matrix.reset();
camera.save();
//旋转角度 -maxDegress - 0
camera.rotateY(-maxDegress + degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片right旋转
matrix.preTranslate(-viewWidth, -viewHeight / 2f);
//旋转轴向右平移,则图片也向右平移
matrix.postTranslate(rotatePivotX, viewHeight / 2f);
//如果是前进,则画下一张图,后退则画当前图,后退时,这边代码画的是动画左方的图片,左方的图片是当前图片
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? nextIndex : currentIndex), viewWidth, viewHeight),
matrix, mPaint);
}
}

分片操作


下面是分片操作,这个地方其实可以不用创建Bitmap缓存,创建Path就行,绘制时对Path区域利用Shader贴图即可。


private Bitmap getBitmapScale(int resId, float width, float height) {
if (ImageCache.getInstance().getBitmapFromMemCache(String.valueOf(resId)) != null) {
return ImageCache.getInstance().getBitmapFromMemCache(String.valueOf(resId));
}
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), resId);
//创建分片
Bitmap bitmapDst = Bitmap.createScaledBitmap(bitmap, (int) width, (int) height, false);
bitmap.recycle();

ImageCache.getInstance().addBitmapToMemoryCache(String.valueOf(resId)
, bitmapDst);
return bitmapDst;
}

小试一下


我们这里通过一个简单的Demo,实现一种特效,这次我们利用网格矩阵分片。说到矩阵,很多人面试的时候都会遇到一些算法题,比较幸运的人遇到的是矩阵旋转90度、逆时针打印矩阵、矩阵孤岛问题、从左上角开始进行矩阵元素搜索,运气稍差的会遇到由外到里顺时针打印矩阵和斜对角打印矩阵,后面两种看似简单的问题实际上做起来并不顺手,有点扯远了,我们来看看效果。


fire_59.gif


你没看错,这次遇到了算法问题,我这边用的空间换取时间的方法。


图像分片


将图片分片,计算出网格的列和行


int col = (int) Math.ceil(mBitmaps[index].getWidth() / blockWidth);
int row = (int) Math.ceil(mBitmaps[index].getHeight() / blockWidth);

分片算法


这个算法实际上是每次将列数 +1,然后按对角分割,把符合的区域添加到path中


int x = xPosition;
int y = 0;
while (x >= 0 && y <= row) {
if (x < col && y < row) {
dstRect.set((int) (x * blockWidth), (int) (y * blockWidth), (int) (x * blockWidth + blockWidth), (int) (y * blockWidth + blockWidth));
// bitmapCanvas.drawBitmap(mBitmaps[index], dstRect, dstRect, mCommonPaint);
path.addRect(dstRect, Path.Direction.CCW); //加入网格分片
}
x--;
y++;
}

Path 路径贴图



  • Path过程中我们添加的rect是闭合区域,是可以贴图的,当然,一般有三种方法:

  • Path的贴图一般使用 clipPath对图片裁剪然后贴图,当然还有将对应的图片区域绘制到View上

  • Path 是Rect,按照Rect将图片区域绘制到Rect区域

  • 使用BitmapShader一次性绘制


实际上我们应该尽可能使用Bitmap,因为BitmapShader唯一是不存在锯齿性能比较好的绘制方法。


int save = bitmapCanvas.save();
mCommonPaint.setShader(new BitmapShader(mBitmaps[index], Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
bitmapCanvas.drawPath(path,mCommonPaint);
bitmapCanvas.restoreToCount(save);

其实我们的核心代码到这里就结束了,我们可以看到,分片可以的意义很重要的,当然,借助其他工具也可以实现,不过代码实现的好处是可以编辑和交互,不是所有的动画都可以产生交互。


到此,我们还可以对今天的demo添加一些想象



  • 从中间外扩效果

  • 奇偶行切换效果

  • 国际象棋黑白格子变换效果

  • ......


总结


这是我们的第三篇关于图片分片特效的博客,希望通过一些了的文章,熟悉一些技术,往往看似高大上的效果,其实就是通过普普通通的方法叠加在一起的,当然,让你的技术承载你的想象,才是最重要的。


本篇demo全部代码


实际上代码贴太多很可能没人看,但是依照惯例,我们给出完整代码。


public class TilesView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private RectF mainRect = new RectF();
private BitmapCanvas bitmapCanvas; //Canvas 封装的
private Bitmap[] mBitmaps;
private RectF dstRect = new RectF();
Path path = new Path();
private float blockWidth = 50f;
private int xPosition = -2;
private int index = 0;
private boolean isTicking = false;
public TilesView(Context context) {
this(context, null);
}
public TilesView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public TilesView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mCommonPaint.setFilterBitmap(true);
mCommonPaint.setDither(true);
mBitmaps = new Bitmap[3];
mBitmaps[0] = decodeBitmap(R.mipmap.mm_013);
mBitmaps[1] = decodeBitmap(R.mipmap.mm_014);
mBitmaps[2] = decodeBitmap(R.mipmap.mm_015);
}

private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (bitmapCanvas != null && bitmapCanvas.bitmap != null && !bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas.bitmap.recycle();
}
bitmapCanvas = null;

}


@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (bitmapCanvas == null || bitmapCanvas.bitmap == null || bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmaps[index].getWidth(), mBitmaps[index].getHeight(), Bitmap.Config.ARGB_8888));
}
int nextIndex = (index + 1) % mBitmaps.length;
canvas.drawBitmap(mBitmaps[nextIndex],0,0,mCommonPaint);

int col = (int) Math.ceil(mBitmaps[index].getWidth() / blockWidth);
int row = (int) Math.ceil(mBitmaps[index].getHeight() / blockWidth);
mCommonPaint.setStyle(Paint.Style.FILL);

// path.reset();
// for (int x = 0; x < row; x++) {
// for (int y = 0; y < col; y++) {
// gridRectF.set(x * blockWidth, y * blockWidth, x * blockWidth + blockWidth, y * blockWidth + blockWidth);
// canvas.drawRect(gridRectF, mCommonPaint);
// path.addRect(gridRectF, Path.Direction.CCW);
// }
// }

diagonalEffect(col,row,xPosition,path);
canvas.drawBitmap(bitmapCanvas.bitmap, 0, 0, mCommonPaint);

if (isTicking && xPosition >= 0 && xPosition < col * 2) {
clockTick();
} else if(isTicking){
xPosition = -1;
index = nextIndex;
isTicking = false;
}
}

private void diagonalEffect(int col, int row, int xPosition,Path path) {
int x = xPosition;
int y = 0;
while (x >= 0 && y <= row) {
if (x < col && y < row) {
dstRect.set((int) (x * blockWidth), (int) (y * blockWidth), (int) (x * blockWidth + blockWidth), (int) (y * blockWidth + blockWidth));
// bitmapCanvas.drawBitmap(mBitmaps[index], dstRect, dstRect, mCommonPaint);
path.addRect(dstRect, Path.Direction.CCW); //加入网格分片
}
x--;
y++;
}
int save = bitmapCanvas.save();
mCommonPaint.setShader(new BitmapShader(mBitmaps[index], Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
bitmapCanvas.drawPath(path,mCommonPaint);
bitmapCanvas.restoreToCount(save);

}

public void tick() {
isTicking = true;
xPosition = -1;
path.reset();
clockTick();
}

private void clockTick() {
xPosition += 1;
postInvalidateDelayed(16);
}


public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
}

static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
//继承在Canvas的绘制是软绘制,因此理论上可以绘制出阴影
this.bitmap = bitmap;
}

public Bitmap getBitmap() {
return bitmap;
}
}
}

作者:时光少年
来源:juejin.cn/post/7309329004497354804
收起阅读 »

flutter 响应式观察值并更新UI

响应式编程是一种以对数据随时间变化做出反应为中心的范式。它有助于自动传播更新,从而可确保 UI 和数据保持同步。用 Flutter 术语来说,这意味着只要状态发生变化就会自动触发重建。 Observables Observables是响应式编程的核心。这些数据...
继续阅读 »

响应式编程是一种以对数据随时间变化做出反应为中心的范式。它有助于自动传播更新,从而可确保 UI 和数据保持同步。用 Flutter 术语来说,这意味着只要状态发生变化就会自动触发重建。


Observables


Observables是响应式编程的核心。这些数据源会在数据发生变化时向订阅者发出更新。Dart 的核心可观察类型是 Stream。


当状态发生变化时,可观察对象会通知侦听器。从用户交互到数据获取操作的任何事情都可以触发此操作。这有助于 Flutter 应用程序实时响应用户输入和其他更改。


Flutter 有两种类型:ValueNotifierChangeNotifier,它们是类似 observable 的类型,但不提供任何真正的可组合性计算。


ValueNotifier


Flutter 中的类ValueNotifier在某种意义上是响应式的,因为当值发生变化时它会通知观察者,但您需要手动监听所有值的变化来计算完整的值。


1、监听


  // 初始化
final ValueNotifier<String> fristName = ValueNotifier('Tom');
final ValueNotifier<String> secondName = ValueNotifier('Joy');
late final ValueNotifier<String> fullName;

@override
void initState() {
super.initState();
fullName = ValueNotifier('${fristName.value} ${secondName.value}');

fristName.addListener(_updateFullName);
secondName.addListener(_updateFullName);
}

void _updateFullName() {
fullName.value = '${fristName.value} ${secondName.value}';
}


//更改值得时候
firstName.value = 'Jane'
secondName.value = 'Jane'

2、使用ValueListenableBuilder更新UI


 //通知观察者
ValueListenableBuilder<String>(
valueListenable: fullName,
builder: (context, value, child) => Text(
'${fristName.value} ${secondName.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
),

ChangeNotifier


1、监听


  String _firstName = 'Jane';
String _secondName = 'Doe';

String get firstName => _firstName;
String get secondName => _secondName;
String get fullName => '$_firstName $_secondName';

set firstName(String newName) {
if (newName != _firstName) {
_firstName = newName;
// Triggers rebuild
notifyListeners();
}
}

set secondName(String newSecondName) {
if (newSecondName != _secondName) {
_secondName = newSecondName;
// Triggers rebuild
notifyListeners();
}
}

//更改值得时候
firstName.value = 'Jane'
secondName.value = 'Jane'

2、使用AnimatedBuilder更新UI


//通知观察者
AnimatedBuilder(
animation: fullName,
builder: (context, child) => Text(
fullName,
style: Theme.of(context).textTheme.headlineMedium,
),
),

get


GetX将响应式编程变得非常简单。



  • 您不需要创建 StreamController。

  • 您不需要为每个变量创建一个 StreamBuilder。

  • 你不需要为每个状态创建一个类。

  • 你不需要创造一个终极价值。


使用 Get 的响应式编程就像使用 setState 一样简单。
让我们想象一下,您有一个名称变量,并且希望每次更改它时,所有使用它的小组件都会自动刷新。


1、监听以及更新UI


//这是一个普通的字符串
var name = 'Jonatas Borges';
为了使观察变得更加可观察,你只需要在它的附加上添加“.obs”。
var name = 'Jonatas Borges'.obs;
而在UI中,当你想显示该值并在值变化时更新页面时,只需这样做。
Obx(() => Text("${controller.name}"));

Riverpod


final fristNameProvider = StateProvider<String>((ref) => 'Tom');
final secondNameProvider = StateProvider<String>((ref) => 'Joy');
final fullNameProvider = StateProvider<String>((ref) {
final fristName = ref.watch(fristNameProvider);
final secondName = ref.watch(secondNameProvider);
return '$fristName $secondName';
});

//更改值得时候
ref.read(fristNameProvider.notifier).state =
'Jane'
ref.read(secondName.notifier).state =
'BB'


2、使用ConsumerWidget更新UI


ref.read(surnameProvider) 读取某个值


ref.read(nameProvider.notifier).state 更新某个值的状态


class MyHomePage extends ConsumerWidget {
const MyHomePage({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) =>
Scaffold(
appBar: AppBar(
title: const Text('Riverpod Example'),
),
body: Text(
ref.watch(fullNameProvider),
style: Theme.of(context).textTheme.headlineMedium,
),
);
}


这里Consumer组件是与状态交互所必需的,Consumer有一个非标准build方法,这意味着如果您需要更改状态管理解决方案,您还必须更改组件而不仅仅是状态。


RxDart


RxDart将ReactiveX的强大功能引入Flutter,需要明确的逻辑来组合不同的数据流并对其做出反应。


存储计算值:它不会以有状态的方式直接存储计算值,但它确实提供了有用的运算符(例如distinctUnique)来帮助您最大限度地减少重新计算。


RxDart 库还有一个流行的类型被称为BehaviorSubject。响应式编程试图解决的核心问题是当依赖图中的任何值(依赖项)发生变化时自动触发计算。如果有多个可观察值,并且您需要将它们合并到计算中,Rx 库自动为我们执行此操作并且自动最小化重新计算以提高性能。


该库向 Dart 的现有流添加了功能。它不会重新发明轮子,并使用其他平台上的开发人员熟悉的模式。


1、监听


 final fristName = BehaviorSubject.seeded('Tom');
final secondName = BehaviorSubject.seeded('Joy');

/// 更新值
fristName.add('Jane'),
secondName.add('Jane'),


2、使用StreamBuilder更新UI


 StreamBuilder<String>(
stream: Rx.combineLatest2(
fristName,
secondName,
(fristName, secondName) => '$fristName $secondName',
),
builder: (context, snapshot) => Text(
snapshot.data ?? '',
style: Theme.of(context).textTheme.headlineMedium,
),
),

Signals


Signals以其computed功能介绍了一种创新、优雅的解决方案。它会自动创建反应式计算,当任何依赖值发生变化时,反应式计算就会更新。


1、监听


  final name = signal('Jane');
final surname = signal('Doe');
late final ReadonlySignal<String> fullName =
computed(() => '${name.value} ${surname.value}');
late final void Function() _dispose;

@override
void initState() {
super.initState();
_dispose = effect(() => fullName.value);
}

2、使用watch更新UI


Text(
fullName.watch(context),
style: Theme.of(context).textTheme.headlineMedium,
),

作者:icc_tips
来源:juejin.cn/post/7309131109740724259
收起阅读 »

拥抱华为,困难重重,第一天开始学习 ArkUI,踩坑踩了一天

今天第一天正式开始学习鸿蒙应用开发。 本来想着,作为一个拥有 10 来年工作经验的资深开发,对 React/Vue,React Native,Android 开发都有丰富的实践经验,对 Swift UI 也有所涉猎,在大前端这一块可以说是信心满满,学习 Ark...
继续阅读 »

今天第一天正式开始学习鸿蒙应用开发。


本来想着,作为一个拥有 10 来年工作经验的资深开发,对 React/Vue,React Native,Android 开发都有丰富的实践经验,对 Swift UI 也有所涉猎,在大前端这一块可以说是信心满满,学习 ArkUI 应该信手拈来才对,谁知道学习的第一天,我就发现我太天真了。


HarmonyOS 与 ArkUI 给我了沉痛一击


学习第一天一点都不顺利,上午还算有所收获,下午直接毫无建树,踩在一个坑里出不来,人直接裂开,差点以为自己要创业未半而中道崩殂了。不过好在晚饭后,侥幸解决了下午遇到的坑


最终今天学习的成果如下


scroll.gif


导航栏的4个图标都是用了 lottie 的动画,因为使用了 gif 录制,可能有点感觉不太明显,真机上的感受非常舒适,用户体验极佳


今天已经学习过的内容包括



  • 基础项目结构

  • 基础布局组件

  • 容器布局组件

  • 滚动组件

  • 导航组件

  • ohpm 安装

  • 引入 lottie 动画

  • 属性动画

  • 配置 hot reload

  • 组件状态管理 @state @props @link

  • 组件逻辑表达式

  • 沉浸式状态栏

  • 真机调试


我的开发设备具体情况如下


MacOS M1
HarmonyOS API 9
华为 P40 pro+,已安装 HarmonyOS 4

作为一个把主要精力放在前端的开发者,做个记录分享一下学习体会


01


组件概念


在前端开发中,不管你是用 React 还是使用 Vue,我们只需要掌握一个概念:组件。复杂的组件是由小的组件组成,页面是由组件组成,项目是由组件组成,超大项目也是由组件组成。组件可以组成一切。因此 React/Vue 的学习会相对更简单一些


和 Android 一样,由于 HarmonyOS 有更复杂的应用场景、多端、分屏等,因此在这一块的概念也更多一些,目前我接触到的几个概念包括


Window 一个项目好像可以有多个窗口,由于接触的时间太短了暂时不是很确定,可以创建子窗口,可以管理窗口的相关属性,创建,销毁等


Ability 用户与应用交互的入口点,一个 app 可以有一个或者对个 Ability


page 页面,一个应用可以由多个 page 组成


Component 组件,可以组合成页面


由于目前接触的内容不够全面,因此对这几个概念的理解还不够笃定,只是根据自己以往的开发经验推测大概可能是什么情况,因此介绍得比较简单,但是可以肯定的是理解这些概念是必备的


02


基础布局


虽然 HarmonyOS 目前也支持 web 那一套逻辑开发,不过官方文档已经明确表示未来将会主推 arkUI,因此我个人觉得还是应该把主要学习重心放在 arkUI 上来


arkUI 的布局思路跟 html + css 有很大不同。


html + css 采用的是结构样式分离的方式,再通过 class/id 关联起来。因此,html + css 的布局写起来会简单很多,我们只需要先写结构,然后慢慢补充样式即可


arkUI 并未采用分离思路,而是把样式和结构紧密结合在一起,这样做的好处就是性能更强了,因为底层渲染引擎不用专门写一套逻辑去匹配结构和样式然后重新计算 render-tree,坏处就是...


代码看着有点糟心


比如下面这行代码,表示两段文字


Column() {
Text('一行文字')
.textAlign(TextAlign.Center)
.fontSize(30)
.width('100%')
.backgroundColor('#aabbcc')
Text('二行文字')
.textAlign(TextAlign.Center)
.fontSize(30)
.width('100%')
.backgroundColor('#aabbcc')
}.width('100%')
.height('100%')
.backgroundColor('red')

如果用 html 来表示的话....


<div>
<p>一行文字p>
<p>一行文字p>
div>

当然我期望能找到一种方式去支持属性的继承和复用。目前简单找了一下没找到,希望有吧 ~


由于 html 中 div 足以应付一切,因此许多前端开发者会在思考过程中忽视或者弱化容器组件的存在,反而 arkUI 的学习偏偏要从容器组件开始理解


我觉得这种思路会对解耦思路有更明确的训练。许多前端开发在布局时不去思考解耦问题,我认为这是一个坏处。


arkUI 的布局思路是:先考虑容器,再考虑子元素,并且要把样式特性结合起来一起思考。而不是只先思考结构,再考虑样式应该怎么写。


例如,上面的 GIF 图中, nav 导航区域是由 4 按钮组成。先考虑容器得是一个横向的布局


然后每一个按钮,包括一个图标和一个文字,他们是纵向的布局,于是结构就应该这样写


Row: 横向布局
Column: 竖向布局
Row() {
Column() { Lottie() Text() }
Column() { Lottie() Text() }
Column() { Lottie() Text() }
Column() { Lottie() Text() }
}

按照这个思路去学习,几个容器组件 Row/Column/FLex/Stack/GridContainer/SideBarContainer ... 很快就能掌握


03


引入 lottie


在引入 lottie 的时候遇到了几个坑。


一个是有一篇最容易找到的文章介绍如何在 arkUI 中引入 lottie,结果这篇文章是错误的。 ~ ~,这篇文章是在官方博客里首发,让我走了不少弯路。


image.png


这里面有两个坑,一个坑是 @ohos/lottie-ohos-ets 的好像库不见了。另外一个坑就是文章里指引我用 npm 下载这个库。但是当我用 npm 下载之后,文件会跑到项目中的 node_modules 目录下,不过如何在 arkUI 的项目中引入 node_modules 中的库,我还没找到方法,应该是要在哪里配置一下


最后在 gitee 的三方仓库里,找到了如下三方库


import lottie from '@ohos/lottie';

这里遇到的一个坑就是我的电脑上的环境变量不知道咋回事被改了,导致 ohpm 没了,找了半天才找到原因,又重新安装 ohpm,然后把环境变量改回来



  1. 到官方文档下载对应的工具包

  2. 把工具包放到你想要放的安装目录,然后解压,进去 ohpm/bin 目录,在该目录下执行 init 脚本开始安装


> init


  1. 然后使用如下指令查看当前文件路径


> pwd

然后执行如下指令


// OHPM_HOME 指的是你自己的安装路径
> export OHPM_HOME=/home/xx/Downloads/ohpm
> export PATH=${OHPM_HOME}/bin:${PATH}


  1. 执行如下指令检查是否安装成功


> ohpm -v

@ohos/lottie


使用如下指令下载 lottie


ohpm install @ohos/lottie

然后在 page 中引入


import lottie from '@ohos/lottie'

在类中通过定义私有变量的方式构建上下文


private mrs: RenderingContextSettings = new RenderingContextSettings(true)
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.mrs)

并且用私有变量保存 lottie 数据路径或者内容


private path: string = 'common/lottie/home.json'

然后在 build 中,结合 Canvas 组件绘制


Canvas(this.ctx).onReady(() => {
lottie.loadAnimation({
container: this.ctx,
renderer: 'canvas',
loop: false,
autoplay: true,
path: this.path
})
})

参考文章:@ohos/lottie


04


hot reload


使用 commond + , 调出配置页面,然后通过如下路径找到配置选中 Perform hot reload


Tools -> Actions on Save -> Perform hot reload

image.png


然后在项目运行的入口处,选择 entry -> edit configrations,弹出如下界面,选中 Hot Reload 的 entry,做好与下图中一致的勾选,点击 apply 按钮之后启动项目即可实现 hot reload


image.png


不过呢,hot reload 在调试样式的时候还能勉强用一用,涉及到代码逻辑的更改,往往没什么用,属实是食之无味,弃之可惜


除此之外,也许 Previewer 更适合开发时使用


image.png


05


沉浸式状态栏


沉浸式状态栏是一款体验良好的 app 必备能力。因此我学会了基础知识之后,第一时间就想要研究一下再 HarmonyOS 中如何达到这个目的。


沉浸式状态栏指的就是下图中位置能够做到与页面标题栏,或者页面背景一致的样式。或者简单来说,可以由我们开发者来控制这一块样式。布局进入全屏模式。


image.png


在我们创建入口 Ability 时,可以在生命周期 onWindowStageCreate 中设置全屏模式


onWindowStageCreate(windowStage: window.WindowStage) {
windowStage.getMainWindow(err, mainWindow: window.Window) {
if (err.code) {
return
}
mainWindow.setWindowLayoutFullScreen(true)
}
}

setWindowLayoutFullScreen 是一个异步函数,因此如果你想要修改状态栏样式的话,可以在它的回调里,通过 setWindowSystemBarProperties 去设置


mainWindow.setWindowLayoutFullScreen(true, (err) => {
if (err) { return }
mainWindow.setWindowSystemBarProperties({ statusBarColor: '#FFF' })
})

具体的参数配置,可以在代码中,查看类型声明获悉。


这里有一个巨大的坑,就是在我的开发环境启动的模拟器中 API 9,当你设置了全屏模式之后,布局会发生混乱。真机调试又是正常的。


我刚开始以为是我的代码哪里没搞对,为了解决这个问题花了一个多小时的时间,结果最后才确定是模拟器的布局 bug...


真机调试


真机调试的设置方式主要跟其他 app 开发都一样,把手机设置为开发者模式即可。不过你需要通过如下方式,配置好一个应用签名才可以。因此你首先需要注册成为华为开发者


File -> Project Structure -> Signing Configs -> Sign in

跟着指引在后台创建项目,然后再回到开发者工具这个页面自动生成签名即可


image.png


真机调试有一个巨大无比的坑,那就是 API 9 创建的项目,在老版本的麒麟芯片上巨卡无比。连基本的点击都无法响应。


这就要了命了。如果连真机调试都做不到,那还拥抱个啥啊?


研究了很久,找到了几个解决这个问题的方法


1、换新机,只要你的手机不是华为被制裁之前的麒麟芯片,都不会存在这个问题


2、创建项目时,选择 API 8


3、在开发者选项的配置中,选择 显示面(surface)更新,虽然不卡了,不过闪瞎了我的狗眼


4、等明年 HarmonyOS next 出来之后再来学,官方说,API 10 将会解决这个问题


上面的解决办法或多或少都有一些坑点。我选择了一种方式可以很好的解决这个问题


那就是:投屏


如果你有一台华为电脑,这个投屏会非常简单。不过由于我是 mac M1,因此我选择的投屏方案是 scrcpy


使用 brew 安装


> brew install scrcpy

然后继续安装


> brew install android-platform-tools

启动


> scrcpy

启动之前确保只有一台手机已经通过 USB 连接到电脑,并允许电脑调试手机就可以成功投屏。在投屏中操作手机,就变得非常流畅了


不过目前我通过这种方式投屏之后,运行起来的项目经常闪退,具体是什么原因我还没找到,只能先忍了


总之就是坑是一个接一个 ~ ~


06


总结


一整天的学习,整体感受下就如标题说的那样:拥抱华为,困难重重。 还好我电脑性能强悍,要是内存少一点,又是虚拟机,又是投屏的,搞不好内存都不够用,可以预想,其他开发者还会遇到比我更多的坑 ~ ~


image.png


个人感觉华为相关的官方文档写得不是很友好,比较混乱,找资料很困难。反而在官方上把一堆莫名其妙的教学视频放在了最重要的位置,我不是很明白,到底是官方文档,还是视频教程网站 ~ ~


官方文档里还涉及了 FA mode 到 Stage mode 的更新,因此通过搜索引擎经常找到 FA mode 的相关内容,可是 FA mode 又是被弃用的,因为这个问题也给我的学习带来了不少的麻烦。由于遇到的坑太多了,以致于我到现在尝试点什么新东西都紧张兮兮的,生怕又是坑


总的来说,自学困难重重,扛得住坑的,才能成为最后的赢家,红利不是那么好吃的


作者:这波能反杀
来源:juejin.cn/post/7309734518586523657
收起阅读 »

Android 实现自动滚动布局

前言 在平时的开发中,有时会碰到这样的场景,设计上布局的内容会比较紧凑,导致部分机型上某些布局中的内容显示不完全,或者在数据内容多的情况下,单行无法显示所有内容。这时如果要进行处理,无非就那几种方式:换行、折叠、缩小、截取内容、布局自动滚动等。而这里可以简单介...
继续阅读 »

前言


在平时的开发中,有时会碰到这样的场景,设计上布局的内容会比较紧凑,导致部分机型上某些布局中的内容显示不完全,或者在数据内容多的情况下,单行无法显示所有内容。这时如果要进行处理,无非就那几种方式:换行、折叠、缩小、截取内容、布局自动滚动等。而这里可以简单介绍下布局自动滚动的一种实现方式。


1. 布局自动滚动的思路


要实现滚动的效果,在Android中无非两种,吸附式的滚动或者顺滑式的滚动,吸附式就是类似viewpager换页的效果,如果需求上是要实现这样的效果,可以使用viewpager进行实现,这个类型比较简单,这里就不过多介绍。另一种是顺滑的,非常丝滑的缓慢移动的那种,要实现这种效果,可以使用RecyclerView或者ScrollView来实现。我这里主要使用ScrollView会简单点。


滑动的控件找到了,那要如何实现丝滑的自动滚动呢?我们都知道ScrollView能用scrollTo和scrollBy去让它滚动到某个位置,但如何去实现丝滑的效果?


这里就用到了属性动画, 我之前的文章也提到过属性动画的强大 juejin.cn/post/714419…


所以我这边会使用ScrollView和属性动画来实现这个效果


2. 最终效果


可以写个Demo来看看最终的效果


799d8a54-ed7d-4137-8a00-ad6bed2e2499.gif


这就是一个横向自动滚动的效果。


3. 代码实现


先写个接口定义自动滚动的行为


interface Autoscroll {

// 开始自动滚动
fun autoStart()

// 停止自动滚动
fun autoStop()

}

然后自定义一个View继承ScrollView,方便阅读,在代码中加了注释


// 自定义View继承HorizontalScrollView,我这里演示横向滚动的,纵向可以使用ScrollView
class HorizontalAutoscrollLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : HorizontalScrollView(context, attrs, defStyleAttr), Autoscroll {

// 一些流程上的变量,可以自己去定义,变量多的情况也可以使用builder模式
var isLoop = true // 滚动到底后,是否循环滚动
var loopDelay = 1000L // 滚动的时间
var duration = 1000L // 每一次滚动的间隔时间

private var offset: Int = 0
val loopHandler = Handler(Looper.getMainLooper())
var isAutoStart = false

private var animator: ValueAnimator? = null

override fun autoStart() {
// 需要计算滚动距离所以要把计算得代码写在post里面,等绘制完才拿得到宽度
post {
var childView = getChildAt(0)
childView?.let {
offset = it.measuredWidth - width
}

// 判断能否滑动,这里只判断了一个方向,如果想做两个方向的话,多加一个变量就行
if (canScrollHorizontally(1)) {
animator = ValueAnimator.ofInt(0, offset)
.setDuration(duration)
// 属性动画去缓慢改变scrollview的滚动位置,抽象上也可以说改变scrollview的属性
animator?.addUpdateListener {
val currentValue = it.animatedValue as Int
scrollTo(currentValue, 0)
}
animator?.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {

}

override fun onAnimationEnd(animation: Animator) {
// 动画结束后判断是否要重复播放
if (isLoop) {
loopHandler?.postDelayed({
if (isAutoStart) {
scrollTo(0, 0)
autoStart()
}
}, loopDelay)
}
}

override fun onAnimationCancel(animation: Animator) {

}

override fun onAnimationRepeat(animation: Animator) {

}

})
animator?.start()
isAutoStart = true
}

}
}

// 动画取消
override fun autoStop() {
animator?.cancel()
isAutoStart = false
loopHandler.removeCallbacksAndMessages(null)
}

}

能看到实现这个功能,写的代码不会很多。其中主要需要注意一些点:

(1)属性动画要熟,我这里只是简单的效果,但如果你对属性动画能熟练使用的话,你还可以做到加速、减速等效果

(2)页面关闭的时候要调用autoStop去关闭动画

(3)这里是用scrollTo去实现滚动的效果,scrollBy也可以,但是写法就不是这样了


从代码可以看出没什么难点,都是比较基础的知识,比较重要的知识就是属性动画,熟练的话做这种效果的上限就很高。其他的像这里为什么用post,为什么用scrollTo,这些就是比较基础的知识,就不扩展讲了。


最后看看使用的地方,先是Demo的布局


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:gravity="center_vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">

<com.kylin.testproject.HorizontalAutoscrollLayout
android:id="@+id/auto_scroll"
android:layout_width="150dp"
android:layout_height="wrap_content">

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text="小日本"
/>

<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:scaleType="fitXY"
android:src="@drawable/a"
/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text="排放核废水污染海洋"
/>

<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:scaleType="fitXY"
android:src="@drawable/b"
/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text=",必遭天谴!!"
/>

</LinearLayout>

</com.kylin.testproject.HorizontalAutoscrollLayout>

</LinearLayout>


然后在开始播放自动滚动(注意页面关闭的时候要手动停止)


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main)

val autoScroll: HorizontalAutoscrollLayout = findViewById(R.id.auto_scroll)
autoScroll.duration = 3000
autoScroll.loopDelay = 2000
autoScroll.autoStart()
}

4. 总结


代码比较简单,而且都加上了注释,所以没有其他要说明的。

前段时间太忙,所以这几个月都没时间写文章。想了一下,这个还是要坚持,如果有时间的话抽出点时间一天写一点,得保持一个常更新的状态。


作者:流浪汉kylin
来源:juejin.cn/post/7309392585679110194
收起阅读 »

【Flutter技术】如何识别一个应用是原生框架开发还是Flutter开发?

前言 根据Google官方的统计,截至2023年5月,已有超过100万个应用程序使用Flutter发布到数亿台设备上。国内许多知名公司也广泛的使用了Flutter技术,例如,腾讯、字节、阿里等等。Flutter也成为了最受欢迎的跨平台技术之一。 当前,我们手机...
继续阅读 »

前言


根据Google官方的统计,截至2023年5月,已有超过100万个应用程序使用Flutter发布到数亿台设备上。国内许多知名公司也广泛的使用了Flutter技术,例如,腾讯、字节、阿里等等。Flutter也成为了最受欢迎的跨平台技术之一。


当前,我们手机上以及各大应用市场有大量的应用采用了Flutter跨平台技术框架,例如,微信、微博、闲鱼等等。由于Flutter框架出色的性能表现,可能不被大家所感知,接下来,跟大家分享几个鉴别一个APP是否使用了Flutter开发的方法。


1 - 双指滚动


一个比较方便、且非常快速的方法就是打开一个可滚动的页面(可以用闲鱼的商品详情页面测试),用双指或者三指滑动,如果滚动的速度加快,是用单指滚动的两倍或者三倍,那么这个页面基本可以确定是用Flutter开发的。大家可以打开手机上应用尝试一下,例如闲鱼商品的详情页面。


这方法的原理是源于Flutter的一个祖传BUG ---> [#11884] Scrolling with two fingers scrolls twice as fast,在Android和iOS平台都是如此的变现,因此可以用来检查应用是否使用了Flutter开发。


当笔者看到这个BUG之后,惊掉了下巴(2017年的ISSUE,2023年才被修复!),于是尝试修复了这个BUG ---> [#136708] Introduce multi-touch drag strategies for DragGestureRecognizer,该Patch引入了一个新的属性MultitouchDragStrategy,可以自定义多指滚动的行为,同时将系统默认行为修改为Android的表现,并计划很快补充iOS的行为。


image.png


该Patch当前已经合入master分支,在release到stable分支之前,通过双指滚动来鉴定是否使用Flutter开发依然是最便捷的方法 :)


2 - 显示布局边界 + dumpsys activity


该方法适用于Android手机,打开手机的“开发者模式”,在设置页面搜索“边界”,找到“显示布局边界”并打开:


drawing
drawing

对于原生开发的Android应用,可以查看到所有元素的边界,例如闲鱼首页采用原生开发:


drawing


当进入分类页面之后,除了手机SystemUI可以看见元素的边界,页面内容的元素系统是识别不到边界的:


drawing


此时,我们通过adb shell命令dumpsys activity activities,可以看到TOP的Activity是MainActivity


drawing

以上识别的原理为:Flutter projects的默认入口为MainActivity,它又继承于FlutterActivity,而它的内部实现为SurfaceView,Flutter通过canvas自绘所有控件,正因为如此,Android是无法识别FlutterView里的元素边界的。


3 - 日志


一般来说,集成了Flutter框架的应用,在log里会有相关Flutter日志的输出,例如,我们在操作 微信 的时候,logcat里会有flutter日志的输出:


Image_20231206105420.png


Image_20231206105403.png


4 - 安装包文件


我们也可以通过安装包里是否集成了Flutter相关lib来推断是否使用了Flutter框架


以Android手机为例:


1,提取apk,方法如下:
# 首先确保已经将ADB工具添加到系统路径中
$ adb devices # 查看设备列表,确认设备正常连接

#
然后使用以下命令获取APK的位置信息
$ adb shell pm path com.example.appname
package:/data/app/com.example.appname-1234567890abcdefg/base.apk

#
最后使用以下命令复制APK到计算机上指定目录
$ adb pull /data/app/com.example.appname-1234567890abcdefg/base.apk ~/Desktop/my_app.apk

提取了apk文件之后,可以通过7-zip提取解压,然后搜索‘flutter’相关文件,如果使用了Flutter框架,会有flutter相关lib文件(闲鱼APK):


image.png


image.png


4 - FlutterShark


image.png


可以在Android手机上安装FlutterShark应用,在赋予它QUERY_ALL_PACKAGES权限后,他可以展示手机中所有使用了Flutter框架的应用:


Screenshot_2023-12-06-15-03-55-64[1].png


同时,FlutterShark还支持显示某个应用所依赖的三方package:


image.png


总结


以上跟大家分享了几种识别Flutter应用的方法,如果你还知道有其它的方法,请在评论区留言吧 : )


作者长期活跃在Flutter开源社区,欢迎大家一起参与开源社区的共建,如果您也有意愿参与Flutter社区的贡献,可以与作者联系。-->GITHUB


您也许还对这些Flutter技术分享感兴趣:



作者:xubaolin
来源:juejin.cn/post/7309065017191088143
收起阅读 »

Android 视频图像实时文字化

一、前言 在本篇之前,很多博客已经实现过图片文本化,但是由于渲染方式的不合理,大部分设计都做不到尽可能实时播放,本篇将在已有的基础上进行一些优化,使得视频文字化具备一定的实时性。 下图总体上视频和文字化的画面基本是实时的,上面是SurfaceView,下面是我...
继续阅读 »

一、前言


在本篇之前,很多博客已经实现过图片文本化,但是由于渲染方式的不合理,大部分设计都做不到尽可能实时播放,本篇将在已有的基础上进行一些优化,使得视频文字化具备一定的实时性。


下图总体上视频和文字化的画面基本是实时的,上面是SurfaceView,下面是我们自定义的View类,通过实时抓帧然后实时转bitmap,做到了基本同步。



二、现状


目前很多流行的方式是修改像素的色值,这个性能差距太大,导致卡顿非常严重,无法做到实时性。当然也有通过open gl mask实现的,但是在贴图这一块我们知道,open gl只支持绘制三角、点和线,因此“文字”纹理生成还得利用Canvas实现。



但对于对帧率要求不高的需求,是不是有更好的方案呢?


三、优化方案


优化点1: 使用Shader


网上很多博客都是利用Bitmap#getPixel和Bitmap#setPixel进行,这个计算量显然太大了,就算使用open gl 也未必好,因此首先解决的问题就是使用Shader着色。


优化点2: 提前计算好单个文字所占的最大空间


显然这个原因是更加整齐的排列文字,其次也可以做到降低计算量和提高灵活度


优化点3:使用队列


对于了编解码的开发而言,使用队列不仅可以复用buffer,而且还能提高绘制性能,另外必要时可以丢帧。


基于以上三点,基本可以做到实时字符化画面,当然,我们这里是彩色的,对于灰度图的需求,可通过设置Paint的ColorMatrix实现,总之,要避免遍历修改像素了RGB。


四、关键代码


使用shader着色


 this.bitmapShader = new BitmapShader(inputBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
this.mCharPaint.setShader(bitmapShader);
//用下面方式清空bitmap
boardBitmap.bitmap.eraseColor(Color.TRANSPARENT);

计算字符size


    private Rect computeMaxCharWidth(TextPaint drawerPaint, String text) {
if (TextUtils.isEmpty(text)) {
return null;
}
Rect result = new Rect(); // 文字所在区域的矩形
Rect md = new Rect();
for (int i = 0; i < text.length(); i++) {
String s = text.charAt(i) + "";
if(TextUtils.isEmpty(s)) {
continue;
}
drawerPaint.getTextBounds(s, 0, 1, md);
if (md.width() > result.width()) {
result.right = md.width();
}
if (md.height() > result.height()) {
result.bottom = md.height();
}
}
return result;
}

定义双队列,实现控制和享元机制


    private BitmapPool bitmapPool = new BitmapPool();
private BitmapPool recyclePool = new BitmapPool();

static class BitmapPool {
int width;
int height;
private LinkedBlockingQueue<BitmapItem> linkedBlockingQueue = new LinkedBlockingQueue<>(5);
}

static class BitmapItem{
Bitmap bitmap;
boolean isUsed = false;
}

完整代码


public class WordBitmapView extends View {
private final DisplayMetrics mDM;
private TextPaint mCharPaint;
private TextPaint mDrawerPaint = null;
private Bitmap inputBitmap;
private Rect charMxWidth = null ;
private String text = "a1b2c3d4e5f6h7j8k9l0";
private float textBaseline;
private BitmapShader bitmapShader;
public WordBitmapView(Context context) {
this(context, null);
}
public WordBitmapView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public WordBitmapView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}

int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

textBaseline = getTextPaintBaseline(mDrawerPaint);
}


public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
recyclePool.clear();
bitmapPool.clear();
}

Matrix matrix = new Matrix();
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width <= 1 || height <= 1) {
return;
}
BitmapItem bitmapItem = bitmapPool.linkedBlockingQueue.poll();
if (bitmapItem == null || inputBitmap == null) {
return;
}
if(!bitmapItem.isUsed){
return;
}
canvas.drawBitmap(bitmapItem.bitmap,matrix,mDrawerPaint);
bitmapItem.isUsed = false;
try {
recyclePool.linkedBlockingQueue.offer(bitmapItem,16,TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static float getTextPaintBaseline(Paint p) {
Paint.FontMetrics fontMetrics = p.getFontMetrics();
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}

private Rect computeMaxCharWidth(TextPaint drawerPaint, String text) {
if (TextUtils.isEmpty(text)) {
return null;
}
Rect result = new Rect(); // 文字所在区域的矩形
Rect md = new Rect();
for (int i = 0; i < text.length(); i++) {
String s = text.charAt(i) + "";
if(TextUtils.isEmpty(s)) {
continue;
}
drawerPaint.getTextBounds(s, 0, 1, md);
if (md.width() > result.width()) {
result.right = md.width();
}
if (md.height() > result.height()) {
result.bottom = md.height();
}
}
return result;
}

private BitmapPool bitmapPool = new BitmapPool();
private BitmapPool recyclePool = new BitmapPool();

static class BitmapPool {
int width;
int height;
private LinkedBlockingQueue<BitmapItem> linkedBlockingQueue = new LinkedBlockingQueue<>(5);
public void clear(){
Iterator<BitmapItem> iterator = linkedBlockingQueue.iterator();
do{
if(!iterator.hasNext()) break;
BitmapItem next = iterator.next();
if(!next.bitmap.isRecycled()) {
next.bitmap.recycle();
}
iterator.remove();
}while (true);
}

public int getWidth() {
return width;
}

public int getHeight() {
return height;
}

public void setHeight(int height) {
this.height = height;
}

public void setWidth(int width) {
this.width = width;
}
}

class BitmapItem{
Bitmap bitmap;
boolean isUsed = false;
}


//视频图片入队
public void queueInputBitmap(Bitmap inputBitmap) {
this.inputBitmap = inputBitmap;

if(charMxWidth == null){
charMxWidth = computeMaxCharWidth(mDrawerPaint,text);
}
if(charMxWidth == null || charMxWidth.width() == 0){
return;
}

if(this.bitmapPool != null && this.inputBitmap != null){
if(this.bitmapPool.getWidth() != this.inputBitmap.getWidth()){
bitmapPool.clear();
recyclePool.clear();
}else if(this.bitmapPool.getHeight() != this.inputBitmap.getHeight()){
bitmapPool.clear();
recyclePool.clear();
}
}
bitmapPool.setWidth(inputBitmap.getWidth());
bitmapPool.setHeight(inputBitmap.getHeight());
recyclePool.setWidth(inputBitmap.getWidth());
recyclePool.setHeight(inputBitmap.getHeight());

BitmapItem boardBitmap = recyclePool.linkedBlockingQueue.poll();
if (boardBitmap == null && inputBitmap != null) {
boardBitmap = new BitmapItem();
boardBitmap.bitmap = Bitmap.createBitmap(inputBitmap.getWidth(), inputBitmap.getHeight(), Bitmap.Config.ARGB_8888);
}
boardBitmap.isUsed = true;
int bitmapWidth = inputBitmap.getWidth();
int bitmapHeight = inputBitmap.getHeight();
int unitWidth = (int) (charMxWidth.width() *1.5);
int unitHeight = charMxWidth.height() + 2;
int centerY = charMxWidth.centerY();
float hLineCharNum = bitmapWidth * 1F / unitWidth;
float vLineCharNum = bitmapHeight * 1F / unitHeight;


this.bitmapShader = new BitmapShader(inputBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
this.mCharPaint.setShader(bitmapShader);

boardBitmap.bitmap.eraseColor(Color.TRANSPARENT);

Canvas drawCanvas = new Canvas(boardBitmap.bitmap);
int k = (int) (Math.random() * text.length());
for (int i = 0; i < vLineCharNum; i++) {
for (int j = 0; j < hLineCharNum; j++) {
int length = text.length();
int x = unitWidth * j;
int y = centerY + i * unitHeight;
String c = text.charAt(k % length) + "";
drawCanvas.drawText(c, x, y + textBaseline, mCharPaint);
k++;
}
}
try {
bitmapPool.linkedBlockingQueue.offer(boardBitmap,16, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
postInvalidate();

}
private void initPaint() {
// 实例化画笔并打开抗锯齿
mCharPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCharPaint.setAntiAlias(true);
mCharPaint.setStyle(Paint.Style.FILL);
mCharPaint.setStrokeCap(Paint.Cap.ROUND);
mDrawerPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mDrawerPaint.setAntiAlias(true);
mDrawerPaint.setStyle(Paint.Style.FILL);
mDrawerPaint.setStrokeCap(Paint.Cap.ROUND);
}


}

五、总结


Android中Shader是非常重要的工具,我们无需单独修改像素的情况下就能实现快速渲染字符,得意与Shader出色的渲染能力。另外由于时间原因,这里对字符的绘制并没有做到很精确,仅仅选了一些比较中规中列的排列,后续再继续完善吧。


作者:时光少年
来源:juejin.cn/post/7304531203772514339
收起阅读 »

Android 使用Xfermode合成TabBarView

一、前言 PorterDuffXfermode  作为Android重要的合成组件,可以通过区域叠加方式进行裁剪和合成,起作用和Path.Op 类似,对于音视频开发中使用蒙版抠图和裁剪的需求,这种情况一般生成不了Path时,因此只能使用PorterDuffXf...
继续阅读 »

一、前言


PorterDuffXfermode  作为Android重要的合成组件,可以通过区域叠加方式进行裁剪和合成,起作用和Path.Op 类似,对于音视频开发中使用蒙版抠图和裁剪的需求,这种情况一般生成不了Path时,因此只能使用PorterDuffXfermode进行合成,当然Paint设置Shader也具备一定的能力,但是还是无法做到很多效果。


二、案例



这个案例使用了Bitmap合成,在边缘区域对色彩裁剪,从而实现了圆觉裁剪。


模版



//裁剪区域



技术上没有太多难点,但要注意的是Xfermode是2个Bitmap之间只使用,不像Shader那样可以单独使用。


Canvas resultCanvas = new Canvas(resultBitmap);
resultCanvas.drawBitmap(dstBitmap, paddingLeft + point.x, paddingTop, mSolidPaint);
mSolidPaint.setXfermode(mPorterDuffXfermode);
resultCanvas.drawBitmap(srcRoundBitmap, 0, 0, mSolidPaint);
canvas.drawBitmap(resultBitmap, 0, 0, null);

另外一点就是速度计算,利用了没有时间的逼近减速公式,当然你可以使用动画去实现


 float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;

下面是速度控制逻辑


    @Override
public void run() {
//计算速度,先按照最大速度5变化,如果小于5,则表示该减速停靠
float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;
speed = (float) Math.max(1f, speed);
float vPos = (mTargetZone - 1) * (contentWidth / mDivideNumber);
if (point.x < vPos) {
point.x += speed;
if (point.x > vPos) {
point.x = vPos;
}
} else {
point.x -= speed;
if (point.x < vPos) {
point.x = vPos;
}
}
if (point.x == vPos) {
isSliding = false;
} else {
isSliding = true;
postDelayed(this, 20);
}
postInvalidate();
}

全部逻辑


public class TabBarView extends View implements Runnable {
//画笔
private Paint mSolidPaint;
//中间竖线与边框间隙
private int gapPadding = 0;
//平分量
private int mDivideNumber = 1;
//边框大小
private final float mBorderSize = 1.5f;
//避免重复绘制Bitmap,短暂保存底色bitmap
private Bitmap srcRoundBitmap;
//图片混合模式
private PorterDuffXfermode mPorterDuffXfermode;
private PointF point;
//内容区域大小
private float contentWidth;
private float contentHeight;
//滑动到的目标区域
private int mTargetZone;
//滑动速度
private float mSpeed;
//主调颜色
private int primaryColor;
//默认字体颜色
private int textColor;
//焦点字体颜色
private int selectedTextColor;
//item
private CharSequence[] mStringItems;
//字体大小
private float textSize;
//是否处于滑动
private boolean isSliding;

Bitmap dstBitmap;
Bitmap resultBitmap;

private RectF rectBound = new RectF();

public TabBarView(Context context) {
super(context);
init(null, 0);
}

public TabBarView(Context context, AttributeSet attrs) {
super(context, attrs);
init(attrs, 0);
}

public TabBarView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(attrs, defStyle);
}

private void init(AttributeSet attrs, int defStyle) {
// Load attributes
final TypedArray a = getContext().obtainStyledAttributes(
attrs, R.styleable.TabBarView, defStyle, 0);

//参数值越大,速度越大,速度指数越小
mSpeed = Math.max(10 - Math.max(a.getInt(R.styleable.TabBarView_speed, 6), 6), 1);

mStringItems = a.getTextArray(R.styleable.TabBarView_tabEntries);
primaryColor = a.getColor(R.styleable.TabBarView_primaryColor, 0xFF4081);
textColor = a.getColor(R.styleable.TabBarView_textColor, primaryColor);
selectedTextColor = a.getColor(R.styleable.TabBarView_selectedTextColor, 0xffffff);
textSize = a.getDimensionPixelSize(R.styleable.TabBarView_textSize, 30);

if (mStringItems != null && mStringItems.length > 0) {
mDivideNumber = mStringItems.length;
}

a.recycle();

mSolidPaint = new Paint();
mSolidPaint.setFlags(Paint.ANTI_ALIAS_FLAG);
mPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.SRC_IN);
point = new PointF(0, 0);
mTargetZone = 1;

invalidateTextPaintAndMeasurements();

}

private void invalidateTextPaintAndMeasurements() {
mSolidPaint.setColor(primaryColor);
mSolidPaint.setStrokeWidth(mBorderSize);
mSolidPaint.setTextSize(textSize);
mSolidPaint.setStyle(Paint.Style.STROKE);
mSolidPaint.setXfermode(null);
}



@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
recycleBitmap();
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int paddingLeft = getPaddingLeft();
int paddingTop = getPaddingTop();
int paddingRight = getPaddingRight();
int paddingBottom = getPaddingBottom();

contentWidth = getWidth() - paddingLeft - paddingRight;
contentHeight = getHeight() - paddingTop - paddingBottom;
float minContentSize = Math.min(contentWidth, contentHeight);

rectBound.set(paddingLeft, paddingTop, paddingLeft + contentWidth, paddingTop + contentHeight);
canvas.drawRoundRect(rectBound, minContentSize / 2F, minContentSize / 2F, mSolidPaint);
for (int i = 1; i < mDivideNumber; i++) {
canvas.drawLine(paddingLeft + 1F * contentWidth * i / mDivideNumber, paddingTop + gapPadding, paddingLeft + contentWidth * i / mDivideNumber, paddingTop + contentHeight - gapPadding, mSolidPaint);

}

if (srcRoundBitmap == null) {
srcRoundBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
Canvas srcCanvas = new Canvas(srcRoundBitmap);
mSolidPaint.setStyle(Paint.Style.FILL_AND_STROKE);
srcCanvas.drawRoundRect(rectBound, minContentSize / 2F, minContentSize / 2F, mSolidPaint);
}

if(dstBitmap == null) {
dstBitmap = Bitmap.createBitmap((int) (contentWidth / mDivideNumber), (int) contentHeight, Bitmap.Config.ARGB_8888);
}
dstBitmap.eraseColor(Color.TRANSPARENT);
Canvas dstCanvas = new Canvas(dstBitmap);
dstCanvas.drawColor(Color.YELLOW);

if(resultBitmap == null) {
resultBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
}
resultBitmap.eraseColor(Color.TRANSPARENT);
Canvas resultCanvas = new Canvas(resultBitmap);
resultCanvas.drawBitmap(dstBitmap, paddingLeft + point.x, paddingTop, mSolidPaint);
mSolidPaint.setXfermode(mPorterDuffXfermode);
resultCanvas.drawBitmap(srcRoundBitmap, 0, 0, mSolidPaint);
canvas.drawBitmap(resultBitmap, 0, 0, null);

invalidateTextPaintAndMeasurements();

if (mStringItems != null) {

for (int i = 0; i < mStringItems.length; i++) {
String itemChar = mStringItems[i].toString();
float textX = (contentWidth / mDivideNumber) * i / 2 + paddingLeft + (contentWidth * (i + 1) / mDivideNumber - mSolidPaint.measureText(itemChar)) / 2;
float textY = paddingTop + (contentHeight - mSolidPaint.getFontMetrics().bottom - mSolidPaint.getFontMetrics().ascent) / 2;
int color = mSolidPaint.getColor();
mSolidPaint.setStyle(Paint.Style.FILL);
if ((i + 1) == mTargetZone && !isSliding) {
mSolidPaint.setColor(selectedTextColor);
} else {
mSolidPaint.setColor(textColor);
}
canvas.drawText(itemChar, textX, textY, mSolidPaint);
mSolidPaint.setColor(color);
mSolidPaint.setStyle(Paint.Style.STROKE);
}
}
}


@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (checkLocationIsOk(event) && !isSliding) {
return true;
}
break;
case MotionEvent.ACTION_MOVE:
return checkLocationIsOk(event);
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_OUTSIDE:
if (checkLocationIsOk(event) && !isSliding) {
float x = event.getX() - getPaddingLeft();
mTargetZone = (int) (x / (contentWidth / mDivideNumber)) + 1;
//规避区域超出范围
mTargetZone = Math.min(mTargetZone, mDivideNumber);
postToMove();
}
break;
}
return super.onTouchEvent(event);
}

private void postToMove() {
if (point.x == (mTargetZone - 1) * (contentWidth / mDivideNumber)) {
return;
}
postDelayed(this, 20);
}

/**
* 检测位置是否可用
*
* @param event
* @return
*/
private boolean checkLocationIsOk(MotionEvent event) {
float x = event.getX();
float y = event.getY();
if (x - getPaddingLeft() > 0 && (getPaddingLeft() + contentWidth - x) > 0 && y - getPaddingTop() > 0 && (getPaddingTop() + contentHeight - y) > 0) {
return true;
}
return false;
}

private void recycleBitmap(Bitmap bmp) {
if (bmp != null && !bmp.isRecycled()) {
bmp.recycle();
}
}

@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
getHandler().removeCallbacksAndMessages(null);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = getResources().getDisplayMetrics().widthPixels / 2;
}

int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
}

@Override
public void run() {
//计算速度,先按照最大速度5变化,如果小于5,则表示该减速停靠
float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;
speed = (float) Math.max(1f, speed);
float vPos = (mTargetZone - 1) * (contentWidth / mDivideNumber);
if (point.x < vPos) {
point.x += speed;
if (point.x > vPos) {
point.x = vPos;
}
} else {
point.x -= speed;
if (point.x < vPos) {
point.x = vPos;
}
}
if (point.x == vPos) {
isSliding = false;
} else {
isSliding = true;
postDelayed(this, 20);
}
postInvalidate();
}

public void setSelectedTab(int tabIndex) {
mTargetZone = Math.max(Math.min(mDivideNumber, tabIndex + 1), 1);
recycleBitmap();
postToMove();
}

public void setTabItems(CharSequence[] mStringItems) {
this.mStringItems = mStringItems;
recycleBitmap();
invalidate();
}

private void recycleBitmap() {
if(dstBitmap != null && !dstBitmap.isRecycled()){
dstBitmap.recycle();
}
if(resultBitmap != null && !resultBitmap.isRecycled()){
resultBitmap.recycle();
}
resultBitmap = null;
dstBitmap = null;
}
}

我们需要自定义一些属性


<declare-styleable name="TabBarView">

<attr name="speed" format="integer" />
<attr name="tabEntries" format="reference"/>
<attr name="primaryColor" format="color|reference"/>
<attr name="textSize" format="dimension"/>
<attr name="textColor" format="color|reference"/>
<attr name="selectedTextColor" format="color|reference"/>

</declare-styleable>

还有部分需要引用的 string-array


<string-array name="tabEntries_array">
<item>A</item>
<item>B</item>
<item>C</item>
<item>D</item>
</string-array>

然后是布局文件(片段)


<com.android.jym.widgets.TabBarView
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="@android:color/transparent"
android:padding="10dp"
app:speed="4"
app:tabEntries="@array/tabEntries_array"
app:primaryColor="@color/colorAccent"
app:textColor="@color/colorPrimaryDark"
app:selectedTextColor="@android:color/white"
/>

三、总结


使用Xfermode + 蒙版进行抠图,是Android中重要的工具,本篇作为技术储备,后续会通过这种方式实现一些新的功能。


作者:时光少年
来源:juejin.cn/post/7306447610096975887
收起阅读 »

Android 侧滑布局逻辑解析

一、前言 测滑布局应用非常广泛,HorizontalScrollView 本身实现的滑动效果让实现变得很简单,实际上有很多种方式实现,有很多现有的方法可以直接调用。 二、逻辑实现 在Android 中,滑动分为2类,一类以ScrollView为代表布局,通过...
继续阅读 »

一、前言


测滑布局应用非常广泛,HorizontalScrollView 本身实现的滑动效果让实现变得很简单,实际上有很多种方式实现,有很多现有的方法可以直接调用。



二、逻辑实现


在Android 中,滑动分为2类,一类以ScrollView为代表布局,通过子View实现布局超出视区(ViewPort)之后,进行Scroll操作的,另一类事以修改Offset为代表的Recycler类,前者实时保持最大高度。形像的理解为前者是“齿轮传动派”,后者是“滑板派”,两派都有过出分头的时候,即便是个派弟子如NestedScrollView和RecyclerView争的你死我活,不过总体上齿轮传动派占在弱势地位。不过android的改版,让他们做了很多和平相处的事情,不如NestedScrolling机制的支持,让他们想传动就传动,想滑翔就滑翔。


齿轮传动派看家本领



  • scrollX,ScrollY,scrollTo等方法

  • 一个长得很长的独生子


滑板派的看家本领



  • offsetXXX方法

  • 被魔改的ScrollXXX

  • 一群会滑板的孩子

  • layout 方法也是他们的榜首


前者为了实现的简单的滑动,后者空间可以无限大,期间还可自由换孩子。


三、代码实现


有很多现成的example都是基于齿轮传动派的,但是如果使用,你得记住,齿轮传动派会的滑板派一定会,反过来就不一样了。


这里我们使用layout方法实现,核心代码


        View leftView = mWrapperView.getChildAt(0);
leftView.layout(l,t,l+leftView.getWidth(),t+leftView.getHeight());
maskAlpha = leftView.getLeft()*1.0f/leftView.getWidth();

之所以使用layout的原因是很多人都不记得ListView可以使用该方法实现吸顶效果,而RecyclerView因为为了兼容更多类型,导致他使用这个很难实现吸顶,但是没关系,child.layout和child.measure方法可以在类的任何地方调用,这个是必须要掌握的。


3.1 布局初始化


 @Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if(isFirstLayout && getRealChildCount()==2){
View leftView = mWrapperView.getChildAt(0);
scrollTo(leftView.getWidth(),0); //初始化状态让右侧View展示处理
}
isFirstLayout = true;
}

3.2 相对运动


滑动时让左侧View保持同样的滑动距离和方向


   View leftView = mWrapperView.getChildAt(0);
leftView.layout(l,t,l+leftView.getWidth(),t+leftView.getHeight());
maskAlpha = leftView.getLeft()*1.0f/leftView.getWidth();

3.3 全部代码


public class SlidingFoldLayout extends HorizontalScrollView {


private TextPaint mPaint = null;
private LinearLayout mWrapperView = null;
private boolean isFirstLayout = true;
private float maskAlpha = 1.0f;

public SlidingFoldLayout(Context context) {
this(context, null);
}

public SlidingFoldLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public SlidingFoldLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
LinearLayout linearLayout = getWrapperLayout(context);
setOverScrollMode(View.OVER_SCROLL_NEVER);
setWillNotDraw(false);
mPaint = createPaint();
addViewInLayout(linearLayout, 0, linearLayout.getLayoutParams(), true);
mWrapperView = linearLayout;
}


public LinearLayout getWrapperLayout(Context context) {
LinearLayout linearLayout = new LinearLayout(context);
HorizontalScrollView.LayoutParams lp = generateDefaultLayoutParams();
lp.width = LayoutParams.WRAP_CONTENT;
linearLayout.setLayoutParams(lp);
linearLayout.setOrientation(LinearLayout.HORIZONTAL);
linearLayout.setPadding(0, 0, 0, 0);
return linearLayout;
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int childCount = mWrapperView.getChildCount();
if (childCount == 0) {
return;
}
int leftMenuWidth = mWrapperView.getChildAt(0).getMeasuredWidth();
ViewGr0up.LayoutParams lp = (ViewGr0up.LayoutParams) getLayoutParams();
int width = getMeasuredWidth() - getPaddingRight() - getPaddingLeft();
if (lp instanceof ViewGr0up.MarginLayoutParams) {
width = width - ((MarginLayoutParams) lp).leftMargin - ((MarginLayoutParams) lp).rightMargin;
}
if (width <= leftMenuWidth) {
mWrapperView.getChildAt(0).getLayoutParams().width = (int) (width - dp2px(50));
measureChild(mWrapperView, widthMeasureSpec, heightMeasureSpec);
}
if (childCount != 2) {
return;
}
View rightView = mWrapperView.getChildAt(1);
int rightMenuWidth = rightView.getMeasuredWidth();
if (width != rightMenuWidth) {
rightView.getLayoutParams().width = width;
measureChild(mWrapperView, widthMeasureSpec, heightMeasureSpec);
rightView.bringToFront();
}
}

private float dp2px(int dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}

@Override
public void addView(View child) {

int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}
ViewGr0up.LayoutParams lp = child.getLayoutParams();
if (lp != null && lp instanceof LinearLayout.LayoutParams) {
lp = new LinearLayout.LayoutParams(lp);
child.setLayoutParams(lp);
}

mWrapperView.addView(child);

}



public int getRealChildCount() {
if (mWrapperView == null) {
return 0;
}
return mWrapperView.getChildCount();
}



@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if(isFirstLayout && getRealChildCount()==2){
View leftView = mWrapperView.getChildAt(0);
scrollTo(leftView.getWidth(),0);
}
isFirstLayout = true;
}

@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
int realCount = getRealChildCount();
if(realCount!=2) return;
View leftView = mWrapperView.getChildAt(0);
leftView.layout(l,t,l+leftView.getWidth(),t+leftView.getHeight());
maskAlpha = leftView.getLeft()*1.0f/leftView.getWidth();
}


@Override
public boolean onTouchEvent(MotionEvent ev) {
int action = ev.getAction();

switch (action){
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_OUTSIDE:
super.onTouchEvent(ev);
scrollToTraget();
break;
}

return super.onTouchEvent(ev);
}

private void scrollToTraget() {

int count = getRealChildCount();
if(count!=2) return;
int with = getWidth();
if(with==0) return;

View leftView = mWrapperView.getChildAt(0);

float x = leftView.getLeft()*1.0f/leftView.getWidth();
if(x > 0.5f){
smoothScrollTo(leftView.getWidth(),0);
}else{
smoothScrollTo(0,0);
}

}

@Override
public void addView(View child, int index) {

int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}
ViewGr0up.LayoutParams lp = child.getLayoutParams();
if (lp != null && lp instanceof LinearLayout.LayoutParams) {
lp = new LinearLayout.LayoutParams(lp);
child.setLayoutParams(lp);
}

mWrapperView.addView(child, index);
}

@Override
public void addView(View child, ViewGr0up.LayoutParams params) {
int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(params);
child.setLayoutParams(lp);
mWrapperView.addView(child, lp);

}

@Override
public void addView(View child, int index, ViewGr0up.LayoutParams params) {
int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}

LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(params);
child.setLayoutParams(lp);
mWrapperView.addView(child, index);
}

private TextPaint createPaint() {
// 实例化画笔并打开抗锯齿
TextPaint paint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
paint.setAntiAlias(true);
return paint;
}
RectF rectF = new RectF();
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);

int realCount = getRealChildCount();
if(realCount!=2) return;
View leftView = mWrapperView.getChildAt(0);
View rightView = mWrapperView.getChildAt(1);


rectF.top = leftView.getTop();
rectF.bottom = leftView.getBottom();
rectF.left = leftView.getLeft();
rectF.right = rightView.getLeft();
int alpha = (int) (153*maskAlpha);
mPaint.setColor(argb(alpha,0x00,0x00,0x00));
int saveId = canvas.save();
canvas.drawRect(rectF,mPaint);
canvas.restoreToCount(saveId);
}

public static int argb(
int alpha,
int red,
int green,
int blue) {
return (alpha << 24) | (red << 16) | (green << 8) | blue;
}

}

三、使用方式


使用方式简单清晰,没有看到ScrollView的独生子,原因是我们把他写到了类里面


  <com.cn.scrolllayout.view.SlidingFoldLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<LinearLayout
android:layout_width="300dp"
android:layout_height="match_parent"
android:gravity="center"
>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="@mipmap/img_sample_text"
/>
</LinearLayout>
<LinearLayout
android:layout_width="500dp"
android:layout_height="match_parent"
android:background="@color/colorAccent"
>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:src="@mipmap/img_sample_panda"
/>
</LinearLayout>
</com.cn.scrolllayout.view.SlidingFoldLayout>

四、总结


掌握ScrollX和OffsetX两种的滑动很重要,但是不能忘记layout的作用,本质上他属于一种OffsetX上层的封装。


作者:时光少年
来源:juejin.cn/post/7307989656288034851
收起阅读 »

Android 14 适配的那些事情

大家好,我叫 Jack Darren,目前主要负责国内游戏发行 Android SDK 开发简介距离 Android 14 发布已经有一段时间了,趁着这次机会,了解和熟悉了 Android 14 更新的内容,现在来和大家分享一下,大家喜欢的话可以点个赞多多支持...
继续阅读 »
  • 大家好,我叫 Jack Darren,目前主要负责国内游戏发行 Android SDK 开发

简介

  • 距离 Android 14 发布已经有一段时间了,趁着这次机会,了解和熟悉了 Android 14 更新的内容,现在来和大家分享一下,大家喜欢的话可以点个赞多多支持一下,文章的内容按照适配内容的重要程度进行排序。

targetSdk 版本要求

  • 在 Android 14 上面,新增了一个要求,要求新安装的应用的 targetSdkVersion 需要大于等于 23(即 Android 6.0 及以上),如果小于这个值将无法在 Android 14 的设备上面安装,此时大家心里可能有疑惑了,谷歌为什么要求那么做呢?我们来看看谷歌的原话是什么
恶意软件通常会以较旧的 API 级别为目标平台,
以绕过在较新版本 Android 中引入的安全和隐私保护机制。
例如,有些恶意软件应用使用 targetSdkVersion 22
以避免受到 Android 6.0 Marshmallow(API 级别 23)在 2015 年引入的运行时权限模型的约束。
这项 Android 14 变更使恶意软件更难以规避安全和隐私权方面的改进限制。
  • 从上面这段话不难看出来谷歌的用意,其实为了保障用户的手机安全,如果用户安装应用的 targetSdkVersion 版本过低,有一些恶意软件会利用高系统会兼容旧软件这一特性(漏洞),故意绕过系统的安全检查,从而会导致 Android 高版本上面一些安全特性无法生效,没有了系统的管束,这些恶意软件可能就会肆意乱来。
  • 另外你如果想在 Android 14 系统上面,仍然要安装 targetSdkVersion 小于 23 的应用,可以通过以下 adb 命令来安装 apk,这样就能绕过系统的安装限制。
adb install --bypass-low-target-sdk-block xxx.apk

前台服务类型要求

  • 如果你的应用 targetSdkVersion 升级到了 34(即 Android 14),并且在 Service 中调用了 startForeground 方法,那么就需要进行适配了,否则系统会抛出 MissingForegroundServiceTypeException 异常,这是因为在 Android 14 上面,要求应用在开启前台服务的时候,需要注明这个前台服务的用途,谷歌给我们列举了以下几种用途:
用途说明清单文件权限要求运行时要求
摄像头继续在后台访问相机,例如支持多任务的视频聊天应用FOREGROUND_SERVICE_CAMERA请求 CAMERA 运行时权限
连接的设备与需要蓝牙、NFC、IR、USB 或网络连接的外部设备进行互动FOREGROUND_SERVICE_CONNECTED_DEVICE必须至少满足以下其中一个条件:

在清单中至少声明以下其中一项权限:

CHANGE_NETWORK_STATE
CHANGE_WIFI_STATE
CHANGE_WIFI_MULTICAST_STATE
NFC
TRANSMIT_IR
至少请求以下其中一项运行时权限:

BLUETOOTH_CONNECT
BLUETOOTH_ADVERTISE
BLUETOOTH_SCAN
UWB_RANGING
调用 UsbManager.requestPermission()

数据同步数据传输操作,例如:

数据上传或下载
备份和恢复操作
导入或导出操作
获取数据
本地文件处理
通过网络在设备和云端之间传输数据
FOREGROUND_SERVICE_DATA_SYNC
健康为健身类别的应用(例如锻炼追踪器)提供支持的所有长时间运行的用例FOREGROUND_SERVICE_HEALTH必须至少满足以下其中一个条件:

在清单中声明 HIGH_SAMPLING_RATE_SENSORS 权限。

至少请求以下其中一项运行时权限:

BODY_SENSORS
ACTIVITY_RECOGNITION
位置需要位置信息使用权的长时间运行的用例,
例如导航和位置信息分享
FOREGROUND_SERVICE_LOCATION至少请求以下其中一项运行时权限:

ACCESS_COARSE_LOCATION
ACCESS_FINE_LOCATION
媒体在后台继续播放音频或视频。
在 Android TV 上支持数字视频录制 (DVR) 功能。
FOREGROUND_SERVICE_MEDIA_PLAYBACK
媒体投影使用 MediaProjection API 将内容投影到非主要显示屏或外部设备。这些内容不必全都为媒体内容。不包括 Cast SDKFOREGROUND_SERVICE_MEDIA_PROJECTION调用 createScreenCaptureIntent() 方法。 无
麦克风在后台继续捕获麦克风内容,例如录音器或通信应用FOREGROUND_SERVICE_MICROPHONE请求 RECORD_AUDIO 运行时权限
打电话使用 ConnectionService API 继续当前通话FOREGROUND_SERVICE_PHONE_CALL在清单文件中声明 MANAGE_OWN_CALLS 权限。
消息服务将短信从一台设备转移到另一台设备。在用户切换设备时,帮助确保用户消息任务的连续性FOREGROUND_SERVICE_REMOTE_MESSAGING
短期服务快速完成不可中断或推迟的关键工作。

这种类型有一些独特的特征:

只能持续运行一小段时间(大约 3 分钟)。
不支持粘性前台服务。
无法启动其他前台服务。
不需要类型专用权限,不过它仍需要 FOREGROUND_SERVICE 权限。
正在运行的前台服务不能更改为 shortService 类型或从该类型更改为其他类型。
特殊用途涵盖其他前台服务类型未涵盖的所有有效前台服务用例。

除了声明 FOREGROUND_SERVICE_TYPE_SPECIAL_USE 前台服务类型之外,开发者还应在清单中声明用例。为此,他们会在 `` 元素内指定  元素。当您在 Google Play 管理中心内提交应用时,我们会审核这些值和相应的用例。
FOREGROUND_SERVICE_SPECIAL_USE
系统豁免为系统应用和特定系统集成预留,
使其能继续使用前台服务。

如需使用此类型,应用必须至少满足以下条件之一:

设备处于演示模式状态
应用是设备所有者
应用是性能分析器所有者
属于具有 ROLE_EMERGENCY 角色的安全应用
属于设备管理应用
否则,声明此类型会导致系统抛出 ForegroundServiceTypeNotAllowedException
FOREGROUND_SERVICE_SYSTEM_EXEMPTED
  • 介绍完这几种前台服务类型,接下来介绍如何适配它,适配前台服务类型的特性方式具体有两种方式,一种是注册清单属性,另外一种是代码动态注册
<service
android:name=".XxxService"
android:foregroundServiceType="dataSync"
android:exported="false">

service>
startForeground(xxx, xxx, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
  • 另外附上前台服务类型对应的适配属性
用途清单文件属性值Java 常量值
摄像头cameraServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
连接的设备connectedDeviceServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
数据同步dataSyncServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
健康healthServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
位置locationServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
媒体mediaPlaybackServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
媒体投影mediaProjectionServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
麦克风microphoneServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
打电话phoneCallServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
消息服务remoteMessagingServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
短期服务shortServiceServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
特殊用途specialUseServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
系统豁免systemExemptedServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED

图片和视频的部分访问权限

  • 谷歌在 API 33(Android 13)上面引入了 READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 这两个权限,目前针对这两个权限在 Android 14 上面有新的变动,具体的变动点就是新增了 READ_MEDIA_VISUAL_USER_SELECTED 权限,那么这个权限的作用是什么呢?我们都知道 READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 是申请图片和视频权限的,但是这样会有一个问题,当第三方应用申请到权限后,就拥有了手机相册中所有照片和视频的访问权限,这是十分危险的,也是非常不可控的,因为用户也无法知道第三方应用会干什么,所以谷歌在 API 34(Android 14)引入了这个权限,这样用户拥有了更多的选择,可以将相册中所有的图片和视频授予给第三方应用,也可以将部分的图片和视频给第三方应用。
  • 讲完了这个特性的来龙去脉,那么接下来讲讲这个权限如何适配,如果你的应用申请了 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限,并且 targetSdkVersion 大于等于 33(Android 13),那么需要在申请权限时携带上 READ_MEDIA_VISUAL_USER_SELECTED 权限方能正常申请,如果不携带上 READ_MEDIA_VISUAL_USER_SELECTED 权限就申请 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限,会弹出权限询问对话框,但是如果用户是选择全部授予,那么 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限状态是已授予的状态,如果用户是选择部分授予,那么 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限状态是已拒绝的状态,假设此时有携带了 READ_MEDIA_VISUAL_USER_SELECTED 权限的情况下,那么 READ_MEDIA_VISUAL_USER_SELECTED 权限是已授予的状态。
  • 看到这里,脑洞大的同学可能有想法了,那我不申请 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限,我就只申请 READ_MEDIA_VISUAL_USER_SELECTED 权限行不行啊?答案也是不行的,我替大家试验过了,这个权限申请会在不会询问用户的情况下,被系统直接拒绝掉。
  • 另外需要的一点是 READ_MEDIA_VISUAL_USER_SELECTED 属于危险权限,除了在运行时动态申请外,还需要在清单文件中进行注册。

registerReceiver 需要指定导出行为

  • 谷歌在 Android 12 (API 31)新增了四大组件需要指定 android:exported 属性的特性,这次在 Android 13 上面做了一些变动,因为谷歌之前只考虑到静态注册四大组件的情况,但是遗漏了一种情况,BroadcastReceiver 不仅可以静态注册,还可以动态注册,动态注册的广播不需要额外在 AndroidManifest.xml 中再进行静态注册,所以这次谷歌将这个规则漏洞补上了,并且要求开发者在动态注册广播的时候,能够指定 BroadcastReceiver 是否能支持导出,由此来保护应用免受安全漏洞的影响。
  • 到此,大家心中可能有一个疑惑,这里的支持导出是什么意思?有产生什么作用?可以先看一下谷歌官方的原话
为了帮助提高运行时接收器的安全性,Android 13 允许您指定您应用中的特定广播接收器是否应被导出以及是否对设备上的其他应用可见。
如果导出广播接收器,其他应用将可以向您的应用发送不受保护的广播。
此导出配置在以 Android 13 或更高版本为目标平台的应用中可用,有助于防止一个主要的应用漏洞来源。

在以前的 Android 版本中,设备上的任何应用都可以向动态注册的接收器发送不受保护的广播,除非该接收器受签名权限的保护。
  • 谷歌的解释很明了,如果广播支持导出,那么其他应用可以通过发送这个广播触发我们应用的逻辑,这可能会发生程序安全漏洞的问题。
  • 那么该如何适配这一特性呢?谷歌官方提供了一个 registerReceiver(BroadcastReceiver receiver, IntentFilter filter, int flags) API,flags 参数传入 Context.RECEIVER_EXPORTED(支持导出) 或 Context.RECEIVER_NOT_EXPORTED(不支持导出),具体的代码适配代码如下:
String action = "xxxxxx";
IntentFilter filter = new IntentFilter(action);
if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
context.registerReceiver(new LocaleChangeReceiver(), filter, Context.RECEIVER_EXPORTED);
} else {
context.registerReceiver(new LocaleChangeReceiver(), filter);
}
  • 还有一种情况,不需要指定 flag 参数,就是当要注册的广播 action 隶属系统的 action 时候,这个时候可以不需要指定导出行为。

更安全的动态代码加载

  • 如果我们应用有动态加载代码的需求,并且此时 targetSdk 升级到了 API 34(即 Android 14),那么需要注意一个点,动态加载的文件(Jar、Dex、Apk 格式)需要设置成可读的,具体案例的代码如下:
File jar = new File("xxxx.jar");
try (FileOutputStream os = new FileOutputStream(jar)) {
jar.setReadOnly();
} catch (IOException e) { ... }
PathClassLoader cl = new PathClassLoader(jar, parentClassLoader);
  • 至于谷歌这样做的原因,我觉得十分简单,是为了程序的安全,防止有人抢先在动态加载之前先把动态文件替换了,那么会导致执行到一些恶意的代码,间接导致应用被入侵或者篡改。
  • 另外需要注意的一个点的是,如果你的应用 targetSdk 大于等于 API 34(即 Android 14),如果不去适配这一特性,那么运行在 Android 14 的手机上面系统会抛出异常。

屏幕截图检测

  • Android 14 新增引入了屏幕截图检测的 API,方便开发者更好地检测到用户的操作,具体的使用案例如下:

    1. 在清单文件中静态注册权限
    <uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
    1. 创建监听器对象
final Activity.ScreenCaptureCallback screenCaptureCallback = new Activity.ScreenCaptureCallback() {

@Override
public void onScreenCaptured() {
// 监听到截图了
}
};
  1. 在合适的时机注册监听
public final class XxxActivity extends Activity {

@Override
protected void onStart() {
super.onStart();
registerScreenCaptureCallback(executor, screenCaptureCallback);
}
}
  1. 在合适的时机取消注册监听
public final class XxxActivity extends Activity {

@Override
protected void onStop() {
super.onStop();
unregisterScreenCaptureCallback(screenCaptureCallback);
}
}
  • 需要注意的是,如果使用的是 adb 进行的截图,并不会触发 onScreenCaptured 监听方法。
  • 如果不想你的应用能被系统截图,可以考虑给当前的 Window 窗口加上 WindowManager.LayoutParams.FLAG_SECURE 标记位。
  • 最后表达一下我对这个 API 看法,这个 API 设计得不是很好,比如应用想知道用户是否截图了,应用可能需要知道的是,截图文件的存放路径,但是 onScreenCaptured 是一个空参函数,也就意味着没有携带任何参数,如果要实现获取截图文件存放路径的需求,可能还需要沿用之前的老方式,即使用 ContentObserver 监听媒体数据库的变化,然后从几个维度(文件时间维度、文件路径维度、图片尺寸维度)判断新增的图片是否为用户的截图,这种实现的方式相对是比较麻烦的,但是也无发现更好的实现方式。
  • 完结,撒花 ✿✿ヽ(°▽°)ノ✿


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

    Android 换种方式实现ViewPager

    一、可行性分析 ViewPager 是一款相对成熟的 Pager 切换 View,能够实现各种优秀的页面效果,也有不少问题,比如频繁会 requestLayout,另外的话如果是加载到 ListView 或者 RecyclerView 非固定头部,会偶现白屏或...
    继续阅读 »

    一、可行性分析


    ViewPager 是一款相对成熟的 Pager 切换 View,能够实现各种优秀的页面效果,也有不少问题,比如频繁会 requestLayout,另外的话如果是加载到 ListView 或者 RecyclerView 非固定头部,会偶现白屏或者 drawble 状态无法更新,还有就是 fragment 数量无法更新,需要重写 FragmentPagerAdapter 才行。


    使用 RecyclerView 相对 ViewPager 来说,会避免很多问题,比如如果是轮播组件 View 可以复用而且会避免白屏问题,当然今天我们使用 RecyclerView 代替 ViewPager 虽然也没有实现复用,但并不影响和 ViewPager 同样的体验。



    二、代码实现


    具体原理是我们在 RecyclerView.Adapter 的如下两个方法中实现 fragment 的 detach 和 attach,这样可以保证 Fragment 的生命周期得到准确执行。


    onViewAttachedToWindow

    onViewDetachedFromWindow

    FragmentPagerAdapter 源码如下(核心代码),另外需要指明的一点是我们使用 PagerSnapHelper 来辅助页面滑动:


    public abstract class FragmentPagerAdapter extends RecyclerView.Adapter<FragmentViewHolder> {

    private static final String TAG = "FragmentPagerAdapter";

    private final FragmentManager mFragmentManager;

    private Fragment mCurrentPrimaryItem = null;
    private PagerSnapHelper snapHelper;

    private RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() {
    @Override
    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
    super.onScrollStateChanged(recyclerView, newState);
    if (newState != RecyclerView.SCROLL_STATE_IDLE) return;
    if (snapHelper == null) return;
    View snapView = snapHelper.findSnapView(recyclerView.getLayoutManager());
    if (snapView == null) return;
    FragmentViewHolder holder = (FragmentViewHolder) recyclerView.getChildViewHolder(snapView);
    setPrimaryItem(holder.getHelper().getFragment());

    }

    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    super.onScrolled(recyclerView, dx, dy);
    }
    };

    public FragmentPagerAdapter(FragmentManager fm) {
    this.mFragmentManager = fm;

    }

    @Override
    public FragmentViewHolder onCreateViewHolder(ViewGr0up parent, int position) {
    RecyclerView recyclerView = (RecyclerView) parent;

    if (snapHelper == null) {
    snapHelper = new PagerSnapHelper();
    recyclerView.addOnScrollListener(onScrollListener);
    snapHelper.attachToRecyclerView(recyclerView);
    }

    FragmentHelper host = new FragmentHelper(recyclerView, getItemViewType(position));
    return new FragmentViewHolder(host);
    }

    @Override
    public void onBindViewHolder(FragmentViewHolder holder, int position) {
    holder.getHelper().updateFragment();

    }


    public abstract Fragment getFragment(int viewType);

    @Override
    public abstract int getItemViewType(int position);


    public Fragment instantiateItem(FragmentHelper host, int position, int fragmentType) {

    FragmentTransaction transaction = host.beginTransaction(mFragmentManager);

    final long itemId = getItemId(position);

    String name = makeFragmentName(host.getContainerId(), itemId, fragmentType);
    Fragment fragment = mFragmentManager.findFragmentByTag(name);
    if (fragment != null) {
    if (BuildConfig.DEBUG) Log.v(TAG, "Attaching item #" + itemId + ": f=" + fragment);
    transaction.attach(fragment);
    } else {
    fragment = getFragment(fragmentType);
    if (BuildConfig.DEBUG) Log.v(TAG, "Adding item #" + itemId + ": f=" + fragment);
    transaction.add(host.getContainerId(), fragment,
    makeFragmentName(host.getContainerId(), itemId, fragmentType));
    }
    if (fragment != mCurrentPrimaryItem) {
    fragment.setMenuVisibility(false);
    fragment.setUserVisibleHint(false);
    }

    return fragment;
    }


    @Override
    public abstract long getItemId(int position);

    @SuppressWarnings("ReferenceEquality")
    public void setPrimaryItem(Fragment fragment) {
    if (fragment != mCurrentPrimaryItem) {
    if (mCurrentPrimaryItem != null) {
    mCurrentPrimaryItem.setMenuVisibility(false);
    mCurrentPrimaryItem.setUserVisibleHint(false);
    }
    if (fragment != null) {
    fragment.setMenuVisibility(true);
    fragment.setUserVisibleHint(true);
    }
    mCurrentPrimaryItem = fragment;
    }
    }

    private static String makeFragmentName(int viewId, long id, int fragmentType) {
    return "android:recyclerview:fragment:" + viewId + ":" + id + ":" + fragmentType;
    }

    @Override
    public void onViewAttachedToWindow(FragmentViewHolder holder) {
    super.onViewAttachedToWindow(holder);
    FragmentHelper host = holder.getHelper();
    Fragment fragment = instantiateItem(holder.getHelper(), holder.getAdapterPosition(), getItemViewType(holder.getAdapterPosition()));
    host.setFragment(fragment);
    host.finishUpdate();
    if (BuildConfig.DEBUG) {
    Log.d("Fragment", holder.getHelper().getFragment().getTag() + " attach");
    }
    }


    @Override
    public void onViewDetachedFromWindow(FragmentViewHolder holder) {
    super.onViewDetachedFromWindow(holder);
    destroyItem(holder.getHelper(), holder.getAdapterPosition());
    holder.getHelper().finishUpdate();

    if (BuildConfig.DEBUG) {
    Log.d("Fragment", holder.getHelper().getFragment().getTag() + " detach");
    }
    }

    public void destroyItem(FragmentHelper host, int position) {
    FragmentTransaction transaction = host.beginTransaction(mFragmentManager);

    if (BuildConfig.DEBUG) Log.v(TAG, "Detaching item #" + getItemId(position) + ": f=" + host.getFragment()
    + " v=" + ((Fragment) host.getFragment()).getView());
    transaction.detach((Fragment) host.getFragment());
    }

    }

    ViewHolder 源码,本类的主要作用是给 FragmentManager 打桩,其次还有个作用是连接 FragmentHelper(负责 Fragment 的事务)


    public class FragmentViewHolder extends RecyclerView.ViewHolder {

    private FragmentHelper mHelper;

    public FragmentViewHolder(FragmentHelper host) {
    super(host.getFragmentView());
    this.mHelper = host;
    }

    public FragmentHelper getHelper() {
    return mHelper;
    }
    }

    FragmentHelper 源码


    public class FragmentHelper {

    private final int id;
    private final Context context;
    private Fragment fragment;
    private ViewGr0up containerView;
    private FragmentTransaction fragmentTransaction;

    public FragmentHelper(RecyclerView recyclerView, int fragmentType) {
    this.id = recyclerView.getId() + fragmentType + 1;
    // 本id依赖于fragment,因此为防止fragmentManager将RecyclerView视为容器,直接将View加载到RecyclerView中,这种View缺少VewHolder,会出现空指针问题,这里加1
    Activity activity = getRealActivity(recyclerView.getContext());
    this.id = getUniqueFakeId(activity,this.id);

    this.context = recyclerView.getContext();
    this.containerView = buildDefualtContainer(this.context,this.id);
    }

    public FragmentHelper(RecyclerView recyclerView,int layoutId, int fragmentType) {

    this.context = recyclerView.getContext();
    this.containerView = (ViewGr0up) LayoutInflater.from( this.context).inflate(layoutId,recyclerView,false);
    Activity activity = getRealActivity(recyclerView.getContext());
    this.id = getUniqueFakeId(activity,this.id);

    this.containerView.setId(id);
    // 本id依赖于fragment,因此为防止fragmentManager多次复用同一个view,这里加1
    }


    private int getUniqueFakeId(Activity activity, int id) {
    if(activity==null){
    return id;
    }
    int newId = id;
    do{
    View v = activity.findViewById(id);
    if(v!=null){
    newId += 1;
    continue;
    }
    newId = id;
    break;
    }while (true);
    return newId;
    }


    public void setFragment(Fragment fragment) {
    this.fragment = fragment;
    }

    public View getFragmentView() {

    return containerView;
    }

    private static ViewGr0up buildDefualtContainer(Context context,int id) {
    FrameLayout frameLayout = new FrameLayout(context);
    RecyclerView.LayoutParams lp = new RecyclerView.LayoutParams(ViewGr0up.LayoutParams.MATCH_PARENT, ViewGr0up.LayoutParams.MATCH_PARENT);
    frameLayout.setLayoutParams(lp);
    frameLayout.setId(id);
    return frameLayout;
    }

    public int getContainerId() {
    return id;
    }

    public void updateFragment() {

    }

    public Fragment getFragment() {
    return fragment;
    }

    public void finishUpdate() {
    if (fragmentTransaction != null) {
    fragmentTransaction.commitNowAllowingStateLoss();
    fragmentTransaction = null;
    }
    }

    public FragmentTransaction beginTransaction(FragmentManager fragmentManager) {
    if (this.fragmentTransaction == null) {
    this.fragmentTransaction = fragmentManager.beginTransaction();
    }
    return this.fragmentTransaction;
    }
    }

    以上提供了一个非常完美的 FragmentPagerAdapter,来支持 RecyclerView 加载 Fragment


    三、新问题


    在 Fragment 使用 RecyclerView 列表时会出现如下问题


    1、交互不准确,比如垂直滑动会变成 Pager 滑动效果


    2、页面 fling 效果出现闪动


    3、事件冲突,导致滑动不了


    因此为了解决上述问题,进行了一下规避


    public class RecyclerPager extends RecyclerView {

    private final DisplayMetrics mDisplayMetrics;
    private int pageTouchSlop = 0;
    float startX = 0;
    float startY = 0;
    boolean canHorizontalSlide = false;

    public RecyclerPager(Context context) {
    this(context, null);
    }

    public RecyclerPager(Context context, @Nullable AttributeSet attrs) {
    this(context, attrs, 0);
    }

    public RecyclerPager(Context context, @Nullable AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    pageTouchSlop = ViewConfiguration.get(context).getScaledPagingTouchSlop();
    mDisplayMetrics = getResources().getDisplayMetrics();

    }

    private int captureMoveAction = 0;
    private int captureMoveCounter = 0;

    @Override
    public boolean dispatchTouchEvent(MotionEvent e) {

    switch (e.getAction()) {
    case MotionEvent.ACTION_DOWN:
    startX = e.getX();
    startY = e.getY();
    canHorizontalSlide = false;
    captureMoveCounter = 0;
    Log.w("onTouchEvent_Pager", "down startY=" + startY + ",startX=" + startX);
    break;
    case MotionEvent.ACTION_MOVE:
    float currentX = e.getX();
    float currentY = e.getY();
    float dx = currentX - startX;
    float dy = currentY - startY;

    if (!canHorizontalSlide && Math.abs(dy) > Math.abs(dx)) {
    startX = currentX;
    startY = currentY;
    if (tryCaptureMoveAction(e)) {
    canHorizontalSlide = false;
    return true;
    }
    break;
    }

    if (Math.abs(dx) > pageTouchSlop && canScrollHorizontally((int) -dx)) {
    canHorizontalSlide = true;
    }

    //这里取相反数,滑动方向与滚动方向是相反的

    Log.d("onTouchEvent_Pager", "move dx=" + dx +",dy="+dy+ ",currentX=" + currentX+",currentY="+currentY + ",canHorizontalSlide=" + canHorizontalSlide);
    if (canHorizontalSlide) {
    startX = currentX;
    startY = currentY;

    if (captureMoveAction == MotionEvent.ACTION_MOVE) {
    return super.dispatchTouchEvent(e);

    }
    if (tryCaptureMoveAction(e)) {
    canHorizontalSlide = false;
    return true;
    }

    }
    break;
    }

    return super.dispatchTouchEvent(e);
    }

    /**
    * 尝试捕获事件,防止事件后被父/子View主动捕获后无法改变捕获状态,简单的说就是没有cancel掉事件
    *
    * @param e 当前事件
    * @return 返回ture表示发送了cancel->down事件
    */

    private boolean tryCaptureMoveAction(MotionEvent e) {

    if (captureMoveAction == MotionEvent.ACTION_MOVE) {
    return false;
    }
    captureMoveCounter++;

    if (captureMoveCounter != 2) {
    return false;
    }
    MotionEvent eventDownMask = MotionEvent.obtain(e);
    eventDownMask.setAction(MotionEvent.ACTION_DOWN);
    Log.d("onTouchEvent_Pager", "事件转换");
    super.dispatchTouchEvent(eventDownMask);

    return true;

    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
    super.onInterceptTouchEvent(e); //该逻辑需要保留,因为recyclerView有自身事件处理
    captureMoveAction = e.getAction();

    switch (e.getActionMasked()) {
    case MotionEvent.ACTION_UP:
    case MotionEvent.ACTION_CANCEL:
    case MotionEvent.ACTION_OUTSIDE:
    canHorizontalSlide = false;//不要拦截该类事件
    break;

    }
    if (canHorizontalSlide) {
    return true;
    }
    return false;
    }

    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow, int type) {
    consumed[1] = dy;
    return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
    }

    @Override
    public int getMinFlingVelocity() {
    return (int) (super.getMinFlingVelocity() * mDisplayMetrics.density);
    }

    @Override
    public int getMaxFlingVelocity() {
    return (int) (super.getMaxFlingVelocity()* mDisplayMetrics.density);
    }

    @Override
    public boolean fling(int velocityX, int velocityY) {
    velocityX = (int) (velocityX / mDisplayMetrics.scaledDensity);
    return super.fling(velocityX, velocityY);
    }
    }

    四、使用


    创建一个 fragment


        @SuppressLint("ValidFragment")
    public static class TestFragment extends Fragment{

    private final int color;
    private String name;

    private int[] colors = {
    0xffDC143C,
    0xff66CDAA,
    0xffDEB887,
    Color.RED,
    Color.BLACK,
    Color.CYAN,
    Color.GRAY
    };
    public TestFragment(int viewType) {
    this.name = "id#"+viewType;
    this.color = colors[viewType%colors.length];
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGr0up container, @Nullable Bundle savedInstanceState) {

    View convertView = inflater.inflate(R.layout.test_fragment, container, false);
    TextView textView = convertView.findViewById(R.id.text);
    textView.setText("fagment: "+name);
    convertView.setBackgroundColor(color);

    if(BuildConfig.DEBUG){
    Log.d("Fragment","onCreateView "+name);
    }
    return convertView;

    }


    @Override
    public void onResume() {
    super.onResume();

    if(BuildConfig.DEBUG){
    Log.d("Fragment","onResume");
    }
    }

    @Override
    public void setUserVisibleHint(boolean isVisibleToUser) {
    super.setUserVisibleHint(isVisibleToUser);
    Log.d("Fragment","setUserVisibleHint"+name);
    }

    @Override
    public void onDestroyView() {
    super.onDestroyView();

    if(BuildConfig.DEBUG){
    Log.d("Fragment","onDestroyView" +name);
    }
    }
    }

    接着我们实现 FragmentPagerAdapter


     public static class MyFragmentPagerAdapter extends FragmentPagerAdapter{

    public MyFragmentPagerAdapter(FragmentManager fm) {
    super(fm);
    }

    @Override
    public Fragment getFragment(int viewType) {
    return new TestFragment(viewType);
    }

    @Override
    public int getItemViewType(int position) {
    return position;
    }

    @Override
    public long getItemId(int position) {
    return position;
    }

    @Override
    public int getItemCount() {
    return 3;
    }
    }

    下面设置 Adapter


     RecyclerView recyclerPagerView = findViewById(R.id.loopviews);
    recyclerPagerView.setLayoutManager(new
    LinearLayoutManager(this,LinearLayoutManager.HORIZONTAL,false));
    recyclerPagerView.setAdapter(new MyFragmentPagerAdapter(getSupportFragmentManager()));

    五、总结


    整个过程轻松而愉快,当然本篇主要学习的是RcyclerView事件冲突的解决,突发奇想然后就写了个轮子,看样子是没什么大问题。


    作者:时光少年
    来源:juejin.cn/post/7307887970664595456
    收起阅读 »

    鸿蒙 Ark ui 视频播放组件 我不允许你不会

    前言: 各位同学有段时间没有见面 因为一直很忙所以就没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章 概述 在手机、平板或是智慧屏这些终端设备上,媒...
    继续阅读 »

    前言:


    各位同学有段时间没有见面 因为一直很忙所以就没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章


    概述


    在手机、平板或是智慧屏这些终端设备上,媒体功能可以算作是我们最常用的场景之一。无论是实现音频的播放、录制、采集,还是视频的播放、切换、循环,亦或是相机的预览、拍照等功能,媒体组件都是必不可少的。以视频功能为例,在应用开发过程中,我们需要通过ArkUI提供的Video组件为应用增加基础的视频播放功能。借助Video组件,我们可以实现视频的播放功能并控制其播放状态。常见的视频播放场景包括观看网络上的较为流行的短视频,也包括查看我们存储在本地的视频内容


    效果图


    image.png


    image.png


    具体实现:




    • 1 添加网络权限




    在module.json5 里面添加网络访问权限


    "requestPermissions": [
    {
    "name": "ohos.permission.INTERNET"
    }
    ]

    image.png
    如果你是播放本地视频那么可以不添加这个 为了严谨我这边就提一下


    我们要播放视频需要用到 video 组件


    video 组件里面参数说明


    参数名参数类型必填
    srcstringResource
    currentProgressRatenumberstringPlaybackSpeed8+
    previewUristringPixelMap8+Resource
    controllerVideoController
    其他属性说明 :
    .muted(false) //是否静音。默认值:false
    .controls(true)//不显示控制栏
    .autoPlay(false) // 手动点击播放
    .loop(false) // 关闭循环播放
    .objectFit(ImageFit.Cover) //设置视频显示模式。默认值:Cover

    具体代码


    @Entry
    @Component
    struct Index {


    @Styles
    customMargin() {
    .margin({ left: 20, right: 20 })
    }

    @State message: string = 'Hello World'
    private controller: VideoController = new VideoController();
    build() {
    Row() {
    Column() {
    Video({
    src: $rawfile('video1.mp4'),
    previewUri: $r('app.media.image3'),
    controller: this.controller
    })
    .muted(false) //是否静音。默认值:false
    .controls(true)//不显示控制栏
    .autoPlay(false) // 手动点击播放
    .loop(false) // 关闭循环播放
    .objectFit(ImageFit.Cover) //设置视频显示模式。默认值:Cover
    .customMargin()// 样式
    .height(200) // 高度
    }
    .width('100%')
    }
    .height('100%')
    }
    }

    最后总结


    鸿蒙的视频播放和安卓还有iOS .里面差不多都有现成的组件使用, 但是底层还是有ffmpeg 的支持。 我们作为上层开发者只需要熟练掌握api使用即可做出来 一个实用的播放器 app, 还有很多细节 由于篇幅有限我就展开讲了 我们下一期再见 最后呢 希望我都文章能帮助到各位同学工作和学习 如果你觉得文章还不错麻烦给我三连 关注点赞和转发 谢谢


    作者:坚果派_xq9527
    来源:juejin.cn/post/7308620787329105971
    收起阅读 »

    前几天有个雏鹰问我,说怎么创建Menu???

    这个很简单了哈,直接上代码算了 自己在这个路径下面创建一个这个的这个这个这个,很直观吧 <?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.a...
    继续阅读 »

    这个很简单了哈,直接上代码算了
    自己在这个路径下面创建一个这个的这个这个这个,很直观吧


    截屏2023-11-29 22.13.12.png


    <?xml version="1.0" encoding="utf-8"?>
    <menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <item
    android:id="@+id/list_view"
    android:title="@string/listview">

    <menu>
    <item
    android:id="@+id/list_view_vertical_only"
    android:title="垂直标准"
    tools:ignore="DuplicateIds" />

    <item
    android:id="@+id/list_view_vertical_reverse"
    android:title="垂直反向" />

    <item
    android:id="@+id/list_view_horizontal_only"
    android:title="水平标准" />

    <item
    android:id="@+id/list_view_horizontal_reverse"
    android:title="水平反转" />

    </menu>
    </item>
    </menu>

    然后读取目录路面的条目的时候有一个过滤器,把你自己添加的目录放进来,点击事件也帮你写好了,里面想怎么整自己搞,


    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu, menu);
    return super.onCreateOptionsMenu(menu);
    }

    @SuppressLint("NonConstantResourceId")
    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
    int itemId = item.getItemId();
    if (itemId != 0)
    switch (itemId){
    case R.id.list_view:
    break;
    case R.id.list_view_vertical_only:
    break;
    case R.id.list_view_vertical_reverse:
    break;
    case R.id.list_view_horizontal_only:
    break;
    case R.id.list_view_horizontal_reverse:
    break;
    }
    return super.onOptionsItemSelected(item);
    }

    结束结束,希望下次雏鹰可以自己看,或者自己搜下,很简单的东西


    作者:贾炬山
    来源:juejin.cn/post/7306706954678763556
    收起阅读 »

    Android APP合规检查,你可能需要这个工具~

    虽迟但到,这是一个通过拦截Java方法调用用以检测应用是否合规的工具,如果你的APP正饱受监管部门或应用市场时不时下发整改通知的折磨,那么用它来检查你的代码以及引用的三方库是再好不过的选择了! 如何引入 Step 1. 添加 mavenCentral all...
    继续阅读 »

    logo.png


    虽迟但到,这是一个通过拦截Java方法调用用以检测应用是否合规的工具,如果你的APP正饱受监管部门或应用市场时不时下发整改通知的折磨,那么用它来检查你的代码以及引用的三方库是再好不过的选择了!


    如何引入


    Step 1. 添加 mavenCentral


    allprojects {
    repositories {
    ...
    mavenCentral()
    }
    }

    Step 2. 添加 Gradle 依赖


    dependencies {
    ...
    implementation 'io.github.loper7:miit-rule-checker:0.1.1'
    }

    如何使用


    检查APP内是否存在不合规的方法调用



    检查MIITRuleChecker内置的不合规的方法,具体可见下方方法列表



     MIITRuleChecker.checkDefaults()


    如果内置的方法不满足当前需求,可自定义方法添加到list中进行检查;

    比如新增一个 MainActivity 的 onCreate 方法的调用检查;



    val list = MIITMethods.getDefaultMethods()
    list.add(MainActivity::class.java.getDeclaredMethod("onCreate" , Bundle::class.java)) MIITRuleChecker.check(list)

    当然,如果你想检查多个内置方法外的方法,只需要创建一个新的集合,往集合里放你想检查的方法member,然后传入 MIITRuleChecker.check()内即可。


    log打印如下所示:


    method_androidid.png


    检查指定方法调用并查看调用栈堆


    //查看 WifiInfo classgetMacAddress 的调用栈堆
    MIITRuleChecker.check(MIITMethods.WifiInfo.getMacAddress)

    log打印如下所示:


    method_macaddress.png


    检查一定时间内指定方法调用次数统计


    //多个方法统计 (deadline 为从方法调用开始到多少毫秒后截至统计)
    val list = mutableListOf().apply {
    add(MIITMethods.LocationManager.getLastKnownLocation)
    add(MIITMethods.LocationManager.requestLocationUpdates)
    add(MIITMethods.Secure.getString)
    }
    MIITMethodCountChecker.startCount(list , 20 * 1000)

    //单个方法统计(deadline 为从方法调用开始到多少毫秒后截至统计)
    MIITMethodCountChecker.startCount(MIITMethods.LocationManager.getLastKnownLocation , deadline = 20 * 1000)

    log打印如下所示:


    log_count.png


    检查完成并完成整改后务必移除方法 miit-rule-checker 库内的所有方法调用,将库一起移除最好


    内置方法表


    内置常量对应的系统方法备注
    MIITMethods.WifiInfo.getMacAddressandroid.net.wifi.WifiInfo.getMacAddress()获取MAC地址
    MIITMethods.WifiInfo.getIpAddressandroid.net.wifi.WifiInfo.getIpAddress()获取IP地址
    MIITMethods.LocationManager.getLastKnownLocationandroid.location.LocationManager.getLastKnownLocation(String)获取上次定位的地址
    MIITMethods.LocationManager.requestLocationUpdatesandroid.location.LocationManager.requestLocationUpdates(String,Long,Float,LocationListener)
    MIITMethods.NetworkInterface.getHardwareAddressjava.net.NetworkInterface.getHardwareAddress()获取主机地址
    MIITMethods.ApplicationPackageManager.getInstalledPackagesandroid.app.ApplicationPackageManager.getInstalledPackages(Int)获取已安装的应用
    MIITMethods.ApplicationPackageManager.getInstalledApplicationsandroid.app.ApplicationPackageManager.getInstalledApplications(Int)获取已安装的应用
    MIITMethods.ApplicationPackageManager.getInstallerPackageNameandroid.app.ApplicationPackageManager.getInstallerPackageName(String)获取应用安装来源
    MIITMethods.ApplicationPackageManager.getPackageInfoandroid.app.ApplicationPackageManager.getPackageInfo(String,Int)获取应用信息
    MIITMethods.PackageManager.getInstalledPackagesandroid.content.pm.PackageManager.getInstalledPackages(Int)获取已安装的应用
    MIITMethods.PackageManager.getInstalledApplicationsandroid.content.pm.PackageManager.getInstalledApplications(Int)获取已安装的应用
    MIITMethods.PackageManager.getInstallerPackageNameandroid.content.pm.PackageManager.getInstallerPackageName(String)获取应用安装来源
    MIITMethods.PackageManager.getPackageInfoandroid.content.pm.PackageManager.getPackageInfo(String,Int)获取应用信息
    MIITMethods.PackageManager.getPackageInfo1android.content.pm.PackageManager.getPackageInfo(String,PackageInfoFlags)获取应用信息(版本号大于33)
    MIITMethods.PackageManager.getPackageInfo2android.content.pm.PackageManager.getPackageInfo(VersionedPackage,Int)获取应用信息(版本号大于26)
    MIITMethods.PackageManager.getPackageInfo3android.content.pm.PackageManager.getPackageInfo(VersionedPackage,PackageInfoFlags)获取应用信息(版本号大于33)
    MIITMethods.Secure.getStringandroid.provider.Settings.Secure.getString(ContentResolver,String)获取androidId
    MIITMethods.TelephonyManager.getDeviceIdandroid.telephony.TelephonyManager.getDeviceId()获取 DeviceId
    MIITMethods.TelephonyManager.getDeviceIdWithIntandroid.telephony.TelephonyManager.getDeviceId(Int)获取 DeviceId
    MIITMethods.TelephonyManager.getImeiandroid.telephony.TelephonyManager.getImei()获取 Imei
    MIITMethods.TelephonyManager.getImeiWithIntandroid.telephony.TelephonyManager.getImei(Int)获取 Imei
    MIITMethods.TelephonyManager.getSubscriberIdandroid.telephony.TelephonyManager.getSubscriberId()获取 SubscriberId

    作者:LOPER7
    来源:juejin.cn/post/7307470097663688731
    收起阅读 »