注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

大公司为什么禁止SpringBoot项目使用Tomcat?

前言 在SpringBoot框架中,我们使用最多的是Tomcat,这是SpringBoot默认的容器技术,而且是内嵌式的Tomcat。同时,SpringBoot也支持Undertow容器,我们可以很方便的用Undertow替换Tomcat,而Undertow的...
继续阅读 »

前言


在SpringBoot框架中,我们使用最多的是Tomcat,这是SpringBoot默认的容器技术,而且是内嵌式的Tomcat。同时,SpringBoot也支持Undertow容器,我们可以很方便的用Undertow替换Tomcat,而Undertow的性能和内存使用方面都优于Tomcat,那我们如何使用Undertow技术呢?本文将为大家细细讲解。


SpringBoot中的Tomcat容器


SpringBoot可以说是目前最火的Java Web框架了。它将开发者从繁重的xml解救了出来,让开发者在几分钟内就可以创建一个完整的Web服务,极大的提高了开发者的工作效率。Web容器技术是Web项目必不可少的组成部分,因为任Web项目都要借助容器技术来运行起来。在SpringBoot框架中,我们使用最多的是Tomcat,这是SpringBoot默认的容器技术,而且是内嵌式的Tomcat。推荐:几乎涵盖你需要的SpringBoot所有操作


SpringBoot设置Undertow


对于Tomcat技术,Java程序员应该都非常熟悉,它是Web应用最常用的容器技术。我们最早的开发的项目基本都是部署在Tomcat下运行,那除了Tomcat容器,SpringBoot中我们还可以使用什么容器技术呢?没错,就是题目中的Undertow容器技术。SrpingBoot已经完全继承了Undertow技术,我们只需要引入Undertow的依赖即可,如下图所示。


image.png


image.png


配置好以后,我们启动应用程序,发现容器已经替换为Undertow。那我们为什么需要替换Tomcat为Undertow技术呢?


Tomcat与Undertow的优劣对比


Tomcat是Apache基金下的一个轻量级的Servlet容器,支持Servlet和JSP。Tomcat具有Web服务器特有的功能,包括 Tomcat管理和控制平台、安全局管理和Tomcat阀等。Tomcat本身包含了HTTP服务器,因此也可以视作单独的Web服务器。但是,Tomcat和ApacheHTTP服务器不是一个东西,ApacheHTTP服务器是用C语言实现的HTTP Web服务器。Tomcat是完全免费的,深受开发者的喜爱。


图片


Undertow是Red Hat公司的开源产品, 它完全采用Java语言开发,是一款灵活的高性能Web服务器,支持阻塞IO和非阻塞IO。由于Undertow采用Java语言开发,可以直接嵌入到Java项目中使用。同时, Undertow完全支持Servlet和Web Socket,在高并发情况下表现非常出色。


图片


我们在相同机器配置下压测Tomcat和Undertow,得到的测试结果如下所示:QPS测试结果对比: Tomcat


图片


Undertow


图片


内存使用对比:


Tomcat


image.png


Undertow


image.png


通过测试发现,在高并发系统中,Tomcat相对来说比较弱。在相同的机器配置下,模拟相等的请求数,Undertow在性能和内存使用方面都是最优的。并且Undertow新版本默认使用持久连接,这将会进一步提高它的并发吞吐能力。所以,如果是高并发的业务系统,Undertow是最佳选择。


最后


SpingBoot中我们既可以使用Tomcat作为Http服务,也可以用Undertow来代替。Undertow在高并发业务场景中,性能优于Tomcat。所以,如果我们的系统是高并发请求,不妨使用一下Undertow,你会发现你的系统性能会得到很大的提升。


作者:程序员追风
链接:https://juejin.cn/post/7117516341984755726
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Flutter 小技巧之优化使用的 BuildContext

Flutter 里的 BuildContext 相信大家都不会陌生,虽然它叫 Context,但是它实际是 Element 的抽象对象,而在 Flutter 里,它主要来自于 ComponentElement 。 关于 ComponentElement...
继续阅读 »

Flutter 里的 BuildContext 相信大家都不会陌生,虽然它叫 Context,但是它实际是 Element 的抽象对象,而在 Flutter 里,它主要来自于 ComponentElement


关于 ComponentElement 可以简单介绍一下,在 Flutter 里根据 Element 可以简单地被归纳为两类:



  • RenderObjectElement :具备 RenderObject ,拥有布局和绘制能力的 Element

  • ComponentElement :没有 RenderObject ,我们常用的 StatelessWidgetStatefulWidget 里对应的 StatelessElementStatefulElement 就是它的子类。


所以一般情况下,我们在 build 方法或者 State 里获取到的 BuildContext 其实就是 ComponentElement


那使用 BuildContext 有什么需要注意的问题


首先如下代码所示,在该例子里当用户点击 FloatingActionButton 的时候,代码里做了一个 2秒的延迟,然后才调用 pop 退出当前页面。


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Future.delayed(Duration(seconds: 2));
Navigator.of(context).pop();
},
),
);
}
}

正常情况下是不会有什么问题,但是当用户在点击了 FloatingActionButton 之后,又马上点击了 AppBar 返回退出应用,这时候就会出现以下的错误提示。



可以看到此时 log 说,Widget 对应的 Element 已经不在了,因为在 Navigator.of(context) 被调用时,context 对应的 Element 已经随着我们的退出销毁。


一般情况下处理这个问题也很简单,那就是增加 mounted 判断,通过 mounted 判断就可以避免上述的错误


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Future.delayed(Duration(seconds: 2));
if (!mounted) return;
Navigator.of(context).pop();
},
),
);
}
}

上面代码里的 mounted 标识位来自于 State因为 State 是依附于 Element 创建,所以它可以感知 Element 的生命周期,例如 mounted 就是判断 _element != null;



那么到这里我们收获了一个小技巧:使用 BuildContext 时,在必须时我们需要通过 mounted 来保证它的有效性


那么单纯使用 mounted 就可以满足 context 优化的要求了吗


如下代码所示,在这个例子里:



  • 我们添加了一个列表,使用 builder 构建 Item

  • 每个列表都有一个点击事件

  • 点击列表时我们模拟网络请求,假设网络也不是很好,所以延迟个 5 秒

  • 之后我们滑动列表让点击的 Item 滑出屏幕不可见


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: ListView.builder(
itemBuilder: (context, index) {
return ListItem();
},
itemCount: 30,
),
);
}
}
class ListItem extends StatefulWidget {
const ListItem({Key? key}) : super(key: key);
@override
State<ListItem> createState() => _ListItemState();
}

class _ListItemState extends State<ListItem> {
@override
Widget build(BuildContext context) {
return ListTile(
title: Container(
height: 160,
color: Colors.amber,
),
onTap: () async {
await Future.delayed(Duration(seconds: 5));
if(!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text("Tip")));
},
);
}
}

由于在 5 秒之内,Item 被划出了屏幕,所以对应的 Elment 其实是被释放了,从而由于 mounted 判断,SnackBar 不会被弹出。


那如果假设需要在开发时展示点击数据上报的结果,也就是 Item 被释放了还需要弹出,这时候需要如何处理


我们知道不管是 ScaffoldMessenger.of(context) 还是 Navigator.of(context) ,它本质还是通过 context 去往上查找对应的 InheritedWidget 泛型,所以其实我们可以提前获取。


所以,如下代码所示,在 Future.delayed 之前我们就通过 ScaffoldMessenger.of(context); 获取到 sm 对象,之后就算你直接退出当前的列表页面,5秒过后 SnackBar 也能正常弹出。


class _ListItemState extends State<ListItem> {
@override
Widget build(BuildContext context) {
return ListTile(
title: Container(
height: 160,
color: Colors.amber,
),
onTap: () async {
var sm = ScaffoldMessenger.of(context);
await Future.delayed(Duration(seconds: 5));
sm.showSnackBar(SnackBar(content: Text("Tip")));
},
);
}
}

为什么页面销毁了,但是 SnackBar 还能正常弹出


因为此时通过 of(context); 获取到的 ScaffoldMessenger 是存在 MaterialApp 里,所以就算页面销毁了也不影响 SnackBar 的执行。


但是如果我们修改例子,如下代码所示,在 Scaffold 上面多嵌套一个 ScaffoldMessenger ,这时候在 Item 里通过 ScaffoldMessenger.of(context) 获取到的就会是当前页面下的 ScaffoldMessenger


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return ScaffoldMessenger(
child: Scaffold(
appBar: AppBar(),
body: ListView.builder(
itemBuilder: (context, index) {
return ListItem();
},
itemCount: 30,
),
),
);
}
}

这种情况下我们只能保证Item 不可见的时候 SnackBar 还能正常弹出, 而如果这时候我们直接退出页面,还是会出现以下的错误提示,因为 ScaffoldMessenger 也被销毁了 。



所以到这里我们收获第二个小技巧:在异步操作里使用 of(context) ,可以提前获取,之后再做异步操作,这样可以尽量保证流程可以完整执行


既然我们说到通过 of(context) 去获取上层共享往下共享的 InheritedWidget ,那在哪里获取就比较好


还记得前面的 log 吗?在第一个例子出错时,log 里就提示了一个方法,也就是 State 的 didChangeDependencies 方法。



为什么是官方会建议在这个方法里去调用 of(context)


首先前面我们一直说,通过 of(context) 获取到的是 InheritedWidget ,而 当 InheritedWidget 发生改变时,就是通过触发绑定过的 Element 里 State 的didChangeDependencies 来触发更新,所以在 didChangeDependencies 里调用 of(context) 有较好的因果关系



对于这部分内容感兴趣的,可以看 Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密全面理解State与Provider



那我能在 initState 里提前调用吗


当然不行,首先如果在 initState 直接调用如 ScaffoldMessenger.of(context).showSnackBar 方法,就会看到以下的错误提示。



这是因为 Element 里会判断此时的 _StateLifecycle 状态,如果此时是 _StateLifecycle.created 或者 _StateLifecycle.defunct ,也就是在 initStatedispose ,是不允许执行 of(context) 操作。




of(context) 操作指的是 context.dependOnInheritedWidgetOfExactTyp



当然,如果你硬是想在 initState 下调用也行,增加一个 Future 执行就可以成功执行


@override
void initState() {
super.initState();
Future((){
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("initState")));
});
}


简单理解,因为 Dart 是单线程轮询执行,initState 里的 Future 相当于是下一次轮询,自然也就不在 _StateLifecycle.created 的状态下。



那我在 build 里直接调用不行吗


直接在 build 里调用肯定可以,虽然 build 会被比较频繁执行,但是 of(context) 操作其实就是在一个 map 里通过 key - value 获取泛型对象,所以对性能不会有太大的影响。


真正对性能有影响的是 of(context) 的绑定数量和获取到对象之后的自定义逻辑,例如你通过 MediaQuery.of(context).size 获取到屏幕大小之后,通过一系列复杂计算来定位你的控件。


  @override
Widget build(BuildContext context) {
var size = MediaQuery.of(context).size;
var padding = MediaQuery.of(context).padding;
var width = size.width / 2;
var height = size.width / size.height * (30 - padding.bottom);
return Container(
color: Colors.amber,
width: width,
height: height,
);
}

例如上面这段代码,可能会导致键盘在弹出的时候,虽然当前页面并没有完全展示,但是也会导致你的控件不断重新计算从而出现卡顿。



详细解释可以参考 Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密



所以到这里我们又收获了一个小技巧: 对于 of(context) 的相关操作逻辑,可以尽量放到 didChangeDependencies 里去处理


最后,今天主要分享了在使用 BuildContext 时的一些注意事项和技巧,如果你对于这方面还有什么疑问,欢迎留言评论。


作者:恋猫de小郭
链接:https://juejin.cn/post/7122409135055831053
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

记录一个温度曲线的View

 最近做项目需求的看到需要自定义一个温度曲线的图。由于之前的同事理解需求的时候没有很好的理解产品的需求,将温度的折线图分成了两个View,温度高的在一个View,温度低的在一个View。这样的做法其实是没有很好的理解产品的需求的。为什么这么说,因为一...
继续阅读 »

image-20220713155216246.png 最近做项目需求的看到需要自定义一个温度曲线的图。由于之前的同事理解需求的时候没有很好的理解产品的需求,将温度的折线图分成了两个View,温度高的在一个View,温度低的在一个View。这样的做法其实是没有很好的理解产品的需求的。为什么这么说,因为一旦拆成两个View,那么哪些相交的点绘制就会有缺陷了。什么意思,看图。

image-20220713155901206.png

如果按照两个View去做,就会有这种局限性。相交的点就会被切。所以这里就重新修改了这个自定义View。

有了上面的需求,那么就开始我们的设计了。首先为了我们自定义View的能比较好的通用性,我们需要把一些可能会变的东西提取出来。这里只是提取一些很常用的属性,其余需要自定义的,可自己加上。直接看代码

<declare-styleable name="NewWeatherChartView">
   <!--开始的x坐标-->
   <attr name="new_start_point_x" format="dimension"/>
   <!--两点之间x坐标的间隔-->
   <attr name="new_point_x_margin" format="dimension"/>
   <!--显示温度的字体大小-->
   <attr name="temperature_text_size" format="dimension"/>
   <!--圆点的半径-->
   <attr name="point_radius" format="dimension"/>

   <!--选中天气项,温度字体的颜色-->
   <attr name="select_temperature_text_color" format="reference|color"/>
   <!--未选中天气项,温度字体的颜色-->
   <attr name="unselect_temperature_text_color" format="reference|color"/>
   <!--选中天气项,圆点的颜色-->
   <attr name="select_point_color" format="reference|color"/>
   <!--未选中天气项,圆点的颜色-->
   <attr name="unselect_point_color" format="reference|color"/>
<!--连接线的颜色-->
   <attr name="line_color" format="reference|color"/>
   <!--连接线的类型,可以是实线,也可以是虚线,默认是虚线。0虚线,1实线-->
   <attr name="line_type" format="integer"/>

</declare-styleable>
public class NewWeatherChartView extends View {
   private final static String TAG = "NewWeatherChartView";
   private List<WeatherInfo> items;//温度的数据源

   //都是可以在XML里面配置的属性,目前项目里面都是用的默认配置。
   private int mLineColor;
   private int mSelectTemperatureColor;
   private int mUnSelectTemperatureColor;
   private int mSelectPointColor;
   private int mUnselectPointColor;
   private int mLineType;
   private int mTemperatureTextSize;
   private int mPointStartX = 0;
   private int mPointXMargin = 0;
   private int mPointRadius;


   
   private Point[] mHighPoints; //高温的点的坐标
   private Point[] mLowPoints; //低温的点的坐标

   //这里是为了方便写代码,多创建了几个画笔,也可以用一个画笔,然后配置不同的属性
   private Paint mLinePaint; //用于画线画笔
   private Paint mTextPaint; // 用于画小圆点旁边的温度文字的画笔
   private Paint mCirclePaint;//用来画小圆点的画笔
 

   private Float mMaxTemperature = Float.MIN_VALUE;//最高温度
   private Float mMinTemperature = Float.MAX_VALUE;//最低温度
   private Path mPath;//连接线的路径
   
   private DecimalFormat mDecimalFormat;


   private int mTodayIndex = -1;//用于判断哪一个被选中

   private Context mContext;
...
}

以上就是一些初始化的东西了,那么现在就来思考一下,怎么去画这些东西,上面的初始化也说明了,我们主要是画线,画文字,然后画圆点。那么应该从哪开始呢?首先是从点坐标开始,因为无论是线,还是文字,他们的位置和点都有关系。那么找到点的坐标就是首要的工作。怎么找点的坐标,以及最开始的X坐标是多少。第一个点的X坐标是根据我们的配置来的,那么第二个点的x坐标呢?,第二个点的x坐标就是第一个点的x坐标加上他们之间的在X方向上距离,而在x方向上的距离也是根据属性配置的。所以我们可以很容易得到所有点的x坐标。那么圆点的y坐标呢?首先我们看一张图。

image-20220713172903532.png

我们的点,应该是均匀分布在剩余高度里面的。

剩余高度 = 控件高度-2*文字的高度。

点的y坐标为

*剩余高度-((当前温度-最低温度)/(最高温度-最低温度)剩余高度)+文字高度

看起来有点复杂,但是有公式的话,代码会比较简单。接下来就需要看初始化的代码了和计算点坐标的代码了

代码如下:

//首先从两个参数的构造函数里面获取各种配置的值
public NewWeatherChartView(Context context, AttributeSet attrs) {
   super(context, attrs);
   TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.NewWeatherChartView);
   mPointStartX = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_new_start_point_x, 0);
   mPointXMargin = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_new_point_x_margin, 0);
   mTemperatureTextSize = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_temperature_text_size, 20);
   mPointRadius = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_point_radius, 8);

   mSelectPointColor = typedArray.getColor(R.styleable.NewWeatherChartView_select_point_color, context.getResources().getColor(R.color.weather_select_point_color));
   mUnselectPointColor = typedArray.getColor(R.styleable.NewWeatherChartView_unselect_point_color, context.getResources().getColor(R.color.weather_unselect_point_color));
   mLineColor = typedArray.getColor(R.styleable.NewWeatherChartView_line_color, context.getResources().getColor(R.color.weather_line_color));
   mSelectTemperatureColor = typedArray.getColor(R.styleable.NewWeatherChartView_select_temperature_text_color, context.getResources().getColor(R.color.weather_select_temperature_color));
   mUnSelectTemperatureColor = typedArray.getColor(R.styleable.NewWeatherChartView_unselect_temperature_text_color, context.getResources().getColor(R.color.weather_unselect_temperature_color));

   mLineType = typedArray.getInt(R.styleable.NewWeatherChartView_line_type, 0);

   this.mContext = context;
   typedArray.recycle();
}

private void initData() {
   //初始化线的画笔
   mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
   mLinePaint.setStyle(Paint.Style.STROKE);
   mLinePaint.setStrokeWidth(2);
   mLinePaint.setDither(true);
   //配置虚线
   if (mLineType == 0) {
       DashPathEffect pathEffect = new DashPathEffect(new float[]{10, 5}, 1);
       mLinePaint.setPathEffect(pathEffect);
  }
   mPath = new Path();

   //初始化文字的画笔
   mTextPaint = new Paint();
   mTextPaint.setAntiAlias(true);
   mTextPaint.setTextSize(sp2px(mTemperatureTextSize));
   mTextPaint.setTextAlign(Paint.Align.CENTER);

   // 初始化圆点的画笔
   mCirclePaint = new Paint();
   mCirclePaint.setStyle(Paint.Style.FILL);

   mDecimalFormat = new DecimalFormat("0");

   for (int i = 0; i < items.size(); i++) {
       float highY = items.get(i).getHigh();
       float lowY = items.get(i).getLow();
       if (highY > mMaxTemperature) {
           mMaxTemperature = highY;
      }
       if (lowY < mMinTemperature) {
           mMinTemperature = lowY;
      }
       if (DateUtil.fromTodayDate(items.get(i).getDate()) == 0) {
           mTodayIndex = i;
      }
  }
   float span = mMaxTemperature - mMinTemperature;
   //这种情况是为了防止所有温度都一样的情况
   if (span == 0) {
       span = 6.0f;
  }
   mMaxTemperature = mMaxTemperature + span / 6.0f;
   mMinTemperature = mMinTemperature - span / 6.0f;

   mHighPoints = new Point[items.size()];
   mLowPoints = new Point[items.size()];
}

public int sp2px(float spValue) {
   return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, spValue, Resources.getSystem().getDisplayMetrics());
}

public int dip2px(float dpValue) {
   return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpValue, Resources.getSystem().getDisplayMetrics());

}

这些准备工作昨晚之后,我们就可以去onDraw里面画图了。

protected void onDraw(Canvas canvas) {
   Logging.d(TAG, "onDraw: ");
   if (items == null) {
       return;
  }
   int pointX = mPointStartX; // 开始的X坐标
   int textHeight = sp2px(mTemperatureTextSize);//文字的高度
   int remainingHeight = getHeight() - textHeight * 2;//除去文字后,剩余的高度

   // 计算每一个点的X和Y坐标
   for (int i = 0; i < items.size(); i++) {
       int x = pointX + mPointXMargin * i;
       float highTemp = items.get(i).getHigh();
       float lowTemp = items.get(i).getLow();
       int highY = remainingHeight - (int) (remainingHeight * ((highTemp - mMinTemperature) / (mMaxTemperature - mMinTemperature))) + textHeight;
       int lowY = remainingHeight - (int) (remainingHeight * ((lowTemp - mMinTemperature) / (mMaxTemperature - mMinTemperature))) + textHeight;
       mHighPoints[i] = new Point(x, highY);
       mLowPoints[i] = new Point(x, lowY);
  }

   // 画线
   drawLine(mHighPoints, canvas);
   drawLine(mLowPoints, canvas);
   for (int i = 0; i < mHighPoints.length; i++) {
       // 画文本度数 例如:3°
       String yHighText = mDecimalFormat.format(items.get(i).getHigh());
       String yLowText = mDecimalFormat.format(items.get(i).getLow());
       int highDrawY = mHighPoints[i].y - dip2px(mPointRadius + 8);
       int lowDrawY = mLowPoints[i].y + dip2px(mPointRadius + 8 + sp2px(mTemperatureTextSize));

       if (i == mTodayIndex) {
           mTextPaint.setColor(mSelectTemperatureColor);
           mCirclePaint.setColor(mSelectPointColor);
      } else {
           mTextPaint.setColor(mUnSelectTemperatureColor);
           mCirclePaint.setColor(mUnselectPointColor);
      }
       canvas.drawText(yHighText + "°", mHighPoints[i].x, highDrawY, mTextPaint);
       canvas.drawText(yLowText + "°", mLowPoints[i].x, lowDrawY, mTextPaint);
       canvas.drawCircle(mHighPoints[i].x, mHighPoints[i].y, mPointRadius, mCirclePaint);
       canvas.drawCircle(mLowPoints[i].x, mLowPoints[i].y, mPointRadius, mCirclePaint);

  }
}


private void drawLine(Point[] ps, Canvas canvas) {
   Point startp;
   Point endp;
   mPath.reset();
   mLinePaint.setAntiAlias(true);
   for (int i = 0; i < ps.length - 1; i++) {
       startp = ps[i];
       endp = ps[i + 1];
       mLinePaint.setColor(mLineColor);
       canvas.drawLine(startp.x, startp.y, endp.x, endp.y, mLinePaint);
  }
}

以上就是所有关键代码了,当然,还有一个赋值的代码

public void setData(List<WeatherInfo> list) {
   this.items = list;
   initData();
}

来看一下最后的效果图吧。

image-20220713194524550.png 以上就是一个简单的温度图了,但是这个图有很多地方可以优化,也有很多地方可以提取出来当作属性。比如我举一个优化的点,文字的测量,上面的代码对文字的测量其实是非常粗糙的。仔细观察会发现上面一条线,文字距离点的距离和下面一条线文字距离点的距离是不一样的。这就是上面没有进行文字测量的结果,我这里进行了一轮文字测量的优化,如下图: image-20220713194423946.png 这里是不是好很多了呢?大家还可以进行很多地方的优化。以上就是这篇文章的全部内容了。


作者:爱海贼的小码农
链接:https://juejin.cn/post/7119826029463470088
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android全局的通知的弹窗

需求分析 如何创建一个全局通知的弹窗?如下图所示。 从手机顶部划入,短暂停留后,再从顶部划出。 首先需要明确的是: 1、这个弹窗的弹出逻辑不一定是当前界面编写的,比如用户上传文件,用户可能继续浏览其他页面的内容,但是监听文件是否上传完成还是在原来的Activ...
继续阅读 »

需求分析


如何创建一个全局通知的弹窗?如下图所示。


image.png


从手机顶部划入,短暂停留后,再从顶部划出。


首先需要明确的是:

1、这个弹窗的弹出逻辑不一定是当前界面编写的,比如用户上传文件,用户可能继续浏览其他页面的内容,但是监听文件是否上传完成还是在原来的Activity,但是Dialog的弹出是需要当前页面的上下文Context的。


2、Dialog弹窗必须支持手势,用户在Dialog上向上滑时,Dialog需要退出,点击时可能需要处理点击事件。


一、Dialog的编写


/**
* 通知的自定义Dialog
*/
class NotificationDialog(context: Context, var title: String, var content: String) :
Dialog(context, R.style.dialog_notifacation_top) {

private var mListener: OnNotificationClick? = null
private var mStartY: Float = 0F
private var mView: View? = null
private var mHeight: Int? = 0

init {
mView = LayoutInflater.from(context).inflate(R.layout.common_layout_notifacation, null)
}


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(mView!!)
window?.setGravity(Gravity.TOP)
val layoutParams = window?.attributes
layoutParams?.width = ViewGroup.LayoutParams.MATCH_PARENT
layoutParams?.height = ViewGroup.LayoutParams.WRAP_CONTENT
layoutParams?.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
window?.attributes = layoutParams
window?.setWindowAnimations(R.style.dialog_animation)
//按空白处不能取消
setCanceledOnTouchOutside(false)
//初始化界面数据
initData()
}

private fun initData() {
val tvTitle = findViewById<TextView>(R.id.tv_title)
val tvContent = findViewById<TextView>(R.id.tv_content)
if (title.isNotEmpty()) {
tvTitle.text = title
}

if (content.isNotEmpty()) {
tvContent.text = content
}
}


override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (isOutOfBounds(event)) {
mStartY = event.y
}
}

MotionEvent.ACTION_UP -> {
if (mStartY > 0 && isOutOfBounds(event)) {
val moveY = event.y
if (abs(mStartY - moveY) >= 20) { //滑动超过20认定为滑动事件
//Dialog消失
} else { //认定为点击事件
//Dialog的点击事件
mListener?.onClick()
}
dismiss()
}
}
}
return false
}

/**
* 点击是否在范围外
*/
private fun isOutOfBounds(event: MotionEvent): Boolean {
val yValue = event.y
if (yValue > 0 && yValue <= (mHeight ?: (0 + 40))) {
return true
}
return false
}


private fun setDialogSize() {
mView?.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
mHeight = v?.height
}
}

/**
* 显示Dialog但是不会自动退出
*/
fun showDialog() {
if (!isShowing) {
show()
setDialogSize()
}
}

/**
* 显示Dialog,3000毫秒后自动退出
*/
fun showDialogAutoDismiss() {
if (!isShowing) {
show()
setDialogSize()
//延迟3000毫秒后自动消失
Handler(Looper.getMainLooper()).postDelayed({
if (isShowing) {
dismiss()
}
}, 3000L)
}
}

//处理通知的点击事件
fun setOnNotificationClickListener(listener: OnNotificationClick) {
mListener = listener
}

interface OnNotificationClick {
fun onClick()
}
}

Dialog的主题


<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">

<style name="dialog_notifacation_top">
<item name="android:windowIsTranslucent">true</item>
<!--设置背景透明-->
<item name="android:windowBackground">@android:color/transparent</item>
<!--设置dialog浮与activity上面-->
<item name="android:windowIsFloating">true</item>
<!--去掉背景模糊效果-->
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowNoTitle">true</item>
<!--去掉边框-->
<item name="android:windowFrame">@null</item>
</style>


<style name="dialog_animation" parent="@android:style/Animation.Dialog">
<!-- 进入时的动画 -->
<item name="android:windowEnterAnimation">@anim/dialog_enter</item>
<!-- 退出时的动画 -->
<item name="android:windowExitAnimation">@anim/dialog_exit</item>
</style>

</resources>

Dialog的动画


<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="600"
android:fromYDelta="-100%p"
android:toYDelta="0%p" />
</set>

<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="300"
android:fromYDelta="0%p"
android:toYDelta="-100%p" />
</set>

Dialog的布局,通CardView包裹一下就有立体阴影的效果


<androidx.cardview.widget.CardView
android:id="@+id/cd"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/size_15dp"
app:cardCornerRadius="@dimen/size_15dp"
app:cardElevation="@dimen/size_15dp"
app:layout_constraintTop_toTopOf="parent">

<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/et_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/size_15dp"
app:layout_constraintTop_toTopOf="parent">

<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#000000"
android:textSize="@dimen/font_14sp" android:textStyle="bold"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/size_15dp"
android:textColor="#333"
android:textSize="@dimen/font_12sp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_title" />


</androidx.constraintlayout.widget.ConstraintLayout>

</androidx.cardview.widget.CardView>

二、获取当前显示的Activity的弱引用


/**
* 前台Activity管理类
*/
class ForegroundActivityManager {

private var currentActivityWeakRef: WeakReference<Activity>? = null

companion object {
val TAG = "ForegroundActivityManager"
private val instance = ForegroundActivityManager()

@JvmStatic
fun getInstance(): ForegroundActivityManager {
return instance
}
}


fun getCurrentActivity(): Activity? {
var currentActivity: Activity? = null
if (currentActivityWeakRef != null) {
currentActivity = currentActivityWeakRef?.get()
}
return currentActivity
}


fun setCurrentActivity(activity: Activity) {
currentActivityWeakRef = WeakReference(activity)
}

}

监听所有Activity的生命周期


class AppLifecycleCallback:Application.ActivityLifecycleCallbacks {

companion object{
val TAG = "AppLifecycleCallback"
}

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
//获取Activity弱引用
ForegroundActivityManager.getInstance().setCurrentActivity(activity)
}

override fun onActivityStarted(activity: Activity) {
}

override fun onActivityResumed(activity: Activity) {
//获取Activity弱引用
ForegroundActivityManager.getInstance().setCurrentActivity(activity)
}

override fun onActivityPaused(activity: Activity) {
}

override fun onActivityStopped(activity: Activity) {
}

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

override fun onActivityDestroyed(activity: Activity) {
}
}

在Application中注册


//注册Activity生命周期
registerActivityLifecycleCallbacks(AppLifecycleCallback())

三、封装和使用


/**
* 通知的管理类
* example:
* //发系统通知
* NotificationControlManager.getInstance()?.notify("文件上传完成", "文件上传完成,请点击查看详情")
* //发应用内通知
* NotificationControlManager.getInstance()?.showNotificationDialog("文件上传完成","文件上传完成,请点击查看详情",
* object : NotificationControlManager.OnNotificationCallback {
* override fun onCallback() {
* Toast.makeText(this@MainActivity, "被点击了", Toast.LENGTH_SHORT).show()
* }
* })
*/

class NotificationControlManager {

private var autoIncreament = AtomicInteger(1001)
private var dialog: NotificationDialog? = null

companion object {
const val channelId = "aaaaa"
const val description = "描述信息"

@Volatile
private var sInstance: NotificationControlManager? = null


@JvmStatic
fun getInstance(): NotificationControlManager? {
if (sInstance == null) {
synchronized(NotificationControlManager::class.java) {
if (sInstance == null) {
sInstance = NotificationControlManager()
}
}
}
return sInstance
}
}


/**
* 是否打开通知
*/
fun isOpenNotification(): Boolean {
val notificationManager: NotificationManagerCompat =
NotificationManagerCompat.from(
ForegroundActivityManager.getInstance().getCurrentActivity()!!
)
return notificationManager.areNotificationsEnabled()
}


/**
* 跳转到系统设置页面去打开通知,注意在这之前应该有个Dialog提醒用户
*/
fun openNotificationInSys() {
val context = ForegroundActivityManager.getInstance().getCurrentActivity()!!
val intent: Intent = Intent()
try {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS

//8.0及以后版本使用这两个extra. >=API 26
intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
intent.putExtra(Settings.EXTRA_CHANNEL_ID, context.applicationInfo.uid)

//5.0-7.1 使用这两个extra. <= API 25, >=API 21
intent.putExtra("app_package", context.packageName)
intent.putExtra("app_uid", context.applicationInfo.uid)

context.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()

//其他低版本或者异常情况,走该节点。进入APP设置界面
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
intent.putExtra("package", context.packageName)

//val uri = Uri.fromParts("package", packageName, null)
//intent.data = uri
context.startActivity(intent)
}
}

/**
* 发通知
* @param title 标题
* @param content 内容
* @param cls 通知点击后跳转的Activity,默认为null跳转到MainActivity
*/
fun notify(title: String, content: String, cls: Class<*>) {
val context = ForegroundActivityManager.getInstance().getCurrentActivity()!!
val notificationManager =
context.getSystemService(AppCompatActivity.NOTIFICATION_SERVICE) as NotificationManager
val builder: Notification.Builder
val intent = Intent(context, cls)
val pendingIntent: PendingIntent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
} else {
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel =
NotificationChannel(channelId, description, NotificationManager.IMPORTANCE_HIGH)
notificationChannel.enableLights(true);
notificationChannel.lightColor = Color.RED;
notificationChannel.enableVibration(true);
notificationChannel.vibrationPattern =
longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400)
notificationManager.createNotificationChannel(notificationChannel)
builder = Notification.Builder(context, channelId)
.setSmallIcon(R.drawable.jpush_notification_icon)
.setContentIntent(pendingIntent)
.setContentTitle(title)
.setContentText(content)
} else {
builder = Notification.Builder(context)
.setSmallIcon(R.drawable.jpush_notification_icon)
.setLargeIcon(
BitmapFactory.decodeResource(
context.resources,
R.drawable.jpush_notification_icon
)
)
.setContentIntent(pendingIntent)
.setContentTitle(title)
.setContentText(content)

}
notificationManager.notify(autoIncreament.incrementAndGet(), builder.build())
}


/**
* 显示应用内通知的Dialog,需要自己处理点击事件。listener默认为null,不处理也可以。dialog会在3000毫秒后自动消失
* @param title 标题
* @param content 内容
* @param listener 点击的回调
*/
fun showNotificationDialog(
title: String,
content: String,
listener: OnNotificationCallback? = null
) {
val activity = ForegroundActivityManager.getInstance().getCurrentActivity()!!
dialog = NotificationDialog(activity, title, content)
if (Thread.currentThread() != Looper.getMainLooper().thread) { //子线程
activity.runOnUiThread {
showDialog(dialog, listener)
}
} else {
showDialog(dialog, listener)
}
}

/**
* show dialog
*/
private fun showDialog(
dialog: NotificationDialog?,
listener: OnNotificationCallback?
) {
dialog?.showDialogAutoDismiss()
if (listener != null) {
dialog?.setOnNotificationClickListener(object :
NotificationDialog.OnNotificationClick {
override fun onClick() = listener.onCallback()
})
}
}

/**
* dismiss Dialog
*/
fun dismissDialog() {
if (dialog != null && dialog!!.isShowing) {
dialog!!.dismiss()
}
}


interface OnNotificationCallback {
fun onCallback()
}

}

另外需要注意的点是,因为dialog是延迟关闭的,可能用户立刻退出Activity,导致延迟时间到时dialog退出时报错,解决办法可以在BaseActivity的onDestroy方法中尝试关闭Dialog:


override fun onDestroy() {
super.onDestroy()
NotificationControlManager.getInstance()?.dismissDialog()
}

作者:TimeFine
链接:https://juejin.cn/post/7119049874175164453
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android Studio Debug:编码五分钟,调试俩小时

前言 整理并积累Android开发过程中用到的一些调试技巧,通过技巧性的调试技能,辅助增强代码的健壮性、安全性、正确性 案例一:抛出明显异常 常见的:除数为0问题 class MainActivty : AppCompatActivity(){ o...
继续阅读 »

前言


整理并积累Android开发过程中用到的一些调试技巧,通过技巧性的调试技能,辅助增强代码的健壮性、安全性、正确性


案例一:抛出明显异常



  • 常见的:除数为0问题


class MainActivty : AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
val i = 1/0
}
}
}

image.png



会提示错误原因,并告知在哪一行




  • 一般错误


class MainActivty : AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
val s = "Candy" //假设此处是在一个方法内,我们无法看到
var i = 0
i = s.toInt()
}
}
}

image.png



会提示错误原因,并告知在哪一行


错误原因可能不认识,直接找错误关键字,检索百度



案例二:逻辑问题



  • println()方式调试


class MainActivty : AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
var res = 0
for (i in 1 until 10){
res=+i //该处将逻辑故意写错 应为 +=
println("i=${i},res=${res}")
}
val s:String = StringTo(res)
Toast.makeText(this,s,Toat.LENGTH.SHORT).show()
}
}
private fun StringTo(res:String){
println("将Int转换成String")
resturn res.roString()
}
}

image.png



会掺杂其他方法日志




  • log方式调试


class MainActivty : AppCompatActivity(){
val TAG = "MainActivity"
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
var res = 0
for (i in 1 until 10){
res=+i //该处将逻辑故意写错 应为 +=
Log.d(TAG,"i=${i},res=${res}")
}
val s:String = StringTo(res)
Toast.makeText(this,s,Toat.LENGTH.SHORT).show()
}
}
private fun StringTo(res:String){
println("将Int转换成String")
resturn res.roString()
}
}

image.png



筛选条件多:Debug、Info、Worn、Error以及自定义筛选等


可以直接根据key筛选


调试数据较多时,不方便查看,不够灵活




  • debug模式调试


image.png



  • resume progrem: 继续执行

  • step over: 跳入下一行

  • step into: 进入自定义方法,非方法则下一行

  • force step into:进入所有方法,非方法则下一行

  • step out: 跳出方法,且方法执行完成

  • run to cursor: 跳入逻辑的下一个标记点
    image.png



debug运行时,会出现提示框,无需操作



案例三:代码丢失||项目问题



  • history

    • 不小心删除代码/文件且已save并退出
      右击项目 -> Local History -> Show History -> 选择某一历史右键 -> Revert




image.png


image.png


image.png


image.png


image.png


作者:Coward881
链接:https://juejin.cn/post/7114926062982660103
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

对移动端app容灾的思考

移动端app容灾 可能很多人对这个概念比较陌生,我们常说的容灾策略,一般都特指服务器端的容灾,那么移动端容灾是个啥!其实跟服务器一样,就是持续保证我们app的可用性,在crash或者anr的时候,能够通过一些手段实现保证后续可用。 本篇不涉及复杂技术,更多的是...
继续阅读 »

移动端app容灾


可能很多人对这个概念比较陌生,我们常说的容灾策略,一般都特指服务器端的容灾,那么移动端容灾是个啥!其实跟服务器一样,就是持续保证我们app的可用性,在crash或者anr的时候,能够通过一些手段实现保证后续可用。


本篇不涉及复杂技术,更多的是对方案的探讨,请放心食用!


为什么会有这个概念


其实在笔者角度上看,技术与业务的关系其实是比较单一的,虽然不至于对立,但是一个业务人员看待技术,最关心的可能就是稳定性了,在“老板”角度上看,他其实不太关心所用的技术是什么,但是一定关心这个服务能不能保证自己的业务能不能持续,这也是笔者访谈了几位非技术人员得出的结论,同时在“降本增效”的今天,追求稳定性可能是大部分公司的选择了。还有就是站在长远立场上看,移动端的容灾也慢慢会成为各大公司角逐的一个点。一个由于crash导致而离开的用户,就有可能带走10个相关联客户,在app场景如此,在游戏场景也是,如果打着游戏突然闪退了,肯定是一个非常不好的体验。


本文希望介绍一些移动端的容灾策略,希望能够给各大开发者提供一个启发。


容灾策略


降级


首先是第一个策略,降级,比如app crash的时候,我们采用降级的手段,转移到h5页面


image.png


这个方案的特点是 存在两套页面,一个是原生页面,一个是h5页面,大部分公司可能都会同时有这两套ui,一个用于投放app,一个用于h5页面,比如网页还有m站这些。
当主页(也可以是特定activity),跳转到其他页面时,如果发生了crash,就从主页直接打开h5容器,展示h5页面,这个在拼*多app方案上常用


进程多活


android在多进程上给我们提供了很多便捷的地方,只需要在activity或者其他的manifest文件上声明process即可


android:process=":test"

一般我们不做特殊配制的话,activity等就是运行在以包名为名称的进程上。这里的多进程方案有两个



  • app crash的时候,通过安全气囊机制,重新为用户打开到当前页面,即我们会杀掉原本的进程,重新打开一个新进程,并为用户定位到当前页,可以携带本地的tag或者其他标识进行页面的定位,这个方案可以运用在游戏中,如果crash了立马主动帮用户重开,并提高这部分用户的载入速度!


image.png



  • (这也是我最推荐的)app crash/anr的时候,不重新进入原页面,而是通过安全气囊机制,打开一个纯净版的链路这个链路是怎么理解呢?这里特指是业务简单的链路,即满足用户最基本需求的链路。比如说我们有一个商城app,那么下单就是最关键的链路,我们只需要在app crash的时候,打开一个业务最简单的页面,让用户去操作即可,这样就避免二次可能产生的crash!


image.png


强制升级


如果某个用户在app的crash次数达到一定时,就直接采取强制升级的方案,让用户的app始终保持最新版本,避免由于老版本的影响导致这部分的用户流失。这个方案的实现可直接对接到app内的升级策略


脏数据清除


有一些crash可能是由于用户本地的脏数据引起而导致的,那么我们可以在crash的时候,把这部分数据清除,或者简单来说直接清除所有缓存,这种“重置”操作会一定程度上避免由于脏数据等特定crash的发生,比较适用于线上存在脏数据用户的情况。


安全气囊机制


可以看到,无论是哪一个方案,我们都需要依靠crash/anr检测的机制,才能够实现,没关系,相关的文章早已准备好黑科技!让Native Crash 与ANR无处发泄!,同时也配备了开源库Signal,运用Signal,我们可以实现很多crash后的安全措施,也希望大家运行起来demo,尝试一下各种脑洞大开的方案!


让业务能够持续稳定下去,降低由于异常导致的损失,这是笔者一开始想要实现的,当然,目前我们的库还在不断完善的过程中,也希望广大开发者能够加入进来,一起去探索一个新方向!


最后


当然,一个app好坏大部分责任在于产品的选择,赛道的选择!能否提供一个好的服务给用户,才是决定一款app好坏的标准!我们技术能做的,就是不断突破场景的限制,给产品提供好的工具啦!


本来决定要分享asm相关的,但是在洗澡的过程中发现其实很多对服务器端的容灾策略的思想也是可以在移动端上去进行的,在app的业务迭代过程中,一定会对稳定性造成很多挑战,在各大公司人员缩减的背景下更是如此,所以说,建立一套安全气囊装置,一定会是后面多个公司的探索方向!


作者:Pika
链接:https://juejin.cn/post/7120802538332372999
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

你真的敢落地Flutter桌面端吗?

如果你想用Flutter技术在桌面端落地,从技术上来讲,你必须解决这三大难题:1. 应用窗口化,提供窗口操作的能力;2. 实现多窗口;3. 对外设的支持。前言首先给个结论,Flutter在桌面端落地,完全是可行的;但生态远没有官方所说的那么完善,我甚至认为其达...
继续阅读 »

如果你想用Flutter技术在桌面端落地,从技术上来讲,你必须解决这三大难题:
1. 应用窗口化,提供窗口操作的能力;
2. 实现多窗口;
3. 对外设的支持。

前言

首先给个结论,Flutter在桌面端落地,完全是可行的;但生态远没有官方所说的那么完善,我甚至认为其达不到stable的标准
目前我们的桌面设备主要有Windows、Android系统,系统不同但UI一致,我们将在这两个平台上解决以上问题,并落地Flutter。

一、窗口化和窗口操作存在的问题

  1. 实现应用窗口化:即应用是窗口化展示的,同时可拖拽、可以点击应用外的地方
    Flutter Windows本身是窗口化的;
    而Android默认是全屏应用,需要让普通应用支持窗口化;若是小工具性质的应用,还需要支持可拖拽、可点击应用外的地方,这些在Flutter上都是需要我们在原生实现的。
  2. 实现应用窗口化后,一般开发过程中,肯定会需要以下对窗口的操作:
    • 应用窗体圆形、阴影效果;
    • 配置应用初始的显示位置;(很多小工具可能不是居中展示)
    • 从窗口变为全屏、从全屏变为窗口;
    • ......

二、支持多窗口

目前Flutter是明确不支持多窗口的。官方好像对多窗口不太感兴趣,一直没有把优先级提上来,还是停留在p4级别,具体见issue
但是作为桌面应用,多窗口的需求是非常普遍的,因此这个技术壁垒是必须打破的。

三、窗口化实现方案

1. Windows端

Windows端Flutter默认支持窗口化,交互方式基本符合习惯,因此无需再做开发。

2. Android端

  • Android普通应用实现窗口化,是把整个应用展示成窗口的效果,但是点击外部窗口外的地方其实是不响应。 同一时间只能显示一个应用进程,这是安卓的机制,也保证了其安全性。要实现窗口化,需要把应用Theme设置成Dialog的样式;同时设置窗口全屏,但是背景色为透明,设置点击外部Dialog不消失,即可实现应用的窗口化展示。

    1. 设置主题

      <style name="Theme.DialogApp" parent="Theme.AppCompat.Light.Dialog"> 
      <item name="android:windowBackground">@drawable/launch_application</item>
      <item name="android:windowIsTranslucent">true</item>
      <item name="android:windowContentOverlay">@null</item>
      <!-- 不显示遮罩层 -->
      <item name="android:backgroundDimEnabled">false</item>
      <item name="windowActionBar">false</item>
      <item name="windowNoTitle">true</item>
      </style>
      <activity
      android:name=".MainActivity"
      android:exported="true"
      android:hardwareAccelerated="true"
      android:launchMode="singleTop"
      android:theme="@style/Theme.DialogApp"
      android:windowSoftInputMode="adjustResize"> <meta-data
      android:name="io.flutter.embedding.android.NormalTheme"
      android:resource="@style/Theme.DialogApp" />
      <intent-filter>
      <action android:name="android.intent.action.MAIN" />
      <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
      </activity>
    2. 设置窗口全屏,但是背景色为透明,点击外部Dialog不消失

      class MainActivity : FlutterActivity() {
      // 设置窗口背景透明
      override fun getTransparencyMode(): TransparencyMode {
      return TransparencyMode.transparent
      }
      override fun onResume() {
      super.onResume()
      // 点击外部,dialog不消失
      setFinishOnTouchOutside(false)
      // 设置窗口全屏
      var lp = window.attributes
      lp.width = -1
      lp.height = -1
      window.attributes = lp
      }
      }
    3. 到这里原生提供给Flutte一个全屏的透明窗体,那么Flutter的视图想长成啥样都可以

  • 若是小工具之类的,需要实现应用可拖拽,可点击应用区域外,这在android的实现相对复杂。我们利用原生的窗口管理,弹出一个悬浮框,然后通过entry-point 找到Flutter层的UI。这其实就是我们实现多窗口的思路,这里就不单纯讲解,跟着后面一起讲了。

窗口化操作

实现窗口化后,需要做很多相关的操作,我们分两个系统讲。

1. Windows端

  • 应用窗体圆形、阴影效果:通过window_manager插件,让应用背景色透明;然后我们在MaterialApp外面套一层Container可以设置圆角和阴影,再在外面加一次Container,加入padding以展示内层容器的阴影;
  • 小工具配置初始位置:通过window_manager插件的setPosition可以设置位置;
  • 从窗口变为全屏、从全屏变为窗口:通过window_manager插件可以实现全屏和退出全屏,在切换的过程中页面会闪烁,解决思路是:把透明度设置为0 → 全屏 → 透明度恢复为1。设置透明度的方法也由window_manager插件提供。

2. Android端

对于普通应用,我们上面实现窗口化后,原生就已经为Flutter提供了一个透明的全屏窗口,因此任何窗体的操作都是Flutter层去实现的,没啥技术难度。

  • 应用窗体圆形、阴影效果:上面我们实现应用窗口后,其实整个应用窗体的背景色就是透明的了,因此我们比Windows少做了背景色透明这一步,然后后面的Container都是通用的,代码达到多平台复用;
  • 小工具配置初始位置:直接通过Stack和Positioned来配置就行了。但这种场景一般使用悬浮弹框做,设置定位见后面多窗口;
  • 从窗口变为全屏、从全屏变为窗口:Android依然很简单,只需要在全屏的时候把整个Flutter窗口的padding去除,恢复的时候加上就可以了。

多窗口的实现

首先明确一个观点,Flutter应用是基于Flutter engine,由原生提供的一个Surface画布,在这个画布上面用Skia 2绘制Flutter Widget。
也就是说本身这个应用就是一个窗口,它绝对没有能力为自己再创建一个窗口。 所以多窗口的实现,需要依赖于原生的窗口管理。下面是Android端的实现原理图,这个原理适用于任何平台。 

  • 原生新建一个Flutter engine,通过dart执行器DartExecutor执行方法executeDartEntrypoint,根据传入的字符串找到对应的方法入口点Entrypoint,从而拿到Flutter widget;
  • Flutter在方法上声明@pragma('vm:entry-point') 后,此方法即便在Flutter项目没有被调用到,也能编译进去,因此原生新的engine就能找到这个切入点,拿到方法返回的widget;

这是非常典型的Flutter玩法,诸如混合开发都是如此。带来的影响是存在多引擎(engine),增加一些内存,但是这个不可避免,除非你定制Flutter引擎. 目前pub上支持多窗口的库也都是这个原理,但是库的质量其实不高,大家还是自己写吧。

实现步骤

  1. Plugin与原生通信,由于操作都是异步的,所以务必使用双向通信机制BasicMessageChannel,而且需要两个通道:主应用与子窗口通道
  2. 定义接口协议,一般至少需提供以下能力
// 主应用打开子窗口
void open(String entryPoint, Size size, GravityConfig? gravityConfig,
bool draggable);

// 主应用关闭子窗口
void close();

// 主应用设置大小
void resize(int width, int height);

// 主应用设置位置
void setPosition(int x, int y);

// 子窗口启动app,需要支持后台唤起以及命令行启动
void launchApp();

// 子窗口自行关闭
void closeByWindows();

// 子窗口设置大小
void resizeByWindows(int width, int height);

// 子窗口设置位置
void setPositionByWindows(int x, int y);
  1. 各端实现,下面贴下Android端的关键代码
  • 新建Flutter engine,找到Dart中的方法,此时engine就拿到了Flutter的widget实例;

    engine = FlutterEngine(application)
    val entry = intent.getStringExtra("entryPoint") ?: "multiWindow"
    val entryPoint = DartExecutor.DartEntrypoint(findAppBundlePath(), entry)
    engine.dartExecutor.executeDartEntrypoint(entryPoint)
  • 新建窗口管理类,通过FlutterViewe吸附engin,然后渲染到悬浮框的view上

    ///......
    private var windowManager = service.getSystemService(Service.WINDOW_SERVICE) as WindowManager
    ///......
    windowManager.addView(rootView, layoutParams)
    ///......///......
    flutterView = FlutterView(inflater.context, FlutterSurfaceView(inflater.context, true))
    flutterView.attachToFlutterEngine(engine)
    ///......
    engine.lifecycleChannel.appIsResumed()
    ///......
    rootView.findViewById<LinearLayout>(R.id.floating_window)
    .addView(
    flutterView,
    ViewGroup.LayoutParams(
    ViewGroup.LayoutParams.MATCH_PARENT,
    ViewGroup.LayoutParams.MATCH_PARENT
    )
    )
  1. 实现悬浮框后,Android平台上的桌面小工具,也就顺利成章的实现了,只是在小工具这个项目的MainAcitivy上,就不需要去加载FlutterActivity了,直接启动悬浮框即可。

外设支持

usb设备在Flutter上,支持也是非常若的。具体可见我上一篇文章:Flutter桌面端实践之识别外接媒体设备

写在最后

以上是我在桌面端预研Flutter的一些经验和思路分享,如果你想在桌面端落地Flutter,我想这边文章对你是很有帮助的。
以上问题,我们遇到了,也解决了。但转念一想这么多基础的操作Flutter都不支持,这真的可以称得上Stable版本了吗?
Flutter桌面端的生态,急需我们共同建设,文中多次提起的window_manager插件就是国内出色的组织:LeanFlutter 提供的,期待Flutter桌面端越来越好!


作者:Karl_wei
链接:https://juejin.cn/post/7120944715419090957
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android性能优化之APK瘦身详解(瘦身73%)

公司项目在不断的改版迭代中,代码在不断的累加,终于apk包不负重负了,已经到了八十多M了。可能要换种方式表达,到目前为止没有正真的往外推过,一直在内部执行7天讨论需求,5天代码实现的阶段。你在写上个版本的内容,好了,下个版本的更新内容已经定稿了。基于这种快速开...
继续阅读 »

公司项目在不断的改版迭代中,代码在不断的累加,终于apk包不负重负了,已经到了八十多M了。可能要换种方式表达,到目前为止没有正真的往外推过,一直在内部执行7天讨论需求,5天代码实现的阶段。你在写上个版本的内容,好了,下个版本的更新内容已经定稿了。基于这种快速开发的现状,我们app优化前已经有87.1M了,包大了,运营说这样转化不高,只能好好搞一下咯。优化过后包大小为23.1M(优化了73%,不要说我标题党)。好了好了,我要阐述我的apk超级无敌魔鬼瘦身之心得了。


文章主要内容从理论出发,再做实际操作。分为下面几个方面:



  1. 结构分析

  2. 具体实操

  3. 总结

  4. 参考资料



1. 结构分析


首先上传一张瘦身前通过Analyze app分析出来的图片(打开方式:Android Studio下 ——> Build——> Analyze app):


这里写图片描述


APK包结构如下:



  1. lib/:包含特定于处理器软件层的编译代码。该目录包含了每种平台的子目录,像armeabi,armeabi-v7a, arm64-v8a,x86,x86_64,和mips。大多数情况下我们可以只用一种armeabi-v7a,后面会讲到原因。

  2. assets/:包含应用可以使用AssetManager对象检索的应用资源。

  3. res/:包含未编译到的资源 resources.arsc,主要有图片资源文件。

  4. META-INF/:包含CERT.SF和 CERT.RSA签名文件以及MANIFEST.MF 清单文件。

  5. resources.arsc:包含已编译的资源。该文件包含res/values/ 文件夹所有配置中的XML内容。打包工具提取此XML内容,将其编译为二进制格式,并将内容归档。此内容包括语言字符串和样式,以及直接包含在resources.arsc文件中的内容路径 ,例如布局文件和图像。

  6. classes.dex:包含以Dalvik / ART虚拟机可理解的DEX文件格式编译的类。

  7. AndroidManifest.xml:包含核心Android清单文件。该文件列出应用程序的名称,版本,访问权限和引用的库文件。该文件使用Android的二进制XML格式。


通过分析图可以知道,目前app主要是so文件占比比较大,占了31.7M,占了整个应用是38.2%。其次是assets目录,整个目录占了32M,第三就是资源文件res目录了。所以接下来我们处理步骤就是按这个顺序来处理。(简单说下图中的Raw File Size(磁盘解压后的大小)和DownLoad Size(从应用商店下载的大小),如果想了解更多关于Analyaer分析的知识,可以参考这篇文章使用APK Analyzer分析你的APK),分析了包结构组成之后,我们可以开始瘦身操作了。


2.具体实操


1. 对lib目录下的文件进行瘦身处理


1. 修改lib配置:


参考资料
so文件的优化:通常我们在使用NDK开发的时候,我们经常会有如下这么一段代码:


ndk {
//设置支持的so库架构
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64", "armeabi"
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xNDtH3zt-1571353784450)(upload-images.jianshu.io/upload_imag…)]


最后我的修改代码如下:


ndk 	{
//设置支持的so库架构
abiFilters "armeabi-v7a"
}

接下来说明这么做的依据:
看上面图分析,armeabi-v7主要不支持ARMv5(1998年诞生)和ARMv6(2001年诞生).目前这两款处理器的手机设备基本不在我公司的适配范围(市场占比太少)。
而许多基于 x86 的设备也可运行 armeabi-v7a 和 armeabi NDK 二进制文件。对于这些设备,主要 ABI 将是 x86,辅助 ABI 是 armeabi-v7a。
最后总结一点:如果适配版本高于4.1版本,可以直接像我上面这样写,当然,如果armeabi-v7a不是设备主要ABI,那么会在性能上造成一定的影响。
参考文章:安卓app打包的时候还需要兼容armeabi么?


好了,我们再打一次包试试。
这里写图片描述


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xv3lhgYo-1571353784451)(upload-images.jianshu.io/upload_imag…)]
确实有点震惊,一下子包小了这么多,从87.1M到51.9M,容我好好算算少了多少M.赶快让测试帮忙测一下。基于之前的理论知识,心里还是有点底。果然,测试效果和之前是一样的。心里的石头先落下罗。


2. 重新编译so文件,用更小的库代替


相信很多开发者都有这种苦恼,很多第三方我们导入进来只用到其中很小一部分功能,大部分功能都是我们用不上的。这时候我们找到源代码,将我们需要的那部分代码提取出来,重新编译成新的so文件,再导入到我们项目中。当然,如果之前没有编译过so文件,这部分建议做最后的优化去处理。不然你会遇到很多问题。上一波处理后的效果图:


这里写图片描述
这里说下,因为项目中有使用到ffmpeg库,之前导入的第三方的放在assets文件夹下,重写编写后的so库文件放在lib文件夹下,所以lib文件夹反而大了。从51.9M到35.6M,效果还是蛮不错的。


对了,别问我为什么assets文件夹下为什么还有12.6M资源,因为很多.mp3都是第三方的人脸识别必备配置文件,我也很无奈。


这里写图片描述


2. 优化res,assets文件大小


1. 手动lint检查,手动删除无用资源


在Android Studio中打开“Analyze” 然后选择"Inspect Code...",范围选择整个项目,然后点击"OK"。配置如下:


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aczX7vG1-1571353784454)(upload-images.jianshu.io/upload_imag…)]


2. 使用tinypng等图片压缩工具对图片进行压缩。


打开网址,将大图片导入到tinypng,替换之前的图片资源。


3. 大部分图片使用Webp格式代替。


可以给UI提要求,让他们将图片资源设置为Webp格式,这样的话图片资源会小很多。如果想了解更多关于webp,请点击这里webp,当然,如果对图片颜色通道要求不高,可以考虑转jpg,最好用webp,因为效果更佳。


4. 尽量不要在项目中使用帧动画


一个帧动画几十张图片,再怎么压缩都还是占很大内存比重的。所以建议是让UI去搞,这里可以参考使用lottie-android,如果项目中动画效果多的话效果更加明显。当然这就要辛苦我们UI设计师大大了。


5. 使用gradle开启shrinkResources


移除无用资源文件,下面是我的配置:


 buildTypes {
release {
// 不显示Log
buildConfigField "boolean", "LOG_DEBUG", "false"
//混淆
minifyEnabled true
// 移除无用的resource文件
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}

通过上述步骤操作,apk效果如下:


这里写图片描述


又优化了将近5M,别问我为什么还有7.5M,里面大量的gif和webp格式的动图,都是UI丢给我的,一个2.7M.后面再慢慢和他细究这个问题。后面要做的两部分,一部分是将资源文件下的所有gif图放后台下载处理,第二个是和UI讨论下如何减小webp 动图的大小(我看其他平台只有100K的样子,给我的就2.7M?)。


3. 减少chasses.dex大小


classes.dex中包含了所有的java代码,当你打包时,gradle会将所有模板力的.class文件转换成classes.dex文件,当然,如果方法数超过64K,将要新增其他文件进行存储。可以通过multidexing分多个文件,比如我这里的chasses2.dex。换句话说,就是减少代码量。我们可以通过以下方法来实现:



  1. 尽量减少第三方库的引用,这个在上面我们已经做过优化了。

  2. 避免使用枚举,这里特别去网上查了一下,具体可以参考下这篇文章Android 中的 Enum 到底占多少内存?该如何用?,得出的结论是,可能几十个枚举的内存占有量才相当一张图片这样子,优化效果也不会特别明显。当然,如果你是个追求极致的人,我不反对你用静态常量替代枚举。

  3. 如果你的dex文件太大,检查是否引入了重复功能的第三方库(图片加载库,glide,picasso,fresco,image_loader,如果不是你一个人单独开发完成的很容易出现这种情况),尽量做到一个功能点一个库解决。


关于classes.dex文件大小分析可以参考这篇译文使用 APK Analyzer 分析你的 APK


4. 其他



  1. 删除无用的语7zip代替

  2. 删除翻译资源,只保留中英文

  3. 尝试将andorid support库彻底踢出你的项目。

  4. 尝试使用动态加载so库文件,插件化开发。

  5. 将大资源文件放到服务端,启动后自动下载使用。


3. 总结


好了,说道这里基本上就结束了,apk包从87.1M减小到了23.1M(优化了73%,不要说我标题党)已经差不多了,关于第四部其他部分的优化我是没有进行再操作的。因为公司运营觉得二三十M的包比较真实,太小了就太假了。所以我暂时就不进行优化了。如果再上面提到的部分通过所有将所有非启动页面首页之外的所有资源,so库放服务端,理论上apk包大小能在10M以内这样子。当然我们有做到就不多加评价了。最后,如果对插件化开发感兴趣的话可以参考下这篇文章Android全面插件化方案-RePlugin踩坑。最后,如果你在Android上有什么疑问,可以添加我的同名微信公众号「aserbaocool」和我一块交流。


4. 参考资料:


文章主要参考文章如下,文章有少部分文字参考了下面文章中的语句。如果有侵犯到作者权益,请和我联系,查实后马上删除。



  1. Android APK 瘦身 - JOOX Music项目实战

  2. APK 瘦身记,如何实现高达 53% 的压缩效果

  3. 使用APK Analyzer分析你的APK

  4. 安卓app打包的时候还需要兼容armeabi么?

  5. 百度百科webp

  6. Android 中的 Enum 到底占多少内存?该如何用?

作者:aserbao
链接:https://juejin.cn/post/7119445311868633096
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android动态更换应用图标

一、背景 近日,微博官方发布了一项新功能,即可以在App设置中动态更换微博的显示图标样式。根据微博官方的说法,除了最原始的图标外,微博还推出了另外10种不同的样式,既有3D微博、炫彩微博等保留了眼睛造型的新样式,也有奶酪甜馨、巧克力等以食物命名的“新口味”,还...
继续阅读 »

一、背景


近日,微博官方发布了一项新功能,即可以在App设置中动态更换微博的显示图标样式。根据微博官方的说法,除了最原始的图标外,微博还推出了另外10种不同的样式,既有3D微博、炫彩微博等保留了眼睛造型的新样式,也有奶酪甜馨、巧克力等以食物命名的“新口味”,还有梦幻紫、幻想星空等抽象派新造型,给了微博用户多种选择的自由。


不过需要注意的是,这一功能并不是面对所有人开放的,只有微博年费会员才能享受。此外,iOS 10.3及以上和Android 10及以上系统版本支持该功能,但是iPad与一加8Pro手机无法使用该功能。因部分手机存在系统差异,会导致该功能不可用,微博方面后续还会对该功能进行进一步优化。


image.png


二、技术实现


其实,说到底,上述功能用到的是动态更换桌面图标的技术。如果说多年以前,实现图标的切换还是一种时髦的技术,那么,我们可以直接使用PackageManager就可以实现动态更换桌面图标。


实现的细节是,在Manifest文件中使用标签准备多个Activity入口,没个activity都指向入口Activity,并且为每个拥有标签的activity设置单独的icon和应用名,最后调用SystemService 服务kill掉launcher,并执行launcher的重启操作。


首先,我们在AndroidManifest.xml文件中添加如下代码:


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.xzh.demo">

<!-- 权限-->
<uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES"/>

<application
android:allowBackup="true"
android:icon="@mipmap/wb_default_logo"
android:label="@string/app_name"
android:roundIcon="@mipmap/wb_default_logo"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidDemo">

...//省略其他代码

<!-- 默认微博-->
<activity-alias
android:name="com.xzh.demo.default"
android:targetActivity=".MainActivity"
android:label="@string/app_name"
android:enabled="false"
android:icon="@mipmap/wb_default_logo"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<!-- 3D微博-->
<activity-alias
android:name=".threedweibo"
android:targetActivity=".MainActivity"
android:label="@string/wb_3d"
android:enabled="false"
android:icon="@mipmap/wb_3dweibo"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

... //省略其他

</application>
</manifest>

上面配置中涉及到的属性如下:



  • android:name:注册的组件名字,启动组件的名称。

  • android:enabled:是否启用这个组件,也就是是否显示这个入口。

  • android:icon:图标

  • android:label:名称

  • android:targetActivity:默认的activity没有这个属性,指定目标activity,与默认的activity中的name属性是一样的,需要有相应的java类文件。


接着,我们在MainActivity触发Logo图标更换逻辑,代码如下:


class MainActivity : AppCompatActivity() {
var list: List<LogoBean> = ArrayList()
var recyclerView: RecyclerView? = null
var adapter: LogoAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initView()
initData()
initRecycle()
}
private fun initView() {
recyclerView = findViewById(R.id.recycle_view)
}
private fun initData() {
list = Arrays.asList(
LogoBean(R.mipmap.wb_default_logo, "默认图标", true),
LogoBean(R.mipmap.wb_3dweibo, "3D微博", false),
LogoBean(R.mipmap.wb_cheese_sweetheart, "奶酪甜心", false),
LogoBean(R.mipmap.wb_chocolate_sweetheart, "巧克力", false),
LogoBean(R.mipmap.wb_clear_colorful, "清透七彩", false),
LogoBean(R.mipmap.wb_colorful_sunset, "多彩日落", false),
LogoBean(R.mipmap.wb_colorful_weibo, "炫彩微博", false),
LogoBean(R.mipmap.wb_cool_pool, "清凉泳池", false),
LogoBean(R.mipmap.wb_fantasy_purple, "梦幻紫", false),
LogoBean(R.mipmap.wb_fantasy_starry_sky, "幻想星空", false),
LogoBean(R.mipmap.wb_hot_weibo, "热感微博", false),
)
}
private fun initRecycle() {
adapter =LogoAdapter(this,list);
val layoutManager = GridLayoutManager(this, 3)
recyclerView?.layoutManager = layoutManager
recyclerView?.adapter = adapter
adapter?.setOnItemClickListener(object : OnItemClickListener {
override fun onItemClick(view: View?, position: Int) {
if(position==1){
changeLogo("com.xzh.demo.threedweibo")
}else if (position==2){
changeLogo("com.xzh.demo.cheese")
}else if (position==3){
changeLogo("com.xzh.demo.chocolate")
}else {
changeLogo("com.xzh.demo.default")
}
}
})
}

fun changeLogo(name: String) {
val pm = packageManager
pm.setComponentEnabledSetting(
componentName,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP
)
pm.setComponentEnabledSetting(
ComponentName(this, name),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP
)
reStartApp(pm)
}
fun reStartApp(pm: PackageManager) {
val am = getSystemService(ACTIVITY_SERVICE) as ActivityManager
val intent = Intent(Intent.ACTION_MAIN)
intent.addCategory(Intent.CATEGORY_HOME)
intent.addCategory(Intent.CATEGORY_DEFAULT)
val resolveInfos = pm.queryIntentActivities(intent, 0)
for (resolveInfo in resolveInfos) {
if (resolveInfo.activityInfo != null) {
am.killBackgroundProcesses(resolveInfo.activityInfo.packageName)
}
}
}
}

注意上面的changeLogo()方法中的字符串需要和AndroidManifest.xml文件中的<activity-alias>的name相对应。运行上面的代码,然后点击应用中的某个图标,就可以更换应用的桌面图标,如下图所示。


image.png


不过,测试的时候也遇到一些适配问题:



  • 小米9:版本升级时,新版本在AndroidManifest中删除A3,老版本切换图标到A3,为卸载直接覆盖安装新版本,手机桌面图标消失。

  • magic 4:版本升级时,新版本在AndroidManifest中删除A3,老版本切换图标到A3,为卸载直接覆盖安装新版本,手机桌面图标切换到默认图标,但点击之后未能打开APP。

作者:xiangzhihong
链接:https://juejin.cn/post/7115413271946985480
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

一些常见的HTTP返回码

一些常见的状态码为:·       200 – 服务器成功返回网页·       404 – 请求的网页不存在·&nbs...
继续阅读 »

一些常见的状态码为:

·      
200 – 服务器成功返回网页

·      
404 – 请求的网页不存在

·      
503 – 服务器超时

1xx(临时响应)

表示临时响应并需要请求者继续执行操作的状态码。











100(继续)



请求者应当继续提出请求。服务器返回此代码表示已收到请求的第一部分,正在等待其余部分。



101(切换协议)



请求者已要求服务器切换协议,服务器已确认并准备切换。


2xx (成功)

表示成功处理了请求的状态码。































200(成功)



服务器已成功处理了请求。通常,这表示服务器提供了请求的网页。如果是对您的 robots.txt 文件显示此状态码,则表示 Googlebot 已成功检索到该文件。



201(已创建)



请求成功并且服务器创建了新的资源。



202(已接受)



服务器已接受请求,但尚未处理。



203(非授权信息)



服务器已成功处理了请求,但返回的信息可能来自另一来源。



204(无内容)



服务器成功处理了请求,但没有返回任何内容。



205(重置内容)



服务器成功处理了请求,但没有返回任何内容。与 204 响应不同,此响应要求请求者重置文档视图(例如,清除表单内容以输入新内容)。



206(部分内容)



服务器成功处理了部分 GET
请求。


3xx (重定向) 

要完成请求,需要进一步操作。通常,这些状态码用来重定向。Google 建议您在每次请求中使用重定向不要超过 5 次。您可以使用网站管理员工具查看一下 Googlebot 在抓取重定向网页时是否遇到问题。诊断下的网络抓取页列出了由于重定向错误导致 Googlebot 无法抓取的网址。































300(多种选择)



针对请求,服务器可执行多种操作。服务器可根据请求者 (user agent) 选择一项操作,或提供操作列表供请求者选择。



301(永久移动)



请求的网页已永久移动到新位置。服务器返回此响应(对 GET HEAD 请求的响应)时,会自动将请求者转到新位置。您应使用此代码告诉 Googlebot 某个网页或网站已永久移动到新位置。



302(临时移动)



服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来响应以后的请求。此代码与响应 GET HEAD 请求的
301
代码类似,会自动将请求者转到不同的位置,但您不应使用此代码来告诉 Googlebot 某个网页或网站已经移动,因为 Googlebot 会继续抓取原有位置并编制索引。



303(查看其他位置)



请求者应当对不同的位置使用单独的 GET 请求来检索响应时,服务器返回此代码。对于除 HEAD 之外的所有请求,服务器会自动转到其他位置。



304(未修改)



自从上次请求后,请求的网页未修改过。服务器返回此响应时,不会返回网页内容。


如果网页自请求者上次请求后再也没有更改过,您应将服务器配置为返回此响应(称为 If-Modified-Since HTTP 标头)。服务器可以告诉
Googlebot
自从上次抓取后网页没有变更,进而节省带宽和开销。


.



305(使用代理)



请求者只能使用代理访问请求的网页。如果服务器返回此响应,还表示请求者应使用代理。



307(临时重定向)



服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来响应以后的请求。此代码与响应 GET HEAD 请求的
<a href=answer.py?answer=>301</a>
代码类似,会自动将请求者转到不同的位置,但您不应使用此代码来告诉 Googlebot 某个页面或网站已经移动,因为 Googlebot 会继续抓取原有位置并编制索引。


4xx(请求错误) 

这些状态码表示请求可能出错,妨碍了服务器的处理。







































































400(错误请求)



服务器不理解请求的语法。



401(未授权)



请求要求身份验证。对于登录后请求的网页,服务器可能返回此响应。



403(禁止)



服务器拒绝请求。如果您在
Googlebot
尝试抓取您网站上的有效网页时看到此状态码(您可以在 Google 网站管理员工具诊断下的网络抓取页面上看到此信息),可能是您的服务器或主机拒绝了 Googlebot 访问。



404(未找到)



服务器找不到请求的网页。例如,对于服务器上不存在的网页经常会返回此代码。


如果您的网站上没有 robots.txt 文件,而您在 Google 网站管理员工具诊断”标签的 robots.txt 上看到此状态码,则这是正确的状态码。但是,如果您有 robots.txt 文件而又看到此状态码,则说明您的 robots.txt 文件可能命名错误或位于错误的位置(该文件应当位于顶级域,名为 robots.txt)。


如果对于 Googlebot 抓取的网址看到此状态码(在诊断标签的 HTTP 错误页面上),则表示 Googlebot 跟随的可能是另一个页面的无效链接(是旧链接或输入有误的链接)。



405(方法禁用)



禁用请求中指定的方法。



406(不接受)



无法使用请求的内容特性响应请求的网页。



407(需要代理授权)



此状态码与 <a
href=answer.py?answer=35128>401
(未授权)</a>类似,但指定请求者应当授权使用代理。如果服务器返回此响应,还表示请求者应当使用代理。



408(请求超时)



服务器等候请求时发生超时。



409(冲突)



服务器在完成请求时发生冲突。服务器必须在响应中包含有关冲突的信息。服务器在响应与前一个请求相冲突的 PUT 请求时可能会返回此代码,以及两个请求的差异列表。



410(已删除)



如果请求的资源已永久删除,服务器就会返回此响应。该代码与 404(未找到)代码类似,但在资源以前存在而现在不存在的情况下,有时会用来替代
404
代码。如果资源已永久移动,您应使用 301 指定资源的新位置。



411(需要有效长度)



服务器不接受不含有效内容长度标头字段的请求。



412(未满足前提条件)



服务器未满足请求者在请求中设置的其中一个前提条件。



413(请求实体过大)



服务器无法处理请求,因为请求实体过大,超出服务器的处理能力。



414(请求的 URI 过长)



请求的 URI(通常为网址)过长,服务器无法处理。



415(不支持的媒体类型)



请求的格式不受请求页面的支持。



416(请求范围不符合要求)



如果页面无法提供请求的范围,则服务器会返回此状态码。



417(未满足期望值)



服务器未满足期望请求标头字段的要求。


5xx(服务器错误)

这些状态码表示服务器在处理请求时发生内部错误。这些错误可能是服务器本身的错误,而不是请求出错。



























500(服务器内部错误)



服务器遇到错误,无法完成请求。



501(尚未实施)



服务器不具备完成请求的功能。例如,服务器无法识别请求方法时可能会返回此代码。



502(错误网关)



服务器作为网关或代理,从上游服务器收到无效响应。



503(服务不可用)



服务器目前无法使用(由于超载或停机维护)。通常,这只是暂时状态。



504(网关超时)



服务器作为网关或代理,但是没有及时从上游服务器收到请求。



505HTTP 版本不受支持)



服务器不支持请求中所用的
HTTP
协议版本。














































 

收起阅读 »

面试官:应用上线后Cpu使用率飙升如何排查?

大家好,我是飘渺。 上次面试官问了个问题:应用上线后Cpu使用率飙升如何排查? 其实这是个很常见的问题,也非常简单,那既然如此我为什么还要写呢?因为上次回答的时候我忘记将线程PID转换成16进制的命令了。 所以我决定再重温一遍这个问题,当然贴心的我还给大家准备...
继续阅读 »

大家好,我是飘渺。


上次面试官问了个问题:应用上线后Cpu使用率飙升如何排查?


其实这是个很常见的问题,也非常简单,那既然如此我为什么还要写呢?因为上次回答的时候我忘记将线程PID转换成16进制的命令了。


所以我决定再重温一遍这个问题,当然贴心的我还给大家准备好了测试代码,大家可以实际操作一下,这样下次就不会忘记了。


模拟一个高CPU场景


public class HighCpuTest {
public static void main(String[] args) {
List<HignCpu> cpus = new ArrayList<>();

Thread highCpuThread = new Thread(()->{
int i = 0;
while (true){
HignCpu cpu = new HignCpu("Java日知录",i);

cpus.add(cpu);
System.out.println("high cpu size:" + cpus.size());
i ++;
}
});
highCpuThread.setName("HignCpu");
highCpuThread.start();
}
}

在main方法中开启了一个线程,无限构建HighCpu对象。


@Data
@AllArgsConstructor
public class HignCpu {
private String name;
private int age;
}

准备好上面的代码,运行HighCpuTest,然后就可以开始一些列的操作来发现问题原因了。


排查步骤


第一步,使用 top 找到占用 CPU 最高的 Java 进程


1. 监控cpu运行状,显示进程运行信息列表
top -c

2. 按CPU使用率排序,键入大写的P
P

image-20220627165915946


第二步,用 top -Hp 命令查看占用 CPU 最高的线程


上一步用 top命令找到了那个 Java 进程。那一个进程中有那么多线程,不可能所有线程都一直占着 CPU 不放,这一步要做的就是揪出这个罪魁祸首,当然有可能不止一个。


执行top -Hp pid命令,pid 就是前面的 Java 进程,我这个例子中就是 16738 ,完整命令为:


top -Hp 16738,然后键入P (大写p),线程按照CPU使用率排序


执行之后的效果如下


image-20220627165953456


查到占用CPU最高的那个线程 PID 为 16756


第三步,查看堆栈信息,定位对应代码


通过printf命令将其转化成16进制,之所以需要转化为16进制,是因为堆栈里,线程id是用16进制表示的。(我当时就是忘记这个命令了~)


[root@review-dev ~]# printf "%x\n" 16756
4174

得到16进制的线程ID为4174。


通过jstack命令查看堆栈信息


jstack 16738 | grep '0x4174' -C10 --color

image-20220627170218909


如上图,找到了耗CPU高的线程对应的线程名称“HighCpu”,以及看到了该线程正在执行代码的堆栈。


最后,根据堆栈里的信息,定位到对应死循环代码,搞定。


小结


cpu使用率飙升后如何排查这个问题不仅面试中经常会问,而且在实际工作中也非常有用,大家最好根据上述步骤实际操作一下,这样才能记得住记得牢。


我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿


作者:飘渺Jam
链接:https://juejin.cn/post/7119116752939646984
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android使用Intent传递大数据

数据传输 在Android开发过程中,我们常常通过Intent在各个组件之间传递数据。例如在使用startActivity(android.content.Intent)方法启动新的 Activity 时,我们就可以通过创建Intent对象然后调用putExt...
继续阅读 »

数据传输


在Android开发过程中,我们常常通过Intent在各个组件之间传递数据。例如在使用startActivity(android.content.Intent)方法启动新的 Activity 时,我们就可以通过创建Intent对象然后调用putExtra() 方法传输参数。


val intent = Intent(this, TestActivity::class.java)
intent.putExtra("name","name")
startActivity(intent)

启动完新的Activity之后,我们可以在新的Activity获取传输的数据。


val name = getIntent().getStringExtra("name")

一般情况下,我们传递的数据都是很小的数据,但是有时候我们想传输一个大对象,比如bitmap,就有可能出现问题。


val intent = Intent(this, TestActivity::class.java)
val data= ByteArray( 1024 * 1024)
intent.putExtra("param",data)
startActivity(intent)

当调用该方法启动新的Activity的时候就会抛出异常。


android.os.TransactionTooLargeException: data parcel size 1048920 bytes

很明显,出错的原因是我们传输的数据量太大了。在官方文档中有这样的描述:



The Binder transaction buffer has a limited fixed size, currently 1Mb, which is shared by all transactions in progress for the process. Consequently this exception can be thrown when there are many transactions in progress even when most of the individual transactions are of moderate size。



即缓冲区最大1MB,并且这是该进程中所有正在进行中的传输对象所公用的。所以我们能传输的数据大小实际上应该比1M要小。


替代方案



  1. 我们可以通过静态变量来共享数据

  2. 使用bundle.putBinder()方法完成大数据传递。
    由于我们要将数据存放在Binder里面,所以先创建一个类继承自Binder。data就是我们传递的数据对象。


class BigBinder(val data:ByteArray):Binder()

然后传递


val intent = Intent(this, TestActivity::class.java)
val data= ByteArray( 1024 * 1024)
val bundle = Bundle()
val bigData = BigBinder(data)
bundle.putBinder("bigData",bigData)
intent.putExtra("bundle",bundle)
startActivity(intent)

然后正常启动新界面,发现可以跳转过去,而且新界面也可以接收到我们传递的数据。


为什么通过这种方式就可以绕过1M的缓冲区限制呢,这是因为直接通过Intent传递的时候,系统采用的是拷贝到缓冲区的方式,而通过putBinder的方式则是利用共享内存,而共享内存的限制远远大于1M,所以不会出现异常。


作者:晚来天欲雪_
链接:https://juejin.cn/post/7109862936604049415
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

货拉拉 Android H5离线包原理与实践

背景 在实际业务中,app中的H5页面使用的场景越来越多,在货拉拉app中也存在大量的H5页面,比如金秋拉货节、余额、车型介绍页等,加载速度成为了困扰用户的一个痛点。为此我们决定引入离线包方案,另外还需要解决传统离线包方案不灵活,体积大,不易管理,不易降级...
继续阅读 »
  1. 背景




在实际业务中,app中的H5页面使用的场景越来越多,在货拉拉app中也存在大量的H5页面,比如金秋拉货节、余额、车型介绍页等,加载速度成为了困扰用户的一个痛点。为此我们决定引入离线包方案,另外还需要解决传统离线包方案不灵活,体积大,不易管理,不易降级等问题,我们设计和开发一套H5离线包系统,经过几个sdk版本的迭代,目前货拉拉H5离线包sdk,已在多个业务中落地,接受了大量用户检验。车型介绍页面使用离线包前后打开效果:






  1. 行业方案




目前H5离线包方案,通常是将离线包置入assets目录中,打包在apk内部,用户使用过程中再按需加载。所以大部分情况下可能存在以下问题:



  1. 由于离线包内容固定导致更新不及时

  2. 当离线包内容较多或者离线包个数较多时,会严重影响App包体积

  3. 由于离线包内部的逻辑固定,当出现问题时无法降级,无法禁用

  4. 上线没有数据对比无法知道上线效果


针对以上痛点,我们团队对离线包进行设计优化,应用于团队内的多个应用,多个业务场景中。




  1. 技术实现




H5离线包的基本原理是将html、js、css、图片等静态资源打包到成压缩文件,然后下载到客户端,H5加载时静态资源直接从本地取文件,减少网络请求,提高速度。加载本地文件路径存在的问题和解决:



























存在问题解决方法
cgi请求跨域跨域请求头增加null支持
cookie跨域问题目前静态js中无cookie操作,没有cookie跨域问题
localstorage跨域问题暂时不涉及域名隔离问题,如果有需要,采取调用原生的方式解决
前端使用绝对路径问题相对路径

4.1 总体结构


H5发布基本流程


image.png


App端流程图


image.png


前端的打包平台,支持发布为线上页面,也支持发布为离线包。离线包模式时,客户端会先查询是否有离线包需要更新,有则更新,同时支持离线包降级为线上网页。


H5离线包和线上H5一样也能进行更新和升级,有三个更新时机:


1)WebView容器打开时更新。在需要开启离线包功能的H5页面打开时,会去后端检查对应的离线包页面是否有更新。如果有更新,则下载离线包到本地,绝大部分场景是下次打开时生效。


2)启动查询离线包更新。对于实时性要求比较高的页面,可配置在启动时检查更新。


3)通过长连接推送的方式通知客户端下载最新的离线包。(需要接入方自己实现长链接,调用SDK更新方法)


4.2 性能优化


1)多业务并行化,单业务串行


离线包检查更新时,存在同时查询多个业务的离线包是否有更新的情况,为了提高查询效率,多个业务离线包检查的请求采取并行请求的方式。考虑到后端改造成本问题,目前还不支持聚合查询,计划在后续版本中完善。另外,考虑业务流程的更新流程取消可能导致不稳定,单业务只做串行,避免过程中文件损坏,下载不全,线程并发的问题。


image.png


2)启动预下载


大部分离线包查询和下载的时机为打开H5页面时,由于离线包查询、下载、解压总体耗时较长,导致首次打开无法命中离线包。所以货拉拉离线包支持配置部分离线包在启动时检查和下载离线包。配置为:


OfflineConfig offlineConfig = new OfflineConfig.Builder(true)

.addPreDownload("offline-pkg-name")//预加载业务名称

.build();,

4.3 可靠性设计


1)解压操作可靠性设计


文件解压耗时较长(大约30ms),如果中间程序退出可能会导致只解压了其中一半文件,影响后续离线包逻辑。所以解压到文件夹操作采取先解压,然后重命名,保证最后的文件夹的里的文件是完整的。同时当离线包正在使用时,一般情况下采取先解压,下次生效的策略,极端情况下可以立刻生效,但会导致页面强刷,影响用户体验。操作过程采取了temp、new、cur三个文件夹,解压细节如下


image.png


2)三重降级策略


a.客户端自动降级。


本地没有离线包时,客户端会自动将启用了离线包的H5页面降级为线上H5页面。


b.客户端远程配置降级。


可以设置局部降级,即临时将某个使用离线包的H5页面降级为线上,也可设置全局降级,关闭所有页面的离线包功能。接入方可以自行根据自己服务端下发参数进行配置:


OfflineConfig offlineConfig = new OfflineConfig.Builder(true)//总开关

.addDisable("disable-offline-pkg-name")//禁用业务名称

.addPreDownload("offline-pkg-name")//预加载业务名称

.build();

c.服务端接口降级。


服务端提供的离线包查询接口也可以设置将某个页面降级为线上H5,也可以支持让客户端更新离线包后强制刷新。目前,强制刷新为空实现,需要接入方自己实现,例如重启当前页面,关闭当前页面等。


降级策略流程图如下:


image.png


3)性能监控


货拉拉对webview的加载成功率,错误码、耗时进行了统计上报,通过监控面板查看。



此外离线包sdk还有离线包下载,请求,解压的耗时、结果数据上报。监控和上报采取的接口扩展方式,接入方根据业务特点选用具体的数据上报sdk。


4.4 效能优化


离线包和URL映射配置化


image.png


配置格式如下:主要通过url中的host、path、Fragment配置命中规则。根据接入方是否需要传入,不需要可以不传递。


//匹配规则相关 可选

ArrayList<String> host = new ArrayList<>();

ArrayList<String> path = new ArrayList<>();

ArrayList<String> fragment = new ArrayList<>();

host.add("www.xxxx.cn");

path.add("/aaa");

fragment.add("/ccc=ddd");



OfflineRuleConfig offlineRuleConfig = new OfflineRuleConfig();

offlineRuleConfig.addRule(new OfflineRuleConfig.RulesInfo("offline-pkg-name",host,path,fragment));


new OfflineParams()

.addRule("offline-pkg-name",host,path,fragment)//自定义配置的形式

.setRule(Constants.RULE_CONFIG)//json形式的规则

.setRule(offlineRuleConfig)//实体类形式

{
"rules": [{
"host": ["test1.xxx.cn", "test2.xxx.cn"],
"path": ["/pathA"],
"offweb": "offline-pkg-name-a"
},
{
"host": ["www.aaa.cn", "aaa.xxxx.cn"],
"path": ["aaa/path", "bbb/path"],
"offweb": "offline-pkg-name-b"
}
]
}



  1. 总结




离线包上线后,收益明显,平均加载速度从2秒提升到1秒,同时H5页面加载成功率也有提升。页面主框架(不考虑动态数据)加载成功率从96%提升到100%。






  1. 后期工作与展望




扩大开源范围。比如支持断点续传的下载SDK,后续会考虑开源。离线包依赖的后端服务暂时未开源,目前采取是通过HttpServer搭建一个简单的本地Web Server,可保证离线包示例在本地正常运行。


具体使用方法参考开源代码中介绍(github.com/HuolalaTech…




  1. 参考资料




zhuanlan.zhihu.com/p/34125968


juejin.cn/post/684490…




  1. 作者介绍




货拉拉移动端技术团队


作者:货拉拉移动技术团队
链接:https://juejin.cn/post/7119662876578545678
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Flutter 绘制探索 | 来一起画箭头吧

0. 前言 可能有人会觉得,画箭头有什么好说的,不就一根线加两个头吗?其实箭头的绘制还是比较复杂的,其中也蕴含着很多绘制的小技巧。箭头本身有着很强的 示意功能 ,通常用于指示、标注、连接。各种不同的箭头端,再加上线型的不同,可以组合成一些固定连接语法,比如 U...
继续阅读 »
0. 前言

可能有人会觉得,画箭头有什么好说的,不就一根线加两个头吗?其实箭头的绘制还是比较复杂的,其中也蕴含着很多绘制的小技巧。箭头本身有着很强的 示意功能 ,通常用于指示、标注、连接。各种不同的箭头端,再加上线型的不同,可以组合成一些固定连接语法,比如 UML 中的类图。





一个箭头,其核心数据是两个点的坐标,由 左右端点线型 构成。这篇文章就来探索一下,如何绘制一个支持各种样式,而且容易拓展的箭头。





1. 箭头部位的划分

首先要说一点,我希望获取的是箭头的 路径 ,而非单纯的绘制箭头。因为有了路径,可以做更多的事,比如根据路径裁剪、沿路径运动、多个路径间的合并操作等。当然,路径形成之后,绘制自然是非常简单的。所以在绘制技巧中,路径一个非常重要的话题。

如下所示,我们先来生成三个部分的路径,并进行绘制,两端暂时是圆形路径:



代码实现如下,测试使用的起始点分别是 (40,40)(200,40),圆形路径以起始点为中心,宽高为 10。可以看出虽然实现了需求,但是都写在一块,代码看起来比较乱。当要涉及生成各种样式箭头时,在这里修改代码也是非常麻烦的,接下来要做的就是对箭头的路径形成过程进行抽象。


final Paint arrowPainter = Paint();

Offset p0 = Offset(40, 40);
Offset p1 = Offset(200, 40);
double width = 10;
double height = 10;

Rect startZone = Rect.fromCenter(center: p0, width: width, height: height);
Path startPath = Path()..addOval(startZone);
Rect endZone = Rect.fromCenter(center: p1, width: width, height: height);
Path endPath = Path()..addOval(endZone);

Path linePath = Path()..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy);

arrowPainter
..style = PaintingStyle.stroke..strokeWidth = 1
..color = Colors.red;

canvas.drawPath(startPath, arrowPainter);
canvas.drawPath(endPath, arrowPainter);
canvas.drawPath(linePath, arrowPainter);



如下,定义抽象类 AbstractPathformPath 抽象出来,交由子类实现。端点的路径衍生出 PortPath 进行实现,这就可以将一些重复的逻辑进行封装,也有利于维护和拓展。整体路径的生成由 ArrowPath 类负责:


abstract class AbstractPath{
Path formPath();
}

class PortPath extends AbstractPath{
final Offset position;
final Size size;

PortPath(this.position, this.size);

@override
Path formPath() {
Path path = Path();
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
path.addOval(zone);
return path;
}
}

class ArrowPath extends AbstractPath{
final PortPath head;
final PortPath tail;

ArrowPath({required this.head,required this.tail});

@override
Path formPath() {
Offset line = (tail.position - head.position);
Offset center = head.position+line/2;
double length = line.distance;
Rect lineZone = Rect.fromCenter(center:center,width:length,height:2);
Path linePath = Path()..addRect(lineZone);
Path temp = Path.combine(PathOperation.union, linePath, head.formPath());
return Path.combine(PathOperation.union, temp, tail.formPath());
}
}



这样,矩形域的确定和路径的生成,交由具体的类进行实现,在使用时就会方便很多:


double width =10;
double height =10;
Size portSize = Size(width, height);
ArrowPath arrow = ArrowPath(
head: PortPath(p0, portSize),
tail: PortPath(p1, portSize),
);
canvas.drawPath(arrow.formPath(), arrowPainter);




2. 关于路径的变换

上面我们的直线其实是矩形路径,这样就会出现一些问题,比如当箭头不是水平线,会出现如下问题:



解决方案也很简单,只要让矩形直线的路径沿两点的中心进行旋转即可,旋转的角度就是两点与水平线的夹角。这就涉及了绘制中非常重要的技巧:矩阵变换 。如下代码添加的四行 Matrix4 的操作,就可以通过矩阵变换,让 linePathcenter 为中心旋转两点间角度。这里注意一下,tag1 处的平移是为了将变换中心变为 center、而tag2 处的反向平移是为了抵消 tag1 平移的影响。这样在两者之间的变换,就是以 center 为中心的变换:


class ArrowPath extends AbstractPath{
final PortPath head;
final PortPath tail;

ArrowPath({required this.head,required this.tail});

@override
Path formPath() {
Offset line = (tail.position - head.position);
Offset center = head.position+line/2;
double length = line.distance;
Rect lineZone = Rect.fromCenter(center:center,width:length,height:2);
Path linePath = Path()..addRect(lineZone);

// 通过矩阵变换,让 linePath 以 center 为中心旋转 两点间角度
Matrix4 lineM4 = Matrix4.translationValues(center.dx, center.dy, 0); // tag1
lineM4.multiply(Matrix4.rotationZ(line.direction));
lineM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0)); // tag2
linePath = linePath.transform(lineM4.storage);

Path temp = Path.combine(PathOperation.union, linePath, head.formPath());
return Path.combine(PathOperation.union, temp, tail.formPath());
}
}

这样就一切正常了,可能有人会疑惑,为什么不直接用两点形成路径呢?这样就不需要旋转了:



前面说了,这里希望获得的是一个 箭头路径 ,使用线型模式就可以看处用矩形的妙处。如果单纯用路径的移动来处理,需要计算点位,比较复杂。而用矩形加旋转,就方便很多:





3.尺寸的矫正

可以看出,目前是以起止点为圆心的矩形区域,但实际我们需要让箭头的两端顶点在两点上。有两种解决方案:其一,在 PortPath 生成路径时,对矩形区域中心进行校正;其二,在合成路径前通过偏移对首位断点进行校正。



我更倾向于后者,因为我希望 PortPath 只负责断点路径的生成,不需要管其他的事。另外 PortPath 本身也不知道端点是起点还是终点,因为起点需要沿线的方向偏移,终点需要沿反方向偏移。处理后效果如下:



---->[ArrowPath#formPath]----
Path headPath = head.formPath();
Matrix4 headM4 = Matrix4.translationValues(head.size.width/2, 0, 0);
headPath = headPath.transform(headM4.storage);

Path tailPath = tail.formPath();
Matrix4 tailM4 = Matrix4.translationValues(-head.size.width/2, 0, 0);
tailPath = tailPath.transform(tailM4.storage);



虽然表面上看起来和顶点对齐了,但换个不水平的线就会看出端倪。我们需要 沿线的方向 进行平移,也就是说,要保证该直线过矩形区域圆心:



如下所示,我们在对断点进行平移时,需要根据线的角度来计算偏移量:



 Path headPath = head.formPath();
double fixDx = head.size.width/2*cos(line.direction);
double fixDy = head.size.height/2*sin(line.direction);

Matrix4 headM4 = Matrix4.translationValues(fixDx, fixDy, 0);
headPath = headPath.transform(headM4.storage);
Path tailPath = tail.formPath();
Matrix4 tailM4 = Matrix4.translationValues(-fixDx, -fixDy, 0);
tailPath = tailPath.transform(tailM4.storage);



4.箭头的绘制

每个 PortPath 都有一个矩形区域,接下来只要专注于在该区域内绘制箭头即可。比如下面的 p0p1p2 可以形成一个三角形:



对应代码如下:


class PortPath extends AbstractPath{
final Offset position;
final Size size;

PortPath(this.position, this.size);

@override
Path formPath() {
Path path = Path();
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..close();
return path;
}
}

由于在 PortPath 中无法感知到子级是头还是尾,所以下面可以看出两个箭头都是向左的。处理方式也很简单,只要转转 180° 就行了。





另外,这样虽然看起来挺好,但也有和上面类似的问题,当改变坐标时,就会出现不和谐的情景。解决方案和前面一样,为断点的箭头根据线的倾角添加旋转变换即可。





如下进行旋转,即可得到期望的箭头,tag3 处可以顺便旋转 180° 把尾点调正。这样任意指定两点的坐标,就可以得到一个箭头。



Matrix4 headM4 = Matrix4.translationValues(fixDx, fixDy, 0);
center = head.position;
headM4.multiply(Matrix4.translationValues(center.dx, center.dy, 0));
headM4.multiply(Matrix4.rotationZ(line.direction));
headM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0));
headPath = headPath.transform(headM4.storage);

Matrix4 tailM4 = Matrix4.translationValues(-fixDx, -fixDy, 0);
center = tail.position;
tailM4.multiply(Matrix4.translationValues(center.dx, center.dy, 0));
tailM4.multiply(Matrix4.rotationZ(line.direction-pi)); // tag3
tailM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0));
tailPath = tailPath.transform(tailM4.storage);



5.箭头的拓展

从上面可以看出,这个箭头断点的拓展能力是很强的,只要在矩形区域内形成相应的路径即可。比如下面带两个尖角的箭头形式,路径生成代码如下:



class PortPath extends AbstractPath{
final Offset position;
final Size size;

PortPath(this.position, this.size);

@override
Path formPath() {
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
return pathBuilder(zone);
}

Path pathBuilder(Rect zone){
Path path = Path();
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
final double rate = 0.8;
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
Offset p3 = p0.translate(rate*zone.width, 0);
path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)
..lineTo(p3.dx, p3.dy)..lineTo(p2.dx, p2.dy)..close();
return path;
}
}

这样如下所示,只要更改 pathBuilder 中的路径构建逻辑,就可以得到不同的箭头样式。而且你只需要在矩形区域创建正着的路径即可,箭头跟随直线的旋转已经被封装在了 ArrowPath 中。这就是 屏蔽细节 ,简化使用流程。不然创建路径时还有进行角度偏转计算,岂不麻烦死了。





到这里,多样式的箭头设置方案应该就呼之欲出了。就像是 Flutter 动画中的各种 Curve 一样,通过抽象进行衍生,实现不同类型的数值转变。这里我们也可以对路径构建的行为进行抽象,来衍生出各种路径类。这样的好处在于:在实现类中,可以定义额外的参数,对绘制的细节进行控制。

如下,抽象出 PortPathBuilder ,通过 fromPathByRect 方法,根据矩形区域生成路径。在 PortPath 中就可以依赖 抽象 来完成任务:


abstract class PortPathBuilder{
const PortPathBuilder();
Path fromPathByRect(Rect zone);
}

class PortPath extends AbstractPath {
final Offset position;
final Size size;
PortPathBuilder portPath;

PortPath(
this.position,
this.size, {
this.portPath = const CustomPortPath(),
});

@override
Path formPath() {
Rect zone = Rect.fromCenter(
center: position, width: size.width, height: size.height);
return portPath.fromPathByRect(zone);
}
}



在使用时,可以通过指定 PortPathBuilder 的实现类,来配置不同的端点样式,比如实现一开始那个常规的 CustomPortPath :


class CustomPortPath extends PortPathBuilder{
const CustomPortPath();

@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..close();
return path;
}
}



以及三个箭头的 ThreeAnglePortPath ,我们可以将 rate 提取出来,作为构造入参,这样就可以让箭头拥有更多的特性,比如下面是 0.50.8 的对比:



class ThreeAnglePortPath extends PortPathBuilder{
final double rate;

ThreeAnglePortPath({this.rate = 0.8});

@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
Offset p3 = p0.translate(rate * zone.width, 0);
path
..moveTo(p0.dx, p0.dy)
..lineTo(p1.dx, p1.dy)
..lineTo(p3.dx, p3.dy)
..lineTo(p2.dx, p2.dy)
..close();
return path;
}
}



想要实现箭头不同的端点类型,只有在构造 PortPath 时,指定对应的 portPath 即可。如下红色箭头的两端分别使用 ThreeAnglePortPathCirclePortPath



ArrowPath arrow = ArrowPath(
head: PortPath(
p0.translate(40, 0),
const Size(10, 10),
portPath: const ThreeAnglePortPath(rate: 0.8),
),
tail: PortPath(
p1.translate(40, 0),
const Size(8, 8),
portPath: const CirclePortPath(),
),
);

这样一个使用者可以自由拓展的箭头绘制小体系就已经能够完美运转了。大家可以基于此体会一下其中 抽象 的意义,以及 多态 的体现。本篇中有很多旋转变换的绘制小技巧,下一篇,我们来一起绘制各种各样的 PortPathBuilder 实现类,以此丰富箭头绘制,打造一个小巧但强大的箭头绘制库。


作者:张风捷特烈
链接:https://juejin.cn/post/7120010916602576926
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

通过拦截 Activity的创建 实现APP的隐私政策改造

序言 最近因为政策收紧,现在要求APP必须在用户同意的情况下才能获取隐私信息。但是很多隐私信息的获取是第三方SDK获取的。而SDK的初始化一般都在application中。由于维护的项目多,如果贸然改动很有可能造成潜在的问题。所以想研究一个低侵入性的方案。在不...
继续阅读 »

序言


最近因为政策收紧,现在要求APP必须在用户同意的情况下才能获取隐私信息。但是很多隐私信息的获取是第三方SDK获取的。而SDK的初始化一般都在application中。由于维护的项目多,如果贸然改动很有可能造成潜在的问题。所以想研究一个低侵入性的方案。在不影响原有APP流程的基础上完成隐私改造。


方案


研究了几个方案,简单的说一下


方案1


通过给APP在设置一个入口,将原有入口的activity的enable设置为false。让客户端先进入到隐私确认界面
。确认完成,再用代码使这个activity的enable设置为false。将原来的入口设置为true。
需要的技术来自这篇文章
(技术)Android修改桌面图标


效果


这种方案基本能满足要求。但是存在两个问题。



  1. 将activity设置为false的时候会让应用崩溃。上一篇文章提到使用别名的方案也不行。

  2. 修改了activity以后,Android Studio启动的时候无法找到在清单文件中声明的activity。


方案2


直接Hook Activity的创建过程,如果用户没有通过协议,就将activity 变为我们的询问界面。
参考文献:
Android Hook Activity 的几种姿势


Android应用进程的创建 — Activity的启动流程


需要注意的是,我们只需要Hook ActivityThread 的mInstrumentation 即可。需要hook的方法是newActivity方法。


public class ApplicationInstrumentation extends Instrumentation {

private static final String TAG = "ApplicationInstrumentation";

// ActivityThread中原始的对象, 保存起来
Instrumentation mBase;

public ApplicationInstrumentation(Instrumentation base) {
mBase = base;
}

public Activity newActivity(ClassLoader cl, String className,
Intent intent)
throws InstantiationException, IllegalAccessException,
ClassNotFoundException {
className = CheckApp.getApp().getActivityName(className);
return mBase.newActivity(cl, className, intent);
}
}

使用


最终使用了方案2。通过一个CheckApp类来实现管理。
使用很简单,将你的Application类继承自CheckApp 将sdk的初始化放置到 initSDK方法中
为了避免出错,在CheckApp中我已经将onCreate设置为final了


public class MyApp extends CheckApp {


public DatabaseHelper dbHelper;
protected void initSDK() {
RxJava1Util.setErrorNotImplementedHandler();
mInstance = this;
initUtils();
}

private void initUtils() {
}
}

在清单文件中只需要注册你需要让用户确认隐私协议的activity。


<application>
...
<meta-data
android:name="com.trs.library.check.activity"
android:value=".activity.splash.GuideActivity" />

</application>

如果要在应用每次升级以后都判断用户协议,只需要覆盖CheckApp中的这个方法。(默认开启该功能)


/**
* 是否每个版本都检查是否拥有用户隐私权限
* @return
*/
protected boolean checkForEachVersion() {
return true;
}

判断用户是否同意用这个方法


CheckApp.getApp().isUserAgree();

用户同意以后的回调,第二个false表示不自动跳转到被拦截的Activity


    /**
* 第二个false表示不自动跳转到被拦截的Activity
* CheckApp 记录了被拦截的Activity的类名。
*/
CheckApp.getApp().agree(this,false,getIntent().getExtras());

源码


一共只有3个类
在这里插入图片描述


ApplicationInstrumentation


import android.app.Activity;
import android.app.Instrumentation;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
import android.util.Log;

import java.lang.reflect.Method;

/**
* Created by zhuguohui
* Date: 2021/7/30
* Time: 13:46
* Desc:
*/
public class ApplicationInstrumentation extends Instrumentation {

private static final String TAG = "ApplicationInstrumentation";

// ActivityThread中原始的对象, 保存起来
Instrumentation mBase;

public ApplicationInstrumentation(Instrumentation base) {
mBase = base;
}

public Activity newActivity(ClassLoader cl, String className,
Intent intent)
throws InstantiationException, IllegalAccessException,
ClassNotFoundException {
className = CheckApp.getApp().getActivityName(className);
return mBase.newActivity(cl, className, intent);
}


}

CheckApp




import android.app.Activity;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.support.multidex.MultiDexApplication;

import com.trs.library.util.SpUtil;

import java.util.List;

/**
* Created by zhuguohui
* Date: 2021/7/30
* Time: 10:01
* Desc:检查用户是否给与权限的application
*/
public abstract class CheckApp extends MultiDexApplication {

/**
* 用户是否同意隐私协议
*/
private static final String KEY_USER_AGREE = CheckApp.class.getName() + "_key_user_agree";
private static final String KEY_CHECK_ACTIVITY = "com.trs.library.check.activity";

private boolean userAgree;

private static CheckApp app;


@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
userAgree = SpUtil.getBoolean(this, getUserAgreeKey(base), false);
getCheckActivityName(base);
if (!userAgree) {
//只有在用户不同意的情况下才hook ,避免性能损失
try {
HookUtil.attachContext();
} catch (Exception e) {
e.printStackTrace();
}
}
}


protected String getUserAgreeKey(Context base) {
if (checkForEachVersion()) {
try {
long longVersionCode = base.getPackageManager().getPackageInfo(base.getPackageName(), 0).versionCode;
return KEY_USER_AGREE + "_version_" + longVersionCode;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
}
return KEY_USER_AGREE;

}

/**
* 是否每个版本都检查是否拥有用户隐私权限
* @return
*/
protected boolean checkForEachVersion() {
return true;
}

private static boolean initSDK = false;//是否已经初始化了SDK

String checkActivityName = null;

private void getCheckActivityName(Context base) {
mPackageManager = base.getPackageManager();
try {
ApplicationInfo appInfo = mPackageManager.getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA);
checkActivityName = appInfo.metaData.getString(KEY_CHECK_ACTIVITY);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
checkActivityName = checkName(checkActivityName);

}

public String getActivityName(String name) {
if (isUserAgree()) {
return name;
} else {
setRealFirstActivityName(name);
return checkActivityName;
}
}

private String checkName(String name) {
String newName = name;
if (!newName.startsWith(".")) {
newName = "." + newName;
}
if (!name.startsWith(getPackageName())) {
newName = getPackageName() + newName;
}

return newName;

}


@Override
public final void onCreate() {
super.onCreate();
if (!isRunOnMainProcess()) {
return;
}
app = this;
initSafeSDK();

//初始化那些和隐私无关的SDK
if (userAgree && !initSDK) {
initSDK = true;
initSDK();
}

}


public static CheckApp getApp() {
return app;
}


/**
* 初始化那些和用户隐私无关的SDK
* 如果无法区分,建议只使用initSDK一个方法
*/
protected void initSafeSDK() {

}


/**
* 判断用户是否同意
*
* @return
*/
public boolean isUserAgree() {
return userAgree;
}


static PackageManager mPackageManager;


private static String realFirstActivityName = null;

public static void setRealFirstActivityName(String realFirstActivityName) {
CheckApp.realFirstActivityName = realFirstActivityName;
}

public void agree(Activity activity, boolean gotoFirstActivity, Bundle extras) {

SpUtil.putBoolean(this, getUserAgreeKey(this), true);
userAgree = true;

if (!initSDK) {
initSDK = true;
initSDK();
}

//启动真正的启动页
if (!gotoFirstActivity) {
//已经是同一个界面了,不需要自动打开
return;
}
try {
Intent intent = new Intent(activity, Class.forName(realFirstActivityName));
if (extras != null) {
intent.putExtras(extras);//也许是从网页中调起app,这时候extras中含有打开特定新闻的参数。需要传递给真正的启动页
}
activity.startActivity(intent);
activity.finish();//关闭当前页面
} catch (ClassNotFoundException e) {
e.printStackTrace();
}

}


/**
* 子类重写用于初始化SDK等相关工作
*/
abstract protected void initSDK();

/**
* 判断是否在主进程中,一些SDK中的PushServer可能运行在其他进程中。
* 也就会造成Application初始化两次,而只有在主进程中才需要初始化。
* * @return
*/
public boolean isRunOnMainProcess() {
ActivityManager am = ((ActivityManager) this.getSystemService(Context.ACTIVITY_SERVICE));
List<ActivityManager.RunningAppProcessInfo> processInfos = am.getRunningAppProcesses();
String mainProcessName = this.getPackageName();
int myPid = android.os.Process.myPid();
for (ActivityManager.RunningAppProcessInfo info : processInfos) {
if (info.pid == myPid && mainProcessName.equals(info.processName)) {
return true;
}
}
return false;
}
}

HookUtil



import android.app.Instrumentation;
import android.util.Log;

import java.lang.reflect.Field;
import java.lang.reflect.Method;

/**
* Created by zhuguohui
* Date: 2021/7/30
* Time: 13:20
* Desc:
*/
public class HookUtil {



public static void attachContext() throws Exception {
Log.i("zzz", "attachContext: ");
// 先获取到当前的ActivityThread对象
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
//currentActivityThread是一个static函数所以可以直接invoke,不需要带实例参数
Object currentActivityThread = currentActivityThreadMethod.invoke(null);

// 拿到原始的 mInstrumentation字段
Field mInstrumentationField = activityThreadClass.getDeclaredField("mInstrumentation");
mInstrumentationField.setAccessible(true);
Instrumentation mInstrumentation = (Instrumentation) mInstrumentationField.get(currentActivityThread);
// 创建代理对象
Instrumentation evilInstrumentation = new ApplicationInstrumentation(mInstrumentation);
// 偷梁换柱
mInstrumentationField.set(currentActivityThread, evilInstrumentation);
}


}

作者:solo_99
链接:https://juejin.cn/post/6990643611130363917
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

给灭霸点颜色看看

前言 继续我们 Flutter 绘图相关的介绍,本篇我们引入一位重量级主角 —— 灭霸。通过绘图的颜色过滤器,我们要给灭霸点颜色看看。通过本篇,你会了解到如下内容: ColorFilter 颜色过滤器的介绍; 彩色图片转换为灰度图; 通过矩阵运算构建自定义的...
继续阅读 »

前言


继续我们 Flutter 绘图相关的介绍,本篇我们引入一位重量级主角 —— 灭霸。通过绘图的颜色过滤器,我们要给灭霸点颜色看看。通过本篇,你会了解到如下内容:



  • ColorFilter 颜色过滤器的介绍;

  • 彩色图片转换为灰度图;

  • 通过矩阵运算构建自定义的颜色过滤器。


ColorFilter 颜色过滤器


其实我们之前在给小姐姐的照片调个颜色滤镜有介绍过颜色滤镜,在 Flutter 中提供了一个 ColorFiltered 的组件,可以将颜色过滤器应用到其子组件上。实际上,颜色过滤器就是对一个图层的每个像素的颜色(包括透明度)进行数学运算,改变像素的颜色来实现特定的效果。数学公式如下:


颜色变换矩阵


在 Flutter 中,ColorFilter 类的继承自 ImageFilter,像 ImageFilter 一样,也只提供了命名构造函数,一共有四个命名构造函数,分别如下:



  • ColorFilter.mode(Color color, BlendMode mode):按制定的混合模式(blend mode),将颜色混入到绘制的目标中。可以理解为图像的色值调整,我们可以用一个指定的颜色调整原图,调整的模式有很多种,具体可以查看 BlendMode 枚举。

  • ColorFilter.linearToSrgbGamma():将一个 SRGB 的 gamma 曲线应用到 RGB 颜色通道中。

  • ColorFilter.srgbToLinearGamma()ColorFilter.linearToSrgbGamma()的反向过程。

  • ColorFilter.matrix(List<double> matrix):应用一个矩阵做颜色变换,也就是我们上面说的矩阵,这是最通用的版本,要什么效果可以自己构建对应的矩阵。


这里说一下 SRGB 的 gamma 曲线的用途。我们人眼在显示屏中对图片进行调色等操作时,是按照线性空间的角度进行的,但显示器是在gamma空间中的,那么图像在计算机中的存储一般都应该是在 gamma 空间下了。也就是计算机存储的是非线性的,但是给我们展示的时候要转为线性的。因此,对于一张图像,可能是线性的也可能是 gamma 空间的,这个时候为了统一可能就需要进行转换,那就会用到linearToSrgbGammasrgbToLinearGamma两个颜色过滤器。


彩色图片转成灰色图片


彩色图片转变为灰色图片有很2种方法,最简单的方法是使用ColorFilter.mode,第一个参数颜色选择灰色或黑色,然后 第二个参数选择 BlendMode.color 或者接近的效果(比如 huesaturation)。BlendMode.color 是取源图的色调和饱和度,然后取目标(即要改变的图片)的亮度。因此,如果我们想更改一张图片的色调,用这种方式最好了。下面是对应的实现代码和变换前后的对比图。


var paint = Paint();
paint.colorFilter = ColorFilter.mode(Colors.grey, BlendMode.color);

canvas.drawImageRect(
bgImage,
Rect.fromLTRB(0, 0, bgImage.width.toDouble(), bgImage.height.toDouble()),
Offset.zero & size,
paint,
);

灰度图.jpg
使用ColorFilter.mode另一个用途就是简单的“修图”了,比如我们可以将一张蓝天白云图修成夕阳西下的效果。


夕阳效果.jpg


当然,转换为灰度图我们也可以通过矩阵实现。


矩阵运算改变颜色


如果要想任意调换颜色,那么使用矩阵运算更合适。在 Flutter 中,ColorFilter.matrix 多增加了一行,这一行主要是在构建一些特殊的矩阵运算更方便,比如反转色的时候。


Matrix 构建公式


比如我们要让变换后的图像实现反转:



  • 红色色值=255-原红色色值

  • 绿色色值=255-原绿色色值

  • 蓝色色值=255-原蓝色色值


那么构建如下矩阵就可以了。


反转色变换矩阵


由于最后一行数值对实际变化没影响,因此实际构建 ColorFilter.matrix 的时候,只需要传入20个参数就可以了。下面是应用了反转效果后的灭霸图,灭霸看起来像一个雕塑了。



下面我们先来看一下使用矩阵实现彩色图变灰度图,用下面的矩阵就能实现,最终得到变换后的 R、G、B值是相等的,而且三个色值的系数相加等于1(保证数值不会超出255)。这个矩阵是官方提供的,实际上也是经过图像学研究推导得到的。


灰度变换公式


对应灰度变换的 ColorFilter 的构造代码如下:


const greyScale = ColorFilter.matrix(<double>[
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0, 0, 0, 1, 0,
]);

最后,我们来看看颜色循环变换的效果,颜色循环变换就是红色部分变为原先像素的绿色值,绿色部分变到原先像素的蓝色值,然后蓝色部分变到原先像素的红色值,对应的 ColorFilter 构造代码如下:


var colorRotation = ColorFilter.matrix(<double>[
0, 1, 0, 0, 0,
0, 0, 1, 0, 0,
1, 0, 0, 0, 0,
0, 0, 0, 1, 0
]);

有了这个我们其实就可以做一些动效了,比如我们把变化过程由动画值控制,得到下面的矩阵。


var colorRotation = ColorFilter.matrix(<double>[
animationValue, 1-animationValue, 0, 0, 0,
0, animationValue, 1-animationValue, 0, 0,
1-animationValue, 0, animationValue, 0, 0,
0, 0, 0, 1, 0
]);

我们看看灭霸图片颜色变化的动画效果,整个画面的色调在不断的变化,感觉像灭霸要开始“打响指”了。


颜色变化动画.gif


ColorFilter 的应用


ColorFilter 的最佳应用场景应该是图片滤镜,我们在图片类应用经常会看到各种滤镜效果(取得名字都很好听,比如什么“清纯”、“蓝调”,“怀旧”等等),实际上这种效果就是将一个颜色预置的变换矩阵应用到图片上。


总结


本篇介绍了颜色过滤器 ColorFilter 的应用以及原理,我们绘图的时候可以使用 ColorFilter 处理图片,实现类似滤镜的效果。如果考虑简单使用,也可以直接使用 ColorFiltered 组件。




本篇源码已上传至:绘图相关源码,文件名为:color_filter_demo.dart


作者:岛上码农
链接:https://juejin.cn/post/7118320708786061325
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android抓包从未如此简单


·  阅读 407

一、情景再现:

有一天你正在王者团战里杀的热火朝天,忽然公司测试人员打你电话问为什么某个功能数据展示不出来了,昨天还好好的纳,是不是你又偷偷写bug了。。。WTF!,你会说你把手机给我,我连上电脑看看打印的请求日志是不是接口有问题,然后吭哧吭哧搞半天看到接口数据返回的格式确实不会,然后去群里丢了几句服务端人员看一下这个接口,数据有问题。然后有回去打游戏,可惜游戏早已结束,以失败告终,自己还没无情的举报禁赛了。。。人生最痛苦的事莫过于如此。假如你的项目已经继承了抓包助手,并且也给其他人员较少过如何使用,那么像这类问题根本就不需要你再来处理了,遇到数据问题他们第一时间会自己看请求数据,而你就可以安心上王者了。

二、Android抓包现状

目前常见的抓包工具有Charles、Fiddler、Wireshark等,这些或多或少都需要一些配置,略显麻烦,只适合开发及测试人员玩,如果产品也想看数据怎么办纳,别急,本文的主角登场了,你可以在项目中集成AndroidMonitor,只需两步简单配置即可实现抓包数据可视化功能,随时随地,人人都可以方便快捷的查看。

三、效果展示

俗话说无图无真相

111.jpg

222.jpg

333.jpg

抓包pc.png

四、如何使用

抓包工具有两个依赖需要添加:monito和monitor-plugin

Demo下载体验

1、monitor接入

添加依赖

   debugImplementation 'io.github.lygttpod:monitor:0.0.4'
复制代码

-备注: 使用debugImplementation是为了只在测试环境中引入

2、monitor-plugin接入

  1. 根目录build.gradle下添加如下依赖
    buildscript {
dependencies {
......
//monitor-plugin需要
classpath 'io.github.lygttpod:monitor-plugin:0.0.1'
}
}

复制代码

2.添加插件

    在APP的build.gradle中添加:

//插件内部会自动判断debug模式下hook到okhttp
apply plugin: 'monitor-plugin'

复制代码

原则上完成以上两步你的APP就成功集成了抓包工具,很简单有没有,如需定制化服务请看下边的个性化配置

3、 个性化配置

1、修改桌面抓包工具入口名字:在主项目string.xml中添加 monitor_app_name即可,例如:
```
<string name="monitor_app_name">XXX-抓包</string>
```
2、定制抓包入口logo图标:
```
添加 monitor_logo.png 即可
```
3、单个项目使用的话,添加依赖后可直接使用,无需初始化,库里会通过ContentProvider方式自动初始化

默认端口8080(端口号要唯一)

4、多个项目都集成抓包工具,需要对不同项目设置不同的端口和数据库名字,用来做区分

在主项目assets目录下新建 monitor.properties 文件,文件内如如下:对需要变更的参数修改即可
```
# 抓包助手参数配置
# Default port = 8080
# Default dbName = monitor_db
# ContentTypes白名单,默认application/json,application/xml,text/html,text/plain,text/xml
# Default whiteContentTypes = application/json,application/xml,text/html,text/plain,text/xml
# Host白名单,默认全部是白名单
# Default whiteHosts =
# Host黑名单,默认没有黑名单
# Default blackHosts =
# 如何多个项目都集成抓包工具,可以设置不同的端口进行访问
monitor.port=8080
monitor.dbName=app_name_monitor_db
```
复制代码

4、 proguard(默认已经添加混淆,如遇到问题可以添加如下混淆代码)

```
# monitor
-keep class com.lygttpod.monitor.** { *; }
```
复制代码

5、 温馨提示

    虽然monitor-plugin只会在debug环境hook代码,
但是release版编译的时候还是会走一遍Transform操作(空操作),
为了保险起见建议生产包禁掉此插件。

在jenkins打包机器的《生产环境》的local.properties中添加monitor.enablePlugin=false,全面禁用monitor插件
复制代码

6、如何使用

  • 集成之后编译运行项目即可在手机上自动生成一个抓包入口的图标,点击即可打开可视化页面查看网络请求数据,这样就可以随时随地的查看我们的请求数据了。
  • 虽然可以很方便的查看请求数据了但是手机屏幕太小,看起来不方便怎么办呐,那就去寻找在PC上展示的方法,首先想到的是能不能直接在浏览器里边直接看呐,这样不用安装任何程序在浏览输入一个地址就可以直接查看数据
  • PC和手机在同一局域网的前提下:直接在任意浏览器输入 手机ip地址+抓包工具设置的端口号即可(地址可以在抓包app首页TitleBar上可以看到)

7、关键原理说明

  • 拦截APP的OKHTTP请求(添加拦截器处理抓包请求,使用ASM字节码插装技术实现)
  • 数据保存到本地数据库(room)
  • APP本地开启一个socket服务AndroidLocalService
  • 与本地socket服务通信
  • UI展示数据(手机端和PC端)

来了!解放你 Flutter Assets 的双手

以下是正文 Flutter 中加载本地资源最原始的方式是手动添加,然后硬编码路径,这种方式使用起来极其麻烦,也是我们开发者的痛点。这篇文章来介绍怎么用自动生成的方式来解放大家的双手,远离这个小痛点😉。 下面,我们来看怎么在 App 中使用资源,这些资源可以是...
继续阅读 »

以下是正文


Flutter 中加载本地资源最原始的方式是手动添加,然后硬编码路径,这种方式使用起来极其麻烦,也是我们开发者的痛点。这篇文章来介绍怎么用自动生成的方式来解放大家的双手,远离这个小痛点😉。


image.png


下面,我们来看怎么在 App 中使用资源,这些资源可以是图片,也可是字体。



· · ·

方式 1 : 手动添加


这是我们最原始的方式,也是带给我们痛苦的方式 😂,我们刚开始 Flutter 的时候基本就是这样的~


我们看一下这种方式麻烦在什么地方!怎么给我们自己制造麻烦的!


Step 1: 文件夹中添加图片


1_8MSLeRTWJJ9cNdRzWHymvg.png


Step 2: 添加图片到 pubspec.yaml 文件中


image.png


注意一点🤏:assets/ 会添加 assets/ 文件下所有可用的图片。


Step 3: 直接在代码中使用


import 'package:asset_generation/page2.dart';
import 'package:flutter/material.dart';

class Page1 extends StatelessWidget {
const Page1({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page 1'),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.next_plan),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const Page2(),
),
),
),
body: Center(
child: Image.asset('assets/dash.png'),
),
);
}
}

我们再创建一个 Page2 页面,并且添加相同的代码。



import 'package:flutter/material.dart';

class Page2 extends StatelessWidget {
const Page2({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page 1'),
),
body: Center(
child: Image.asset('assets/dash.png'),
),
);
}
}

效果如下:


1_6nRtHc2RD8eU8i1VprJ_mA.gif


现在,假如我们想要修改文件的名字。只要我们改变了文件的名字,我们必须在代码中每一个使用到文件的地方修改一遍字符串。这就是痛苦且麻烦的地方!!!


在这里例子中,我们仅仅有两个页面,修改的时候貌似简单。但是我们维护的是一个大型 APP,开发者还修改了文件名,想想这个代码中重命名的任务就恶心🤢。



· · ·

方式 2 : 为资源变量创建一个常量文件


现在我们稍微进步一点点🤏来减缓我们的痛苦。我们创建一个常量来保持文件的路径,然后在代码中使用常量文件!


Step 1: 创建 constants.dart 文件


class Constants {
static String dashImage = 'assets/dash.png';
}

Step 2: 在Page1 和 Page2 中使用常量:


Center(
child: Image.asset(Constants.dashImage),
),

在这个例子里面,如果开发者想要修改文件名字,仅仅改变常量的内容就可以了,只在 Constants 类中一处而已。


Step 3: 自动创建常量文件


接下来就是魔法的地方~


Step 1: 在 pubspec.yaml 添加 flutter_gen 依赖


在 dependencies 下面添加 flutter_gen 依赖,然后在 dev_dependencies 添加 flutter_gen_runnerbuild_runner 依赖。


Step 2: 生成 assets


添加依赖之后,执行 flutter pub get,然后运行下面的命令:


flutter packages pub run build_runner build

这里命令之后,会创建一个 lib/gen 文件夹,在文件夹里面,会存在一个 assets.gen.dart 文件,这个文件会保存所有的资源信息!


Step 3: 在代码中使用


现在,使用生成的资源,开发者可以访问资源文件:


Center(
child: Image.asset(Assets.dash.path),
),

现在,加入开发者想要重命名文件,仅仅需要在运行一遍命令就可以了,我们什么也不用做了!



· · ·

希望大家喜欢文章~


如果文章对大家有帮助,并且想要在自己的 APP 中使用,可以在这个仓库中看 👉GitHub Repository


作者:一条上岸小咸鱼
链接:https://juejin.cn/post/7071077124982964260
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Flutter: 卡顿检测,实用小工具推荐

前言 对于任何一款应用来说,页面的流畅度是用户体验最重要的几个指标之一。我们需要用数据的形式标识出页面的流畅程度。 如何了解页面流畅度 对于大部分人而言,当每秒的画面达到60,也就是俗称60FPS的时候,整个过程就是流畅的。一秒 60 帧,也就意味着平均两帧之...
继续阅读 »

前言


对于任何一款应用来说,页面的流畅度是用户体验最重要的几个指标之一。我们需要用数据的形式标识出页面的流畅程度。


如何了解页面流畅度


对于大部分人而言,当每秒的画面达到60,也就是俗称60FPS的时候,整个过程就是流畅的。一秒 60 帧,也就意味着平均两帧之间的间隔为 16.7ms。但并不意味着一秒低于60帧,人眼就会感觉到卡顿。小轰将查阅到的资料列出如下:



  • 流畅:FPS大于55,即一帧耗时低于 18ms

  • 良好:FPS在30-55之间,即一帧耗时在 18ms-33ms 之间

  • 轻微卡顿:FPS在15-30之间,即一帧耗时在 33ms-67ms 之间

  • 卡顿:FPS低于15,即一帧耗时大于 66.7ms


两款帧率检测工具


1. PerformanceOverLay


官方SDK为开发者提供的帧率检测工具,使用非常简单,在MaterialApp下添加属性showPerformanceOverlay:true


MaterialApp(
showPerformanceOverlay: true,
home: ...,
)

image.png
如图,PerformanceOverLay 会分别为我们展示了构建(UI)耗时和渲染(Raster)耗时。



注意:我们在判断流畅度的时候,要看一帧的总耗时(UI耗时+Raster耗时)。



2. fps_monitor


一款pub上的开源工具,链接地址:fps_monitor


集成步骤



  1. 添加引用 fps_monitor: ^2.0.0

  2. 根布局添加包裹组件


Widget build(BuildContext context) {
GlobalKey<NavigatorState> globalKey = GlobalKey();
WidgetsBinding.instance.addPostFrameCallback((t) {
//overlayState 为 fps_monitor 内提供变量,用于overlay.insert
overlayState = globalKey.currentState.overlay;
});
return MaterialApp(
showPerformanceOverlay: false,
navigatorKey: globalKey,
builder: (ctx, child) => CustomWidgetInspector(
child: child,
),
home: MyApp(),
);
}
复制代码

参数说明


  • navigatorKey : MaterialApp指定GlobalKey

  • overlayState 赋值: 指定overLayState ,因为需要弹出一个Fps的统计页面,所以当前指定overLayState。

  • CustomWidgetInspector: 在build属性中包裹组件


image.png



与 PerformanceOverLay 不同,fps_monitor在使用上更加直观,省略了两组数据的相加。



原理分析:



  • Flutter 会在每帧完成绘制后,将耗时进行回调List<FrameTiming> 。[构建时间;绘制时间;总时间]。WidgetsBinding.instance.addTimingsCallback(Function(List<FrameTiming> timings));

  • 每一帧的耗时 duration = frameTiming.totalSpan.inMilliseconds.toDouble()

  • 根据每一帧的耗时,依照规则进行流畅度匹配,完成widget的绘制。然后通过 overlay.insert(),作为浮窗展示给开发者

作者:刘小轰
链接:https://juejin.cn/post/7016602675538034719
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Flutter之GetX依赖注入使用详解

put 为了验证依赖注入的功能,首先创建两个测试页面:PageA 和 PageB ,PageA 添加两个按钮 toB 和 find ,分别为跳转 PageB 和获取依赖;在 PageB 中通过 put 方法注入依赖对象,然后调用按钮触发 find 获取依赖。关...
继续阅读 »

put


为了验证依赖注入的功能,首先创建两个测试页面:PageA 和 PageB ,PageA 添加两个按钮 toBfind ,分别为跳转 PageB 和获取依赖;在 PageB 中通过 put 方法注入依赖对象,然后调用按钮触发 find 获取依赖。关键源码如下:


PageA


TextButton(
child: const Text("toB"),
onPressed: (){
/// Navigator.push(context, MaterialPageRoute(builder: (context) => const PageB()));
/// Get.to(const PageB());
},
),

TextButton(
child: const Text("find"),
onPressed: () async {
User user = Get.find();
print("page a username : ${user.name} id: ${user.id}");
})

PageB:


Get.put(User.create("张三", DateTime.now().millisecondsSinceEpoch)));

User user = Get.find();

TextButton(
child: const Text("find"),
onPressed: (){
User user = Get.find();
print("${DateTime.now()} page b username : ${user.name} id: ${user.id}");
})

其中 User 为自定义对象,用于测试注入,源码如下:


User:


class User{
final String? name;
final int? id;

factory User.create(String name, int id){
print("${DateTime.now()} create User");
return User(name, id);
}
}

Navigator 路由跳转


首先使用 Flutter 自带的路由管理从 PageA 跳转 PageB, 然后返回 PageA 再点击 find 按钮获取 User 依赖:


Navigator.push(context, MaterialPageRoute(builder: (context) => const PageB()));

流程:PageA -> PageB -> put -> find -> PageA -> find


输出结果:


/// put
I/flutter (31878): 2022-01-27 19:18:20.851800 create User
[GETX] Instance "User" has been created

/// page b find
I/flutter (31878): 2022-01-27 19:18:22.170133 page b username : 张三 id: 1643282300139

/// page a find
I/flutter (31878): 2022-01-27 19:18:25.554667 page a username : 张三 id: 1643282300139

通过输出结果发现,在 PageB 注入的依赖 User,在返回 PageA 后通过 find 依然能获取,并且是同一个对象。通过 Flutter 通过源码一步一步剖析 Getx 依赖管理的实现 这篇文章知道,在页面销毁的时候会回收依赖,但是这里为什么返回 PageA 后还能获取到依赖对象呢?是因为在页面销毁时回收有个前提是使用 GetX 的路由管理页面,使用官方的 Navigator 进行路由跳转时页面销毁不会触发回收依赖。


GetX 路由跳转


接下来换成使用 GetX 进行路由跳转进行同样的操作,再看看输出结果:


Get.to(const PageB());

流程:PageA -> PageB -> put -> find -> PageA -> find


输出结果:


[GETX] GOING TO ROUTE /PageB

/// put
I/flutter (31878): 2022-01-27 19:16:32.014530 create User
[GETX] Instance "User" has been created

/// page b find
I/flutter (31878): 2022-01-27 19:16:34.043144 page b username : 张三 id: 1643282192014
[GETX] CLOSE TO ROUTE /PageB
[GETX] "User" deleted from memory

/// page a find error
E/flutter (31878): [ERROR:flutter/lib/ui/ui_dart_state.cc(209)] Unhandled Exception: "User" not found. You need to call "Get.put(User())" or "Get.lazyPut(()=>User())"

发现在 PageB 中获取是正常,关闭 PageB 时输出了一句 "User" deleted from memory 即在 PageB 注入的 User 被删除了,此时在 PageA 再通过 find 获取 User 就报错了,提示需要先调用 put 或者 lazyPut 先注入依赖对象。这就验证了使用 GetX 路由跳转时,使用 put 默认注入依赖时,当页面销毁依赖也会被回收。


permanent


put 还有一个 permanent 参数,在 Flutter应用框架搭建(一)GetX集成及使用详解 这篇文章里介绍过,permanent 的作用是永久保留,默认为 false,接下来在 put 时设置 permanent 为 true,并同样使用 GetX 的路由跳转重复上面的流程。


关键代码:


Get.put(User.create("张三", DateTime.now().millisecondsSinceEpoch), permanent: true);

流程:PageA -> PageB -> put -> find -> PageA -> find


输出结果:


[GETX] GOING TO ROUTE /PageB
/// put
I/flutter (31878): 2022-01-27 19:15:16.110403 create User
[GETX] Instance "User" has been created

/// page b find
I/flutter (31878): 2022-01-27 19:15:18.667360 page b username : 张三 id: 1643282116109
[GETX] CLOSE TO ROUTE /PageB
[GETX] "User" has been marked as permanent, SmartManagement is not authorized to delete it.

/// page a find success
I/flutter (31878): page a username : 张三 id: 1643282116109

设置 permanent 为 true 后,返回 PageA 同样能获取到依赖对象,说明依赖并没有因为页面销毁而回收,GetX 的日志输出也说明了 User 被标记为 permanent 而不会被删除:"User" has been marked as permanent, SmartManagement is not authorized to delete it.


lazyPut


lazyPut 为延迟初始化依赖对象 :


Get.lazyPut(() => User.create("张三", DateTime.now().millisecondsSinceEpoch));

TextButton(
child: const Text("find"),
onPressed: () async {
User user = Get.find();
print("${DateTime.now()} page b username : ${user.name} id: ${user.id}");
})

流程:PageA -> PageB -> put -> find -> find -> PageA -> find, 从 PageA 跳转 PageB,先通过 lazyPut 注入依赖,然后点击 find 获取依赖,过 3 秒再点击一次,然后返回 PageA 点击 find 获取一次。


输出结果:


[GETX] GOING TO ROUTE /PageB
/// lazyPut

/// page b find 1
I/flutter (31878): 2022-01-27 17:38:49.590295 create User
[GETX] Instance "User" has been created
I/flutter (31878): 2022-01-27 17:38:49.603063 page b username : 张三 id: 1643276329589

/// page b find 2
I/flutter (31878): 2022-01-27 17:38:52.297049 page b username : 张三 id: 1643276329589
[GETX] CLOSE TO ROUTE /PageB
[GETX] "User" deleted from memory

/// page a find error
E/flutter (31878): [ERROR:flutter/lib/ui/ui_dart_state.cc(209)] Unhandled Exception: "User" not found. You need to call "Get.put(User())" or "Get.lazyPut(()=>User())"

通过日志发现 User 对象是在第一次调用 find 时进行初始化话的,第二次 find 时不会再次初始化 User;同样的 PageB 销毁时依赖也会被回收,导致在 PageA 中获取会报错。


fenix


lazyPut 还有一个 fenix 参数默认为 false,作用是当销毁时,会将依赖移除,但是下次 find 时又会重新创建依赖对象。


lazyPut 添加 fenix 参数 :


 Get.lazyPut(() => User.create("张三", DateTime.now().millisecondsSinceEpoch), fenix: true);

流程:PageA -> PageB -> put -> find -> find -> PageA -> find,与上面流程一致。


输出结果:


[GETX] GOING TO ROUTE /PageB
/// lazyPut

/// page b find 1
[GETX] Instance "User" has been created
I/flutter (31878): 2022-01-27 17:58:58.321564 create User
I/flutter (31878): 2022-01-27 17:58:58.333369 page b username : 张三 id: 1643277538321

/// page b find 2
I/flutter (31878): 2022-01-27 17:59:01.647629 page b username : 张三 id: 1643277538321
[GETX] CLOSE TO ROUTE /PageB

/// page a find success
I/flutter (31878): 2022-01-27 17:59:07.666929 create User
[GETX] Instance "User" has been created
I/flutter (31878): page a username : 张三 id: 1643277547666

通过输出日志分析,在 PageB 中的表现与不加 fenix 表现一致,但是返回 PageA 后获取依赖并没有报错,而是重新创建了依赖对象。这就是 fenix 的作用。


putAsync


putAsyncput 基本一致,不同的是传入依赖可以异步初始化。测试代码修改如下:


print("${DateTime.now()} : page b putAsync User");
Get.putAsync(() async {
await Future.delayed(const Duration(seconds: 3));
return User.create("张三", DateTime.now().millisecondsSinceEpoch);
});

使用 Future.delayed 模拟耗时操作。


流程:PageA -> PageB -> put -> find -> PageA -> find


输出结果:


[GETX] GOING TO ROUTE /PageB

/// putAsync
I/flutter (31878): 2022-01-27 18:48:34.280337 : page b putAsync User

/// create user
I/flutter (31878): 2022-01-27 18:48:37.306073 create User
[GETX] Instance "User" has been created

/// page b find
I/flutter (31878): 2022-01-27 18:48:40.264854 page b username : 张三 id: 1643280517305
[GETX] CLOSE TO ROUTE /PageB
[GETX] "User" deleted from memory

/// page a find error
E/flutter (31878): [ERROR:flutter/lib/ui/ui_dart_state.cc(209)] Unhandled Exception: "User" not found. You need to call "Get.put(User())" or "Get.lazyPut(()=>User())"

通过日志发现,put 后确实是过了 3s 才创建 User。


create


createpermanent 参数默认为 true,即永久保留,但是通过 Flutter应用框架搭建(一)GetX集成及使用详解 这篇源码分析知道,create 内部调用时 isSingleton 设置为 false,即每次 find 时都会重新创建依赖对象。


Get.create(() => User.create("张三", DateTime.now().millisecondsSinceEpoch));

流程:PageA -> PageB -> put -> find -> find -> PageA -> find


输出结果:


[GETX] GOING TO ROUTE /PageB
/// create

/// page b find 1
I/flutter (31878): 2022-01-27 18:56:10.520961 create User
I/flutter (31878): 2022-01-27 18:56:10.532465 page b username : 张三 id: 1643280970520

/// page b find 2
I/flutter (31878): 2022-01-27 18:56:18.933750 create User
I/flutter (31878): 2022-01-27 18:56:18.934188 page b username : 张三 id: 1643280978933

[GETX] CLOSE TO ROUTE /PageB

/// page a find success
I/flutter (31878): 2022-01-27 18:56:25.319224 create User
I/flutter (31878): page a username : 张三 id: 1643280985319

通过日志发现,确实是每次 find 时都会重新创建 User 对象,并且退出 PageB 后还能通过 find 获取依赖对象。


总结


通过代码调用不同的注入方法,设置不同的参数,分析输出日志,详细的介绍了 putlazyPutputAsynccreate 以及 permanentfenix 参数的具体作用,开发中可根据实际业务场景灵活使用不同注入方式。关于注入的 tag 参数将在后续文章中详细介绍。


作者:loongwind
链接:https://juejin.cn/post/7057851021380288549
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

原生Android工程接入Flutter aar

一、环境搭建 首先,需要开发者按照原生Android、iOS的搭建流程搭建好开发环境。然后,去Flutter官网下载最新的SDK,下载完毕后解压到自定义目录即可。如果出现下载问题,可以使用Flutter官方为中国开发者搭建的临时镜像。 export PUB_H...
继续阅读 »

一、环境搭建


首先,需要开发者按照原生Android、iOS的搭建流程搭建好开发环境。然后,去Flutter官网下载最新的SDK,下载完毕后解压到自定义目录即可。如果出现下载问题,可以使用Flutter官方为中国开发者搭建的临时镜像。


export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn

为了方便使用命令行,还需要额外配置下环境变量。首先,使用vim命令打开终端。


vim ~/.bash_profile  

然后,将如下代码添加到.bash_profile文件中,并使用source ~/.bash_profile命令使文件更改生效。


export PATH=/Users/mac/Flutter/flutter/bin:$PATH
//刷新.bash_profile
source ~/.bash_profile

完成上述操作之后,接下来使用flutter doctor命令检查环境是否正确,成功会输出如下信息。
在这里插入图片描述


二、创建Flutter aar包


原生Android集成Flutter主要有两种方式,一种是创建flutter module,然后以原生module那样依赖;另一种方式是将flutter module打包成aar,然后在原生工程中依赖aar包,官方推荐aar的方式接入。


创建flutter aar有两种方式,一种是使用Android Studio进行生成,另一种是直接使用命令行。使用命令行创建flutter module如下:


flutter create -t module flutter_module

然后,进入到flutter_module,执行flutter build aar命令生成aar包,如果没有任何出错,会在/flutter_module/.android/Flutter/build/outputs目录下生成对应的aar包,如下图。


在这里插入图片描述


build/host/outputs/repo
└── com
└── example
└── my_flutter
├── flutter_release
│ ├── 1.0
│ │ ├── flutter_release-1.0.aar
│ │ ├── flutter_release-1.0.aar.md5
│ │ ├── flutter_release-1.0.aar.sha1
│ │ ├── flutter_release-1.0.pom
│ │ ├── flutter_release-1.0.pom.md5
│ │ └── flutter_release-1.0.pom.sha1
│ ├── maven-metadata.xml
│ ├── maven-metadata.xml.md5
│ └── maven-metadata.xml.sha1
├── flutter_profile
│ ├── ...
└── flutter_debug
└── ...


当然,我们也可以使用Android Studio来生成aar包。依次选择File -> New -> New Flutter Project -> Flutter Module生成Flutter module工程。
在这里插入图片描述


然后我们依次选择build ->Flutter ->Build AAR即可生成aar包。


在这里插入图片描述
接下来,就是在原生Android工程中集成aar即可。


三、添加Flutter依赖


3.1 添加aar依赖


官方推荐方式


集成aar包的方式和集成普通的aar包的方式是一样大的。首先,在app的目录下新建libs文件夹 并在build.gradle中添加如下配置。


android {
...

buildTypes {
profile {
initWith debug
}
}

String storageUrl = System.env.FLUTTER_STORAGE_BASE_URL ?:
"https://storage.googleapis.com"
repositories {
maven {
url '/Users/mac/Flutter/module_flutter/build/host/outputs/repo'
}
maven {
url "$storageUrl/download.flutter.io"
}
}

}

dependencies {
debugImplementation 'com.xzh.module_flutter:flutter_debug:1.0'
profileImplementation 'com.xzh.module_flutter:flutter_profile:1.0'
releaseImplementation 'com.xzh.module_flutter:flutter_release:1.0'
}

本地Libs方式


当然,我们也可以把生成的aar包拷贝到本地libs中,然后打开app/build.grade添加本地依赖,如下所示。


repositories {
flatDir {
dirs 'libs'
}
}

dependencies {
...
//添加本地依赖
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation(name: 'flutter_debug-1.0', ext: 'aar')
implementation 'io.flutter:flutter_embedding_debug:1.0.0-f0826da7ef2d301eb8f4ead91aaf026aa2b52881'
implementation 'io.flutter:armeabi_v7a_debug:1.0.0-f0826da7ef2d301eb8f4ead91aaf026aa2b52881'
implementation 'io.flutter:arm64_v8a_debug:1.0.0-f0826da7ef2d301eb8f4ead91aaf026aa2b52881'
implementation 'io.flutter:x86_64_debug:1.0.0-f0826da7ef2d301eb8f4ead91aaf026aa2b52881'
}


io.flutter:flutter_embedding_debug来自哪里呢,其实是build/host/outputs/repo生成的时候flutter_release-1.0.pom文件中,
在这里插入图片描述


  <groupId>com.example.flutter_library</groupId>
<artifactId>flutter_release</artifactId>
<version>1.0</version>
<packaging>aar</packaging>
<dependencies>
<dependency>
<groupId>io.flutter.plugins.sharedpreferences</groupId>
<artifactId>shared_preferences_release</artifactId>
<version>1.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.flutter</groupId>
<artifactId>flutter_embedding_release</artifactId>
<version>1.0.0-626244a72c5d53cc6d00c840987f9059faed511a</version>
<scope>compile</scope>
</dependency>

在拷贝的时候,注意我们本地aar包的环境,它们是一一对应的。接下来,为了能够正确依赖,还需要在外层的build.gradle中添加如下依赖。


buildscript {
repositories {
google()
jcenter()
maven {
url "http://download.flutter.io" //flutter依赖
}
}
dependencies {
classpath 'com.android.tools.build:gradle:4.0.0'
}
}

如果,原生Android工程使用的是组件化开发思路,通常是在某个module/lib下依赖,比如module_flutter进行添加。


 在module_flutter build.gradle下配置
repositories {
flatDir {
dirs 'libs' // aar目录
}
}

在主App 下配置
repositories {
// 详细路径
flatDir {
dirs 'libs', '../module_flutter/libs'
}
}

3.2 源码依赖


除了使用aar方式外, 我们还可以使用flutter模块源码的方式进行依赖。首先,我们在原生Android工程中创建一个module,如下图。
在这里插入图片描述
添加成功后,系统会默认在settings.gradle文件中生成如下代码。


 
include ':app'
setBinding(new Binding([gradle: this]))
evaluate(new File(
settingsDir.parentFile,
'my_flutter/.android/include_flutter.groovy'
))

然后,在app/build.gradle文件中添加源码依赖。


dependencies {
implementation project(':flutter')
}

3.3 使用 fat-aar 编译 aar


如果flutter 中引入了第三方的一些库,那么多个项目在使用flutter的时候就需要使用 fat-aar。首先,在 .android/build.gradle 中添加fat-aar 依赖。


 dependencies {
...
com.github.kezong:fat-aar:1.3.6
}


然后,在 .android/Flutter/build.gradle 中添加如下 plugin 和依赖。


dependencies {
testImplementation 'junit:junit:4.12'

// 添加 flutter_embedding.jar debug
embed "io.flutter:flutter_embedding_debug:1.0.0-eed171ff3538aa44f061f3768eec3a5908e8e852"
// 添加 flutter_embedding.jar release
embed "io.flutter:flutter_embedding_release:1.0.0-e1e6ced81d029258d449bdec2ba3cddca9c2ca0c"
// 添加各个 cpu 版本 flutter.so
embed "io.flutter:arm64_v8a_debug:1.0.0-eed171ff3538aa44f061f3768eec3a5908e8e852"
embed "io.flutter:armeabi_v7a_debug:1.0.0-eed171ff3538aa44f061f3768eec3a5908e8e852"
embed "io.flutter:x86_64_debug:1.0.0-eed171ff3538aa44f061f3768eec3a5908e8e852"
embed "io.flutter:x86_debug:1.0.0-eed171ff3538aa44f061f3768eec3a5908e8e852"

此时,如果我们运行项目,可能会报一个Cannot fit requested classes in a single dex file的错误。这是一个很古老的分包问题,意思是dex超过65k方法一个dex已经装不下了需要个多个dex。解决的方法是,只需要在 app/build.gradle 添加multidex即可。


android {
defaultConfig {
···
multiDexEnabled true
}
}

dependencies {
//androidx支持库的multidex库
implementation 'androidx.multidex:multidex:2.0.1'
}

五、跳转Flutter


5.1 启动FlutterActivity


集成Flutter之后,接下来我们在AndroidManifest.xml中注册FlutterActivity实现一个简单的跳转。


<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
android:exported="true" />

然后在任何页面添加一个跳转代码,比如。


myButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
startActivity(
FlutterActivity.createDefaultIntent(this)
);
}
});

不过当我运行项目,执行跳转的时候还是报错了,错误的信息如下。


   java.lang.RuntimeException: Unable to start activity ComponentInfo{com.snbc.honey_app/io.flutter.embedding.android.FlutterActivity}: java.lang.IllegalStateException: ensureInitializationComplete must be called after startInitialization
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2946)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3081)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:78)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:108)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:68)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1831)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:201)
at android.app.ActivityThread.main(ActivityThread.java:6806)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:873)

看报错应该是初始化的问题,但是官方文档没有提到任何初始化步骤相关的代码,查查Flutter 官方的issue,表示要加一行初始化代码:


public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
FlutterMain.startInitialization(this);
}
}

然后,我再次运行,发现报了如下错误。


java.lang.NoClassDefFoundError: Failed resolution of: Landroid/arch/lifecycle/DefaultLifecycleObserver;
at io.flutter.embedding.engine.FlutterEngine.<init>(FlutterEngine.java:152)
at io.flutter.embedding.android.FlutterActivityAndFragmentDelegate.setupFlutterEngine(FlutterActivityAndFragmentDelegate.java:221)
at io.flutter.embedding.android.FlutterActivityAndFragmentDelegate.onAttach(FlutterActivityAndFragmentDelegate.java:145)
at io.flutter.embedding.android.FlutterActivity.onCreate(FlutterActivity.java:399)
at android.app.Activity.performCreate(Activity.java:7224)
at android.app.Activity.performCreate(Activity.java:7213)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1272)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2926)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3081)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:78)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:108)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:68)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1831)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:201)
at android.app.ActivityThread.main(ActivityThread.java:6806)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:873)
Caused by: java.lang.ClassNotFoundException: Didn't find class "android.arch.lifecycle.DefaultLifecycleObserver" on path: DexPathList[[zip file "/data/app/com.example.myapplication-kZH0dnJ-qI1ow1NqGOB2ug==/base.apk"],nativeLibraryDirectories=[/data/app/com.example.myapplication-kZH0dnJ-qI1ow1NqGOB2ug==/lib/arm64, /data/app/com.example.myapplication-kZH0dnJ-qI1ow1NqGOB2ug==/base.apk!/lib/arm64-v8a, /system/lib64, /vendor/lib64]]

最后的日志给出的提示是lifecycle缺失,所以添加lifecycle的依赖即可,如下。


   implementation 'android.arch.lifecycle:common-java8:1.1.0'

然后再次运行就没啥问题了。
在这里插入图片描述


5.2 使用FlutterEngine启动


默认情况下,每个FlutterActivity被创建时都会创建一个FlutterEngine,每个FlutterEngine都有一个初始化操作。这意味着在启动一个标准的FlutterActivity时会有一定的延迟。为了减少此延迟,我们可以在启动FlutterActivity之前预先创建一个FlutterEngine,然后在跳转FlutterActivity时使用FlutterEngine即可。最常见的做法是在Application中先初始化FlutterEngine,比如。


class MyApplication : Application() {

lateinit var flutterEngine : FlutterEngine

override fun onCreate() {
super.onCreate()
flutterEngine = FlutterEngine(this)
flutterEngine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
)
FlutterEngineCache
.getInstance()
.put("my_engine_id", flutterEngine)
}
}

然后,我们在跳转FlutterActivity时使用这个缓冲的FlutterEngine即可,由于FlutterEngine初始化的时候已经添加了engine_id,所以启动的时候需要使用这个engine_id进行启动。


myButton.setOnClickListener {
startActivity(
FlutterActivity
.withCachedEngine("my_engine_id")
.build(this)
)
}

当然,在启动的时候,我们也可以跳转一个默认的路由,只需要在启动的时候调用setInitialRoute方法即可。


class MyApplication : Application() {
lateinit var flutterEngine : FlutterEngine
override fun onCreate() {
super.onCreate()
// Instantiate a FlutterEngine.
flutterEngine = FlutterEngine(this)
// Configure an initial route.
flutterEngine.navigationChannel.setInitialRoute("your/route/here");
// Start executing Dart code to pre-warm the FlutterEngine.
flutterEngine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
)
// Cache the FlutterEngine to be used by FlutterActivity or FlutterFragment.
FlutterEngineCache
.getInstance()
.put("my_engine_id", flutterEngine)
}
}

六、与Flutter通信


经过上面的操作,我们已经能够完成原生Android 跳转Flutter,那如何实现Flutter跳转原生Activity或者Flutter如何销毁自己返回原生页面呢?此时就用到了Flutter和原生Android的通迅机制,即Channel,分别是MethodChannel、EventChannel和BasicMessageChannel。



  • MethodChannel:用于传递方法调用,是比较常用的PlatformChannel。

  • EventChannel: 用于传递事件。

  • BasicMessageChannel:用于传递数据。


对于这种简单的跳转操作,直接使用MethodChannel即可完成。首先,我们在flutter_module中新建一个PluginManager的类,然后添加如下代码。


import 'package:flutter/services.dart';

class PluginManager {
static const MethodChannel _channel = MethodChannel('plugin_demo');

static Future<String> pushFirstActivity(Map params) async {
String resultStr = await _channel.invokeMethod('jumpToMain', params);
return resultStr;
}

}

然后,当我们点击Flutter入口页面的返回按钮时,添加一个返回的方法,主要是调用PluginManager发送消息,如下。


Future<void> backToNative() async {
String result;
try {
result = await PluginManager.pushFirstActivity({'key': 'value'});
} on PlatformException {
result = '失败';
}
print('backToNative: '+result);
}

接下来,重新使用flutter build aar重新编译aar包,并在原生Android的Flutter入口页面的configureFlutterEngine方法中添加如下代码。


class FlutterContainerActivity : FlutterActivity() {

private val CHANNEL = "plugin_demo"

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

}


override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
GeneratedPluginRegistrant.registerWith(flutterEngine)
MethodChannel(flutterEngine.dartExecutor, CHANNEL).setMethodCallHandler { call, result ->
if (call.method == "jumpToMain") {
val params = call.argument<String>("key")
Toast.makeText(this,"返回原生页面",Toast.LENGTH_SHORT).show()
finish()
result.success(params)
} else {
result.notImplemented()
}
}
}

}

重新运行原生项目时,点击Flutter左上角的返回按钮就可以返回到原生页面,其他的混合跳转也可以使用这种方式进行解决。


在这里插入图片描述


关于混合开发中混合路由和FlutterEngine多实例的问题,可以参考FlutterBoost


作者:xiangzhihong
链接:https://juejin.cn/post/7044097107506626597
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

美团动态线程池实践思路,开源了

写在前面 稍微有些Java编程经验的小伙伴都知道,Java的精髓在juc包,这是大名鼎鼎的Doug Lea老爷 子的杰作,评价一个程序员Java水平怎么样,一定程度上看他对juc包下的一些技术掌握的怎么样,这也是面试中的基本上必问的一些技术点之一。 juc包主...
继续阅读 »

写在前面


稍微有些Java编程经验的小伙伴都知道,Java的精髓在juc包,这是大名鼎鼎的Doug Lea老爷
子的杰作,评价一个程序员Java水平怎么样,一定程度上看他对juc包下的一些技术掌握的怎么样,这也是面试中的基本上必问的一些技术点之一。


juc包主要包括:



1.原子类(AtomicXXX)


2.锁类(XXXLock)


3.线程同步类(AQS、CountDownLatch、CyclicBarrier、Semaphore、Exchanger)


4.任务执行器类(Executor体系类,包括今天的主角ThreadPoolExecutor)


5.并发集合类(ConcurrentXXX、CopyOnWriteXXX)相关集合类


6.阻塞队列类(BlockingQueue继承体系类)


7.Future相关类


8.其他一些辅助工具类



多线程编程场景下,这些类都是必备技能,会这些可以帮助我们写出高质量、高性能、少bug的代码,同时这些也是Java中比较难啃的一些技术,需要持之以恒,学以致用,在使用中感受他们带来的奥妙。


上边简单罗列了下juc包下功能分类,这篇文章我们主要来介绍动态可监控线程池的,所以具体内容也就不展开讲了,以后有时间单独来聊吧。看这篇文章前,希望读者最好有一定的线程池ThreadPoolExecutor使用经验,不然看起来会有点懵。


如果你对ThreadPoolExecutor不是很熟悉,推荐阅读下面两篇文章


javadoop: http://www.javadoop.com/post/java-t…


美团技术博客: tech.meituan.com/2020/04/02/…




背景


使用ThreadPoolExecutor过程中你是否有以下痛点呢?



1.代码中创建了一个ThreadPoolExecutor,但是不知道那几个核心参数设置多少比较合适


2.凭经验设置参数值,上线后发现需要调整,改代码重启服务,非常麻烦


3.线程池相对开发人员来说是个黑盒,运行情况不能感知到,直到出现问题



如果你有以上痛点,这篇文章要介绍的动态可监控线程池(DynamicTp)或许能帮助到你。


如果看过ThreadPoolExecutor的源码,大概可以知道其实它有提供一些set方法,可以在运行时动态去修改相应的值,这些方法有:


public void setCorePoolSize(int corePoolSize);
public void setMaximumPoolSize(int maximumPoolSize);
public void setKeepAliveTime(long time, TimeUnit unit);
public void setThreadFactory(ThreadFactory threadFactory);
public void setRejectedExecutionHandler(RejectedExecutionHandler handler);

现在大多数的互联网项目其实都会微服务化部署,有一套自己的服务治理体系,微服务组件中的分布式配置中心扮演的就是动态修改配置,实时生效的角色。那么我们是否可以结合配置中心来做运行时线程池参数的动态调整呢?答案是肯定的,而且配置中心相对都是高可用的,使用它也不用过于担心配置推送出现问题这类事儿,而且也能减少研发动态线程池组件的难度和工作量。


综上,我们总结出以下的背景



  • 广泛性:在Java开发中,想要提高系统性能,线程池已经是一个90%以上的人都会选择使用的基础工具

  • 不确定性:项目中可能会创建很多线程池,既有IO密集型的,也有CPU密集型的,但线程池的参数并不好确定;需要有套机制在运行过程中动态去调整参数

  • 无感知性,线程池运行过程中的各项指标一般感知不到;需要有套监控报警机制在事前、事中就能让开发人员感知到线程池的运行状况,及时处理

  • 高可用性,配置变更需要及时推送到客户端;需要有高可用的配置管理推送服务,配置中心是现在大多数互联网系统都会使用的组件,与之结合可以大幅度减少开发量及接入难度




简介


我们基于配置中心对线程池ThreadPoolExecutor做一些扩展,实现对运行中线程池参数的动态修改,实时生效;以及实时监控线程池的运行状态,触发设置的报警策略时报警,报警信息会推送办公平台(钉钉、企微等)。报警维度包括(队列容量、线程池活性、拒绝触发等);同时也会定时采集线程池指标数据供监控平台可视化使用。使我们能时刻感知到线程池的负载,根据情况及时调整,避免出现问题影响线上业务。


    |  __ \                            (_) |__   __|
| | | |_ _ _ __ __ _ _ __ ___ _ ___| |_ __
| | | | | | | '_ \ / _` | '_ ` _ | |/ __| | '_ \
| |__| | |_| | | | | (_| | | | | | | | (__| | |_) |
|_____/ __, |_| |_|__,_|_| |_| |_|_|___|_| .__/
__/ | | |
|___/ |_|
:: Dynamic Thread Pool ::

特性



  • 参考美团线程池实践 ,对线程池参数动态化管理,增加监控、报警功能

  • 基于Spring框架,现只支持SpringBoot项目使用,轻量级,引入starter即可食用

  • 基于配置中心实现线程池参数动态调整,实时生效;集成主流配置中心,默认支持Nacos、Apollo,同时也提供SPI接口可自定义扩展实现

  • 内置通知报警功能,提供多种报警维度(配置变更通知、活性报警、容量阈值报警、拒绝策略触发报警),默认支持企业微信、钉钉报警,同时提供SPI接口可自定义扩展实现

  • 内置线程池指标采集功能,支持通过MicroMeter、JsonLog日志输出、Endpoint三种方式,可通过SPI接口自定义扩展实现

  • 集成管理常用第三方组件的线程池,已集成SpringBoot内置WebServer(Tomcat、Undertow、Jetty)的线程池管理




架构设计


主要分四大模块




  • 配置变更监听模块:


    1.监听特定配置中心的指定配置文件(默认实现Nacos、Apollo),可通过内部提供的SPI接口扩展其他实现


    2.解析配置文件内容,内置实现yml、properties配置文件的解析,可通过内部提供的SPI接口扩展其他实现


    3.通知线程池管理模块实现刷新




  • 线程池管理模块:


    1.服务启动时从配置中心拉取配置信息,生成线程池实例注册到内部线程池注册中心中


    2.监听模块监听到配置变更时,将变更信息传递给管理模块,实现线程池参数的刷新


    3.代码中通过getExecutor()方法根据线程池名称来获取线程池对象实例




  • 监控模块:


    实现监控指标采集以及输出,默认提供以下三种方式,也可通过内部提供的SPI接口扩展其他实现


    1.默认实现Json log输出到磁盘


    2.MicroMeter采集,引入MicroMeter相关依赖


    3.暴雷Endpoint端点,可通过http方式访问




  • 通知告警模块:


    对接办公平台,实现通告告警功能,默认实现钉钉、企微,可通过内部提供的SPI接口扩展其他实现,通知告警类型如下


    1.线程池参数变更通知


    2.阻塞队列容量达到设置阈值告警


    3.线程池活性达到设置阈值告警


    4.触发拒绝策略告警







使用



  • maven依赖



  1. apollo应用用接入用此依赖
        <dependency>
    <groupId>io.github.lyh200</groupId>
    <artifactId>dynamic-tp-spring-boot-starter-apollo</artifactId>
    <version>1.0.0</version>
    </dependency>


  2. spring-cloud场景下的nacos应用接入用此依赖
        <dependency>
    <groupId>io.github.lyh200</groupId>
    <artifactId>dynamic-tp-spring-cloud-starter-nacos</artifactId>
    <version>1.0.0</version>
    </dependency>


  3. 非spring-cloud场景下的nacos应用接入用此依赖
        <dependency>
    <groupId>io.github.lyh200</groupId>
    <artifactId>dynamic-tp-spring-boot-starter-nacos</artifactId>
    <version>1.0.0</version>
    </dependency>





  • 线程池配置


    spring:
    dynamic:
    tp:
    enabled: true
    enabledBanner: true # 是否开启banner打印,默认true
    enabledCollect: false # 是否开启监控指标采集,默认false
    collectorType: logging # 监控数据采集器类型(JsonLog | MicroMeter),默认logging
    logPath: /home/logs # 监控日志数据路径,默认${user.home}/logs
    monitorInterval: 5 # 监控时间间隔(报警判断、指标采集),默认5s
    nacos: # nacos配置,不配置有默认值(规则name-dev.yml这样)
    dataId: dynamic-tp-demo-dev.yml
    group: DEFAULT_GROUP
    apollo: # apollo配置,不配置默认拿apollo配置第一个namespace
    namespace: dynamic-tp-demo-dev.yml
    configType: yml # 配置文件类型
    platforms: # 通知报警平台配置
    - platform: wechat
    urlKey: 3a7500-1287-4bd-a798-c5c3d8b69c # 替换
    receivers: test1,test2 # 接受人企微名称
    - platform: ding
    urlKey: f80dad441fcd655438f4a08dcd6a # 替换
    secret: SECb5441fa6f375d5b9d21 # 替换,非sign模式可以没有此值
    receivers: 15810119805 # 钉钉账号手机号
    tomcatTp: # tomcat web server线程池配置
    minSpare: 100
    max: 400
    jettyTp: # jetty web server线程池配置
    min: 100
    max: 400
    undertowTp: # undertow web server线程池配置
    ioThreads: 100
    workerThreads: 400
    executors: # 动态线程池配置
    - threadPoolName: dynamic-tp-test-1
    corePoolSize: 6
    maximumPoolSize: 8
    queueCapacity: 200
    queueType: VariableLinkedBlockingQueue # 任务队列,查看源码QueueTypeEnum枚举类
    rejectedHandlerType: CallerRunsPolicy # 拒绝策略,查看RejectedTypeEnum枚举类
    keepAliveTime: 50
    allowCoreThreadTimeOut: false
    threadNamePrefix: test # 线程名前缀
    notifyItems: # 报警项,不配置自动会配置(变更通知、容量报警、活性报警、拒绝报警)
    - type: capacity # 报警项类型,查看源码 NotifyTypeEnum枚举类
    enabled: true
    threshold: 80 # 报警阈值
    platforms: [ding,wechat] # 可选配置,不配置默认拿上层platforms配置的所以平台
    interval: 120 # 报警间隔(单位:s)
    - type: change
    enabled: true
    - type: liveness
    enabled: true
    threshold: 80
    - type: reject
    enabled: true
    threshold: 1



  • 代码方式生成,服务启动会自动注册


    @Configuration
    public class DtpConfig {

    @Bean
    public DtpExecutor demo1Executor() {
    return DtpCreator.createDynamicFast("demo1-executor");
    }

    @Bean
    public ThreadPoolExecutor demo2Executor() {
    return ThreadPoolBuilder.newBuilder()
    .threadPoolName("demo2-executor")
    .corePoolSize(8)
    .maximumPoolSize(16)
    .keepAliveTime(50)
    .allowCoreThreadTimeOut(true)
    .workQueue(QueueTypeEnum.SYNCHRONOUS_QUEUE.getName(), null, false)
    .rejectedExecutionHandler(RejectedTypeEnum.CALLER_RUNS_POLICY.getName())
    .buildDynamic();
    }
    }



  • 代码调用,根据线程池名称获取


    public static void main(String[] args) {
    DtpExecutor dtpExecutor = DtpRegistry.getExecutor("dynamic-tp-test-1");
    dtpExecutor.execute(() -> System.out.println("test"));
    }





注意事项




  1. 配置文件配置的参数会覆盖通过代码生成方式配置的参数




  2. 阻塞队列只有VariableLinkedBlockingQueue类型可以修改capacity,该类型功能和LinkedBlockingQueue相似,只是capacity不是final类型,可以修改,




VariableLinkedBlockingQueue参考RabbitMq的实现




  1. 启动看到如下日志输出证明接入成功



    | __ \ (_) |__ __|
    | | | |_ _ _ __ __ _ _ __ ___ _ ___| |_ __
    | | | | | | | '_ \ / _` | '_ ` _ | |/ __| | '_ \
    | |__| | |_| | | | | (_| | | | | | | | (__| | |_) |
    |_____/ __, |_| |_|__,_|_| |_| |_|_|___|_| .__/
    __/ | | |
    |___/ |_|
    :: Dynamic Thread Pool ::

    DynamicTp register, executor: DtpMainPropWrapper(dtpName=dynamic-tp-test-1, corePoolSize=6, maxPoolSize=8, keepAliveTime=50, queueType=VariableLinkedBlockingQueue, queueCapacity=200, rejectType=RejectedCountableCallerRunsPolicy, allowCoreThreadTimeOut=false)



  2. 配置变更会推送通知消息,且会高亮变更的字段



    DynamicTp [dynamic-tp-test-2] refresh end, changed keys: [corePoolSize, queueCapacity], corePoolSize: [6 => 4], maxPoolSize: [8 => 8], queueType: [VariableLinkedBlockingQueue => VariableLinkedBlockingQueue], queueCapacity: [200 => 2000], keepAliveTime: [50s => 50s], rejectedType: [CallerRunsPolicy => CallerRunsPolicy], allowsCoreThreadTimeOut: [false => false]





通知报警


触发报警阈值会推送相应报警消息,且会高亮显示相关字段,活性告警、容量告警、拒绝告警



配置变更会推送通知消息,且会高亮变更的字段





监控日志


通过主配置文件collectType属性配置指标采集类型,默认值:logging



  • micrometer方式:通过引入micrometer相关依赖采集到相应的平台


(如Prometheus,InfluxDb...)




  • logging:指标数据以json格式输出日志到磁盘,地址logPath/dynamictp/{logPath}/ dynamictp/{appName}.monitor.log


    2022-01-16 15:25:20.599 INFO [dtp-monitor-thread-1:d.m.log] {"activeCount":2,"queueSize":100,"largestPoolSize":4,"poolSize":2,"rejectHandlerName":"CallerRunsPolicy","queueCapacity":1024,"fair":false,"rejectCount":0,"waitTaskCount":10,"taskCount":120,"queueRemainingCapacity":1024,"corePoolSize":6,"queueType":"VariableLinkedBlockingQueue","completedTaskCount":1078,"dtpName":"remoting-call","maximumPoolSize":8}
    2022-01-16 15:25:25.603 INFO [dtp-monitor-thread-1:d.m.log] {"activeCount":2,"queueSize":120,"largestPoolSize":4,"poolSize":2,"rejectHandlerName":"CallerRunsPolicy","queueCapacity":1024,"fair":false,"rejectCount":0,"waitTaskCount":20,"taskCount":140,"queueRemainingCapacity":1024,"corePoolSize":6,"queueType":"VariableLinkedBlockingQueue","completedTaskCount":1459,"dtpName":"remoting-call","maximumPoolSize":8}
    2022-01-16 15:25:30.609 INFO [dtp-monitor-thread-1:d.m.log] {"activeCount":2,"queueSize":140,"largestPoolSize":4,"poolSize":2,"rejectHandlerName":"CallerRunsPolicy","queueCapacity":1024,"fair":false,"rejectCount":0,"waitTaskCount":89,"taskCount":180,"queueRemainingCapacity":1024,"corePoolSize":6,"queueType":"VariableLinkedBlockingQueue","completedTaskCount":1890,"dtpName":"remoting-call","maximumPoolSize":8}
    2022-01-16 15:25:35.613 INFO [dtp-monitor-thread-1:d.m.log] {"activeCount":2,"queueSize":160,"largestPoolSize":4,"poolSize":2,"rejectHandlerName":"CallerRunsPolicy","queueCapacity":1024,"fair":false,"rejectCount":0,"waitTaskCount":99,"taskCount":230,"queueRemainingCapacity":1024,"corePoolSize":6,"queueType":"VariableLinkedBlockingQueue","completedTaskCount":2780,"dtpName":"remoting-call","maximumPoolSize":8}
    2022-01-16 15:25:40.616 INFO [dtp-monitor-thread-1:d.m.log] {"activeCount":2,"queueSize":230,"largestPoolSize":4,"poolSize":2,"rejectHandlerName":"CallerRunsPolicy","queueCapacity":1024,"fair":false,"rejectCount":0,"waitTaskCount":0,"taskCount":300,"queueRemainingCapacity":1024,"corePoolSize":6,"queueType":"VariableLinkedBlockingQueue","completedTaskCount":4030,"dtpName":"remoting-call","maximumPoolSize":8}



  • 暴露EndPoint端点(dynamic-tp),可以通过http方式请求


    [
    {
    "dtp_name": "remoting-call",
    "core_pool_size": 8,
    "maximum_pool_size": 16,
    "queue_type": "SynchronousQueue",
    "queue_capacity": 0,
    "queue_size": 0,
    "fair": false,
    "queue_remaining_capacity": 0,
    "active_count": 2,
    "task_count": 2760,
    "completed_task_count": 2760,
    "largest_pool_size": 16,
    "pool_size": 8,
    "wait_task_count": 0,
    "reject_count": 12462,
    "reject_handler_name": "CallerRunsPolicy"
    }
    ]




作者:CodeFox
链接:https://juejin.cn/post/7063408526894301192
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

ARouter原理解析分享

前言 炎炎夏日,不知道大家的周末会是如何安排。本文将给大家带来的分享是ARouter的的原理介绍,通过了解它的原理,我们可以知道它是如何支持组件化和不互相依赖的模块间进行调用或者页面的跳转。 正文 1.ARouter介绍 ARouter是阿里开源的一个用于进行...
继续阅读 »

前言


炎炎夏日,不知道大家的周末会是如何安排。本文将给大家带来的分享是ARouter的的原理介绍,通过了解它的原理,我们可以知道它是如何支持组件化和不互相依赖的模块间进行调用或者页面的跳转。


正文


1.ARouter介绍


ARouter是阿里开源的一个用于进行组件化的路由框架,它可以帮助互不依赖的组件间进行页面跳转和服务调用。


2.ARouter使用


添加依赖:


android {
//...
defaultConfig {
kapt {
arguments {
arg("AROUTER_MODULE_NAME", project.getName())
}
}
}
//...
}

dependencies {
api 'com.alibaba:arouter-api:1.5.0'
kapt 'com.alibaba:arouter-compiler:1.2.2'
}

定义跳转Activity的path:


@Route(path = "/test/router_activity")
class RouterActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_router)
}
}

初始化Router框架:


class RouterDemoApp : Application() {
override fun onCreate() {
super.onCreate()
//初始化、注入
ARouter.init(this)
}
}

调用跳转:


ARouter.getInstance().build("/test/router_activity").navigation()

3.生成的代码(生成的路由表)


当我们给Activity或者服务等加上Route注解后,build一下,ARouter框架便会按照模版帮我们生成java文件,并且是在运行的时候可以访问的。其中使用的技术是apt技术。下面我们一起看看上边的示例生成的代码:


public class ARouter$$Root$$app implements IRouteRoot {
@Override
public void loadInto(Map<String, Class<? extends IRouteGroup>> routes) {
routes.put("test", ARouter$$Group$$test.class);
}
}

public class ARouter$$Group$$test implements IRouteGroup {
@Override
public void loadInto(Map<String, RouteMeta> atlas) {
atlas.put("/test/router_activity", RouteMeta.build(RouteType.ACTIVITY, RouterActivity.class, "/test/router_activity", "test", null, -1, -2147483648));
}
}

根据上边生成的代码可以看出,生成的代码就是一张路由表,先将群组跟群组的class对应起来,每个群组里边是该群组下的路由表。


4.初始化init()(加载路由表的群组)


接下来我们看看初始化时,路由框架里边做了哪些事情:


//#ARouter
public static void init(Application application) {
if (!hasInit) {
//...省略部分代码
hasInit = _ARouter.init(application);
//...省略部分代码
}
}

//#_ARouter
protected static synchronized boolean init(Application application) {
mContext = application;
LogisticsCenter.init(mContext, executor);
logger.info(Consts.TAG, "ARouter init success!");
hasInit = true;
mHandler = new Handler(Looper.getMainLooper());
return true;
}

初始化的核心代码看起来就在LogisticsCenter中:


public synchronized static void init(Context context, ThreadPoolExecutor tpe) throws HandlerException {
mContext = context;
executor = tpe;

try {
//...省略代码
if (registerByPlugin) {
//...省略代码
} else {
Set<String> routerMap;

// 如果是debug包或者更新版本
if (ARouter.debuggable() || PackageUtils.isNewVersion(context)) {
//获取在com.alibaba.android.arouter.routes下的所以class类名
routerMap = ClassUtils.getFileNameByPackageName(mContext, ROUTE_ROOT_PAKCAGE);
//更新到sp中
if (!routerMap.isEmpty()) {
context.getSharedPreferences(AROUTER_SP_CACHE_KEY, Context.MODE_PRIVATE).edit().putStringSet(AROUTER_SP_KEY_MAP, routerMap).apply();
}
//更新版本
PackageUtils.updateVersion(context);
} else {
//直接从缓存拿出之前存放的class
routerMap = new HashSet<>(context.getSharedPreferences(AROUTER_SP_CACHE_KEY, Context.MODE_PRIVATE).getStringSet(AROUTER_SP_KEY_MAP, new HashSet<String>()));
}

//遍历routerMap,将group的类加载到缓存中
for (String className : routerMap) {
if (className.startsWith(ROUTE_ROOT_PAKCAGE + DOT + SDK_NAME + SEPARATOR + SUFFIX_ROOT)) {
//生成的Root、比如我们上面示例的ARouter$$Root$$app,调用loadInto相当于加载了routes.put("test", ARouter$$Group$$test.class)
((IRouteRoot) (Class.forName(className).getConstructor().newInstance())).loadInto(Warehouse.groupsIndex);
} else if (className.startsWith(ROUTE_ROOT_PAKCAGE + DOT + SDK_NAME + SEPARATOR + SUFFIX_INTERCEPTORS)) {
//加载拦截器,例如生成的ARouter$$Interceptors$$app
((IInterceptorGroup) (Class.forName(className).getConstructor().newInstance())).loadInto(Warehouse.interceptorsIndex);
} else if (className.startsWith(ROUTE_ROOT_PAKCAGE + DOT + SDK_NAME + SEPARATOR + SUFFIX_PROVIDERS)) {
// 加载Provider,例如生成的ARouter$$Providers$$app
((IProviderGroup) (Class.forName(className).getConstructor().newInstance())).loadInto(Warehouse.providersIndex);
}
}
}
//...省略代码
} catch (Exception e) {
throw new HandlerException(TAG + "ARouter init logistics center exception! [" + e.getMessage() + "]");
}
}

上边的核心逻辑就是如果是debug包或者更新版本,那么就去获取com.alibaba.android.arouter.routes下的所以class类名,然后更新到sp中,并且更新版本号。然后通过反射加载IRouteRoot,去加载群组及对应的class对象,除此还会加载拦截器,Provider。


这里我们重点看一下获取class文件路径的方法getFileNameByPackageName:


public static Set<String> getFileNameByPackageName(Context context, final String packageName) throws NameNotFoundException, IOException, InterruptedException {
final Set<String> classNames = new HashSet();
//获取到dex文件路径
List<String> paths = getSourcePaths(context);
final CountDownLatch parserCtl = new CountDownLatch(paths.size());
Iterator var5 = paths.iterator();

while(var5.hasNext()) {
final String path = (String)var5.next();
DefaultPoolExecutor.getInstance().execute(new Runnable() {
public void run() {
DexFile dexfile = null;
try {
//加载出dexfile文件
if (path.endsWith(".zip")) {
dexfile = DexFile.loadDex(path, path + ".tmp", 0);
} else {
dexfile = new DexFile(path);
}

Enumeration dexEntries = dexfile.entries();
// 遍历dexFile里边的元素,加载出.class文件
while(dexEntries.hasMoreElements()) {
String className = (String)dexEntries.nextElement();
//开头"com.alibaba.android.arouter.routes"
if (className.startsWith(packageName)) {
classNames.add(className);
}
}
} catch (Throwable var12) {
Log.e("ARouter", "Scan map file in dex files made error.", var12);
} finally {
//...省略代码
parserCtl.countDown();
}
}
});
}

parserCtl.await();
//。。。省略代码
return classNames;
}

此方法里边的核心逻辑就是加载出dex文件的路径,然后通过路径构建出DexFile,构建后遍历它里边的元素,如果是com.alibaba.android.arouter.routes开头的class文件,则保存到列表里等待返回。


getSourcePaths:


public static List<String> getSourcePaths(Context context) throws NameNotFoundException, IOException {
ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
File sourceApk = new File(applicationInfo.sourceDir);
List<String> sourcePaths = new ArrayList();
sourcePaths.add(applicationInfo.sourceDir);
String extractedFilePrefix = sourceApk.getName() + ".classes";
//是否开启了multidex,如果开启的话,则需获取每个dex路径
if (!isVMMultidexCapable()) {
int totalDexNumber = getMultiDexPreferences(context).getInt("dex.number", 1);
File dexDir = new File(applicationInfo.dataDir, SECONDARY_FOLDER_NAME);
//遍历每一个dex文件
for(int secondaryNumber = 2; secondaryNumber <= totalDexNumber; ++secondaryNumber) {
//app.classes2.zip、app.classes3.zip ...
String fileName = extractedFilePrefix + secondaryNumber + ".zip";
File extractedFile = new File(dexDir, fileName);
if (!extractedFile.isFile()) {
throw new IOException("Missing extracted secondary dex file '" + extractedFile.getPath() + "'");
}
sourcePaths.add(extractedFile.getAbsolutePath());
}
}

if (ARouter.debuggable()) {
sourcePaths.addAll(tryLoadInstantRunDexFile(applicationInfo));
}

return sourcePaths;
}

getSourcePaths的功能就是获取app的所有dex文件的路径,为后面转成class文件从而获取class文件路径提供数据。


小结:



  • ARouter.init(this)调用交给了内部的_ARouter.init(application),然后真正做事的是LogisticsCenter.init(mContext, executor)

  • 如果是debug包或者升级版本,则去加载出com.alibaba.android.arouter.routes包下的dex文件的路径,并且更新到缓存里边

  • 通过这些dex去获取对应的所有class文件的路径

  • 最后根据类名的前缀加载到Warehouse中对应的map里,其中就有group、interceptor和provider


5.调用及处理


ARouter.getInstance().build("/test/router_activity").navigation()

build会构建一个Postcard对象出来:


//#Router
public Postcard build(String path) {
return _ARouter.getInstance().build(path);
}

//#_ARouter
protected Postcard build(String path) {
if (TextUtils.isEmpty(path)) {
throw new HandlerException(Consts.TAG + "Parameter is invalid!");
} else {
PathReplaceService pService = ARouter.getInstance().navigation(PathReplaceService.class);
if (null != pService) {
path = pService.forString(path);
}
//extractGroup方法就是从path中提取出group,比如"/test/router_activity",test便是提取出来的group
return build(path, extractGroup(path));
}
}

build(path, group)方法最终会构建一个Postcard对象出来。


构建好PostCard之后,调用它的navigation方法便可以实现我们的跳转或者获取对应的实体。navigation方法最后会调用到_ARouter的navigation方法:


protected Object navigation(final Context context, final Postcard postcard, final int requestCode, final NavigationCallback callback) {
//...省略代码
try {
//1.根据postCard的group加载路由表,并且补全postCard的信息
LogisticsCenter.completion(postcard);
} catch (NoRouteFoundException ex) {
//...异常处理
return null;
}
if (null != callback) {
callback.onFound(postcard);
}

//如果不是绿色通道的话,需要走拦截器的逻辑,否则会跳过拦截器
if (!postcard.isGreenChannel()) {
interceptorService.doInterceptions(postcard, new InterceptorCallback() {
@Override
public void onContinue(Postcard postcard) {
//2.真正实现动作处理
_navigation(context, postcard, requestCode, callback);
}
@Override
public void onInterrupt(Throwable exception) {
if (null != callback) {
callback.onInterrupt(postcard);
}
//...省略代码
}
});
} else {
//2.真正实现动作处理
return _navigation(context, postcard, requestCode, callback);
}
return null;
}

navigation方法的核心逻辑为:加载路由表,并且补全postCard的信息,然后真正处理跳转或者请求逻辑。


LogisticsCenter.completion(postcard)的核心源码如下:


public synchronized static void completion(Postcard postcard) {
if (null == postcard) {
throw new NoRouteFoundException(TAG + "No postcard!");
}

RouteMeta routeMeta = Warehouse.routes.get(postcard.getPath());
if (null == routeMeta) {
//groupsIndex在init的时候已经加载好了,这里就可以通过group获取到对应group的class对象
Class<? extends IRouteGroup> groupMeta = Warehouse.groupsIndex.get(postcard.getGroup()); // Load route meta.
if (null == groupMeta) {
throw new NoRouteFoundException(TAG + "There is no route match the path [" + postcard.getPath() + "], in group [" + postcard.getGroup() + "]");
} else {
// Load route and cache it into memory, then delete from metas.
try {
//...省略代码
IRouteGroup iGroupInstance = groupMeta.getConstructor().newInstance();
//将group里边的路由表加载进内存,我们最开始的例子想当于执行:atlas.put("/test/router_activity", RouteMeta.build(RouteType.ACTIVITY, RouterActivity.class, "/test/router_activity", "test", null, -1, -2147483648));
iGroupInstance.loadInto(Warehouse.routes);
//因为加载路由表了,所以可以将group从内存中移除,节省内存
Warehouse.groupsIndex.remove(postcard.getGroup());
//...省略代码
} catch (Exception e) {
throw new HandlerException(TAG + "Fatal exception when loading group meta. [" + e.getMessage() + "]");
}
//已经将group里的路由表加载出来了,再执行一遍函数。
completion(postcard); // Reload
}
} else {
// 第二次时,给postCard填补信息
postcard.setDestination(routeMeta.getDestination());
postcard.setType(routeMeta.getType());
postcard.setPriority(routeMeta.getPriority());
postcard.setExtra(routeMeta.getExtra());
//...省略代码,主要是解析uri然后参数的赋值

//根据路由获取的不同的类型,继续补充一些信息给postCard
switch (routeMeta.getType()) {
case PROVIDER:
//...省略代码,主要是补充一些其他参数
postcard.greenChannel(); // Provider should skip all of interceptors
break;
case FRAGMENT:
postcard.greenChannel(); // Fragment needn't interceptors
default:
break;
}
}
}

补充完postCard信息之后,接下来我们看看_navigation方法:


private Object _navigation(final Context context, final Postcard postcard, final int requestCode, final NavigationCallback callback) {
final Context currentContext = null == context ? mContext : context;

switch (postcard.getType()) {
case ACTIVITY:
//构造Intent,然后切换到主线程,并且跳转到指定的Activity
break;
case PROVIDER:
return postcard.getProvider();
case BOARDCAST:
case CONTENT_PROVIDER:
case FRAGMENT:
//反射构造出实例并返回
case METHOD:
default:
return null;
}

return null;
}

可以看到,最终会根据不同的type,去做出不同的响应,例如ACTIVITY的话,会进行activity的跳转,其他的会通过反射构造出实例返回等操作。


小结:



  • 调用的最开始,会构建一个PostCard对象,初始化path和group

  • navigation方法最终会调用到_ARouter的navigation方法,然后通过LogisticsCenter.completion(postcard)去加载group里边的路由表,并且补全postcard信息。

  • 如果是有绿色通道的话,则不执行拦截器,直接跳过,否则需要执行拦截器。

  • 最后便是通过不同类型执行对应的操作。


结语


本文的分享到这里就结束了,相信看完后,能够对ARouter的原理有了一定的理解,以便我们后面如果有使用到它的时候,能够更好的地使用,或者为项目定制出路由框架提供了很好的思路参考。同时,这么优秀的框架也值得我们去学习它的一些设计思路。


作者:敖森迪
链接:https://juejin.cn/post/7118385586850758686
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

为什么说获取堆栈从来就不是一件简单的事情

碎碎谈 为了不让文章看上去过于枯燥,笔者考虑了一下,特意增加了碎碎谈环节!自从上次这篇文章发出去后 黑科技!让Native Crash 与ANR无处发泄!,就挺受读者欢迎的呀,收藏数大于点赞数是什么鬼,嘿嘿!从我的角度出发,本来Signal出发的目的就是想建...
继续阅读 »

碎碎谈


为了不让文章看上去过于枯燥,笔者考虑了一下,特意增加了碎碎谈环节!自从上次这篇文章发出去后 黑科技!让Native Crash 与ANR无处发泄!,就挺受读者欢迎的呀,收藏数大于点赞数是什么鬼,嘿嘿!从我的角度出发,本来Signal出发的目的就是想建造一个类似于安全气囊的装置,保证crash后第一时间重启恢复,达到一个应用稳定的目的,但是慢慢写着写着,发现很多crash监控平台的也是用了相同的核心原理(大部分还没开源噢),只是作用的目标不同,那么为什么不把Signal打造成一个通用的基础件呢!无论是安全气囊还是监控,其实都是上层的应用不同罢了!嗯!有了这个想法之后,给Signal补充一些日志监控逻辑,就更加完善了!所以就有了本篇文章!算是一个补充文!如果没看过黑科技!让Native Crash 与ANR无处发泄!这篇文章的新朋友,请先阅读!(如果没有ndk开发经验也没关系,里面也不涉及很复杂的c知识)


获取堆栈


获取堆栈!可能很多新朋友看到这个就会想,这有什么难的嘛!直接new 一个Throwable获取不就可以了嘛,或者Thread.currentThread().stackTrace(kotlin)等等也可以呀!嗯!是的!我们在java层通常会有很固定的获取堆栈方式,这得益于java虚拟机的设计,也得益于java语言的设计,因为屏蔽了多平台底层的差异,我们就可以用相对统一的api去获取当前的堆栈。这个堆栈也特指java虚拟机堆栈!


但是对于native的堆栈,问题就来了!我们知道native层通常跟很多因素有关,比如链接器,编译器,还有各种库的版本,各种abi等等影响,获取一个堆栈消息,可没有那么简单,因为太多因素干扰了,这也是历史的包袱!还有对于我们android来说,android官方在对堆栈获取的方式,也是有历史变化的


4.1.1以上,5.0以下,android native使用系统自带的libcorkscrew.so,5.0开始,系统中没有了libcorkscrew.so 高版本的安卓源码中就使用了他的优化版替换libunwind。同时对于ndk来说,编译器的版本也在不断变化,从默认的gcc变成clang(ndk >=13),可以看到,我们会在众多版本,众多因素下,找一个统一的方式,还真的不简单!不过呀!在2022的今天,google早已推出了一个计划统一库 breakpad ,嗯!虽然能不能成为标准还未定,但是也是一个生态的进步


Signal的选择


前面介绍了这么多方案,breakpad是不是Signal的首选呢!虽然breakpad不错,但是里面覆盖了太多其他系统的编译,比如mac,window等等标准,还有就是作为一个开源库,还是希望减少这些库的导入,所以跟大多数主流方案一直,我们选择用unwind.h去实现堆栈打印,因为这个就直接内置在我们的默认编译中了,而且这个在在android也能用!下面我们来看一下实现!即Signal项目的unwind-utils的实现。那么我们要考虑一些什么呢!


堆栈大小


日志当然需要设定追溯的堆栈大小,内容太多不好(过于臃肿,排查困难),内容太少也不好(很有可能漏掉关键crash堆栈),所以Signal默认设置30条,可以根据实际项目修改


std::string backtraceToLogcat() {
默认30个
const size_t max = 30;
void *buffer[max];
//ostringstream方便输出string
std::ostringstream oss;
dumpBacktrace(oss, buffer, captureBacktrace(buffer, max));
return oss.str();
}

_Unwind_Backtrace


_Unwind_Backtrace是unwind提供给我们堆栈回溯函数


_Unwind_Reason_Code _Unwind_Backtrace(_Unwind_Trace_Fn, void *);

那么这个_Unwind_Trace_Fn是个啥,其实点进去看


typedef _Unwind_Reason_Code (*_Unwind_Trace_Fn)(struct _Unwind_Context *,
void *);

其实这就代表一个函数,对于我们常年写java的朋友有点不友好对吧,以java的方式,其实意思就是传xxx(随便函数名)( _Unwind_Context *,void *)这样的结构的函数即可,这里的意思就是一个callback函数,当我们获取到地址信息就会回调该参数,第二个就是需要传递给参数一的参数,这里有点绕对吧,我们怎么理解呢!参数一其实就是一个函数的引用,那么这个函数需要参数怎么办,就通过第二个参数传递!


我们看个例子:这个在Signal也有


static _Unwind_Reason_Code unwindCallback(struct _Unwind_Context *context, void *args) {
BacktraceState *state = static_cast<BacktraceState *>(args);
uintptr_t pc = _Unwind_GetIP(context);
if (pc) {
if (state->current == state->end) {
return _URC_END_OF_STACK;
} else {
*state->current++ = reinterpret_cast<void *>(pc);
}
}
return _URC_NO_REASON;
}


size_t captureBacktrace(void **buffer, size_t max) {
BacktraceState state = {buffer, buffer + max};
_Unwind_Backtrace(unwindCallback, &state);
// 获取大小
return state.current - buffer;
}

struct BacktraceState {
void **current;
void **end;
};

我们定义了一个结构体BacktraceState,其实是为了后面记录函数地址而用,这里有两个作用,end代表日志限定的大小,current表示实际日志条数大小(因为堆栈条数可能小于end)


_Unwind_GetIP


我们在unwindCallback这里拿到了系统回调给我们的参数,关键就是这个了 _Unwind_Context这个结构体参数了,这个参数的作用就是传递给_Unwind_GetIP这个函数,获取我们当前的执行地址,即pc值!那么这个pc值又有什么用呢!这个就是我们获取堆栈的关键!native堆栈的获取需要地址去解析!(不同于java)我们先有这个概念,后面会继续讲解


dladdr


经过了_Unwind_GetIP我们获取了pc值,这个时候就用上dladdr函数去解析了,这个是linux内核函数,专门用于地址符号解析


The function dladdr() determines whether the address specified in
addr is located in one of the shared objects loaded by the
calling application. If it is, then dladdr() returns information
about the shared object and symbol that overlaps addr. This
information is returned in a Dl_info structure:

typedef struct {
const char *dli_fname; /* Pathname of shared object that
contains address */
void *dli_fbase; /* Base address at which shared
object is loaded */
const char *dli_sname; /* Name of symbol whose definition
overlaps addr */
void *dli_saddr; /* Exact address of symbol named
in dli_sname */
} Dl_info;

If no symbol matching addr could be found, then dli_sname and
dli_saddr are set to NULL.

可以看到,每个地址会的解析信息会保存在Dl_info中,如果有运行符号满足,dli_sname和dli_saddr就会被设定为相应的so名称跟地址,dli_fbase是基址信息,因为我们的so库被加载到程序的位置是不固定的!所以一般采用地址偏移的方式去在运行时寻找真正的so库,所以就有这个dli_fbase信息。


Dl_info info;
if (dladdr(addr, &info) && info.dli_sname) {
symbol = info.dli_sname;

}
os << " #" << idx << ": " << addr << " " <<" "<<symbol <<"\n" ;

最终我们可以通过dladdr,一一把保存的地址信息解析出来,打印到native日志中比如Signal中demo crash信息(如果需要打印so名称,也可以通过dli_fname去获取,这里不举例)


image.png


native堆栈产生过程


通过上面的日志分析(最好看下demo中的app演示crash),我们其实在MainActivity中设定了一个crash函数


private external fun throwNativeCrash()

按照堆栈日志分析来看,只有在第16条才出现了调用符号,这跟我们在日常java开发中是不是很不一样!因为java层的堆栈一般都是最近的堆栈消息代表着错误消息,比如应该是第0条才导致的crash,但是演示中真正的堆栈crash却隐藏在了日志海里面!相信有不少朋友在看native crash日志也是,是不是也感到无从下手,因为首条日志往往并不是真正crash的主因!我们来看一下真正的过程:我们程序从正常态到crash,究竟发生了什么!


image.png


可以看到,我们真正dump_stack前,是有很多前置的步骤,为什么会有这么多呢!其实这就涉及到linux内核中断的原理,这里给一张粗略图


image.png
crash产生后,一般会在用户态阶段调用中断进入内核态,把自己的中断信号(这里区分一下,不是我们signal.h里面的信号)放在eax寄存器中(大部分,也有其他的寄存器,这里仅举例)


然后内核层通过传来的中断信号,找到信号表,然后根据对应的处理程序,再抛回给用户态,这个时候才进行sigaction的逻辑


所以说,crash产生到真正dump日志,其实会有一个过程,这里面根据sigaction的设置也会有多个变化,我们要了解的一点是,真正的crash信息,往往藏在堆栈海中,需要我们一步步去解析,比如通过addr2line等工具去分析地址,才能得到真正的原因,而且一般的android项目,都是依赖于第三方的so,这也给我们的排查带来难度,不过只要我们能识别出特定的so(dli_fname信息就有),是不是就可以把锅甩出去了呢,对吧!


最后


看到这里,读者朋友应该有一个对native堆栈的大概模型了,当然也不用怕!Signal项目中就包含了相关的unwind-utils工具类,直接用也是可以的,不过目前打印的信息比较简单,后续可以根据大家的实际,去添加参数!代码都在里面,求star求pr !Signal,当然,看完了本文,别忘了留下你的赞跟评论呀!


作者:Pika
链接:https://juejin.cn/post/7118609781832548383
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Kotlin 快速编译背后的黑科技,了解一下~

前言 快速编译大量代码一向是一个难题,尤其是当编译器必须执行很多复杂操作时,例如重载方法解析和泛型类型推断。 本文主要介绍在日常开发中做一些小改动时,Kotlin编译器是如何加快编译速度的 为什么编译那么耗时? 编译时间长通常有三大原因: 代码库大小:通常代...
继续阅读 »

前言


快速编译大量代码一向是一个难题,尤其是当编译器必须执行很多复杂操作时,例如重载方法解析和泛型类型推断。 本文主要介绍在日常开发中做一些小改动时,Kotlin编译器是如何加快编译速度的


为什么编译那么耗时?


编译时间长通常有三大原因:



  1. 代码库大小:通常代码码越大,编译耗时越长

  2. 你的工具链优化了多少,这包括编译器本身和你正在使用的任何构建工具。

  3. 你的编译器有多智能:无论是在不打扰用户的情况下计算出许多事情,还是需要不断提示和样板代码


前两个因素很明显,让我们谈谈第三个因素:编译器的智能。 这通常是一个复杂的权衡,在 Kotlin 中,我们决定支持干净可读的类型安全代码。这意味着编译器必须非常智能,因为我们在编译期需要做很多工作。


Kotlin 旨在用于项目寿命长、规模大且涉及大量人员的工业开发环境。


因此,我们希望静态类型安全,能够及早发现错误,并获得精确的提示(支持自动补全、重构和在 IDE 中查找使用、精确的代码导航等)。


然后,我们还想要干净可读的代码,没有不必要的噪音。这意味着我们不希望代码中到处都是类型。 这就是为什么我们有支持 lambda 和扩展函数类型的智能类型推断和重载解析算法等等。 Kotlin 编译器会自己计算出很多东西,以同时保持代码干净和类型安全。


编译器可以同时智能与高效吗?


为了让智能编译器快速运行,您当然需要优化工具链的每一部分,这是我们一直在努力的事情。 除此之外,我们正在开发新一代 Kotlin 编译器,它的运行速度将比当前编译器快得多,但这篇文章不是关于这个的。


不管编译器有多快,在大型项目上都不会太快。 而且,在调试时所做的每一个小改动都重新编译整个代码库是一种巨大的浪费。 因此,我们试图尽可能多地复用之前的编译,并且只编译我们绝对需要的文件。


有两种通用方法可以减少重新编译的代码量:



  • 编译避免:即只重新编译受影响的模块,

  • 增量编译:即只重新编译受影响的文件。


人们可能会想到一种更细粒度的方法,它可以跟踪单个函数或类的变化,因此重新编译的次数甚至少于一个文件,但我不知道这种方法在工业语言中的实际实现,总的来说它似乎没有必要。


现在让我们更详细地了解一下编译避免和增量编译。


编译避免


编译避免的核心思想是:



  • 查找dirty(即发生更改)的文件

  • 重新编译这些文件所属的module

  • 确定哪些其他模块可能会受到更改的影响,重新编译这些模块,并检查它们的ABI

  • 然后重复这个过程直到重新编译所有受影响的模块


从以上步骤可以看出,没有人依赖的模块中的更改将比每个人都依赖的模块(比如util模块)中的更改编译得更快(如果它影响其 ABI),因为如果你修改了util模块,依赖了它的模块全都需要编译


ABI是什么


上面介绍了在编译过程中会检查ABI,那么ABI是什么呢?


ABI 代表应用程序二进制接口,它与 API 相同,但用于二进制文件。本质上,ABI 是依赖模块关心的二进制文件中唯一的部分。


粗略地说,Kotlin 二进制文件(无论是 JVM 类文件还是 KLib)包含declarationbody两部分。其他模块可以引用declaration,但不是所有declaration。因此,例如,私有类和成员不是 ABI 的一部分。


body可以成为 ABI 的一部分吗?也是可以的,比如当我们使用inline时。 同时Kotlin 具有内联函数和编译时常量(const val)。因此如果内联函数的bodyconst val 的值发生更改,则可能需要重新编译相关模块。


因此,粗略地说,Kotlin 模块的 ABIdeclaration、内联body和其他模块可见的const val值组成。


因此检测 ABI 变化的直接方法是



  • 以某种形式存储先前编译的 ABI(您可能希望存储哈希以提高效率)

  • 编译模块后,将结果与存储的 ABI 进行比较:

  • 如果相同,我们就完成了;

  • 如果改变了,重新编译依赖模块。


编译避免的优缺点


避免编译的最大优点是相对简单。


当模块很小时,这种方法确实很有帮助,因为重新编译的单元是整个模块。 但如果你的模块很大,重新编译的耗时会很长。 因此为了尽可能地利用编译避免提升速度,决定了我们的工程应该由很多小模块组成。作为开发人员,我们可能想要也可能不想要这个。 小模块不一定听起来像一个糟糕的设计,但我宁愿为人而不是机器构建我的代码。为了利用编译避免,实际上限制了我们项目的架构。


另一个观察结果是,许多项目都有类似于util的基础模块,其中包含许多有用的小功能。 几乎所有其他模块都依赖于util模块,至少是可传递的。 现在,假设我想添加另一个在我的代码库中使用了 3 次的小实用函数。 它添加到util模块中会导致ABI发生变化,因此所有依赖模块都受到影响,进而导致整个项目都需要重新编译。


最重要的是,拥有许多小模块(每个都依赖于多个其他模块)意味着我的项目的configuration时间可能会变得巨大,因为对于每个模块,它都包含其独特的依赖项集(源代码和二进制文件)。 在 Gradle 中配置每个模块通常需要 50-100 毫秒。 大型项目拥有超过 1000 个模块的情况并不少见,因此总配置时间可能会超过一分钟。 它必须在每次构建以及每次将项目导入 IDE 时都运行(例如,添加新依赖项时)。


Gradle 中有许多特性可以减轻编译避免的一些缺点:例如,可以使用缓存configuration cache。 尽管如此,这里仍有很大的改进空间,这就是为什么在 Kotlin 中我们使用增量编译。


增量编译


增量编译比编译避免更加细粒度:它适用于单个文件而不是模块。 因此,当通用模块的 ABI 发生微小变化时,它不关心模块大小,也不重新编译整个项目。这种方式不会限制用户项目的架构,并且可以加快编译速度


JPS(IntelliJ的内置构建系统)一直支持增量编译。 而Gradle仅支持开箱即用的编译避免。 从 1.4 开始,Kotlin Gradle 插件为 Gradle 带来了一些有限的增量编译实现,但仍有很大的改进空间。


理想情况下,我们只需查看更改的文件,准确确定哪些文件依赖于它们,然后重新编译所有这些文件。


听起来很简单,但实际上准确地确定这组依赖文件非常复杂。


一方面,源文件之间可能存在循环依赖关系,这是大多数现代构建系统中的模块所不允许的。并且单个文件的依赖关系没有明确声明。请注意,如果引用了相同的包和链调用,imports不足以确定依赖关系:对于 A.b.c(),我们最多需要导入 A,但 B 类型的更改也会影响我们。


由于所有这些复杂性,增量编译试图通过多轮来获取受影响的文件集,以下是它的完成方式的概要:



  • 查找dirty(更改)的文件

  • 重新编译它们(使用之前编译的结果作为二进制依赖,而不是编译其他源文件)

  • 检查这些文件对应的ABI是否发生了变化

  • 如果没有,我们就完成了!

  • 如果发生了变化,则查找受更改影响的文件,将它们添加到脏文件集中,重新编译

  • 重复直到 ABI 稳定(这称为“固定点”)


由于我们已经知道如何比较 ABI,所以这里基本上只有两个棘手的地方:



  • 使用先前编译的结果来编译源的任意子集

  • 查找受一组给定的 ABI 更改影响的文件。


这两者都是 Kotlin 增量编译器的功能。 让我们一个一个看一下。


编译脏文件


编译器知道如何使用先前编译结果的子集来跳过编译非脏文件,而只需加载其中定义的符号来为脏文件生成二进制文件。 如果不是为了增量,编译器不一定能够做到这一点:从模块生成一个大二进制文件而不是每个源文件生成一个小二进制文件,这在 JVM 世界之外并不常见。 而且它不是 Kotlin 语言的一个特性,它是增量编译器的一个实现细节。


当我们将脏文件的 ABI 与之前的结果进行比较时,我们可能会发现我们很幸运,不需要再进行几轮重新编译。 以下是一些只需要重新编译脏文件的更改示例(因为它们不会更改 ABI):



  • 注释、字符串文字(const val 除外)等,例如:更改调试输出中的某些内容

  • 更改仅限于非内联且不影响返回类型推断的函数体,例如:添加/删除调试输出,或更改函数的内部逻辑

  • 仅限于私有声明的更改(它们可以是类或文件私有的),例如:引入或重命名私有函数

  • 重新排序函数声明


如您所见,这些情况在调试和迭代改进代码时非常常见。


扩大脏文件集


如果我们不那么幸运并且某些声明已更改,则意味着某些依赖于脏文件的文件在重新编译时可能会产生不同的结果,即使它们的代码中没有任何一行被更改。


一个简单的策略是此时放弃并重新编译整个模块。

这将把所有编译避免的问题都摆在桌面上:一旦你修改了一个声明,大模块就会成为一个问题,而且大量的小模块也有性能成本,如上所述。

所以,我们需要更细化:找到受影响的文件并重新编译它们。


因此,我们希望找到依赖于实际更改的 ABI 部分的文件。

例如,如果用户将 foo 重命名为 bar,我们只想重新编译关心名称 foobar 的文件,而不管其他文件,即使它们引用了此 ABI的其他部分。

增量编译器会记住哪些文件依赖于先前编译中的哪个声明,我们可以使用这种数据,有点像模块依赖图。同样,这不是非增量编译器通常会做的事情。


理想情况下,对于每个文件,我们应该存储哪些文件依赖于它,以及它们关心 ABI 的哪些部分。实际上,如此精确地存储所有依赖项的成本太高了。而且在许多情况下,存储完整签名毫无意义。


我们看一下下面这个例子:


// dirty.kt
// rename this to be 'fun foo(i: Int)'
fun changeMe(i: Int) = if (i == 1) 0 else bar().length

// clean.kt
fun foo(a: Any) = ""
fun bar() = foo(1)

我们定义两个kt文件 ,dirty.ktclean.kt


假设用户将函数 changeMe 重命名为 foo。 请注意,虽然 clean.kt 没有改变,但 bar() 的主体将在重新编译时改变:它现在将从dirty.kt 调用 foo(Int),而不是从 clean.kt 调用 foo(Any) ,并且它的返回类型 也会改变。


这意味着我们必须重新编译dirty.ktclean.kt。 增量编译器如何发现这一点?


我们首先重新编译更改的文件:dirty.kt。 然后我们看到 ABI 中的某些内容发生了变化:



  • 没有功能 changeMe

  • 有一个函数 foo 接受一个 Int 并返回一个 Int


现在我们看到 clean.kt 依赖于名称 foo。 这意味着我们必须再次重新编译 clean.ktdirty.kt。 为什么? 因为类型不能被信任。


增量编译必须产生与所有代码的完全重新编译相同的结果。

考虑dirty.kt 中新出现的foo 的返回类型。它是推断出来的,实际上它取决于 clean.ktbar 的类型,它是文件之间的循环依赖。

因此,当我们将 clean.kt 添加到组合中时,返回类型可能会发生变化。在这个例子中,我们会得到一个编译错误,但是在我们重新编译 clean.ktdirty.kt 之前,我们不知道它。


Kotlin 增量编译的第一原则:您可以信任的只是名称。


这就是为什么对于每个文件,我们存储它产生的 ABI,以及在编译期间查找的名称(不是完整的声明)。


我们存储所有这些的方式可以进行一些优化。


例如,某些名称永远不会在文件之外查找,例如局部变量的名称,在某些情况下还有局部函数的名称。

我们可以从索引中省略它们。为了使算法更精确,我们记录了在查找每个名称时查阅了哪些文件。为了压缩我们使用散列的索引。这里有更多改进的空间。


您可能已经注意到,我们必须多次重新编译初始的脏文件集。 唉,没有办法解决这个问题:可能存在循环依赖,只有一次编译所有受影响的文件才能产生正确的结果。


在最坏的情况下,增量编译可能会比编译避免做更多的工作,因此应该有适当的启发式方法来防止它。


跨模块的增量编译


迄今为止最大的挑战是可以跨越模块边界的增量编译。


比如说,我们在一个模块中有脏文件,我们做了几轮并在那里到达一个固定点。现在我们有了这个模块的新 ABI,需要对依赖的模块做一些事情。


当然,我们知道初始模块的 ABI 中哪些名称受到影响,并且我们知道依赖模块中的哪些文件查找了这些名称。


现在,我们可以应用基本相同的增量算法,但从 ABI 更改开始,而不是从一组脏文件开始。


如果模块之间没有循环依赖,单独重新编译依赖文件就足够了。但是,如果他们的 ABI 发生了变化,我们需要将更多来自同一模块的文件添加到集合中,并再次重新编译相同的文件。


Gradle 中完全实现这一点是一个公开的挑战。这可能需要对 Gradle 架构进行一些更改,但我们从过去的经验中知道,这样的事情是可能的,并且受到 Gradle 团队的欢迎。


总结


现在,您对现代编程语言中的快速编译所带来的挑战有了基本的了解。请注意,一些语言故意选择让他们的编译器不那么智能,以避免不得不做这一切。不管好坏,Kotlin 走的是另一条路,让 Kotlin 编译器如此智能似乎是用户最喜欢的特性,因为它们同时提供了强大的抽象、可读性和简洁的代码。


虽然我们正在开发新一代编译器前端,它将通过重新考虑核心类型检查和名称解析算法的实现来加快编译速度,但我们知道这篇博文中描述的所有内容都不会过时。


原因之一是使用 Java 编程语言的体验,它享受 IntelliJ IDEA 的增量编译功能,甚至拥有比今天的 kotlinc 快得多的编译器。


另一个原因是我们的目标是尽可能接近解释语言的开发体验,这些语言无需任何编译即可立即获取更改。


所以,Kotlin 的快速编译策略是:优化的编译器 + 优化的工具链 + 复杂的增量。


译者总结


本文主要介绍了Kotlin编译器在加快编译速度方面做的一些工作,介绍了编译避免与增量编译的区别以及什么是ABI


了解Kotlin增量编译的原理可以帮助我们提高增量编译成功的概率,比如inline函数体也是ABI的一部分,因此当我们声明内联函数时,内联函数体应该写得尽量简单,内部通常只需要调用另一个非内联函数即可。


这样当inline函数内部逻辑发生更改时,不需要重新编译依赖于它的那些文件,从而实现增量编译。


同时从实际开发过程中体验,Kotlin增量编译还是经常会失效,尤其是发生跨模块更改时。Kotlin新一代编译器已经发布了Alpha版本,期待会有更好的表现~


作者:程序员江同学
链接:https://juejin.cn/post/7118908219841314823
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

两天两夜,1M图片优化到100kb!

坦白从宽吧,我就是那个花了两天两夜把 1M 图片优化到 100kb 的家伙——王小二! 自从因为一篇报道登上热搜后,我差点抑郁,每天要靠 50 片安眠药才能入睡。 网络上曝光的那些关于一码通的消息,有真有假,我这里就不再澄清了。就说说我是怎么把图片从 1M ...
继续阅读 »

坦白从宽吧,我就是那个花了两天两夜把 1M 图片优化到 100kb 的家伙——王小二!


自从因为一篇报道登上热搜后,我差点抑郁,每天要靠 50 片安眠药才能入睡。


网络上曝光的那些关于一码通的消息,有真有假,我这里就不再澄清了。就说说我是怎么把图片从 1M 优化到 100kb 的故事吧。


是的,由于系统群体规模和访问规模的特殊性,每一行代码、每一张图片、每一个技术文档都反复核准,优化再优化,精益求精。为确保系统运行得更高效,我们将一张图片从1MB压缩到500KB,再从500KB优化到100KB。


这样的工作在外人看起来,简单到就好像悄悄给学妹塞一张情书就能让她做我女朋友一样简单。


但殊不知,这其中蕴含着极高的技术含量!


不信,我给你们普及下。


一、图像压缩


图像压缩是数据压缩技术在数字图像上的应用,目的是减少图像数据中的冗余信息,从而用更加高效的格式存储和传输数据。


图像压缩可以是有损数据压缩,也可以是无损数据压缩。




怎么样?


是不是感觉图像压缩技术没有想象中那么简单了?


更多关于图像压缩的资料可参考以下链接。



机器之心:http://www.jiqizhixin.com/graph/techn…



二、Java数字图像处理


作为这次“20 多万外包项目”的“主力开发人员”,我这里就给大家介绍下 Java 数字图像处理技术吧,一开始我就是用它来处理图片的。


数字图像处理(Digital Image Processing)是通过计算机对图像进行去除噪声、增强、复原、分割、提取特征等处理的方法和技术。



输入的是图像信号,然后经过 DIP 进行有效的算法处理后,输出为数字信号。


为了压缩图像,我们需要读取图像并将其转换成 BufferedImage 对象,BufferedImage 是 Image 类的一个子类,描述了一个具有可访问的图像数据缓冲区,由 ColorModel 和 Raster 的图像数据组成。



废话我就不多说了,直接进入实战吧!


三、图像压缩实战


刚好我本地有一张之前用过的封面图,离 1M 只差 236 KB,可以拿来作为测试用。



这其中要用到 ImageIO 类,这是一个静态类,提供了一系列方法用来读和写图像,同时还可以对图像进行简单的编码和解码。


比如说通过 ImageIO.read() 可以将图像读取到 BufferedImage 对象:


File input = new File("ceshi.jpg");
BufferedImage image = ImageIO.read(input);

比如说通过 ImageIO.getImageWritersByFormatName() 可以返回一个Iterator,其中包含了通过命名格式对图像进行编码的 ImageWriter。


Iterator<ImageWriter> writers =  ImageIO.getImageWritersByFormatName("jpg");
ImageWriter writer = (ImageWriter) writers.next();

比如说通过 ImageIO.createImageOutputStream() 可以创建一个图像的输出流对象,有了该对象后就可以通过 ImageWriter.setOutput() 将其设置为输出流。


File compressedImageFile = new File("bbcompress.jpg");
OutputStream os =new FileOutputStream(compressedImageFile);
ImageOutputStream ios = ImageIO.createImageOutputStream(os);
writer.setOutput(ios);

紧接着,可以对 ImageWriter 进行一些参数配置,比如说压缩模式,压缩质量等等。


ImageWriteParam param = writer.getDefaultWriteParam();

param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(0.01f);

压缩模式一共有四种,MODE_EXPLICIT 是其中一种,表示 ImageWriter 可以根据后续的 set 的附加信息进行平铺和压缩,比如说接下来的 setCompressionQuality() 方法。


setCompressionQuality() 方法的参数是一个 0-1 之间的数,0.0 表示尽最大程度压缩,1.0 表示保证图像质量很重要。对于有损压缩方案,压缩质量应该控制文件大小和图像质量之间的权衡(例如,通过在写入 JPEG 图像时选择量化表)。 对于无损方案,压缩质量可用于控制文件大小和执行压缩所需的时间之间的权衡(例如,通过优化行过滤器并在写入 PNG 图像时设置 ZLIB 压缩级别)。


整体代码如下所示:


public class Demo {
public static void main(String[] args) {

try {
File input = new File("ceshi.jpg");
BufferedImage image = ImageIO.read(input);


Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpg");
ImageWriter writer = (ImageWriter) writers.next();

File compressedImageFile = new File("bbcompress.jpg");
OutputStream os = new FileOutputStream(compressedImageFile);
ImageOutputStream ios = ImageIO.createImageOutputStream(os);
writer.setOutput(ios);


ImageWriteParam param = writer.getDefaultWriteParam();

param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(0.01f);

writer.write(null, new IIOImage(image, null, null), param);

os.close();
ios.close();
writer.dispose();

} catch (IOException e) {
e.printStackTrace();
}
}
}

执行压缩后,可以看到图片的大小压缩到了 19 KB:



可以看得出,质量因子为 0.01f 的时候图片已经有些失真了,可以适当提高质量因子比如说 0.5f,再来看一下。



图片质量明显提高了,但大小依然只有 64 KB,压缩效果还是值得信赖的。


四、其他开源库


接下来,推荐一些可以轻松集成到项目中的图像处理库吧,它们全都是免费的。


1)ImageJ,用 Java 编写的,可以编辑、分析、处理、保存和打印图像。



2)Apache Commons Imaging,一个读取和写入各种图像格式的库,包括快速解析图像信息(如大小,颜色,空间,ICC配置文件等)和元数据。



3)ImageMagick,可以读取和写入超过100种格式的图像,包括DPX、EXR、GIF、JPEG、JPEG-2000、PDF、PNG、Postscript、SVG和TIFF。还可以调整大小、翻转、镜像、旋转、扭曲、剪切和变换图像,调整图像颜色,应用各种特殊效果,包括绘制文本、线条、多边形、椭圆和贝塞尔曲线。



4)OpenCV,由BSD许可证发布,可以免费学习和商业使用,提供了包括 C/C++、Python 和 Java 等主流编程语言在内的接口。OpenCV 专为计算效率而设计,强调实时应用,可以充分发挥多核处理器的优势。



这里就以 OpenCV 为例,来演示一下图像压缩。当然了,OpenCV 用来压缩图像属于典型的大材小用。


第一步,添加 OpenCV 依赖到我们的项目当中,以 Maven 为例。


<dependency>
<groupId>org.openpnp</groupId>
<artifactId>opencv</artifactId>
<version>4.5.1-2</version>
</dependency>

第二步,要想使用 OpenCV,需要先初始化。


OpenCV.loadShared();

第三步,使用 OpenCV 读取图片。


Mat src = Imgcodecs.imread(imagePath);

第四步,使用 OpenCV 压缩图片。


MatOfInt dstImage = new MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, 1);
Imgcodecs.imwrite("resized_image.jpg", sourceImage, dstImage);

MatOfInt 的构造参数是一个可变参数,第一个参数 IMWRITE_JPEG_QUALITY 表示对图片的质量进行改变,第二个是质量因子,1-100,值越大表示质量越高。


执行代码后得到的图片如下所示:



借这个机会,来对比下 OpenCV 和 JDK 原生 API 在压缩图像时所使用的时间。


这是我本机的配置情况,早年买的顶配 iMac,也是我的主力机。一开始只有 16 G 内存,后来加了一个 16 G 内存条,不过最近半年电脑突然死机重启的频率明显提高了,不知道是不是 Big Sur 这个操作系统的问题还是电脑硬件老了。



结果如下所示:


opencvCompress压缩完成,所花时间:1070
jdkCompress压缩完成,所花时间:322

压缩后的图片大小差不多,都是 19 KB,并且质量因子都是最低值。



四、一点点心声


经过上面的技术分析后,相信你们都明白了,把1M图片优化到100kb实在是一件“不太容易”的事情。。。。


100KB 很小了吧?只有原来的 1/10。


要知道,我可是连续加班了两天两夜,不眠不休。



累到最后,我趴在电脑上都睡着了。


没想到哈喇子直接给电脑整短路了,我这才算是从梦里面吓醒来了!


😔,生活不易,且行且珍惜吧~


作者:沉默王二
链接:https://juejin.cn/post/7072614545381916708
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

重学Android-EditText的进阶操作

EditText的进阶使用 EditText 是我们常用的输入框控件,平常我们只是使用它输入文本,这里记录一些它不太常见的操作和一些解决方案。 一、焦点的自动获取 如果一个页面内定义了EditText,那么有可能我们进入此页面的时候会自动弹起软键盘,(分机型,...
继续阅读 »

EditText的进阶使用


EditText 是我们常用的输入框控件,平常我们只是使用它输入文本,这里记录一些它不太常见的操作和一些解决方案。


一、焦点的自动获取


如果一个页面内定义了EditText,那么有可能我们进入此页面的时候会自动弹起软键盘,(分机型,有的会弹,有的不弹)。如果我们需要弹软键盘,我们制定给 EditText 设置


    android:focusable="true"
android:focusableInTouchMode="true"

但是如果我们不想这个页面进去就弹出软键盘,我们可以给根布局或者 EditText 的父布局设置 focusable 。


二、光标和背景的控制


默认的 EditText 是带下划线和粗光标的,我们可以对它们进行简单的修改


android:background="@null" //去掉了下划线

android:textCursorDrawable="@null" //去掉光标的颜色

自定义光标的颜色和宽度:


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

<size android:width="2dp" />

<solid android:color="#BDC7D8" />

</shape>

使用自定义光标


android:textCursorDrawable="@drawable/edittext_cursor"

三、限制小数点位数


我们可以通过监听 EditText 的文本变化的方式来改变文本值,我们还能通过 DigitsKeyListener 的方式监听文本的改变。


3.1 TextWatcher的方式

我们可以通过监听 EditText 的文本变化,比如我们只想要小数点后面2位数,我们就监听文本变化,点后面的2位数,如果多了就把他删除掉。


public class MoneyTextWatcher implements TextWatcher {
private EditText editText;
private int digits = 2;

public MoneyTextWatcher(EditText et) {
editText = et;
}
public MoneyTextWatcher setDigits(int d) {
digits = d;
return this;
}


@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {

}

@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
//删除“.”后面超过2位后的数据
if (s.toString().contains(".")) {
if (s.length() - 1 - s.toString().indexOf(".") > digits) {
s = s.toString().subSequence(0,
s.toString().indexOf(".") + digits+1);
editText.setText(s);
editText.setSelection(s.length()); //光标移到最后
}
}
//如果"."在起始位置,则起始位置自动补0
if (s.toString().trim().substring(0).equals(".")) {
s = "0" + s;
editText.setText(s);
editText.setSelection(2);
}

//如果起始位置为0,且第二位跟的不是".",则无法后续输入
if (s.toString().startsWith("0")
&& s.toString().trim().length() > 1) {
if (!s.toString().substring(1, 2).equals(".")) {
editText.setText(s.subSequence(0, 1));
editText.setSelection(1);
return;
}
}
}

@Override
public void afterTextChanged(Editable s) {

}
}


使用:


//默认两位小数
mEditText.addTextChangedListener(new MoneyTextWatcher(mEditText1));

//手动设置其他位数,例如3
mEditText.addTextChangedListener(new MoneyTextWatcher(mEditText1).setDigits(3);

3.2 DigitsKeyListener的方式

public class ETMoneyValueFilter extends DigitsKeyListener {

public ETMoneyValueFilter(int d) {
super(false, true);
digits = d;
}

private int digits = 2; //默认显示二位数的小数点

public ETMoneyValueFilter setDigits(int d) {
digits = d;
return this;
}

@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
CharSequence out = super.filter(source, start, end, dest, dstart, dend);

if (out != null) {
source = out;
start = 0;
end = out.length();
}

int len = end - start;

if (len == 0) {
return source;
}

//以点开始的时候,自动在前面添加0
if (source.toString().equals(".") && dstart == 0) {
return "0.";
}
//如果起始位置为0,且第二位跟的不是".",则无法后续输入
if (!source.toString().equals(".") && dest.toString().equals("0")) {
return "";
}

int dlen = dest.length();

for (int i = 0; i < dstart; i++) {
if (dest.charAt(i) == '.') {
return (dlen - (i + 1) + len > digits) ?
"" :
new SpannableStringBuilder(source, start, end);
}
}

for (int i = start; i < end; ++i) {
if (source.charAt(i) == '.') {
if ((dlen - dend) + (end - (i + 1)) > digits)
return "";
else
break;
}
}

return new SpannableStringBuilder(source, start, end);
}
}

其实是和 TextWatcher 类似的方式,那么使用的时候我们这样使用:


//默认两位小数
mEditText.setFilters(new InputFilter[]{new MoneyValueFilter()});

//手动设置其他位数,例如3
mEditText.setFilters(new InputFilter[]{new MoneyValueFilter().setDigits(3)});

在Kotlin代码中是这样使用:


et_input.filters = arrayOf(ETMoneyValueFilter().setDigits(3))

这样就可以实现小数点后面二位数的控制,还顺便加入了.的判断,自动加0的操作。


四、EditText的Search操作


当此 EditText 软键盘弹起的时候,右下角的确定变为搜索,我们需要给 EditText 设置一个属性:


 android:imeOptions="actionSearch"

然后给软键盘设置一个监听


       //点击软键盘搜索按钮
etSearch.setOnKeyListener(new View.OnKeyListener() {

@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {

if (keyCode == KeyEvent.KEYCODE_ENTER) {
// 先隐藏键盘
((InputMethodManager) getSystemService(INPUT_METHOD_SERVICE))
.hideSoftInputFromWindow(TransactionHistorySearchActivity.this.getCurrentFocus()
.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS);

if (isSearch){
isSearch = false;
if (!TextUtils.isEmpty(etSearch.getText().toString()))
searchHistory(etSearch.getText().toString());
}

}
return false;
}
});

这里使用一个flag来判断,是因为部分机型会回调2次。所以为了统一效果,我们使用拦截判断只调用一次。


当然Search的逻辑如果你使用 Kotlin + DataBinding 来实现,那么就更简单了。


        //执行搜索
fun doSearch() {
KeyboardUtils.hideSoftInput(mActivity)
scrollTopRefresh()
}

//搜索的删除
fun searchDel() {
mViewModel.mKeywordLiveData.value = ""
doSearch()
}

           <LinearLayout
android:layout_width="match_parent"
android:layout_height="32dp"
android:layout_marginLeft="@dimen/d_15dp"
android:background="@drawable/shape_search_gray_bg_corners20"
android:gravity="center_vertical"
android:orientation="horizontal">

<ImageView
android:layout_width="@dimen/d_16dp"
android:layout_height="@dimen/d_16dp"
android:layout_marginLeft="@dimen/d_12dp"
android:src="@drawable/search_icon"
binding:clicks="@{click.doSearch}" />

<EditText
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/d_12dp"
android:layout_weight="1"
android:background="@color/transparent"
android:hint="大家都在搜"
android:imeOptions="actionSearch"
android:singleLine="true"
android:text="@={viewModel.mKeywordLiveData}"
android:textColor="@color/black"
android:textColorHint="@color/gray_99"
android:textSize="@dimen/d_14sp"
binding:onKeyEnter="@{click.doSearch}"
binding:typefaceMedium="@{true}" />

<ImageView
android:layout_width="@dimen/d_16dp"
android:layout_height="@dimen/d_16dp"
android:layout_marginRight="@dimen/d_10dp"
android:src="@drawable/search_delete"
android:visibility="gone"
binding:clicks="@{click.searchDel}"
binding:isVisibleGone="@{!TextUtils.isEmpty(viewModel.MKeywordLiveData)}" />

</LinearLayout>

主要的 Binding Adapter 方法为 onKeyEnter ,它实现了软键盘的搜索。


下面是自定义BindingAdapter的方法:


var _viewClickFlag = false
var _clickRunnable = Runnable { _viewClickFlag = false }

/**
* Edit的确认按键事件
*/
@BindingAdapter("onKeyEnter")
fun EditText.onKeyEnter(action: () -> Unit) {
setOnKeyListener { _, keyCode, _ ->
if (keyCode == KeyEvent.KEYCODE_ENTER) {
KeyboardUtils.closeSoftKeyboard(this)

if (!_viewClickFlag) {
_viewClickFlag = true
action()
}
removeCallbacks(_clickRunnable)
postDelayed(_clickRunnable, 1000)
}
return@setOnKeyListener false
}
}

和上面Java的实现方式类似,同样的做了防抖的操作。为了部分机型连续调用多次的问题。


效果:



五、焦点与软键盘的自由控制


上面说到的焦点,不自动弹出软键盘,如果我想自由的控制焦点与软键盘怎么办?


一个例子来说明,比如我们的需求,点击 EditText 的时候弹出弹框提示用户注意事项,当点击确定或者取消之后再继续输入。分解步骤如下:



  1. 我们点击EditText不能弹出软键盘

  2. 监听焦点获取之后弹出弹框

  3. 弹框完成之后我们需要手动的给EditText焦点

  4. 获取焦点之后需要设置光标与软键盘


代码逻辑如下:


      mBankAccountEt.setShowSoftInputOnFocus(false);
mBankAccountEt.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus && !isShowedBankAccountNotice) {
showBankAccountNoticePopup();
}
});


private void showBankAccountNoticePopup() {
BasePopupView mPopupView = new XPopup.Builder(mActivity)
.moveUpToKeyboard(false)
.hasShadowBg(true)
.asCustom(new BankNameNoticePopup(mActivity, () -> {
isShowedBankAccountNotice = true;
mBankAccountEt.setShowSoftInputOnFocus(true);

//需要把焦点设置回EditText
mBankAccountEt.setFocusable(true);
mBankAccountEt.setFocusableInTouchMode(true);
mBankAccountEt.requestFocus();
mBankAccountEt.setSelection(mBankAccountEt.getText().toString().length());

KeyboardUtils.showKeyboard(mBankAccountEt);
}));

if (mPopupView != null && !mPopupView.isShow()) {
mPopupView.show();
}
}

弹框就不给大家展示了,非常简单的弹窗,定义使用的弹窗库,逻辑都在完成的回调中。


KeyboardUtils工具类,控制EditText的软键盘展示与隐藏


	/*
* 显示键盘
* */
public static void showKeyboard(View view) {
InputMethodManager imm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
view.requestFocus();
imm.showSoftInput(view, 0);
}
}

/*
* 隐藏键盘
* */
public static void hideKeyboard(View view){
InputMethodManager imm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
imm.hideSoftInputFromWindow(view.getWindowToken(),0);
}
}

效果:



六、RV + EditText复用的问题


不知道大家有没有在RV中使用过 EditText ,Item中如果有 EditText 那么在滚出屏幕之后 再拉回来可能刚才输入的文本就消失了,或者换成不是刚才输入的文本了,是因为缓存复用,可能复用了别的Item上面的 EditText 控件。


有几种解决方法如下:


方法一: 强制的停用Recyclerview的复用


helper.setIsRecyclable(false);

但是RV就无法缓存与回收了,如果你的Item数量就是固定的并且不多,那么使用这个方法是最好的。


方法二: 通过监听焦点来添加或移除Edittext的TextChangedListener


@Override
protected void convert(BaseViewHolder helper, EdittextInRecyclerViewOfBean item) {
EditText editText = helper.getView(R.id.et);
editText.setText(item.getNum() + "");

TextWatcher textWatcher = new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {

}

@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {

}

@Override
public void afterTextChanged(Editable s) {
//这里处理数据
if (TextUtils.isEmpty(s.toString())) {
item.setNum(0);
} else {
item.setNum(Integer.parseInt(s.toString()));
}
}
};

editText.setOnFocusChangeListener(new View.OnFocusChangeListener() {
@Override
public void onFocusChange(View v, boolean hasFocus) {
if (hasFocus){
editText.addTextChangedListener(textWatcher);
}else {
editText.removeTextChangedListener(textWatcher);
}
}
});
}


方法三: 通过view的setTag()方法解决


@Override
protected void convert(BaseViewHolder helper, EdittextInRecyclerViewOfBean item) {
TextWatcher textWatcher = new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {

}

@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {

}

@Override
public void afterTextChanged(Editable s) {
if (TextUtils.isEmpty(s.toString())) {
item.setNum(0);
} else {
item.setNum(Integer.parseInt(s.toString()));
}
}
};

EditText editText = helper.getView(R.id.et);
//为了避免TextWatcher在调用settext()时被调用,提前将它移除
if (editText.getTag() instanceof TextWatcher) {
editText.removeTextChangedListener((TextWatcher) editText.getTag());
}
editText.setText(item.getNum() + "");
//重新添加上TextWatcher监听
editText.addTextChangedListener(textWatcher);
//将TextWatcher绑定到EditText
editText.setTag(textWatcher);
}


方法四: 为每个EditText的绑定位置


public class EditTextInRecyclerViewAdapter extends RecyclerView.Adapter {
private List<EdittextInRecyclerViewOfBean> mList = new ArrayList<>();

public void setData(List<EdittextInRecyclerViewOfBean> list) {
this.mList = list;
}

@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_edittext, parent, false);
return new ViewHolder(v, new ITextWatcher());
}

@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
ViewHolder viewHolder = (ViewHolder) holder;
viewHolder.mITextWatcher.bindPosition(position);
viewHolder.mEditText.setText(mList.get(position).getNum()+"");
}

@Override
public int getItemCount() {
return mList.size();
}

class ViewHolder extends RecyclerView.ViewHolder {
EditText mEditText;
ITextWatcher mITextWatcher;

private ViewHolder(View v, ITextWatcher watcher) {
super(v);
this.mEditText = v.findViewById(R.id.et);
this.mITextWatcher = watcher;
this.mEditText.addTextChangedListener(watcher);
}
}

class ITextWatcher implements TextWatcher {
private int position;

private void bindPosition(int position) {
this.position = position;
}

@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}

@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}

@Override
public void afterTextChanged(Editable s) {
if (TextUtils.isEmpty(s.toString())) {
mList.get(position).setNum(0);
} else {
mList.get(position).setNum(Integer.parseInt(s.toString()));
}
}
}
}


方法五: 构造方法中添加TextChanged


class PicViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

var ivPic: ImageView = itemView.findViewById(R.id.ivPic)
var etScore: EditText = itemView.findViewById(R.id.etScore)
var tvTitle: TextView = itemView.findViewById(R.id.tvTitle)
var myTextWatcher: MyTextWatcher = MyTextWatcher()

init {
etScore.addTextChangedListener(myTextWatcher)
}

fun updateView(picItem: PicItem) {
myTextWatcher.picItem = picItem
ivPic.setImageResource(picItem.picResId)
tvTitle.text = picItem.title
if (picItem.score == null) {
etScore.hint = "请输入分数"
etScore.setText("")
} else {
etScore.setText(picItem.score)
}
}
}

class MyTextWatcher: TextWatcher {

lateinit var picItem:PicItem

override fun afterTextChanged(s: Editable?) {
picItem?.apply {
score=s?.toString()
}
}

override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}

override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
}

方法六: 让产品改需求,不要使用EditText,或者我们干脆使用TextView,然后点击Item弹出输入框的弹框方式来实现。


作者:newki
链接:https://juejin.cn/post/7115621556700577828
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Idea不推荐使用@Autowired进行Field注入的原因

大家在使用IDEA开发的时候有没有注意到过一个提示,在字段上使用Spring的依赖注入注解@Autowired后会出现如下警告 Field injection is not recommended (字段注入是不被推荐的) 但是使用@Resource却不会...
继续阅读 »

大家在使用IDEA开发的时候有没有注意到过一个提示,在字段上使用Spring的依赖注入注解@Autowired后会出现如下警告



Field injection is not recommended (字段注入是不被推荐的)



但是使用@Resource却不会出现此提示


网上文章大部分都是介绍两者的区别,没有提到为什么,当时想了好久想出了可能的原因,今天来总结一下


Spring常见的DI方式



  • 构造器注入:利用构造方法的参数注入依赖

  • Setter注入:调用Setter的方法注入依赖

  • 字段注入:在字段上使用@Autowired/Resource注解


@Autowired VS @Resource


事实上,他们的基本功能都是通过注解实现依赖注入,只不过@AutowiredSpring定义的,而@ResourceJSR-250定义的。大致功能基本相同,但是还有一些细节不同:



  • 依赖识别方式:@Autowired默认是byType可以使用@Qualifier指定Name,@Resource默认ByName如果找不到则ByType

  • 适用对象:@Autowired可以对构造器、方法、参数、字段使用,@Resource只能对方法、字段使用

  • 提供方:@Autowired是Spring提供的,@Resource是JSR-250提供的


各种DI方式的优缺点


参考Spring官方文档,建议了如下的使用场景:



  • 构造器注入强依赖性(即必须使用此依赖),不变性(各依赖不会经常变动)

  • Setter注入可选(没有此依赖也可以工作),可变(依赖会经常变动)

  • Field注入:大多数情况下尽量少使用字段注入,一定要使用的话, @Resource相对@Autowired对IoC容器的耦合更低


Field注入的缺点



  • 不能像构造器那样注入不可变的对象

  • 依赖对外部不可见,外界可以看到构造器和setter,但无法看到私有字段,自然无法了解所需依赖

  • 会导致组件与IoC容器紧耦合(这是最重要的原因,离开了IoC容器去使用组件,在注入依赖时就会十分困难)

  • 导致单元测试也必须使用IoC容器,原因同上

  • 依赖过多时不够明显,比如我需要10个依赖,用构造器注入就会显得庞大,这时候应该考虑一下此组件是不是违反了单一职责原则


为什么IDEA只对@Autowired警告


Field注入虽然有很多缺点,但它的好处也不可忽略:那就是太方便了。使用构造器或者setter注入需要写更多业务无关的代码,十分麻烦,而字段注入大幅简化了它们。并且绝大多数情况下业务代码和框架就是强绑定的,完全松耦合只是一件理想上的事,牺牲了敏捷度去过度追求松耦合反而得不偿失。


那么问题来了,为什么IDEA只对@Autowired警告,却对@Resource视而不见呢?


个人认为,就像我们前面提到过的: @AutowiredSpring提供的,它是特定IoC提供的特定注解,这就导致了应用与框架的强绑定,一旦换用了其他的IoC框架,是不能够支持注入的。而 @ResourceJSR-250提供的,它是Java标准,我们使用的IoC容器应当去兼容它,这样即使更换容器,也可以正常工作。


作者:小亮哥Ya
链接:https://juejin.cn/post/7080441168462348319
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

官方core-ktx库能对富文本Span开发带来哪些便利?

当前SpannableStringBuilder的使用现状private fun test() { val stringBuilder = SpannableStringBuilder() var length = stringBuilder....
继续阅读 »

当前SpannableStringBuilder的使用现状

private fun test() {
val stringBuilder = SpannableStringBuilder()
var length = stringBuilder.length
stringBuilder.append("开始了")
//设置文本大小
stringBuilder.setSpan(
RelativeSizeSpan(20f),
length,
stringBuilder.length,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
length = stringBuilder.length
stringBuilder.append("执行了")
//设置背景颜色
stringBuilder.setSpan(
BackgroundColorSpan(Color.parseColor("#ffffff")),
length,
stringBuilder.length,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
length = stringBuilder.length
stringBuilder.append("结束了")
//设置点击事件
stringBuilder.setSpan(
object : ClickableSpan() {
override fun onClick(widget: View) {
}
},
length,
stringBuilder.length,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
}

以上代码就实现了三个功能,设置文本大小、背景颜色及点击事件,却写了这么一大坨代码,写起来好麻烦!!

core-ktx库的SpannableStringBuilder扩展

  1. 看下如何构造一个SpannableStringBuilder:

    image.png

    我们就可以在代码中这样使用:

    private fun test4() {
    val build = buildSpannedString {
    //操作各种Span
    }
    }

    请注意,这个buildSpannedString()方法的函数类型属于带接收者的函数类型,意为着我们可以访问SpannableStringBuilder定义的公共的属性方法(包括扩展方法),接下来我们就看下core-ktx库为SpannableStringBuilder提供了哪些扩展方法。

  2. SpannableStringBuilder.backgroundColor()设置背景色:

    image.png

    这个扩展方法需要传入一个颜色值充当背景色,backgroundColor()会自动帮助我们创建一个ForegroundColorSpan对象;还可以传入一个函数类型builderAction,比如用作使用append()方法设置要渲染的文本内容,最终会调用到inSpan()方法:

    image.png

    是不是明白了,最终我们是在这个方法中将xxxSpan设置给SpannableStringBuilder的。最终就可以这样使用了:

    val build = buildSpannedString {
    //操作各种Span
    backgroundColor(Color.RED) {
    append("开始了")
    }
    }
  3. SpannableStringBuilder.bold()设置粗体:

    image.png

    可以看到bold()方法中会自动帮助我们创建一个StyleSpan对象,使用起来和上面差不多:

    val build = buildSpannedString {
    bold {
    append("开始了")
    }
    }
  4. 其他SpannableStringBuilder.xxx()富文本设置扩展:

    core-ktx库提供了很多富文本设置的扩展方法,这里就只介绍上面的两个,其他的就不再这里介绍了,可以自行看下源码:

    image.png

  5. 一个非常非常简单的使用技巧

    假设当前有一小段文本遮天是一群人的完美,完美是一个人的遮天,我想要对整段文本设置一个背景色,对一群人这三个字设置一个粗体大小,利用上面core-ktx库提供的扩展,我们可以这样实现:

    private fun test4() {
    val build = buildSpannedString {
    backgroundColor(Color.RED) {
    append("遮天是")
    bold {
    append("一群人")
    }
    append("的完美,完美是一个人的遮天")
    }
    }
    }

    核心就是SpannableStringBuilder.xxx()系列的富文本扩展方法的第二个参数是一个接收者为SpannableStringBuilder的函数类型,所以backgroundColor()bold()strikeThrough()等等可以相互嵌套使用,从来更简单的实现一些富文本效果。

使用时请注意,buildSpannedString()这个方法创建的SpannableStringBuilder最终会包装成一个SpannedString不可变对象,请根据实际情况使用。

core-ktx库的Spannable扩展

SpannableStringBuilderSpannableString等实现了Spannable接口,所以Spannable定义的扩展方法对常用的SpannableStringBuilderSpannableString同样适用。

  1. Spannable.clearSpans清理所有标识(包括各种Span)

    image.png

    使用时,直接对Spannable及其子类调用clearSpans()即可。

  2. Spannable.set(start: Int, end: Int, span: Any)设置Span

    image.png

    这个扩展方法就比较牛逼了,它是一个运算符重载函数且重载了[xxx]运算符来设置Span的,我们看下使用:

    val stringBuilder = SpannableStringBuilder()
    //设置背景色
    stringBuilder[0, 2] = BackgroundColorSpan(Color.RED)

    有没有眼前一亮的感觉哈!!

  3. Spannable.set(range: IntRange, span: Any)设置Span

    image.png

    这个方法和上一个方法很像,不过传入的设置Span标识范围的方式发生了改变,变成了一个IntRange类型,我们直接看下使用:

    val stringBuilder = SpannableStringBuilder()
    //设置背景色
    stringBuilder[0..3] = BackgroundColorSpan(Color.RED)
  4. CharSequence.toSpannable()转换CharSequenceSpannableString

    image.png

    这个很简单,就不再进行举例说明了。

core-ktx库的Spanned扩展

Spanned的子接口包括我们上面刚讲到的Spannable,所以它定义的扩展方法对于SpannableStringBuilderSpannableString同样适用。

  1. CharSequence.toSpanned()转换CharSequenceSpannedString

    image.png

    注意和isSpannable()转换的区别,一个能设置Span,一个不能设。

  2. Spanned.getSpans()获取指定类型的Span标识

    image.png

    借助于Kotlin的泛型实化reified+inline简化了传入具体Span类型的逻辑,我们看下使用:

    private fun test4(builder: SpannableStringBuilder) {
    val spans = builder.getSpans<BackgroundColorSpan>()
    }

    获取类型为BackgroudColorSpan的所有Span对象,如果我们想要获取所有的Span对象,直接将传入的泛型类型改为Any即可。

  3. Spanned.toHtml()将富文本转换成同等效果显示的html代码

    image.png

    也就是说如果你富文本中存在ImageSpan,转换成html代码时,就会帮你在对应位置添加一个<img src="" />的标签,我们简单看下其核心源码Html.withinParagraph()中的片段:

    image.png

富文本绘制复杂布局的两种技巧

  1. ReplacementSpan这个Span使用非常灵活,它提供了方法draw()可自定义绘制你想要的布局效果;

  2. 如果使用ReplacementSpan自定义绘制布局还是太过于复杂,可以考虑先使用原生组件在xml中实现这个布局效果,然后将这个xml通过Inflate转换成View,并将调用ViewonDraw()方法,手动绘制到我们自定义Bitmap中,经过这个流程,我们就将这个复杂的布局转换成了Bitmap图像,然后使用ImageSpan加载该Bitmap,最终渲染到富文本中即可。

    请注意,请根据实际情况判断,是否需要先手动测量这个转换的View,然后再将其绘制到我们自定义的Bitmap中,否则可能不生效。

总结

以上就是core-ktx库针对于富文本提供的所有扩展方法,核心的源码就在SpannableStringBuilder.ktSpannableString.ktSpannedString.kt这三个文件中,大家有需要请自行查看。


作者:长安皈故里
链接:https://juejin.cn/post/7116920821150400519
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android 无障碍监听通知的过程

监听通知 Android 中的 AccessibilityService 可以监听通知信息的变化,首先需要创建一个无障碍服务,这个教程可以自行百度。在无障碍服务的配置文件中,需要以下配置: <accessibility-service ... and...
继续阅读 »

监听通知


Android 中的 AccessibilityService 可以监听通知信息的变化,首先需要创建一个无障碍服务,这个教程可以自行百度。在无障碍服务的配置文件中,需要以下配置:


<accessibility-service
...
android:accessibilityEventTypes="其他内容|typeNotificationStateChanged"
android:canRetrieveWindowContent="true" />

然后在 AccessibilityService 的 onAccessibilityEvent 方法中监听消息:


override fun onAccessibilityEvent(event: AccessibilityEvent?) {
when (event.eventType) {
AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED -> {
Log.d(Tag, "Notification: $event")
}
}
}

当有新的通知或 Toast 出现时,在这个方法中就会收到 AccessibilityEvent 。


另一种方案是通过 NotificationListenerService 进行监听,这里不做详细介绍了。两种方案的应用场景不同,推荐使用 NotificationListenerService 而不是无障碍服务。stackoverflow 上一个比较好的回答:



It depends on WHY you want to read it. The general answer would be Notification Listener. Accessibility Services are for unique accessibility services. A user has to enable an accessibility service from within the Accessibility Service menu (where TalkBack and Switch Access are). Their ability to read notifications is a secondary ability, to help them achieve the goal of creating assistive technologies (alternative ways for people to interact with mobile devices).


Whereas, Notification Listeners, this is their primary goal. They exist as part of the context of an app and as such don't need to be specifically turned on from the accessibility menu.


Basically, unless you are in fact building an accessibility service, you should not use this approach, and go with the generic Notification Listener.



无障碍服务监听通知逻辑


从用法中可以看出一个关键信息 -- TYPE_NOTIFICATION_STATE_CHANGED ,通过这个事件类型入手,发现它用于两个类中:



  • ToastPresenter:用于在应用程序进程中展示系统 UI 样式的 Toast 。

  • NotificationManagerService:通知管理服务。


ToastPresenter


ToastPresenter 的 trySendAccessibilityEvent 方法中,构建了一个 TYPE_NOTIFICATION_STATE_CHANGED 类型的消息:


public void trySendAccessibilityEvent(View view, String packageName) {
if (!mAccessibilityManager.isEnabled()) {
return;
}
AccessibilityEvent event = AccessibilityEvent.obtain(
AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
event.setClassName(Toast.class.getName());
event.setPackageName(packageName);
view.dispatchPopulateAccessibilityEvent(event);
mAccessibilityManager.sendAccessibilityEvent(event);
}

这个方法的调用在 ToastPresenter 中的 show 方法中:


public void show(View view, IBinder token, IBinder windowToken, int duration, int gravity,
int xOffset, int yOffset, float horizontalMargin, float verticalMargin,
@Nullable ITransientNotificationCallback callback) {
// ...
trySendAccessibilityEvent(mView, mPackageName);
// ...
}

而这个方法的调用就是在 Toast 中的 TN 类中的 handleShow 方法。


Toast.makeText(this, "", Toast.LENGTH_SHORT).show()

在 Toast 的 show 方法中,获取了一个 INotificationManager ,这个是 NotificationManagerService 在客户端暴露的 Binder 对象,通过这个 Binder 对象的方法可以调用 NMS 中的逻辑。


也就是说,Toast 的 show 方法调用了 NMS :


public void show() {
// ...
INotificationManager service = getService();
String pkg = mContext.getOpPackageName();
TN tn = mTN;
tn.mNextView = mNextView;
final int displayId = mContext.getDisplayId();

try {
if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
if (mNextView != null) {
// It's a custom toast
service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
} else {
// It's a text toast
ITransientNotificationCallback callback = new CallbackBinder(mCallbacks, mHandler);
service.enqueueTextToast(pkg, mToken, mText, mDuration, displayId, callback);
}
} else {
service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
}
} catch (RemoteException e) {
// Empty
}
}

这里是 enqueueToast 方法中,最后调用:


private void enqueueToast(String pkg, IBinder token, @Nullable CharSequence text,
@Nullable ITransientNotification callback, int duration, int displayId,
@Nullable ITransientNotificationCallback textCallback) {
// ...
record = getToastRecord(callingUid, callingPid, pkg, token, text, callback, duration, windowToken, displayId, textCallback);
// ...
}

getToastRecord 中根据 callback 是否为空产生了不同的 Toast :


private ToastRecord getToastRecord(int uid, int pid, String packageName, IBinder token,
@Nullable CharSequence text, @Nullable ITransientNotification callback, int duration,
Binder windowToken, int displayId,
@Nullable ITransientNotificationCallback textCallback) {
if (callback == null) {
return new TextToastRecord(this, mStatusBar, uid, pid, packageName, token, text,duration, windowToken, displayId, textCallback);
} else {
return new CustomToastRecord(this, uid, pid, packageName, token, callback, duration, windowToken, displayId);
}
}

两者的区别是展示对象的不同:




  • TextToastRecord 因为 ITransientNotification 为空,所以它是通过 mStatusBar 进行展示的:


        @Override
    public boolean show() {
    if (DBG) {
    Slog.d(TAG, "Show pkg=" + pkg + " text=" + text);
    }
    if (mStatusBar == null) {
    Slog.w(TAG, "StatusBar not available to show text toast for package " + pkg);
    return false;
    }
    mStatusBar.showToast(uid, pkg, token, text, windowToken, getDuration(), mCallback);
    return true;
    }



  • CustomToastRecord 调用 ITransientNotification 的 show 方法:


        @Override
    public boolean show() {
    if (DBG) {
    Slog.d(TAG, "Show pkg=" + pkg + " callback=" + callback);
    }
    try {
    callback.show(windowToken);
    return true;
    } catch (RemoteException e) {
    Slog.w(TAG, "Object died trying to show custom toast " + token + " in package "
    + pkg);
    mNotificationManager.keepProcessAliveForToastIfNeeded(pid);
    return false;
    }
    }

    这个 callback 最在 Toast.show() 时传进去的 TN :


    TN tn = mTN;
    service.enqueueToast(pkg, mToken, tn, mDuration, displayId);

    也就是调用到了 TN 的 show 方法:


            @Override
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
    public void show(IBinder windowToken) {
    if (localLOGV) Log.v(TAG, "SHOW: " + this);
    mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
    }




TN 的 show 方法中通过 mHandler 来传递了一个类型是 SHOW 的消息:


            mHandler = new Handler(looper, null) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case SHOW: {
IBinder token = (IBinder) msg.obj;
handleShow(token);
break;
}
case HIDE: {
handleHide();
// Don't do this in handleHide() because it is also invoked by
// handleShow()
mNextView = null;
break;
}
case CANCEL: {
handleHide();
// Don't do this in handleHide() because it is also invoked by
// handleShow()
mNextView = null;
try {
getService().cancelToast(mPackageName, mToken);
} catch (RemoteException e) {
}
break;
}
}
}
};

而这个 Handler 在处理 SHOW 时,会调用 handleShow(token) 这个方法里面也就是会触发 ToastPresenter 的 show 方法的地方:


public void handleShow(IBinder windowToken) {
// If a cancel/hide is pending - no need to show - at this point
// the window token is already invalid and no need to do any work.
if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
return;
}
if (mView != mNextView) {
// remove the old view if necessary
handleHide();
mView = mNextView;
// 【here】
mPresenter.show(mView, mToken, windowToken, mDuration, mGravity, mX, mY, mHorizontalMargin, mVerticalMargin, new CallbackBinder(getCallbacks(), mHandler));
}
}

本章节最开始介绍到了 ToastPresenter 的 show 方法中会调用 trySendAccessibilityEvent 方法,也就是从这个方法发送类型是 TYPE_NOTIFICATION_STATE_CHANGED 的无障碍消息给无障碍服务的。


NotificationManagerService


在通知流程中,是通过 NMS 中的 sendAccessibilityEvent 方法来向无障碍发送消息的:


void sendAccessibilityEvent(Notification notification, CharSequence packageName) {
if (!mAccessibilityManager.isEnabled()) {
return;
}

AccessibilityEvent event =
AccessibilityEvent.obtain(AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
event.setPackageName(packageName);
event.setClassName(Notification.class.getName());
event.setParcelableData(notification);
CharSequence tickerText = notification.tickerText;
if (!TextUtils.isEmpty(tickerText)) {
event.getText().add(tickerText);
}

mAccessibilityManager.sendAccessibilityEvent(event);
}

这个方法的调用有两处,均在 NMS 的 buzzBeepBlinkLocked 方法中,buzzBeepBlinkLocked 方法是用来处理通知是否应该发出铃声、震动或闪烁 LED 的。省略无关逻辑:


int buzzBeepBlinkLocked(NotificationRecord record) {
// ...
if (!record.isUpdate && record.getImportance() > IMPORTANCE_MIN && !suppressedByDnd) {
sendAccessibilityEvent(notification, record.getSbn().getPackageName());
sentAccessibilityEvent = true;
}

if (aboveThreshold && isNotificationForCurrentUser(record)) {
if (mSystemReady && mAudioManager != null) {
// ...
if (hasAudibleAlert && !shouldMuteNotificationLocked(record)) {
if (!sentAccessibilityEvent) {
sendAccessibilityEvent(notification, record.getSbn().getPackageName());
sentAccessibilityEvent = true;
}
// ...
} else if ((record.getFlags() & Notification.FLAG_INSISTENT) != 0) {
hasValidSound = false;
}
}
}
// ...
}

buzzBeepBlinkLocked 的调用路径有两个:




  • handleRankingReconsideration 方法中 RankingHandlerWorker (这是一个 Handler)调用 handleMessage 处理 MESSAGE_RECONSIDER_RANKING 类型的消息:


    @Override
    public void handleMessage(Message msg) {
    switch (msg.what) {
    case MESSAGE_RECONSIDER_RANKING:
    handleRankingReconsideration(msg);
    break;
    case MESSAGE_RANKING_SORT:
    handleRankingSort();
    break;
    }
    }

    handleRankingReconsideration 方法中调用了 buzzBeepBlinkLocked :


    private void handleRankingReconsideration(Message message) {
    // ...
    synchronized (mNotificationLock) {
    // ...
    if (interceptBefore && !record.isIntercepted()
    && record.isNewEnoughForAlerting(System.currentTimeMillis())) {
    buzzBeepBlinkLocked(record);
    }
    }
    if (changed) {
    mHandler.scheduleSendRankingUpdate();
    }
    }



  • PostNotificationRunnable 的 run 方法。




PostNotificationRunnable


这个东西是用来发送通知并进行处理的,例如提示和重排序等。


PostNotificationRunnable 的构建和 post 在 EnqueueNotificationRunnable 中。在 EnqueueNotificationRunnable 的 run 最后,进行了 post:


public void run() {
// ...
// tell the assistant service about the notification
if (mAssistants.isEnabled()) {
mAssistants.onNotificationEnqueuedLocked(r);
mHandler.postDelayed(new PostNotificationRunnable(r.getKey()), DELAY_FOR_ASSISTANT_TIME);
} else {
mHandler.post(new PostNotificationRunnable(r.getKey()));
}
}

EnqueueNotificationRunnable 在 enqueueNotificationInternal 方法中使用,enqueueNotificationInternal 方法是 INotificationManager 接口中定义的方法,它的实现在 NotificationManager 中:


    public void notifyAsPackage(@NonNull String targetPackage, @Nullable String tag, int id,
@NonNull Notification notification) {
INotificationManager service = getService();
String sender = mContext.getPackageName();

try {
if (localLOGV) Log.v(TAG, sender + ": notify(" + id + ", " + notification + ")");
service.enqueueNotificationWithTag(targetPackage, sender, tag, id,
fixNotification(notification), mContext.getUser().getIdentifier());
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}

@UnsupportedAppUsage
public void notifyAsUser(String tag, int id, Notification notification, UserHandle user)
{
INotificationManager service = getService();
String pkg = mContext.getPackageName();

try {
if (localLOGV) Log.v(TAG, pkg + ": notify(" + id + ", " + notification + ")");
service.enqueueNotificationWithTag(pkg, mContext.getOpPackageName(), tag, id,
fixNotification(notification), user.getIdentifier());
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}

一般发送一个通知都是通过 NotificationManager 或 NotificationManagerCompat 来发送的,例如:


NotificationManagerCompat.from(this).notify(1, builder.build());

NotificationManagerCompat 中的 notify 方法本质上调用的是 NotificationManager:


// NotificationManagerCompat
public void notify(int id, @NonNull Notification notification) {
notify(null, id, notification);
}

public void notify(@Nullable String tag, int id, @NonNull Notification notification) {
if (useSideChannelForNotification(notification)) {
pushSideChannelQueue(new NotifyTask(mContext.getPackageName(), id, tag, notification));
// Cancel this notification in notification manager if it just transitioned to being side channelled.
mNotificationManager.cancel(tag, id);
} else {
mNotificationManager.notify(tag, id, notification);
}
}

mNotificationManager.notify(tag, id, notification) 中的实现:


public void notify(String tag, int id, Notification notification) {
notifyAsUser(tag, id, notification, mContext.getUser());
}

public void cancel(@Nullable String tag, int id) {
cancelAsUser(tag, id, mContext.getUser());
}

串起来了,最终就是通过 NotificationManager 的 notify 相关方法发送通知,然后触发了通知是否要触发铃声/震动/LED 闪烁的逻辑,并且在这个逻辑中,发送出了无障碍消息。


作者:自动化BUG制造器
链接:https://juejin.cn/post/7117191453821894686
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

监控主线程耗时操作,从开发中解决ANR

ANR
背景:在 debug 环境中主线程中方法体执行的时间与指定的时间做对比后的堆栈信息,针对性的优化超过指定时间的耗时方法体,减少 ANR 的发生此工具类主要是通过向主线程Looper打印超过指定时间的耗时堆栈信息以及耗时时...
继续阅读 »

背景:

在 debug 环境中主线程中方法体执行的时间与指定的时间做对比后的堆栈信息,针对性的优化超过指定时间的耗时方法体,减少 ANR 的发生

此工具类主要是通过向主线程Looper打印超过指定时间的耗时堆栈信息以及耗时时长,其中校验时间自已定义,主动查看主线程中的耗时操作,防患未然。

原理:

此工具类为最简单最直接处理、优化耗时操作的工具

大家都知道Android 对于ANR的判断标准:

最简单的一句话就是:ANR——应用无响应,Activity是5秒,BroadCastReceiver是10秒,Service是20秒

然后此工具类的方案就是将主线程的堆栈信息作时间对比监控,超时的打印出来

Looper.loop 解析:

  1. 应用之所以未退出,就是运行在loop 中,如果有阻塞loop 的操作就会发生ANR、崩溃
public static void loop() {
final Looper me = myLooper();
//....
for (;;) {
if (!loopOnce(me, ident, thresholdOverride)) {
return;
}
}
}
  1. 主要看死循环

loopOnce

private static boolean loopOnce(final Looper me,
final long ident, final int thresholdOverride) {
Message msg = me.mQueue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return false;
}

// This must be in a local variable, in case a UI event sets the logger
// *当有任务的时候打印Dispatching to *
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " "
+ msg.callback + ": " + msg.what);
}
//.... 中间部分未任务执行的代码

//执行结束之后打印 Finished to
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}

// Make sure that during the course of dispatching the
// identity of the thread wasn't corrupted.
final long newIdent = Binder.clearCallingIdentity();
if (ident != newIdent) {
Log.wtf(TAG, "Thread identity changed from 0x"
+ Long.toHexString(ident) + " to 0x"
+ Long.toHexString(newIdent) + " while dispatching to "
+ msg.target.getClass().getName() + " "
+ msg.callback + " what=" + msg.what);
}

msg.recycleUnchecked();

return true;
}
  1. 上述注释之间的耗时就是主线程在执行某个任务时的耗时,我们只要拿这个时间和指定时间相比就能监控主线程的耗时堆栈信息了

使用方式:

  1. Application:
 //主线程中方法体执行的时间与指定的时间做对比后的堆栈信息,针对性的优化超过指定时间的耗时方法体,
MainThreadDoctor.init(500)
  1. 查看日志:

image.png

日志等级为明显起见使用error级别

工具类:

 /**
* @author kong
* @date 2022/7/6 15:55
* @description 在debug环境中主线程中方法体执行的时间与指定的时间做对比后的堆栈信息,针对性的优化超过指定时间的耗时方法体,减少ANR的发生
**/
object MainThreadDoctor {

private var startTime = 0L
private var currentJob: Job? = null
private const val START = ">>>>> Dispatching"
private const val END = "<<<<< Finished"

fun init(diagnoseStandardTime: Long) {
if (BuildConfigs.DEBUG) {
diagnoseFromMainThread(diagnoseStandardTime)
}
}

/**
* @param diagnoseStandardTime 执行诊断的标准时间
*/
fun diagnoseFromMainThread(diagnoseStandardTime: Long) {
Looper.getMainLooper().setMessageLogging {
if (it.startsWith(START)) {
startTime = System.currentTimeMillis()
currentJob = GlobalScope.launch(Dispatchers.IO) {
delay(diagnoseStandardTime)
val stackTrace = Looper.getMainLooper().thread.stackTrace
val builder = StringBuilder()
for (s in stackTrace) {
builder.append(s.toString())
builder.append("\n")
}
PPLog.e("looperMessageMain $builder")
}
}

if (it.startsWith(END)) {
if (currentJob?.isCompleted == false) {
currentJob?.cancel()
} else {
PPLog.e("looperMessageMain 总时间 = ${System.currentTimeMillis() - startTime} 毫秒")
}
}
}
}
}


作者:汐颜染瞳べ
链接:https://juejin.cn/post/7117194640826368036
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

听说Compose与RecyclerView结合会有水土不服?

背景&笔者碎碎谈 最近Compose也慢慢火起来了,作为google力推的ui框架,我们也要用起来才能进步呀!在最新一期的评测中LazyRow等LazyXXX列表组件已经慢慢逼近RecyclerView的性能了!但是还是有很多同学顾虑呀!没关系,我们就...
继续阅读 »

背景&笔者碎碎谈


最近Compose也慢慢火起来了,作为google力推的ui框架,我们也要用起来才能进步呀!在最新一期的评测中LazyRow等LazyXXX列表组件已经慢慢逼近RecyclerView的性能了!但是还是有很多同学顾虑呀!没关系,我们就算用原有的view开发体系,也可以快速迁移到compose,这个利器就是ComposeView了,那么我们在RecyclerView的基础上,集成Compose用起来!这样我们有RecyclerView的性能又有Compose的好处不是嘛!相信很多人都有跟我一样的想法,但是这两者结合起来可是有隐藏的性能开销!(本次使用compose版本为1.1.1)


在原有view体系接入Compose


在纯compose项目中,我们都会用setContent代替原有view体系的setContentView,比如


setContent {
ComposeTestTheme {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Greeting("Android")
Hello()
}
}
}

那么setContent到底做了什么事情呢?我们看下源码


public fun ComponentActivity.setContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) {
val existingComposeView = window.decorView
.findViewById<ViewGroup>(android.R.id.content)
.getChildAt(0) as? ComposeView

if (existingComposeView != null) with(existingComposeView) {
setParentCompositionContext(parent)
setContent(content)
} else ComposeView(this).apply {
// 第一步走到这里
// Set content and parent **before** setContentView
// to have ComposeView create the composition on attach
setParentCompositionContext(parent)
setContent(content)
// Set the view tree owners before setting the content view so that the inflation process
// and attach listeners will see them already present
setOwners()
setContentView(this, DefaultActivityContentLayoutParams)
}
}

由于是第一次进入,那么一定就走到了else分支,其实就是创建了一个ComposeView,放在了android.R.id.content里面的第一个child中,这里就可以看到,compose并不是完全脱了原有的view体系,而是采用了移花接木的方式,把compose体系迁移了过来!ComposeView就是我们能用Compose的前提啦!所以在原有的view体系中,我们也可以通过ComposeView去“嫁接”到view体系中,我们举个例子


class CustomActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_custom)
val recyclerView = this.findViewById<RecyclerView>(R.id.recyclerView)
recyclerView.adapter = MyRecyclerViewAdapter()
recyclerView.layoutManager = LinearLayoutManager(this)
}
}


class MyRecyclerViewAdapter:RecyclerView.Adapter<MyComposeViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyComposeViewHolder {
val view = ComposeView(parent.context)
return MyComposeViewHolder(view)
}

override fun onBindViewHolder(holder: MyComposeViewHolder, position: Int) {
holder.composeView.setContent {
Text(text = "test $position", modifier = Modifier.size(200.dp).padding(20.dp), textAlign = TextAlign.Center)
}

}

override fun getItemCount(): Int {
return 200
}
}

class MyComposeViewHolder(val composeView:ComposeView):RecyclerView.ViewHolder(composeView){

}

这样一来,我们的compose就被移到了RecyclerView中,当然,每一列其实就是一个文本。嗯!普普通通,好像也没啥特别的对吧,假如这个时候你打开了profiler,当我们向下滑动的时候,会发现内存会慢慢的往上浮动


image.png
滑动嘛!有点内存很正常,毕竟谁不生成对象呢,但是这跟我们平常用RecyclerView的时候有点差异,因为RecyclerView滑动的涨幅可没有这个大,那究竟是什么原因导致的呢?


探究Compose


有过对Compose了解的同学可能会知道,Compose的界面构成会有一个重组的过程,当然!本文就不展开聊重组了,因为这类文章有挺多的(填个坑,如果有机会就填),我们聊点特别的,那么什么时候停止重组呢?或者说什么时候这个Compose被dispose掉,即不再参与重组!


Dispose策略


其实我们的ComposeView,以1.1.1版本为例,其实创建的时候,也创建了取消重组策略,即


@Suppress("LeakingThis")
private var disposeViewCompositionStrategy: (() -> Unit)? =
ViewCompositionStrategy.DisposeOnDetachedFromWindow.installFor(this)

这个策略是什么呢?我们点进去看源码


object DisposeOnDetachedFromWindow : ViewCompositionStrategy {
override fun installFor(view: AbstractComposeView): () -> Unit {
val listener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {}

override fun onViewDetachedFromWindow(v: View?) {
view.disposeComposition()
}
}
view.addOnAttachStateChangeListener(listener)
return { view.removeOnAttachStateChangeListener(listener) }
}
}

看起来是不是很简单呢,其实就加了一个监听,在onViewDetachedFromWindow的时候调用的view.disposeComposition(),声明当前的ComposeView不参与接下来的重组过程了,我们再继续看


fun disposeComposition() {
composition?.dispose()
composition = null
requestLayout()
}

再看dispose方法


override fun dispose() {
synchronized(lock) {
if (!disposed) {
disposed = true
composable = {}
val nonEmptySlotTable = slotTable.groupsSize > 0
if (nonEmptySlotTable || abandonSet.isNotEmpty()) {
val manager = RememberEventDispatcher(abandonSet)
if (nonEmptySlotTable) {
slotTable.write { writer ->
writer.removeCurrentGroup(manager)
}
applier.clear()
manager.dispatchRememberObservers()
}
manager.dispatchAbandons()
}
composer.dispose()
}
}
parent.unregisterComposition(this)
}

那么怎么样才算是不参与接下里的重组呢,其实就是这里


slotTable.write { writer ->
writer.removeCurrentGroup(manager)
}

...
composer.dispose()

而removeCurrentGroup其实就是把当前的group移除了


for (slot in groupSlots()) {
when (slot) {
....
is RecomposeScopeImpl -> {
val composition = slot.composition
if (composition != null) {
composition.pendingInvalidScopes = true
slot.composition = null
}
}
}
}

这里又多了一个概念,slottable,我们可以这么理解,这里面就是Compose的快照系统,其实就相当于对应着某个时刻view的状态!之所以Compose是声明式的,就是通过slottable里的slot去判断,如果最新的slot跟前一个slot不一致,就回调给监听者,实现更新!这里又是一个大话题了,我们点到为止


image.png


跟RecyclerView有冲突吗


我们看到,默认的策略是当view被移出当前的window就不参与重组了,嗯!这个在99%的场景都是有效的策略,因为你都看不到了,还重组干嘛对吧!但是这跟我们的RecyclerView有什么冲突吗?想想看!诶,RecyclerView最重要的是啥,Recycle呀,就是因为会重复利用holder,间接重复利用了view才显得高效不是嘛!那么问题就来了


image.png
如图,我们item5其实完全可以利用item1进行显示的对不对,差别就只是Text组件的文本不一致罢了,但是我们从上文的分析来看,这个ComposeView对应的composition被回收了,即不参与重组了,换句话来说,我们Adapter在onBindViewHolder的时候,岂不是用了一个没有compositon的ComposeView(即不能参加重组的ComposeView)?这样怎么行呢?我们来猜一下,那么这样的话,RecyclerView岂不是都要生成新的ComposeView(即每次都调用onCreateViewHolder)才能保证正确?emmm,很有道理,但是却不是的!如果我们把代码跑起来看的话,复用的时候依旧是会调用onBindViewHolder,这就是Compose的秘密了,那么这个秘密在哪呢


override fun onBindViewHolder(holder: MyComposeViewHolder, position: Int) {
holder.composeView.setContent {
Text(text = "test $position", modifier = Modifier.size(200.dp).padding(20.dp), textAlign = TextAlign.Center)
}

}

其实就是在ComposeView的setContent方法中,


fun setContent(content: @Composable () -> Unit) {
shouldCreateCompositionOnAttachedToWindow = true
this.content.value = content
if (isAttachedToWindow) {
createComposition()
}
}

fun createComposition() {
check(parentContext != null || isAttachedToWindow) {
"createComposition requires either a parent reference or the View to be attached" +
"to a window. Attach the View or call setParentCompositionReference."
}
ensureCompositionCreated()
}

最终调用的是


private fun ensureCompositionCreated() {
if (composition == null) {
try {
creatingComposition = true
composition = setContent(resolveParentCompositionContext()) {
Content()
}
} finally {
creatingComposition = false
}
}
}

看到了吗!如果composition为null,就会重新创建一个!这样ComposeView就完全嫁接到RecyclerView中而不出现问题了!


其他Dispose策略


我们看到,虽然在ComposeView在RecyclerView中能正常运行,但是还存在缺陷对不对,因为每次复用都要重新创建一个composition对象是不是!归根到底就是,我们默认的dispose策略不太适合这种拥有复用逻辑或者自己生命周期的组件使用,那么有其他策略适合RecyclerView吗?别急,其实是有的,比如DisposeOnViewTreeLifecycleDestroyed


object DisposeOnViewTreeLifecycleDestroyed : ViewCompositionStrategy {
override fun installFor(view: AbstractComposeView): () -> Unit {
if (view.isAttachedToWindow) {
val lco = checkNotNull(ViewTreeLifecycleOwner.get(view)) {
"View tree for $view has no ViewTreeLifecycleOwner"
}
return installForLifecycle(view, lco.lifecycle)
} else {
// We change this reference after we successfully attach
var disposer: () -> Unit
val listener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
val lco = checkNotNull(ViewTreeLifecycleOwner.get(view)) {
"View tree for $view has no ViewTreeLifecycleOwner"
}
disposer = installForLifecycle(view, lco.lifecycle)

// Ensure this runs only once
view.removeOnAttachStateChangeListener(this)
}

override fun onViewDetachedFromWindow(v: View?) {}
}
view.addOnAttachStateChangeListener(listener)
disposer = { view.removeOnAttachStateChangeListener(listener) }
return { disposer() }
}
}
}

然后我们在ViewHolder的init方法中对composeview设置一下就可以了


class MyComposeViewHolder(val composeView:ComposeView):RecyclerView.ViewHolder(composeView){
init {
composeView.setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
}
}

为什么DisposeOnViewTreeLifecycleDestroyed更加适合呢?我们可以看到在onViewAttachedToWindow中调用了
installForLifecycle(view, lco.lifecycle) 方法,然后就removeOnAttachStateChangeListener,保证了该ComposeView创建的时候只会被调用一次,那么removeOnAttachStateChangeListener又做了什么呢?


val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_DESTROY) {
view.disposeComposition()
}
}
lifecycle.addObserver(observer)
return { lifecycle.removeObserver(observer) }

可以看到,是在对应的生命周期事件为ON_DESTROY(Lifecycle.Event跟activity生命周期不是一一对应的,要注意)的时候,才调用view.disposeComposition(),本例子的lifecycleOwner就是CustomActivity啦,这样就保证了只有当前被lifecycleOwner处于特定状态的时候,才会销毁,这样是不是就提高了compose的性能了!


扩展


我们留意到了Compose其实存在这样的小问题,那么如果我们用了其他的组件类似RecyclerView这种的怎么办,又或者我们的开发没有读过这篇文章怎么办!(ps:看到这里的同学还不点赞点赞),没关系,官方也注意到了,并且在1.3.0-alpha02以上版本添加了更换了默认策略,我们来看一下


val Default: ViewCompositionStrategy
get() = DisposeOnDetachedFromWindowOrReleasedFromPool

object DisposeOnDetachedFromWindowOrReleasedFromPool : ViewCompositionStrategy {
override fun installFor(view: AbstractComposeView): () -> Unit {
val listener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {}

override fun onViewDetachedFromWindow(v: View) {
// 注意这里
if (!view.isWithinPoolingContainer) {
view.disposeComposition()
}
}
}
view.addOnAttachStateChangeListener(listener)

val poolingContainerListener = PoolingContainerListener { view.disposeComposition() }
view.addPoolingContainerListener(poolingContainerListener)

return {
view.removeOnAttachStateChangeListener(listener)
view.removePoolingContainerListener(poolingContainerListener)
}
}
}

DisposeOnDetachedFromWindow从变成了DisposeOnDetachedFromWindowOrReleasedFromPool,其实主要变化点就是一个view.isWithinPoolingContainer = false,才会进行dispose,isWithinPoolingContainer定义如下


image.png


也就是说,如果我们view的祖先存在isPoolingContainer = true的时候,就不会进行dispose啦!所以说,如果我们的自定义view是这种情况,就一定要把isPoolingContainer变成true才不会有隐藏的性能开销噢!当然,RecyclerView也要同步到1.3.0-alpha02以上才会有这个属性改写!现在稳定版本还是会存在本文的隐藏性能开销,请注意噢!不过相信看完这篇文章,性能优化啥的,不存在了对不对!


作者:Pika
链接:https://juejin.cn/post/7117176526893744142
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Dart(三)—方法定义、箭头函数、函数相互调用、匿名、自执行方法及闭包

方法定义 dart自定义方法的基本格式: 返回类型 方法名称(参数1,参数2,...){ 方法体 return 返回值 / 或无返回值; } 定义方法的的几个例子: void printInfo(){ print('我是一个自定义方法');...
继续阅读 »

方法定义



dart自定义方法的基本格式:

返回类型 方法名称(参数1,参数2,...){
方法体
return 返回值 / 或无返回值;
}

定义方法的的几个例子:


void printInfo(){
print('我是一个自定义方法');
}

int getNum(){
var count = 123;
return count;
}

String printUserInfo(){

return 'this is str';
}

List getList(){

return ['111','2222','333'];
}

Dart没有public 、private等关键字,_ 下横向直接代表 private


方法的作用域


void main(){

void outFun(){
innerFun(){

print('aaa');
}
innerFun();
}

// innerFun(); 错误写法

outFun(); //调用方法
}

方法传参


一般定义:


String getUserInfo(String username, int age) {
//形参
return "姓名:$username -> 年龄:$age";
}

print(printUserInfo('小明', 23)); //实参

Dart中可以定义一个带可选参数的方法 ,可选参数需要指定类型默认值:


void main() {
String printUserInfo(String username, [int age = 0]) { //age格式表示可选
//形参
if (age != 0) {
return "姓名:$username -> 年龄:$age";
}
return "姓名:$username -> 年龄不详";
}

print(printUserInfo('小明', 28)); //实参
//可选就可以不传了
print(printUserInfo('李四'));
}

定义一个带默认参数的方法:


String getUserInfo(String username,[String sex='男',int age=0]){  //形参
if(age!=0){
return "姓名:$username -> 性别:$sex -> 年龄:$age";
}
return "姓名:$username -> 性别:$sex -> 年龄不详";
}
print(getUserInfo('张三'));
print(getUserInfo('李四','男'));
print(getUserInfo('李梅梅','女',25));

定义一个命名参数的方法,定义命名参数需要指定类型默认值:


命名参数的好处是在使用时可以不用按顺序赋值,看下面代码:


String getUserInfo(String username, {int age = 0, String sex = '男'}) {//形参
if (age != 0) {
return "姓名:$username -> 性别:$sex -> 年龄:$age";
}
return "姓名:$username -> 性别:$sex -> 年龄保密";
}
print(getUserInfo('张三',sex: '男',age: 20));

定义一个把方法当做参数的方法:


其实就是方法可以当做参数来用,这点和Kotlin也是一样的:


//方法1 随便打印一下
fun1() {
print('fun1');
}

//方法2 参数是一个方法
fun2(fun) {
fun();
}

//调用fun2这个方法 把fun1这个方法当做参数传入
fun2(fun1());

箭头函数和函数的相互调用


箭头函数


在之前的学习中,我们知道可以使用forEach来遍历List,其一般格式如下:


List list = ['a', 'b', 'c'];
list.forEach((value) {
print(value);
});

而箭头函数就是可以简写这种格式:


list.forEach((value) => print(value));

箭头后面指向的就是方法的返回值,这里要注意的是:



箭头函数内只能写一条语句,并且语句后面没有分号(;)



对于之前map转换的例子也可以使用箭头方法来简化一下:


List list = [1, 3, 6, 8, 9];
var newList = list.map((value) {
if (value > 3) {
return value * 2;
}
return value;
});

这里就是修改List里面的数据,让数组中大于3的值乘以2。那用箭头函数简化后可以写成:


var newList = list.map((value) => value > 3 ? value*2 : value);

一句代码完成,非常有意思。


函数的相互调用


  // 定义一个方法来判断一个数是否是偶数  
bool isEvenNumber(int n) {
if (n % 2 == 0) {
return true;
}
return false;
}
// 定义一个方法打印1-n以内的所有偶数
prinEvenNumber(int n) {
for (var i = 1; i <= n; i++) {
if (isEvenNumber(i)) {
print(i);
}
}
}
prinEvenNumber(10);

匿名方法、自执行方法及方法的递归


匿名方法


var printNum = (){
print(12);
};
printNum();

这里很明显跟Kotlin中的特性基本是一样的。带参数的匿名方法:


var printNum = (int n) {
print(n + 2);
};

printNum(3);

自执行方法


自执行方法顾名思义就是不需要调用,会自动去执行的,这是因为自执行函数的定义和调用合为了一体。当我们创建了一个匿名函数,并执行了它,由于外部无法引用的它的内部变量,所以在执行完就会很快被释放,而且这种做法不会污染到全局对象。看如下代码:


((int n) {
print("这是一个自执行方法 + $n");
})(666);
}

方法的递归


方法的递归无非就是在条件满足的条件下继续在方法内调用自己本身,看以下代码:


var sum = 0;
void fn(int n) {
sum += n;
if (n == 0) {
return;
}
fn(n - 1);
}
fn(100);
print(sum);

实现的是1加到100。


闭包


闭包是一个前端的概念,客户端开发早期使用Java可以说是不支持闭包,或是不完整的闭包,但Kotlin是可以支持闭包的操作。


闭包的意思就是函数嵌套函数, 内部函数会调用外部函数的变量或参数, 变量或参数不会被系统回收(不会释放内存)。所以闭包解决的两个问题是:



  • 变量常驻内存

  • 变量不污染全局


闭包的一般写法是:



  • 函数嵌套函数,并return 里面的函数,这样就形成了闭包


闭包的写法:


Function func() {
var a = 1; /*不会污染全局 常驻内存*/
return () {
a++;
print(a);
};
}

这里return匿名方法后,a的值就可以常驻内存了:


var mFun = func();
mFun();
mFun();
mFun();

打印:2、3、4。


作者:柒叁
链接:https://juejin.cn/post/7116205745367941150
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Dart(二)—循环表达式、List、Set及Map的常用属性和方法

Dart的循环表达式 for循环 for (int i = 1; i<=100; i++) { print(i); } 也可以写成: for (var i = 1; i<=10; i++) { print(i); } 对于List...
继续阅读 »

Dart的循环表达式


for循环


for (int i = 1; i<=100; i++) {   
print(i);
}

也可以写成:


for (var i = 1; i<=10; i++) {
print(i);
}

对于List的遍历我们可以这样做:


var list = <String>["张三","李四","王五"];
for (var element in list) {
print(element);
}

对于Map的迭代我们也可以使用for循环语句:


var person={
"name":"小明",
"age":28,
"work":["程序员","Android开发"]
};

person.forEach((key, value) {
print(value);
});

while语句


while有两种语句格式:


while(表达式/循环条件){    

}

do{
语句/循环体

}while(表达式/循环条件);


注意:



  • 1、最后的分号不要忘记

  • 2、循环条件中使用的变量需要经过初始化

  • 3、循环体中,应有结束循环的条件,否则会造成死循环。



看下面代码:


int i = 1;
while (i <= 10) {
print(i);
i++;
}

do...while()最大的区别就是不管条件成立与否都会至少执行一次:


var i = 2;
do{
print('执行代码');
}while(i < 2);

break和continue语句


break语句功能:



  • switch语句中使流程跳出switch结构。

  • 在循环语句中使流程跳出当前循环,遇到break循环终止,后面代码也不会执行


需要强调的是:



  • 如果在循环中已经执行了break语句,就不会执行循环体中位于break后的语句。

  • 在多层循环中,一个break语句只能向外跳出一层


break可以用在switch case中 也可以用在for循环和while循环中。


continue语句的功能:


只能在循环语句中使用,使本次循环结束,即跳过循环体中下面尚未执行的语句,接着进行下次的是否执行循环的判断。


continue可以用在for循环以及while循环中,但是不建议用在while循环中,不小心容易死循环。


break使用:


//如果 i等于4的话跳出循环
for(var i=1;i<=10;i++){
if(i==4){
break; /*跳出循环体*/
}
print(i);
}

//break语句只能向外跳出一层
for(var i = 0;i < 5;i++){
for(var j = 0;j< 3;j++){
if(j == 1){
break;
}
}
}

while循环跳出:


//while循环 break跳出循环

var i = 1;

while(i< =10){
if(i == 4){
break;
}
print(i);
i++;
}

continue使用:


//如果i等于4的话跳过

for(var i=1;i<=5;i++){
if(i == 2){
continue; //跳过当前循环体 然后循环还会继续执行
}
print(i);
}

List常用属性和方法


常用属性:



  • length 长度

  • reversed 翻转

  • isEmpty 是否为空

  • isNotEmpty 是否不为空


常用方法:



  • add 增加

  • addAll 拼接数组

  • indexOf 查找 传入具体值

  • remove 删除 传入具体值

  • removeAt 删除 传入索引值

  • fillRange 修改

  • insert(index,value) 指定位置插入

  • insertAll(index,list) 指定位置插入List

  • toList() 其他类型转换成List

  • join() List转换成字符串

  • split() 字符串转化成List

  • forEach

  • map

  • where

  • any


一些常用属性和方法使用举例:


var list=['张三','李四','王五',"小明"];
print(list.length);
print(list.isEmpty);
print(list.isNotEmpty);
print(list.reversed); //对列表倒序排序

print(list.indexOf('李四')); //indexOf查找数据 查找不到返回-1 查找到返回索引值

list.remove('王五');

list.removeAt(2);

list.fillRange(1, 2,'a'); //修改 1是开始的位置 2二是结束的位置

print(list);

list.insert(1,'a');

print(list);

list.insertAll(1, ['a','b']); //插入多个

Set


Set的最主要的功能就是去除数组重复内容,它是没有顺序且不能重复的集合,所以不能通过索引去获取值。


var s = new Set();
s.add('A');
s.add('B');
s.add('B');

print(s); //{A, B}

add相同内容时候无法添加进去的。


Set可以通过add方法添加一个List,并清除值相同的元素:


var list = ['香蕉','苹果','西瓜','香蕉','苹果','香蕉','苹果'];
var s = new Set();
s.addAll(list);
print(s);
print(s.toList());

Map常用属性和方法


Map是无序的键值对,它的常用属性主要有以下:


常用属性:



  • keys 获取所有的key值

  • values 获取所有的value值

  • isEmpty 是否为空

  • isNotEmpty 是否不为空


常用方法:



  • remove(key) 删除指定key的数据

  • addAll({...}) 合并映射 给映射内增加属性

  • containsValue 查看映射内的值 返回true/false

  • forEach

  • map

  • where

  • any

  • every


map转换:


List list = [1, 3, 4];
//map转换,根据返回值返回新的元素列表
var newList = list.map((value) {
return value * 2;
});
print(newList.toList());

where:获取符合条件的元素:


List list = [1,3,4,5,7,8,9];

var newList = list.where((value){
return value > 5;
});
print(newList.toList());

any:是否有符合条件的元素


List list = [1, 3, 4, 5, 7, 8, 9];
//只要集合里面有满足条件的就返回true
var isContain = list.any((value) {
return value > 5;
});
print(isContain);

every:需要每一个都满足条件


List myList=[1,3,4,5,7,8,9];
//每一个都满足条件返回true 否则返回false
var flag = myList.every((value){

return value > 5;
});
print(flag);

Set使用forEach遍历:


var s=new Set();

s.addAll([11,22,33]);

s.forEach((value) => print(value));

Map使用forEach遍历:


Map person={
"name":"张三",
"age":28
};

person.forEach((key,value){
print("$key -> $value");
});

作者:柒叁
链接:https://juejin.cn/post/7116159285054144526/
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Dart(一)—变量、常量、基本类型、运算符、条件判断以及类型转换

前言 Dart语言跟Kotlin都是一种强大的脚本语言,它的很多语法跟Kotlin是很相似的。比如Dart也是可以不预先定义变量类型 ,自动会类型推倒,它修饰一般变量的关键字也是var,所以如果我们熟悉Kotlin,Dart也会很容易上手。 Dart变量和常量...
继续阅读 »

前言


Dart语言跟Kotlin都是一种强大的脚本语言,它的很多语法跟Kotlin是很相似的。比如Dart也是可以不预先定义变量类型 ,自动会类型推倒,它修饰一般变量的关键字也是var,所以如果我们熟悉KotlinDart也会很容易上手。


Dart变量和常量


变量


如前言所说,DartKotlin一样是强大的脚本类语言,可以不预先定义变量类型 ,自动会类型推倒,Dart中定义变量可以通过var关键字可以通过类型来申明变量:


var str = 'dart';

String str2 = 'this is dart';

int count = 123;


注意: var 后就不要写类型 , 写了类型 不要var 两者都写 var a int = 5; 报错



常量:final 和 const修饰符



  • const修饰的值不变 要在定义变量的时候就得赋值;

  • final可以开始不赋值 只能赋一次,而final不仅有const的编译时常量的特性,最重要的它是运行时常量,并且final是惰性初始化,即在运行时第一次使用前才初始化。


final name = 'Max';
final String sex = '男';

const bar = 1000000;
const double atm = 1.01325 * bar;

如果我们使用了阿里的代码规范插件,其实他会提示我们最好用const代替final


Dart的命名规则



  • 变量名称必须由数字、字母、下划线和美元符($)组成。

  • 注意:标识符开头不能是数字

  • 标识符不能是保留字和关键字。

  • 变量的名字是区分大小写的如: age和Age是不同的变量。在实际的运用中,也建议,不要用一个单词大小写区分两个变量。

  • 标识符(变量名称)一定要见名思意 :变量名称建议用名词,方法名称建议用动词


Dart的入口方法


Dart 入口方法main有两种定义


//表示main方法没有返回值
void main(){
print('dart');
}

main(){

print('dart');
}

Dart基本类型


数据类型


Dart中常用的数据类型有以下的类型:


Numbers(数值):
int
double
Strings(字符串)
String
Booleans(布尔)
bool
List(数组)
在Dart中,数组是列表对象,所以大多数人只是称它们为列表
Maps(字典)
通常来说,Map 是一个键值对相关的对象。 键和值可以是任何类型的对象。每个键只出现一次,而一个值则可以出现多次

数值类型: int double


int整型:


  int a=123;
a=45;

double既可以是整型,也可是浮点型:


double b=23.5;

b=24;

字符串类型


字符串定义:


var str1='this is str1';

String str2='this is str2';

字符串拼接:


print("$str1 $str2");

print(str1 + str2);

布尔类型


定义方式:


bool flag1=true;

var flag2=true;

判断条件上和Kotlin使用无异。


List(数组/集合)


不指定类型定义List


var list1 = ["张三",20,true];

print(list1);
print(list1[2]);

这就有点颠覆我们以往的观念了,一个list里面还可以有不同的类型。


指定类型定义List


var list2 = <String>["张三","李四"];

print(list2);

通过[]来定义Lsit


通过[]创建的集合的容量可以变化:


var list = [];

list.add("小明");
list.add(24);
list.add(true);

print(list);

也可以指定List中的元素类型:


List<String> list = [];

又或者是:


List<String> list = List.empty(growable: true);

growable 为 false 是为 固定长度列表,为 true 是为 长度可变列表


通过List.filled创建的集合长度是固定:


var list1 = List.filled(2, "");

var list2 = List<String>.filled(2, "");

Map


Map的定义:


直接赋值方式:


var person={
"name":"小明",
"age":28,
"work":["程序员","Android开发"]
};

print(person["name"]);

print(person["age"]);

print(person["work"]);


通过Map分别赋值:


var map =new Map();

map["name"]="小明";
map["age"]=26;
map["work"]=["程序员","Android开发"];
print(map);

is 关键词来判断类型


var str = 123;

if(str is String){
print('是string类型');
}else if(str is int){
print('int');
}else{
print('其他类型');
}

运算符


算术运算符


使用和符号上和Kotlin中的基本无异:


int a=13;
int b=5;

print(a+b); //加
print(a-b); //减
print(a*b); //乘
print(a/b); //除
print(a%b); //其余
print(a~/b); //取整

关系运算符


关系运算符主要有:


==    !=   >    <    >=    <=

使用:


int a=5;
int b=3;

print(a==b); //判断是否相等
print(a!=b); //判断是否不等
print(a>b); //判断是否大于
print(a<b); //判断是否小于
print(a>=b); //判断是否大于等于
print(a<=b); //判断是否小于等于

逻辑运算符


! 取反:


bool flag=false;
print(!flag); //取反

&&并且:全部为true的话值为true 否则值为false:


bool a=true;
bool b=true;

print(a && b);

||或者:全为false的话值为false 否则值为true:


bool a=false;
bool b=false;

print(a || b);

赋值运算符


基础赋值运算符 =、??= ++ --


int c=a+b;   //从右向左

b??=23;  表示如果b为空的话把 23赋值给b

++ --


// ++  --   表示自增 自减 1
//在赋值运算里面 如果++ -- 写在前面 这时候先运算 再赋值,如果++ --写在后面 先赋值后运行运算

var a = 10;
var b = a--;

print(a); //9
print(b); //10

// var a=10;

// a++; //a=a+1;

// print(a);

复合赋值运算符 +=、-= 、*= 、 /= 、%= 、~/=


+=


var a=12;
a+=12; //a = a+12
print(a);

-=


a-=6; // a = a-6

*=


a*=3;  //a=a*3;

/=


需要返回double类型


double a=12;
a/=12;

%=


double a=12;
a %= 12;

~/=


返回的是int整型


int a1 = 3;
int a2 = 2;

int a = a1 ~/= a2;

a = 1.


条件表达式


**if else **


 bool flag=true;

if(flag){
print('true');
}else{
print('false');
}

switch case


var sex = "女";
switch (sex) {
case "男":
print('性别是男');
break;
case "女":
print('性别是女');
break;
default:
print('传入参数错误');
break;
}

三目运算符


bool flag = false;
String str = flag?'我是true':'我是false';
print(str);

??运算符


var a;
var b= a ?? 10;

print(b); // a为空,则赋值为10

// var a=22;
// var b= a ?? 10;
//
// print(b); // 20

类型转换


Number与String类型之间的转换



  • Number类型转换成String类型toString()

  • String类型转成Number类型int.parse()


StringNumber


String str = '123';

var myNum = int.parse(str);

print(myNum is int);

// String str='123.1';

// var myNum=double.parse(str);

// print(myNum is double);


String:


var myNum=12;

var str=myNum.toString();

print(str is String);

其他类型转换成Boolean类型


isEmpty:判断字符串是否为空


var str = '';
if (str.isEmpty) {
print('str空');
} else {
print('str不为空');
}

isNaN:判断值是否为非数字


var myNum = 0 / 0;

if (myNum.isNaN) {
print('NaN');
}

作者:柒叁
链接:https://juejin.cn/post/7116024077273432094
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Flutter 桌面端实践之识别外接媒体设备

最近我们希望Flutter技术在桌面端的应用能有所突破,所以笔者跨进了本不熟悉的桌面端应用领域。今天给大家分享下我们是如何让Flutter如何识别外接媒体设备,并且实现视频流渲染和拍照;从官方插件外界纹理到platformView实践,都尝试了一遍,最后选择了...
继续阅读 »

最近我们希望Flutter技术在桌面端的应用能有所突破,所以笔者跨进了本不熟悉的桌面端应用领域。今天给大家分享下我们是如何让Flutter如何识别外接媒体设备,并且实现视频流渲染和拍照;从官方插件外界纹理platformView实践,都尝试了一遍,最后选择了webRtc,整个预研过程一波三折,学到了很多知识!



需求背景


需求是在win10和Android9的设备上支持外接摄像头,能够进行实时拍摄,做一个类似相机的应用。

从技术流程上来分析,我们需要识别出相机设备,拿到媒体流信息然后做渲染(渲染机制一般通过外接纹理Texture去实现),最后捕获帧进行拍照/录制。Flutter中,任何对象渲染后自然能拿到RanderObject,只要有RanderObject这个真实的渲染对象,我们就能进行照片的存储。

以上流程,理论上库已经帮我们做好,但是桌面端的生态,往往没那么简单~~~


一、官方Plugin


Android端使用camera,windows使用camera_windows。官方的库对于内置相机的支持做的很不错,直接引用后在手机和普通电脑上效果都很好;但是两个库都是明确不支持外接设备,见issus-1issus-2,优先级分别是P4、P5,显然官方认为这些问题优先级不高。
而纵观整个Flutter生态对USB外设的支持,并没有一个官方的库,pub上的基本也是参差不齐,大多只支持单一平台。


实现原理



  • Android端的camera插件,使用原生Camera2 Api,通过TextureRegistry创建纹理,然后Flutter用Texture进行绘制。



  1. 创建相机实例,返回textureId


// camera_android-0.9.8+3\lib\src\android_camera.dart
@override
Future<int> createCamera(
CameraDescription cameraDescription,
ResolutionPreset? resolutionPreset, {
bool enableAudio = false,
}) async {
try {
final Map<String, dynamic>? reply = await _channel
.invokeMapMethod<String, dynamic>('create', <String, dynamic>{
'cameraName': cameraDescription.name,
'resolutionPreset': resolutionPreset != null
? _serializeResolutionPreset(resolutionPreset)
: null,
'enableAudio': enableAudio,
});

return reply!['cameraId']! as int;
} on PlatformException catch (e) {
throw CameraException(e.code, e.message);
}
}


  1. 预览控件返回Flutter Texture Widget,与原生返回的纹理id形成绑定,从而接收纹理信息然后绘制


// camera_android-0.9.8+3\lib\src\android_camera.dart
@override
Widget buildPreview(int cameraId) {
return Texture(textureId: cameraId);
}


  1. Android端通过TextureRegistry创建createSurfaceTexture,把textureId返回到Dart层。


// camera_android-0.9.8+3\android\src\main\java\io\flutter\plugins\camera\MethodCallHandlerImpl.java
private void instantiateCamera(MethodCall call, Result result) throws CameraAccessException {
String cameraName = call.argument("cameraName");
String preset = call.argument("resolutionPreset");
boolean enableAudio = call.argument("enableAudio");

TextureRegistry.SurfaceTextureEntry flutterSurfaceTexture =
textureRegistry.createSurfaceTexture();
DartMessenger dartMessenger =
new DartMessenger(
messenger, flutterSurfaceTexture.id(), new Handler(Looper.getMainLooper()));
CameraProperties cameraProperties =
new CameraPropertiesImpl(cameraName, CameraUtils.getCameraManager(activity));
ResolutionPreset resolutionPreset = ResolutionPreset.valueOf(preset);

camera =
new Camera(
activity,
flutterSurfaceTexture,
new CameraFeatureFactoryImpl(),
dartMessenger,
cameraProperties,
resolutionPreset,
enableAudio);

Map<String, Object> reply = new HashMap<>();
reply.put("cameraId", flutterSurfaceTexture.id());
result.success(reply);
}

值得一提的是Flutter3.0后,官方的原生绘制方式已经抛弃了VirtualDisplay,拥抱TextureLayer,性能上已经优化了不少,让Flutter的音视频渲染能力提升了不少。 但问题就是在instantiateCamera之前,官方在Camera2的实现上,没有对外界设备进行处理,从而搜索不到对应的外接相机。



  • Windows端的实现完全一样,都是通过Texture做渲染,原因也是获取相机列表的时候没有做外接设备的实现,这里不在赘述。


解决方案


基于多端的camera接口做处理,把外设设备的逻辑加上,应该就可以了。 在Texture纹理这块官方的实现是没有问题的。
当然这个思路我目前只停留在理论层面,并未真正去实现,原因如下:



  1. 两个库都是设计原生知识,我们维护成本会很大;

  2. 官方的库维护的很频繁,后面更多优化还得看官方,很有可能哪个版本就得全部推翻重新来一遍。


二、PlatformView


明确一个观点,这个方案不可落地。预研这个方案的原因是我们本身已经有原生的代码封装,基于CameraX的Android实现,我只需要在Plugin上注册下视图即可,具体实现代码如下:



  1. 注册视图


override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
val key: String = "camera"

channel = MethodChannel(flutterPluginBinding.binaryMessenger, "camera_plugin")
channel.setMethodCallHandler(this)
CameraInfoManager.getCameraInfoList().forEach {
Log.d(TAG, "onAttachedToEngine: $it")
}

// 注册视图
flutterPluginBinding.platformViewRegistry.registerViewFactory(
key,
CameraFactory(flutterPluginBinding.binaryMessenger)
)
}


  1. 视图工厂


class CameraFactory(private val messenger: BinaryMessenger) :
PlatformViewFactory(StandardMessageCodec.INSTANCE) {

override fun create(context: Context?, id: Int, args: Any?): PlatformView {
return CameraPlatformView(context!!)
}

}


  1. 引入CameraX视图


class CameraPreView(context: Context, attrs: AttributeSet?) :
LinearLayout(context, attrs), LifecycleOwner {

private var camera: PreviewView

private val mLifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)

init {
val view: View = LayoutInflater.from(context).inflate(R.layout.layout_camera_preview, this)
camera = view.findViewById(R.id.camera_preview_view)
mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}

override fun onAttachedToWindow() {
super.onAttachedToWindow()
mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)

// 这里是封装好的CameraX预览视图
CameraXPreview
.bindLifecycle(this)
.setPreviewView(camera)
.setCameraId(0)
.startPreview(context)
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}

override fun getLifecycle(): Lifecycle = mLifecycleRegistry
}

问题显而易见,Flutter引擎启动白屏300ms,视图同步产生延时,内存平均新增20M+,而且视图生命周期没法同步,都是致命问题。

基于上面的实践,Windows上我们没有再做尝试了,Fail。


三、webRtc


上面两种方案都以失败告终后,大佬提到了webRtc,从基础协议出发,往往能解决核心问题。于是flutter_webrtc上场,WebRTC提供音视频的采集、编解码、网络传输、显示等功能,并且还支持跨平台:windows,linux,mac,android,已经被纳入被纳入W3C推荐标准。webRtc开发文档



  • 引用flutter_webrtc这个库,其渲染原理依旧是外接纹理,使用方法查看官方的example实例即可;

  • 重点在实现拍照功能,拍照无非就是进行帧捕获,Android已经实现:


  final videoTrack = localStream!
.getVideoTracks()
.firstWhere((track) => track.kind == 'video');
final frame = await videoTrack.captureFrame();

// 使用image.memory即可渲染
frameList = frame.asUint8List();


  • 而windows端很遗憾,还没有实现拍照功能,见issus;于是我想到了曲线救国,通过截取屏幕来保存图像由于是使用Texture渲染,通常的RenderRepaintBoundary+GlobalKey是没办法拿到RanderObject的!
    幸好插件提供了截取屏幕的方式,也算完成曲线救国了。


try {
var sources = await desktopCapturer.getSources(types: [SourceType.Window]);
DesktopCapturerSource capture =
sources.firstWhere((element) => element.name == 'my_camera');

// 使用image.memory即可渲染
frameList = capture.thumbnail;
return;
} catch (e) {
print(e.toString());
}

写在最后


到此,坎坷的外接相机预研之路告一段落。但是性能比起原生,真的差了一截,这让我们意识到,在官方不支持外接设备之前,针对此类需求,还是少用Flutter来实现。

Flutter桌面应用虽然发布了Stable版本,但说句实话生态确实比移动端差了不少,这意味着我们需要共同建设这个生态,但是趋势起来了,我们也愿意社区共建!

另外插个题外话,关于上面windows截取屏幕的需求,其实是有issus未关闭的,7月1号下午刚参与了issue的讨论,傍晚作者就拉了pull request,并且更了一版,解了燃眉之急啊!!!

怎么说呢,开源万岁,Respect!


image.png


image.png


作者:Karl_wei
链接:https://juejin.cn/post/7115674087682375717
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android组件化思路引文

前言本文并不是具体的实现文,主要讨论组件化的思路,分模块 和 模块通信。并实践一个简单的路由组件,完成模块间页面跳转和通信这两个功能,意图在于帮助刚接触组件化的同学提供一个简单不复杂的思路,对组件化有感性认识快速入门具体的实践文,可看这篇:Android 组件...
继续阅读 »

前言

本文并不是具体的实现文,主要讨论组件化的思路,分模块 和 模块通信。并实践一个简单的路由组件,完成模块间页面跳转和通信这两个功能,意图在于帮助刚接触组件化的同学提供一个简单不复杂的思路,对组件化有感性认识快速入门

具体的实践文,可看这篇:Android 组件化最佳实践 - 掘金 (juejin.cn)

什么是组件化

组件化本质上是一种组织代码的方式,只不过它的粒度更大,以module为单位。在未使用组件化之前,所有的代码都放在app模块中,在app模块内部通过分包划分业务代码和功能代码

如下图所示,

根据业务划分三个包:

  1. find 发现
  2. home 首页
  3. shop 商城

根据功能划分两个包:

  1. http 网络请求
  2. utils 工具类

Untitled.png 上述是未使用组件化情况,所有代码都在一个模块中编写,这样做并没有什么问题,但是当项目代码越来越多的 或者 有多人参数到项目中就有很大的问题了,比如:

  1. 代码都在写在一个模块中,不论怎么细致的分包,都免不了一个包下出现10多个类甚至更多的情况
  2. 分包的形式几乎对代码没有约束
  3. 开发人员多了,代码都写在一个模块中,每一位开发都拥有对文件读写的权利,容易出现代码覆盖冲突问题

总之组件化是为了应对代码多,人多 或者代码和人都多的情况而使用的一种组织代码的方式,一个模块中的代码分散到多个模块中。由于代码不在一个模块中,会出现到A模块无法引用到B模块中的类,引出通信问题。

所以组件化面对的主要问题主要有两个:

  1. 分模块
  2. 模块间通信

分模块

模块依据什么划分呢? 四个大字:单一职责 。老实说,写代码的时候 能够时刻牢记 单一职责,就能写出很不错的代码了。

拆分巨型单模块 与 拆分巨型单一类 的思想都是一致的。 其实它们出现的原因也一致,把不同职责的代码都放到一个类/模块中。所以拆分代码可以理解为代码归类

代码大致可以分为业务代码 和 功能代码,比如:

  1. 首页属于业务,网络请求属于功能
  2. 商城属于业务,数据库属于功能

所以当你的项目计划进行模块化的时候,只需要根据项目实际情况划分即可,没有什么硬性规定。

拆分代码有两个好处:

  1. 高复用性
    1. 体现功能模块上,比如:网络请求,轮播图,播放器,支付,分享等功能,任何一个业务都能可能会使用。实现为一个单独的模块,哪里使用哪里引入。
  2. 代码隔离
    1. 体现在业务模块,比如:A模块实现商城,B模块实现文章论坛,两者绝大部分代码没有任何关联,独立存在。
    2. 假设有一天项目不做文章论坛了,业务直接砍掉。那么删除B模块即可,A模块不受任何影响
    3. 但A,B模块都有可能有到分享功能,所以分享作为功能模块出现,不包含任何业务,只提供分享功能。

经过划分模块的代码结构如图

Untitled 1.png

三个业务模块:

  1. module_find 发现
  2. module_home 首页
  3. module_shop 商城

两个功能模块:

  1. library_network 网络请求
  2. library_utils 工具类

总之代码模块的拆分是单一职责的体现,大概可以分为业务模块和功能模块两种,功能模块的粒度更小可复用性更高,比如:轮播图,播放器任何位置都可能使用。

业务模块的粒度更大,可以引用多个功能模块解决问题,大多数代码都是依据业务逻辑编写,与功能模块相比 除非是同一个公司有相同业务否则复用性没那么高,

通信分析

上面主要讲了拆分模块的思路,现在聊聊模块间通信。特意设计模块间通信方案,主要是用于业务模块间通信。

业务模块和功能模块之间是单向通信,业务模块直接引用功能模块,调用功能模块暴露的方法即可。

但业务模块不同,业务模块之间存在互相通信的情况,核心情况有两种:

  1. 页面跳转
    1. A模块跳转B模块的页面,B模块跳转A模块的页面
  2. 数据通信
    1. A模块获取B模块的数据,比如调用B模块的网络请求。
    2. 可能会有点疑问,直接在A模块写要调用的接口不就好了,为什么要费劲巴拉的进行模块间通信,可以是可以。组件化就是为了隔离,解耦,复用。如果A模块直接实现了要用的网络请求,还要组件化干嘛呢,出现类似情况都这么干,项目内就会出现很多重复代码,除了图方便 没有别好处

单一模块开发时所有的类都能直接访问,上述的问题简直不是问题,从MainActivity 跳转到 TestActivity ,可以直接获取TestActivity的class对象完成跳转

val intent = Intent(this@MainActivity,TestActivity::class.java)
startActivity(intent)

但是分开多模块就是问题了,MainActivity 和 TestActivity 分别在A,B两个模块中,两个业务模块之间没有直接引用代码隔离,所以不能直接调用到想使用的类。

这种情况就需要一个中间人,帮助A,B模块通信。

(需求简单,实现简单)中间人好像邮局,两人住在同一个村甚至对门,想要唠嗑,送点东西,因为距离近走着就去了。如果两人相隔千里不能见面,想要唠嗑需要写信,标记地址交给邮局,让邮局转发。

(需求复杂,实现复杂)信件好保存一般不会损坏,运送比较方便。如果想要快点到,加钱用更快的运送工具。 如果想要送一块家乡的红烧肉,为了保鲜原汁原味,可能要加更多的钱用飞机+各种保险措施送过去

模块间通信也是类似,A,B模块通过中间人,也就是路由组件通信。页面跳转是最简单的通信需求实现简单,如果想要访问数据,获取对象应用等更复杂的需求,可能需要更加复杂的设计和其他技术手段才实现目标。

但总之A,B模块代码隔离之后不会无缘无故就实现了通信,一定会存在路由角色帮助A,B模块通信。区别在于路由是否强大,支持多少功能。

Untitled 2.png

粗糙的路由实现

页面跳转

实现路由组件最基本的功能页面跳转,讨论具体技术方案之前,先理清思路。

Android原生跳转页面只有一种办法 startActivity(intent(context,class)) ,调用startActivity方法有三要素

  1. context 提供的 startActivity方法
  2. 构造intent 需要 context
  3. 构造intent 需要 目标类的class对象

世面上所有的路由组件封装跳转页面功能,就算他封装出花来,也是基于AndroidSDK,无法脱离原生提供的方法。

所以我们现在需要想办法调用完整的startActivity(intent(context,class))

关键点在于,由于代码隔离,我们无法直接获取目标activity的class,直白点说无法 直接**“.”**出class。那么怎么可以在代码隔离的情况下拿到目标类的class呢

有个小技巧,先要说明一个事,模块A,模块B仅仅在编码的时候处于代码隔离的状态,但是打包之后它们还是一个应用,代码在一个虚拟机中。所以可以使用 Class.forName(包名+类名) 运行时获取class对象,完成跳转

val clazz = Class.forName("com.xxx.TestActivity")
val intent = Intent(this,clazz);
startActivity(intent)

这种方式可以帮助我们实现页面跳转的逻辑,但是非常粗糙,总不能需要模块间页面跳转,就硬编码包名+类名 获取class,太麻烦了,太容易出错了,代码散落在程序各处。

但是这种粗糙的方式也为我们提供了一点思想火花

如果我们能通过一种方式收集到 有模块间跳转需求的页面class对象 或者 包名+类名,在需要跳转的时候取出不就可以了么。

大概步骤:

  1. 创建路由组件
  2. 模块向路由注册页面信息
  3. 从路由取出页面信息实现跳转

创建路由组件,只有一个Route类

object Route {

private val routeMap = ArrayMap<String, Class<*>>()

fun register(path: String, clazz: Class<*>) {
routeMap[path] = clazz
}

fun navigation(context: Context, path: String) {
val clazz = routeMap[path]
val intent = Intent(context, clazz)
context.startActivity(intent)
}

}

其他组件在初始化时注册路由

Route.register("home/HomeActivity", HomeActivity::class.java)

模块间跳转页面

class TestActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_test)
val button: Button = findViewById(R.id.button)
button.setOnClickListener {
Route.navigation(this, "home/HomeActivity")
}
}
}

把握住核心思想快速实现简单的模块间页面跳转还是非常简单的,在来回顾一下

  1. 代码隔离后 实现页面跳转最关键的问题是,无法直接获取目标类的Class引用
  2. 项目只是在编码期隔离,打包之后仍然是在一个虚拟机内,可以通过 Class.forName(包名+类名) 获取引用
  3. key-value的形式存储 需要模块间跳转类的Class信息,在需要的时候取出

看没什么用的效果图

QQ图片20220603100940.gif

上述代码肯定是可用的,但是实际运行并不是仅仅引入一个路由组件就可以了,还有很多项目配置细节,可以参考 开头推荐的文章

模块间通信

接口下沉方案,在Route组件中定义通讯接口,使所有模块都可以引用,具体实现只在某个业务模块中,在初始化时注册实现类,运行时通过反射动态创建实现类对象。

添加模块通信后,Route组件有两种逻辑要处理,页面跳转和模块通信。 保存的Class可能是Activity 或 某个接口实现类,业务操作也不同。

为了区分两种不同的业务,对Route组件进行一点小改造,新增 RouteEntity 保存数据,RouteType 路由类型用于区分,如下:

object Route {

private val routeMap = ArrayMap<String, RouteEntity>()

/**
* 注册信息
*/
fun register(route: RouteEntity) {
routeMap[route.path] = route
}

/**
* 页面导航
*/
fun navigation(context: Context, path: String) {
val routeEntity = routeMap[path] ?: throw RuntimeException("path错误 找不到类")
val intent = Intent(context, routeEntity.clazz)
context.startActivity(intent)
}

/**
* 获取通信实例
*/
fun getService(path: String): Any {
val routeEntity = routeMap[path] ?: throw RuntimeException("path错误 找不到类")
return routeEntity.clazz.newInstance()
}

}

/**
* 保存路由信息
* @param path 路径 用于查找class
* @param type 类型 区分 页面跳转 和 通信
* @param clazz 类信息
*/
data class RouteEntity(val path: String,@RouteType val type:Int,val clazz: Class<*>)

/**
* 路由类型
*/
@IntDef(RouteType.ACTIVITY, RouteType.SERVICE)
annotation class RouteType() {
companion object {
const val ACTIVITY = 0
const val SERVICE = 1
}
}

使用如下:

//在 Route组件中 定义接口

interface IShopService {
fun getPrice(): Int
}

//业务模块中实现接口
class ShopServiceImpl :IShopService {
override fun getPrice(): Int {
return 12
}
}

//模块初始化时注册
override fun create(context: Context) {
Route.register(RouteEntity("shop/ShopActivity",RouteType.ACTIVITY,ShopActivity::class.java))
Route.register(RouteEntity("shop/ShopService",RouteType.SERVICE,ShopServiceImpl::class.java))
}

//其他模块中使用

class HomeActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.home_activity_home)
val btnGoShop = findViewById<Button>(R.id.btn_go_shop)
val btnGetPrice = findViewById<Button>(R.id.btn_get_price)
btnGoShop.setOnClickListener {
//跳转页面
Route.navigation(this, "shop/ShopActivity")
}
btnGetPrice.setOnClickListener {
//模块通信
val shopService: IShopService = Route.getService("shop/ShopService") as IShopService
Toast.makeText(this, "价格:${shopService.getPrice()}", Toast.LENGTH_SHORT).show()
}
}
}

几点路由优化思路

  1. 路由信息 每次都要手动注册 很麻烦
    1. 利用编译时注解结合APT技术优化
    2. 自定义注解,跳转的页面和通信类添加注解
    3. 定义注解处理器,在编译时读取注解
    4. 根据注解携带的信息 处理业务逻辑 生成java类 完成组件注册功能
  2. 路由组件 所有路由信息在初始化的时候一次性加载到内存中,需要优化
    1. 分组保存,懒加载信息
    2. 根据路径 把路由信息分组保存,
      1. RootManager 保存 内部持有map 保存所有group 信息
      2. Group 内部持有 List 保存所有 节点信息
    3. 当用到某一group时,
      1. 通过反射实例化Group 加载当前Group下的节点信息到内存中
  3. 每次获取对象时,都是通过反射创建新对象,消耗内存
    1. 新增缓存机制,只在第一次创建新对象
    2. 可以使用 LruCache 缓存

上述路由组件的例子是非常简单的,难点在于从零开始,没有任何借鉴的情况下搞出这个”简单的”路由组件,反正我是没有这个创造能力 哈哈。

如果想要搞一个成熟完美的路由组件还是非常难的,但是最初肯定都是从基础功能开始一点一点迭代。除非是大佬,不然不推荐自定义路由组件


作者:图个喜庆
链接:https://juejin.cn/post/7105576036720443405
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

FlutterWeb浏览器刷新后无法回退的解决方案

一、问题 在Flutter开发的网页运行时,浏览器刷新网页后,虽然会显示刷新前的页面(前提是用静态路由跳转),但这时调用Navigator.pop方法是回不到上一页的,包括点击浏览器的回退按钮也是无效的(地址栏中的url会变,页面不会变)。 二、原因 当浏览器...
继续阅读 »

一、问题


在Flutter开发的网页运行时,浏览器刷新网页后,虽然会显示刷新前的页面(前提是用静态路由跳转),但这时调用Navigator.pop方法是回不到上一页的,包括点击浏览器的回退按钮也是无效的(地址栏中的url会变,页面不会变)。


二、原因


当浏览器刷新时,Flutter引擎会重新启动,并加载当前页面,也就是说,刷新后的Flutter内存中所有静态变量都被初始化,页面栈内之前的页面记录都未保留,只有当前的页面。就像是浏览网页时,把其中一页的网址拷出来,在新的标签页再次打开。


三、解决方案


1. 思路


知道什么原因引起的,就针对性解决。页面栈记录丢失,那么就代码中自己维护一套备用栈,监听页面路由,每次进入新页面时,记录当前页面的URL,当退出时,删除记录的URL,在浏览器刷新栈记录失效时,帮助回退到上一页。


2.方案优缺点


优点: 可实现回退效果无异常,调用Navigator.pop方法或点击浏览器回退按钮都支持;


缺点: Navigator.pushName().then的回调无法生效,因为是重新生成的上一页,所以并不会调用回调;回退后的页面中的临时数据都会消失,比如输入框内的内容,成员变量等;跳转必须用静态路由的方式,并且传参要用Uri包裹,不能用构造函数传参。


四、实现


1. Web本地存储工具—localStorage


localStorage是在html包下window中的一个存储对象,以keyvalue的形式进行存储


// 导包
import 'dart:html' as html;

// 使用方式
html.window.localStorage["key"] = "value"

对存储工具的封装这里就不写到文章里了,根据实现业务情况去封装,方便调用就行。


2. 栈记录工具类RouterHistory


这是一个栈记录工具,主要作用是注册监听,添加删除记录等。


/// DB()为封装好的本地数据库
class RouterHistory {
/// 监听浏览器刷新前的回调
static Function(html.Event event)? _beforeUnload;

/// 监听浏览器回退时的回调
static Function(html.Event event)? _popState;

/// 目前页面是否被刷新过
static bool isRefresh = false;

/// 初始化与注册监听
static void register() {
// 刷新时回调
_beforeUnload = (event) {
// 本地记录,标记成"已刷新"
DB(DBKey.isRefresh).value = true;
// 移除刷新前的实例的监听
html.window.removeEventListener('beforeunload', _beforeUnload);
html.window.removeEventListener('popstate', _popState);
};
// 浏览器回退按钮回调
_popState = (event) {
// 页面被刷新,触发备用回调
if (isRefresh) {
_back(R.currentContext); //R.currentContext 为当前页面的Context
}
};
// 添加监听
html.window.addEventListener('beforeunload', _beforeUnload);
html.window.addEventListener('popstate', _popState);

// 从本地数据库中取出"刷新"标记
isRefresh = DB(DBKey.isRefresh).get(false);

// 如果未被刷新,清除上次备用栈中的历史记录
if (!isRefresh) {
clean();
}

// 还原本地库中的刷新标记
DB(DBKey.isRefresh).value = false;
}


static bool checkBack(currentContext) {
// 是否能正常 pop
if (Navigator.canPop(currentContext)) {
return true;
}

// 不能则启用备用栈
_back(currentContext);
return false;
}

// 返回
static void _back(currentContext) {
List history = get();
if (history.length > 1) {
history.removeLast();
set(history);
//跳转至上一页并关闭当前页
Navigator.of(currentContext).popAndPushNamed(history.last);
}
}

// 添加记录
static add(String? path) {
if (path ` null) return;
List history = get();
if (history.contains(path)) return;
history.add(path);
set(history);
}

// 删除记录
static remove(String? path) {
if (path ` null) return;
List history = get();
history.remove(path);
set(history);
}

// 设置备用栈数据
static String set(List<dynamic> history) => DB(DBKey.history).value = json.encode(history);

// 取出备用栈数据
static get() => json.decode(DB(DBKey.history).get('[]'));

// 清除备用栈
static clean() => DB(DBKey.history).value = '[]';
}

3. 监听Flutter路由


自定义类并实现NavigatorObserver,并将实现类放在MaterialApp中的navigatorObservers参数中。


// 实现类
class HistoryObs extends NavigatorObserver {
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
// 存路由信息
RouterHistory.add(route.settings.name);
}

@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
// 删路由信息
RouterHistory.remove(route.settings.name);
}
}


// 设置监听
MaterialApp(
......
navigatorObservers: [ HistoryObs() ],
......
)

4. 路由方法封装


跳转方法必须为静态路由,以保证参数和路径都能在url中,才可实现回退效果


 /// 替换 Navigator.pop ,
static pop() {
// 检测是否能正常返回,不能则返回FALSE
if (RouterHistory.checkBack(currentContext)) {
Navigator.pop(currentContext);
}
}

/// 静态路由跳转
static Future toName(String pageName, {Map<String, dynamic>? params}) {
// 封装路径以及参数
var uri = Uri(scheme: RoutePath.scheme, host: pageName, queryParameters: params ?? {});
return Navigator.of(currentContext).pushNamed(uri.toString());
}


5. 初始化位置


放在MaterialApp外层的build中,或initState中即可。


  @override
void initState() {
super.initState();
RouterHistory.register();
}

@override
Widget build(BuildContext context) {
// 或 RouterHistory.register();
return MaterialApp(
navigatorObservers: [MiddleWare()],
);
}


以上就是该方案的关键代码


五、最后


该方案只是能解决问题,但不是最好的解决方案。有更好的解决方案欢迎留言~


Flutter官方的Navigator 2.0 虽然能实现回退,本质上也是跳转了新页面,并造成栈内记录混乱,不能像真正的web一样,感兴趣的同学可以自行了解下Navigator 2.0



Navigator2.0在浏览器回退按钮的处理上又与Navigator1.0不同,点击回退按钮时Navigator2.0并不是执行pop操作,而是执行setNewRoutePath操作,本质上应该是从浏览器的history中获取上一个页面的url,然后重新加载。这样确实解决了刷新后回退的问题,因为刷新后浏览器的history并未丢失,但是也导致了文章中我们提到的flutter中的页面栈混乱的问题。


作者:苏啵曼
链接:https://juejin.cn/post/7114848130004156446
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android - setVisibility() 失效,竟然是因为内存泄露

一、前情概要 目前,我在开发的一个 Android 项目需要各个功能做到线上动态化,其中,App 启动时显示的 Loading 模块,会优先检测加载远程的 Loading 模块,加载失败时,会使用 App 本身默认的 Loading 视图,为此,我编写了一个 ...
继续阅读 »

一、前情概要


目前,我在开发的一个 Android 项目需要各个功能做到线上动态化,其中,App 启动时显示的 Loading 模块,会优先检测加载远程的 Loading 模块,加载失败时,会使用 App 本身默认的 Loading 视图,为此,我编写了一个 LoadingLoader 工具类:


/**
* Loading 加载器
*
* @author GitLqr
* @since 2022/7/2
*/
object LoadingLoader {

private var isInited = false // 防止多次初始化
private lateinit var onLoadFail: () -> Unit // 远程loading加载失败时的回调
private lateinit var onLoadComplete: () -> Unit // 加载完成后回调(无论成功失败)

fun init(onLoadFail: () -> Unit = {}, onLoadComplete: () -> Unit = {}): LoadingLoader {
if (!isInited) {
this.onLoadFail = onLoadFail
this.onLoadComplete = onLoadComplete
isInited = true
} else {
log("you have inited, this time is not valid")
}
return this
}

fun go() {
if (isInited) {
loadRemoteLoading(callback = { isSuccess ->
if (!isSuccess) onLoadFail()
onLoadComplete()
})
} else {
log("you must invoke init() firstly")
}
}

private fun loadRemoteLoading(callback: (boolean: Boolean) -> Unit) {
// 模拟远程 Loading 模块加载失败
Handler(Looper.getMainLooper()).postDelayed({
callback(false)
}, 1000)
}

private fun log(msg: String) {
Log.e("LoadingUpdater", msg)
}
}

LoadingLoader 工具类使用 Kotlin 的单例模式,init() 方法接收 2 个回调参数,go() 方法触发加载远程 Loading 模块,并根据加载结果执行回调,其中 isInited 用于防止该工具类被初始化多次。然后,在 App 的主入口 LoadingActivity 中使用 LoadingLoader,当加载远程 Loading 模块失败时,将原本隐藏的默认 Loading 视图显示出来;当加载 Loading 模块完成后(无论成功失败),模拟初始化数据并跳转主界面,关闭 LoadingActivity:


/**
* App 启动时的 Loading 界面
*
* @author GitLqr
* @since 2022/7/2
*/
class LoadingActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_loading)
// Loading 模块加载器
LoadingLoader.init(onLoadFail, onLoadComplete).go()
}

override fun onDestroy() {
super.onDestroy()
Log.e("GitLqr", "onDestroy")
}

private val onLoadFail: () -> Unit = {
// 显示默认 loading 界面
findViewById<View>(R.id.cl_def_loading).setVisibility(View.VISIBLE)
}

private val onLoadComplete: () -> Unit = {
// 模拟初始化数据,1秒后跳转主界面
Handler(Looper.getMainLooper()).postDelayed({
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
// 注意:此处意图使用的 flag,会将 LoadingActivity 界面关闭,触发 onDestroy()
startActivity(intent)
}, 1000)
}
}

LoadingActivity 的 xml 布局代码如下,默认的 Loading 布局初始状态不可见,即 visibility="gone"


<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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:background="@color/black">

<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_def_loading"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f00"
android:visibility="gone"
tools:visibility="visible">

<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="很好看的默认loading界面"
android:textColor="@color/white"
android:textSize="60dp" />

<ProgressBar
android:id="@+id/pb_loading"
android:layout_width="0dp"
android:layout_height="0dp"
android:indeterminateDrawable="@drawable/anim_loading"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.75"
app:layout_constraintWidth_percent="0.064" />

</androidx.constraintlayout.widget.ConstraintLayout>

</FrameLayout>

以上代码比较简单,现在来看下演示效果:



这里会发现一个问题,因为是以清空栈的方式启动 MainActivity,所以第二次启动时,理论上应该会跟第一次启动时界面显示效果完全一致,即每次启动都会显示默认的 Loading 视图,但是实际情况并没有,而控制台的日志也证实了 LoadingActivity 的 onDestroy() 有被触发:



二、摸索过程


1、代码执行了吗?


难道第二次启动 App 时,LoadingActivity.onLoadFail 没有触发吗?加上日志验证一下:


class LoadingActivity : AppCompatActivity() {
...
private val onLoadFail: () -> Unit = {
// 显示默认 loading 界面
val defLoading = findViewById<View>(R.id.cl_def_loading)
defLoading.setVisibility(View.VISIBLE)
Log.e("GitLqr", "defLoading.setVisibility --> ${defLoading.visibility}")
}
}

重新打包再执行一遍上面的演示操作,日志输出如下:



说明 2 次启动都是有触发 LoadingActivity.onLoadFail 的,并且结果都是 0 ,即 View.VISIBLE。



此时有点怀疑人生,于是网上找了一圈 setVisibility() 失效 的原因,基本上都是同一个内容(都特么抄来抄去的),说是做动画导致的,可是我这里并没有做动画,所以与网上说的情况不相符。



2、视图不显示的直接原因是什么?


既然,代码有输出日志,那说明 setVisibility(View.VISIBLE) 这行代码肯定执行过了,而界面上不显示,直接原因是什么?是因为默认 Loading 视图的 visibility 依旧为 View.GONE?又或者是因为其他因素导致 View 的尺寸出现了问题?这时,可以使用 AndroidStudio 的 Layout Inspector 工具,可以直观的分析界面的布局情况,为了方便 Layout Inspector 工具获取 LoadingActivity 的布局信息,需要将 LoadingActivity.onLoadComplete 中跳转主界面的代码注释掉,其他保持不变:


class LoadingActivity : AppCompatActivity() {
...
private val onLoadComplete: () -> Unit = {
// 模拟初始化数据,1秒后跳转主界面
Handler(Looper.getMainLooper()).postDelayed({
// val intent = Intent(this, MainActivity::class.java)
// intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
// // 注意:此处意图的 flag,会将 LoadingActivity 界面关闭,触发 onDestroy()
// startActivity(intent)
}, 1000)
}
}

然后重复上述演示操作,第一次启动,显示出默认 Loading,手动按返回键退出 App,再第二次启动,不显示默认 Loading:



控制台日志信息也如期输出,第二次启动确实执行了 setVisibility(View.VISIBLE)



这时,使用 Layout Inspector(菜单栏 -> Tools -> Layout Inspector),获取到 LoadingActivity 的布局信息:



这里可以断定,就是默认 Loading 视图的 visibility 依旧为 View.GONE 的情况。



注:因为 View.GONE 不占据屏幕空间,所以宽高都为 0,是正常的。



3、操作的视图是同一个吗?


现在回顾一下上述的 2 个线索,首先,代码中确定执行了 setVisibility(View.VISIBLE),并且日志里也显示了该视图的显示状态为 0,即 View.VISIBLE:



其次,使用 Layout Inspector 看到的的视图状态却为 View.GONE:



所以,真相只有一个,日志输出的视图 和 Layout Inspector 看到的的视图,肯定不是同一个!!为了验证这一点,代码再做如下调整,分别在 onCreate() 和 onLoadFail 中打印默认 Loading 视图信息:


class LoadingActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_loading)

val defLoading = findViewById<View>(R.id.cl_def_loading)
Log.e("GitLqr", "onCreate ---> view is ${defLoading}")

// Loading 模块加载器
LoadingLoader.init(onLoadFail, onLoadComplete).go()
}

private val onLoadFail: () -> Unit = {
// 显示默认 loading 界面
val defLoading = findViewById<View>(R.id.cl_def_loading)
defLoading.setVisibility(View.VISIBLE)
Log.e("GitLqr", "defLoading.setVisibility --> ${defLoading.visibility}, view is ${defLoading}")
}
}

再如上述演示操作一遍,日志输出如下:



可以看到第二次启动时,LoadingActivity.onLoadFail 中操作的视图,还是第一次启动时的那个视图,该视图是通过 findViewById 获取到的,说明 LoadingActivity.onLoadFail 中引用的 Activity 是第一次启动时的 LoadingActivity,也就是说 LoadingActivity 发生内存泄露了。此时才焕然大悟,Kotlin 中的 Lambda 表达式(像 onLoadFail、onLoadComplete 这种),对应到 Java 中就是匿名内部类,通过 Kotlin Bytecode 再反编译成 java 代码可以验证这点:


public final class LoadingActivity extends AppCompatActivity {
private final Function0 onLoadFail = (Function0)(new Function0() {
// $FF: synthetic method
// $FF: bridge method
public Object invoke() {
this.invoke();
return Unit.INSTANCE;
}

public final void invoke() {
View defLoading = LoadingActivity.this.findViewById(1000000);
defLoading.setVisibility(0);
StringBuilder var10001 = (new StringBuilder()).append("defLoading.setVisibility --> ");
Intrinsics.checkExpressionValueIsNotNull(defLoading, "defLoading");
Log.e("GitLqr", var10001.append(defLoading.getVisibility()).append(", view is ").append(defLoading).toString());
}
});
private final Function0 onLoadComplete;

protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.setContentView(1300004);
View defLoading = this.findViewById(1000000);
Log.e("GitLqr", "onCreate ---> view is " + defLoading);
LoadingLoader.INSTANCE.init(this.onLoadFail, this.onLoadComplete).go();
}

protected void onDestroy() {
super.onDestroy();
Log.e("GitLqr", "onDestroy");
}

public LoadingActivity() {
this.onLoadComplete = (Function0)null.INSTANCE;
}
}

我们知道,Java 中,匿名内部类会持有外部类的引用,即匿名内部类实例 onLoadFail 持有 LoadingActivity 实例,而 onLoadFail 又会通过 LoadingLoader.init() 方法传递给 LoadingLoader 这个单例对象,所以间接导致 LoadingLoader 持有了 LoadingActivity,因为单例生命周期与整个 App 进程相同,所以只要 App 进程不死,内存中就只有一分 LoadingLoader 实例,又因为是强引用,所以 GC 无法回收掉第一次初始化时传递给 LoadingLoader 的 LoadingActivity 实例,所以,无论重启多少次,onLoadFail 中永远都是拿着第一次启动时的 LoadingActivity 来执行 findViewById,拿到的 Loading 视图自然也不会是当前最新 LoadingActivity 的 Loading 视图。


三、解决方案


既然知道是因为 LoadingActivity 内存泄露导致的,那么解决方案也简单,就是在 LoadingLoader 完成它的使命之后,及时释放掉对 LoadingActivity 的引用即可,又因为 LoadingActivity 实际上并不是被 LoadingLoader 直接引用,而是被其内部变量 onLoadFail 直接引用的,那么在 LoadingLoader 中只需要将 onLoadFail 的引用切断就行了:


object LoadingLoader {
private var isInited = false // 防止多次初始化
private lateinit var onLoadFail: () -> Unit // 远程loading加载失败时的回调
private lateinit var onLoadComplete: () -> Unit // 加载完成后回调

fun go() {
if (isInited) {
loadRemoteLoading(callback = { isSuccess ->
if (!isSuccess) onLoadFail()
onLoadComplete()
destroy() // 使命完成,释放资源
})
} else {
log("you must invoke init() firstly")
}
}

fun destroy() {
this.onLoadFail = {}
this.onLoadComplete = {}
this.isInited = false
}
}

至此,因内存泄露导致 setVisibility() 失效的问题就解决掉了,要坚信,在代码的世界里,没有魔法~


作者:GitLqr
链接:https://juejin.cn/post/7115781013170552840
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

一文读懂Kotlin的数据流

一、Android分层架构 不管是早期的MVC、MVP,还是最新的MVVM和MVI架构,这些框架一直解决的都是一个数据流的问题。一个良好的数据流框架,每一层的职责是单一的。例如,我们可以在表现层(Presentation Layer)的基础上添加一个领域层(D...
继续阅读 »

一、Android分层架构


不管是早期的MVC、MVP,还是最新的MVVM和MVI架构,这些框架一直解决的都是一个数据流的问题。一个良好的数据流框架,每一层的职责是单一的。例如,我们可以在表现层(Presentation Layer)的基础上添加一个领域层(Domain Layer) 来保存业务逻辑,使用数据层(Data Layer)对上层屏蔽数据来源(数据可能来自远程服务,可能是本地数据库)。


在Android中,一个典型的Android分层架构图如下:


image.png


其中,我们需要重点看下Presenter 和 ViewModel, Presenter 和 ViewModel向 View 提供数据的机制是不同的。



  • Presenter: Presenter通过持有 View 的引用并直接调用操作 View,以此向 View 提供和更新数据。

  • ViewModel:ViewModel 通过将可观察的数据暴露给观察者来向 View 提供和更新数据。


目前,官方提供的可观察的数据组件有LiveData、StateFlow和SharedFlow。可能大家对LiveData比较熟悉,配合ViewModel可以很方便的实现数据流的流转。不过,LiveData也有很多常见的缺陷,并且使用场景也比较固定,如果网上出现了KotlinFlow 替代 LiveData的声音。那么 Flow 真的会替代 LiveData吗?Flow 真的适合你的项目吗?看完下面的分析后,你定会有所收获。


二、ViewModel + LiveData


ViewModel的作用是将视图和逻辑进行分离,Activity或者Fragment只负责UI显示部分,网络请求或者数据库操作则有ViewModel负责。ViewModel旨在以注重生命周期的方式存储和管理界面相关的数据,让数据可在发生屏幕旋转等配置更改后继续留存。并且ViewModel不持有View层的实例,通过LiveData与Activity或者Fragment通讯,不需要担心潜在的内存泄漏问题。


而LiveData 则是一种可观察的数据存储器类,与常规的可观察类不同,LiveData 具有生命周期感知能力,它遵循其他应用组件(如 Activity、Fragment 或 Service)的生命周期。这种感知能力可确保LiveData当数据源发生变化的时候,通知它的观察者更新UI界面。同时它只会通知处于Active状态的观察者更新界面,如果某个观察者的状态处于Paused或Destroyed时那么它将不会收到通知,所以不用担心内存泄漏问题。


下面是官方发布的架构组件库的生命周期的说明:


image.png


2.1 LiveData 特性


通过前面的介绍可以知道,LiveData 是 Android Jetpack Lifecycle 组件中的内容,具有生命周期感知能力。一句话概括就是:LiveData 是可感知生命周期的,可观察的,数据持有者。特点如下:



  • 观察者的回调永远发生在主线程

  • 仅持有单个且最新的数据

  • 自动取消订阅

  • 提供「可读可写」和「仅可读」两个版本收缩权限

  • 配合 DataBinding 实现「双向绑定」


观察者的回调永远发生在主线程


因为LiveData 是被用来更新 UI的,因此 Observer 接口的 onChanged() 方法必须在主线程回调。


public interface Observer<T> {
void onChanged(T t);
}

背后的道理也很简单,LiveData 的 setValue() 发生在主线程(非主线程调用会抛异常),而如果调用postValue()方法,则它的内部会切换到主线程调用 setValue()。


protected void postValue(T value) {
boolean postTask;
synchronized (mDataLock) {
postTask = mPendingData == NOT_SET;
mPendingData = value;
}
if (!postTask) {
return;
}
ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable);
}

可以看到,postValue()方法的内部调用了postToMainThread()实现线程的切换,之后遍历所有观察者的 onChanged() 方法。


仅持有单个且最新数据


作为数据持有者,LiveData仅持有【单个且最新】的数据。单个且最新,意味着 LiveData 每次只能持有一个数据,如果有新数据则会覆盖上一个。并且,由于LiveData具备生命周期感知能力,所以观察者只会在活跃状态下(STARTED 到 RESUMED)才会接收到 LiveData 最新的数据,在非活跃状态下则不会收到。


自动取消订阅


可感知生命周期的重要优势就是可以自动取消订阅,这意味着开发者无需手动编写那些取消订阅的模板代码,降低了内存泄漏的可能性。背后的实现逻辑是在生命周期处于 DESTROYED 时,移除观察者。


@Override
public void onStateChanged(@NonNull LifecycleOwner source,
@NonNull Lifecycle.Event event) {
Lifecycle.State currentState = mOwner.getLifecycle().getCurrentState();
if (currentState == DESTROYED) {
removeObserver(mObserver);
return;
}
... //省略其他代码
}

提供「可读可写」和「仅可读」两种方式


LiveData 提供了setValue() 和 postValue()两种方式来操作实体数据,而为了细化权限,LiveData又提供了mutable(MutableLiveData) 和 immutable(LiveData) 两个类,前者「可读可写」,后者则「仅可读」。


image.png


配合 DataBinding 实现「双向绑定」


LiveData 配合 DataBinding 可以实现更新数据自动驱动UI变化,如果使用「双向绑定」还能实现 UI 变化影响数据的变化功能。


2.2 LiveData的缺陷


正如前面说的,LiveData有自己的使用场景,只有满足使用场景才会最大限度的发挥它的功能,而下面这些则是在设计时将自带的一些缺陷:



  • value 可以是 nullable 的

  • 在 fragment 订阅时需要传入正确的 lifecycleOwner

  • 当 LiveData 持有的数据是「事件」时,可能会遇到「粘性事件」

  • LiveData 是不防抖的

  • LiveData 的 transformation 需要工作在主线程


value 可以是 nullable 的


由于LiveData的getValue() 是可空的,所以在使用时应该注意判空,否则容易出现空指针的报错。


@Nullable
public T getValue() {
Object data = mData;
if (data != NOT_SET) {
return (T) data;
}
return null;
}

传入正确的 lifecycleOwner


Fragment 调用 LiveData的observe() 方法时传入 this 和 viewLifecycleOwner 的含义是不一样的。因为Fragment与Fragment中的View的生命周期并不一致,有时候我们需要的让observer感知Fragment中的View的生命周期而非Fragment。


粘性事件


粘性事件的定义是,发射的事件如果早于注册,那么注册之后依然可以接收到的事件,这一现象称为粘性事件。解决办法是:将事件作为状态的一部分,在事件被消费后,不再通知观察者。推荐两种解决方式:



  • KunMinX/UnPeek-LiveData

  • 使用kotlin 扩展函数和 typealias 封装解决「粘性」事件的 LiveData


默认不防抖


当setValue()/postValue() 传入相同的值且多次调用时,观察者的 onChanged() 也会被多次调用。不过,严格来讲,这也不算一个问题,我们只需要在调用 setValue()/postValue() 前判断一下 vlaue 与之前是否相同即可。


transformation 工作在主线程


有些时候,我们需要对从Repository 层得到的数据进行处理。例如,从数据库获得 User列表,我们需要根据 id 获取某个 User, 那么就需要用到MediatorLiveData 和 Transformatoins 来实现。



  • Transformations.map

  • Transformations.switchMap


并且,map 和 switchMap 内部均是使用 MediatorLiveData的addSource() 方法实现的,而该方法会在主线程调用,使用不当会有性能问题。


@MainThread
public <S> void addSource(@NonNull LiveData<S> source, @NonNull Observer<? super S> onChanged) {
Source<S> e = new Source<>(source, onChanged);
Source<?> existing = mSources.putIfAbsent(source, e);
if (existing != null && existing.mObserver != onChanged) {
throw new IllegalArgumentException(
"This source was already added with the different observer");
}
if (existing != null) {
return;
}
if (hasActiveObservers()) {
e.plug();
}
}

2.3 LiveData 小结


LiveData 是一种可观察的数据存储器类,与常规的可观察类不同,LiveData 具有生命周期感知能力,它遵循其他应用组件(如 Activity、Fragment 或 Service)的生命周期。这种感知能力可确保LiveData当数据源发生变化的时候,通知它的观察者更新UI界面。同时它只会通知处于Active状态的观察者更新界面,如果某个观察者的状态处于Paused或Destroyed时那么它将不会收到通知,所以不用担心内存泄漏问题。


同时,LiveData 专注单一功能,因此它的一些方法使用上是有局限性的,并且需要配合 ViewModel 使用才能显示其价值。


三、Flow


3.1 简介


Flow是Google官方提供的一套基于kotlin协程的响应式编程模型,它与RxJava的使用类似,但相比之下Flow使用起来更简单,另外Flow作用在协程内,可以与协程的生命周期绑定,当协程取消时,Flow也会被取消,避免了内存泄漏风险。


协程是轻量级的线程,本质上协程、线程都是服务于并发场景下,其中协程是协作式任务,线程是抢占式任务。默认协程用来处理实时性不高的数据,请求到结果后整个协程就结束了。比如,有下面一个例子:


image.png


其中,红框中需要展示的内容实时性不高,而需要交互的,比如转发和点赞属于实时性很高的数据需要定时刷新。对于实时性不高的场景,直接使用 Kotlin 的协程处理即可,比如。


suspend fun loadData(): Data

uiScope.launch {
val data = loadData()
updateUI(data)
}

而对于实时性要求较高的场景,上面的方式就不起作用了,此时需要用到Kotlin提供的Flow数据流。


fun dataStream(): Flow<Data>uiScope.launch { 
dataStream().collect { data ->
updateUI(data)
}
}

3.2 基本概念


Kotlin的数据流主要由三个成员组成,分别是生产者、消费者和中介。
生产者:生成添加到数据流中的数据,可以配合得协程使用,使用异步方式生成数据。
中介(可选):可以修改发送到数据流的值,或修正数据流本身。
消费者:使用方则使用数据流中的值。


其中,中介可以对数据流中的数据进行更改,甚至可以更改数据流本身,他们的架构示意图如下。


image.png


在Kotlin中,Flow 是一种冷流,不过有一种特殊的Flow( StateFlow/SharedFlow) 是热流。什么是冷流,他和热流又有什么关系呢?


冷流:只有订阅者订阅时,才开始执行发射数据流的代码。并且冷流和订阅者只能是一对一的关系,当有多个不同的订阅者时,消息是重新完整发送的。也就是说对冷流而言,有多个订阅者的时候,他们各自的事件是独立的。
热流:无论有没有订阅者订阅,事件始终都会发生。当 热流有多个订阅者时,热流与订阅者们的关系是一对多的关系,可以与多个订阅者共享信息。


3.3 StateFlow


前面说过,冷流和订阅者只能是一对一的关系,当我们要实现一个流多个订阅者的场景时,就需要使用热流了。


StateFlow 是一个状态容器式可观察数据流,可以向其收集器发出当前状态更新和新状态更新。可以通过其 value 属性读取当前状态值,如需更新状态并将其发送到数据流,那么就需要使用MutableStateFlow。


3.3.1 基本使用


在Android 中,StateFlow 非常适合需要让可变状态保持可观察的类。由于StateFlow并不是系统API,所以使用前需要添加依赖:


dependencies {
... //省略其他

implementation "androidx.activity:activity-ktx:1.3.1"
implementation "androidx.fragment:fragment-ktx:1.4.1"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
}

接着,我们需要创建一个ViewModel,比如:


class StateFlowViewModel: ViewModel() {
val data = MutableStateFlow<Int>(0)
fun add(v: View) {
data.value++
}
fun del(v: View) {
data.value--
}
}

可以看到,我们使用MutableStateFlow包裹需要操作的数据,并添加了add()和del()两个方法。然后,我们再编写一段测试代码实现数据的修改,并自动刷新数据。


class StateFlowActivity : AppCompatActivity() {
private val viewModel by viewModels<StateFlowViewModel>()
private val mBinding : ActivityStateFlowBinding by lazy {
ActivityStateFlowBinding.inflate(layoutInflater)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(mBinding.root)
initFlow()
}
private fun initFlow() {
mBinding.apply {
btnAdd.setOnClickListener {
viewModel.add(it)
}
btnDel.setOnClickListener {
viewModel.del(it)
}
}
}

}

上面代码中涉及到的布局代码如下:


<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="stateFlowViewModel"
type="com.xzh.demo.flow.StateFlowViewModel" />
</data>

<FrameLayout
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:layout_marginLeft="200dp"
android:layout_marginTop="30dp"
android:text="@{String.valueOf(stateFlowViewModel.data)}"
android:textSize="24sp" />

<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btn_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="10dp"
android:layout_marginBottom="10dp"
android:contentDescription="start"
android:src="@android:drawable/ic_input_add" />

<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btn_del"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="10dp"
android:layout_marginBottom="10dp"
android:contentDescription="cancel"
android:src="@android:drawable/ic_menu_close_clear_cancel" />
</FrameLayout>
</layout>

上面代码中,我们使用了DataBing写法,因此不需要再手动的绑定数据和刷新数据。


3.4 SharedFlow


3.4.1 SharedFlow基本概念


SharedFlow提供了SharedFlow 与 MutableSharedFlow两个版本,平时使用较多的是MutableSharedFlow。它们的区别是,SharedFlow可以保留历史数据,MutableSharedFlow 没有起始值,发送数据时需要调用 emit()/tryEmit() 方法。


首先,我们来看看SharedFlow的构造函数:


public fun <T> MutableSharedFlow(
replay: Int = 0,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): MutableSharedFlow<T>

可以看到,MutableSharedFlow需要三个参数:



  • replay:表示当新的订阅者Collect时,发送几个已经发送过的数据给它,默认为0,即默认新订阅者不会获取以前的数据

  • extraBufferCapacity:表示减去replay,MutableSharedFlow还缓存多少数据,默认为0

  • onBufferOverflow:表示缓存策略,即缓冲区满了之后Flow如何处理,默认为挂起。除此之外,还支持DROP_OLDEST 和DROP_LATEST 。


 //ViewModel
val sharedFlow=MutableSharedFlow<String>()
viewModelScope.launch{
sharedFlow.emit("Hello")
sharedFlow.emit("SharedFlow")
}

//Activity
lifecycleScope.launch{
viewMode.sharedFlow.collect {
print(it)
}
}

3.4.2 基本使用


SharedFlow并不是系统API,所以使用前需要添加依赖:


dependencies {
... //省略其他

implementation "androidx.activity:activity-ktx:1.3.1"
implementation "androidx.fragment:fragment-ktx:1.4.1"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
}

接下来,我们创建一个SharedFlow,由于需要一对多的进行通知,所以我们MutableSharedFlow,然后重写postEvent()方法,代码如下:


object LocalEventBus  {
private val events= MutableSharedFlow< Event>()
suspend fun postEvent(event: Event){
events.emit(event)
}
}
data class Event(val timestamp:Long)

接下来,我们再创建一个ViewModel,里面添加startRefresh()和cancelRefresh()两个方法,如下。


class SharedViewModel: ViewModel() {
private lateinit var job: Job

fun startRefresh(){
job=viewModelScope.launch (Dispatchers.IO){
while (true){
LocalEventBus.postEvent(Event(System.currentTimeMillis()))
}
}
}

fun cancelRefresh(){
job.cancel()
}
}

前面说过,一个典型的Flow是由三部分构成的。所以,此处我们先新建一个用于数据消费的Fragment,代码如下:


class FlowFragment: Fragment() {
private val mBinding : FragmentFlowBinding by lazy {
FragmentFlowBinding.inflate(layoutInflater)
}

override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return mBinding.root
}
override fun onStart() {
super.onStart()
lifecycleScope.launchWhenCreated {
LocalEventBus.events.collect {
mBinding.tvShow.text=" ${it.timestamp}"
}
}
}
}

FlowFragment的主要作用就是接收LocalEventBus的数据,并显示到视图上。接下来,我们还需要创建一个数据的生产者,为了简单,我们只在生产者页面中开启协程,代码如下:


class FlowActivity : AppCompatActivity() {
private val viewModel by viewModels<SharedViewModel>()
private val mBinding : ActivityFlowBinding by lazy {
ActivityFlowBinding.inflate(layoutInflater)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(mBinding.root)
initFlow()
}

private fun initFlow() {
mBinding.apply {
btnStart.setOnClickListener {
viewModel.startRefresh()
}
btnStop.setOnClickListener {
viewModel.cancelRefresh()
}
}
}
}

其中,FlowActivity代码中涉及的布局如下:


<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".fragment.SharedFragment">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<fragment
android:name="com.xzh.demo.FlowFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>

<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btn_start"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="10dp"
android:layout_marginBottom="10dp"
android:src="@android:drawable/ic_input_add"
android:contentDescription="start" />

<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btn_stop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="10dp"
android:layout_marginBottom="10dp"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:contentDescription="cancel" />
</FrameLayout>
</layout>

最后,当我们运行上面的代码时,就会在FlowFragment的页面上显示当前的时间戳,并且页面的数据会自动进行刷新。


3.5 冷流转热流


前文说过,Kotlin的Flow是一种冷流,而StateFlow/SharedFlow则属于热流。那么有人会问:怎么将冷流转化为热流呢?答案就是kotlin提供的shareIn()和stateIn()两个方法。


首先,来看一下StateFlow的shareIn的定义:


public fun <T> Flow<T>.stateIn(
scope: CoroutineScope,
started: SharingStarted,
initialValue: T
): StateFlow<T>

shareIn方法将流转换为SharedFlow,需要三个参数,我们重点看一下started参数,表示流启动的条件,支持三种:



  • SharingStarted.Eagerly:无论当前有没有订阅者,流都会启动,订阅者只能接收到replay个缓冲区的值。

  • SharingStarted.Lazily:当有第一个订阅者时,流才会开始,后面的订阅者只能接收到replay个缓冲区的值,当没有订阅者时流还是活跃的。

  • SharingStarted.WhileSubscribed:只有满足特定的条件时才会启动。


接下来,我们在看一下SharedFlow的shareIn的定义:


public fun <T> Flow<T>.shareIn(
scope: CoroutineScope,
started: SharingStarted,
replay: Int = 0
): SharedFlow<T>

此处,我们重点看下replay参数,该参数表示转换为SharedFlow之后,当有新的订阅者的时候发送缓存中值的个数。


3.6 StateFlow与SharedFlow对比


从前文的介绍可以知道,StateFlow与SharedFlow都是热流,都是为了满足流的多个订阅者的使用场景的,一时间让人有些傻傻分不清,那StateFlow与SharedFlow究竟有什么区别呢?总结起来,大概有以下几点:



  • SharedFlow配置更为灵活,支持配置replay、缓冲区大小等,StateFlow是SharedFlow的特殊化版本,replay固定为1,缓冲区大小默认为0。

  • StateFlow与LiveData类似,支持通过myFlow.value获取当前状态,如果有这个需求,必须使用StateFlow。

  • SharedFlow支持发出和收集重复值,而StateFlow当value重复时,不会回调collect给新的订阅者,StateFlow只会重播当前最新值,SharedFlow可配置重播元素个数(默认为0,即不重播)。


从上面的描述可以看出,StateFlow为我们做了一些默认的配置,而SharedFlow泽添加了一些默认约束。总的来说,SharedFlow相比StateFlow更灵活。


四、总结


目前,官方提供的可观察的数据组件有LiveData、StateFlow和SharedFlow。LiveData是Android早期的数据流组件,具有生命周期感知能力,需要配合ViewModel才能实现它的价值。不过,LiveData也有很多使用场景缺陷,常见的有粘性事件、不支持防抖等。


于是,Kotlin在1.4.0版本,陆续推出了StateFlow与SharedFlow两个组件,StateFlow与SharedFlow都是热流,都是为了满足流的多个订阅者的使用场景,不过它们也有微妙的区别,具体参考前面内容的说明。


作者:xiangzhihong
链接:https://juejin.cn/post/7116704131141615629
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Flutter JSON 解析最佳实践

这篇文章其实早该写了,之前的业余时间一直花在开源项目或其它文章上了。 JSON 解析对于 Flutter 新人来讲是个绕不开的话题,大家都在吐槽 Flutter 没有反射,导致 JSON 解析无法像 Android 那样方便,其实是不必要的,因为可以做到一样方...
继续阅读 »

这篇文章其实早该写了,之前的业余时间一直花在开源项目或其它文章上了。


JSON 解析对于 Flutter 新人来讲是个绕不开的话题,大家都在吐槽 Flutter 没有反射,导致 JSON 解析无法像 Android 那样方便,其实是不必要的,因为可以做到一样方便。


网上讲 JSON 解析的文章很多,大家自行去学习即可,本篇文章直接给出我创造出的、我认为的最佳方案,如有雷同,纯属巧合:


使用 JsonToDart 插件自动生成 Bean 类,再使用 dynamic 关键字的能力,自动将 JSON 字符串代表的数据填充到 Bean 类中


我们以如下 JSON 文本为例:


{
"nickName": "hackware",
"realName": "陈方兵",
"age": 29,
"sex": "男"
}

这是个 Person 对象的描述,我们先使用 JsonToDart 插件将其转换成 Bean 类,这样我们就无需手写解析代码了:


Snipaste_2022-07-02_07-31-58.png


Snipaste_2022-07-02_07-33-45.png


生成的 Bean 类代码如下:


class Person {
Person({
this.nickName,
this.realName,
this.age,
this.sex,
});

Person.fromJson(dynamic json) {
nickName = json['nickName'];
realName = json['realName'];
age = json['age'];
sex = json['sex'];
}

String? nickName;
String? realName;
int? age;
String? sex;

Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['nickName'] = nickName;
map['realName'] = realName;
map['age'] = age;
map['sex'] = sex;
return map;
}
}

这段代码最核心的是 fromJson 这个构造函数,由于 Flutter 中没有反射,我们无法动态的调用 fromJson 方法。但我们可以先构造一个空的 Person 对象,再使用 dynamic 关键字调用它,但需要对 fromJson 做一下更改,将它从构造函数改为普通函数,如下:


Person fromJson(dynamic json) {
nickName = json['nickName'];
realName = json['realName'];
age = json['age'];
sex = json['sex'];
return this;
}

或是:


void fromJson(dynamic json) {
nickName = json['nickName'];
realName = json['realName'];
age = json['age'];
sex = json['sex'];
}

真正的解析代码如下:


String jsonData = ''; // 从网络加载的 JSON 文本
Person person = Person().fromJson(jsonDecode(jsonData));

我想说明的是:使用反射自动创建对象和手动创建对象后再自动为该对象填充数据是一样方便的。因此即便没有反射,我们也能对网络请求做很好的封装。


目前由于我用的是自创的 PVState 架构模式,它是 MVC 的改进版,它也是个轻量级的状态管理方案。只有不到 120 行代码。它分为 PState 和 VState,这里的 State 指的是 StatefulWidget 的 State。前者封装业务逻辑,后者描述 UI,UI 和业务逻辑可以完全隔离。我把网络请求的基础能力封装到了 BasePState 中,如下:


void sendRequest<BEAN>({
required Future<Response<String>> call,
required BEAN bean,
OnStartCallback? startCallback,
OnSuccessCallback<BEAN>? successCallback,
OnFailCallback<BEAN>? failCallback,
}) async {
startCallback?.call();
bool? success;
Object? exception;
try {
Response<String> resp = await call;
dynamic result = (bean as dynamic).fromJson(jsonDecode(resp.data!));
success = result.success;
} catch (e) {
debugPrint('$e');
exception = e;
} finally {
try {
if (success == true) {
successCallback?.call(bean);
} else {
failCallback?.call(bean, exception);
}
} catch (e) {
exception = e;
failCallback?.call(bean, exception);
}
}
}

真正发起请求的代码如下:


sendRequest(
call: dio.get(
'https://xxx',
),
bean: RealtimeAlarmListBean(),
startCallback: () {
setState(() {
loadingRealtimeAlarm = true;
});
},
successCallback: (RealtimeAlarmListBean bean) {
setState(() {
realtimeAlarmListBean = bean;
loadingRealtimeAlarm = false;
});
},
failCallback: (_, __) {
setState(() {
loadingRealtimeAlarm = false;
showToast('请求失败');
});
},
);

可见我在外部构造好了空的 Bean 对象传进去,当请求回来后会把数据填充进去,最后在 successCallback 再把非空的 Bean 对象回传回来。整个过程我没有手动对 JSON 做解析。是不是挺方便的呢?


我比较喜欢这种网络模块的封装模式,当然你也可以使用 async、await 做“同步”的封装,萝卜青菜各有所爱吧。


这是目前我看到的最好的 JSON 解析方法,如果你有更好的方法,欢迎在评论区交流哦!


作者:hackware
链接:https://juejin.cn/post/7115564008047902727
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套

这次的 Flutter 小技巧是 ListView 和 PageView 的花式嵌套,不同 Scrollable 的嵌套冲突问题相信大家不会陌生,今天就通过 ListView 和 PageView 的三种嵌套模式带大家收获一些不一样的小技巧。 正常嵌套 最常...
继续阅读 »

这次的 Flutter 小技巧是 ListViewPageView 的花式嵌套,不同 Scrollable 的嵌套冲突问题相信大家不会陌生,今天就通过 ListViewPageView 的三种嵌套模式带大家收获一些不一样的小技巧。


正常嵌套


最常见的嵌套应该就是横向 PageView 加纵向 ListView 的组合,一般情况下这个组合不会有什么问题,除非你硬是要斜着滑


最近刚好遇到好几个人同时在问:“斜滑 ListView 容易切换到 PageView 滑动” 的问题,如下 GIF 所示,当用户在滑动 ListView 时,滑动角度带上倾斜之后,可能就会导致滑动的是 PageView 而不是 ListView


xiehuadong


虽然从我个人体验上并不觉得这是个问题,但是如果产品硬是要你修改,难道要自己重写 PageView 的手势响应吗?


我们简单看一下,不管是 PageView 还是 ListView 它们的滑动效果都来自于 Scrollable ,而 Scrollable 内部针对不同方向的响应,是通过 RawGestureDetector 完成:



  • VerticalDragGestureRecognizer 处理垂直方向的手势

  • HorizontalDragGestureRecognizer 处理水平方向的手势


所以简单看它们响应的判断逻辑,可以看到一个很有趣的方法 computeHitSlop根据 pointer 的类型确定当然命中需要的最小像素,触摸默认是 kTouchSlop (18.0)


image-20220613103745974


看到这你有没有灵光一闪:如果我们把 PageView 的 touchSlop 修改了,是不是就可以调整它响应的灵敏度? 恰好在 computeHitSlop 方法里,它可以通过 DeviceGestureSettings 来配置,而 DeviceGestureSettings 来自于 MediaQuery ,所以如下代码所示:


body: MediaQuery(
///调高 touchSlop 到 50 ,这样 pageview 滑动可能有点点影响,
///但是大概率处理了斜着滑动触发的问题
data: MediaQuery.of(context).copyWith(
gestureSettings: DeviceGestureSettings(
touchSlop: 50,
)),
child: PageView(
scrollDirection: Axis.horizontal,
pageSnapping: true,
children: [
HandlerListView(),
HandlerListView(),
],
),
),

小技巧一:通过嵌套一个 MediaQuery ,然后调整 gestureSettingstouchSlop 从而修改 PageView 的灵明度 ,另外不要忘记,还需要把 ListViewtouchSlop 切换会默认 的 kTouchSlop


class HandlerListView extends StatefulWidget {
@override
_MyListViewState createState() => _MyListViewState();
}
class _MyListViewState extends State<HandlerListView> {
@override
Widget build(BuildContext context) {
return MediaQuery(
///这里 touchSlop 需要调回默认
data: MediaQuery.of(context).copyWith(
gestureSettings: DeviceGestureSettings(
touchSlop: kTouchSlop,
)),
child: ListView.separated(
itemCount: 15,
itemBuilder: (context, index) {
return ListTile(
title: Text('Item $index'),
);
},
separatorBuilder: (context, index) {
return const Divider(
thickness: 3,
);
},
),
);
}
}

最后我们看一下效果,如下 GIF 所示,现在就算你斜着滑动,也很触发 PageView 的水平滑动,只有横向移动时才会触发 PageView 的手势,当然, 如果要说这个粗暴的写法有什么问题的话,大概就是降低了 PageView 响应的灵敏度


xiehuabudong


同方向 PageView 嵌套 ListView


介绍完常规使用,接着来点不一样的,在垂直切换的 PageView 里嵌套垂直滚动的 ListView , 你第一感觉是不是觉得不靠谱,为什么会有这样的场景?



对于产品来说,他们不会考虑你如何实现的问题,他们只会拍着脑袋说淘宝可以,为什么你不行,所以如果是你,你会怎么做?



而关于这个需求,社区目前讨论的结果是:PageViewListView 的滑动禁用,然后通过 RawGestureDetector 自己管理



如果对实现逻辑分析没兴趣,可以直接看本小节末尾的 源码链接



看到自己管理先不要慌,虽然要自己实现 PageViewListView 的手势分发,但是其实并不需要重写 PageViewListView ,我们可以复用它们的 Darg 响应逻辑,如下代码所示:



  • 通过 NeverScrollableScrollPhysics 禁止了 PageViewListView 的滚动效果

  • 通过顶部 RawGestureDetector VerticalDragGestureRecognizer 自己管理手势事件

  • 配置 PageControllerScrollController 用于获取状态


body: RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
instance
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
})
},
behavior: HitTestBehavior.opaque,
child: PageView(
controller: _pageController,
scrollDirection: Axis.vertical,
///屏蔽默认的滑动响应
physics: const NeverScrollableScrollPhysics(),
children: [
ListView.builder(
controller: _listScrollController,
///屏蔽默认的滑动响应
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return ListTile(title: Text('List Item $index'));
},
itemCount: 30,
),
Container(
color: Colors.green,
child: Center(
child: Text(
'Page View',
style: TextStyle(fontSize: 50),
),
),
)
],
),
),

接着我们看 _handleDragStart 实现,如下代码所示,在产生手势 details 时,我们主要判断:



  • 通过 ScrollController 判断 ListView 是否可见

  • 判断触摸位置是否在 ListIView 范围内

  • 根据状态判断通过哪个 Controller 去生产 Drag 对象,用于响应后续的滑动事件



void _handleDragStart(DragStartDetails details) {
///先判断 Listview 是否可见或者可以调用
///一般不可见时 hasClients false ,因为 PageView 也没有 keepAlive
if (_listScrollController?.hasClients == true &&
_listScrollController?.position.context.storageContext != null) {
///获取 ListView 的 renderBox
final RenderBox? renderBox = _listScrollController
?.position.context.storageContext
.findRenderObject() as RenderBox;

///判断触摸的位置是否在 ListView 内
///不在范围内一般是因为 ListView 已经滑动上去了,坐标位置和触摸位置不一致
if (renderBox?.paintBounds
.shift(renderBox.localToGlobal(Offset.zero))
.contains(details.globalPosition) ==
true) {
_activeScrollController = _listScrollController;
_drag = _activeScrollController?.position.drag(details, _disposeDrag);
return;
}
}

///这时候就可以认为是 PageView 需要滑动
_activeScrollController = _pageController;
_drag = _pageController?.position.drag(details, _disposeDrag);
}

前面我们主要在触摸开始时,判断需要响应的对象时 ListView 还是 PageView ,然后通过 _activeScrollController 保存当然响应对象,并且通过 Controller 生成用于响应手势信息的 Drag 对象。



简单说:滑动事件发生时,默认会建立一个 Drag 用于处理后续的滑动事件,Drag 会对原始事件进行加工之后再给到 ScrollPosition 去触发后续滑动效果。



接着在 _handleDragUpdate 方法里,主要是判断响应是不是需要切换到 PageView :



  • 如果不需要就继续用前面得到的 _drag?.update(details)响应 ListView 滚动

  • 如果需要就通过 _pageController 切换新的 _drag 对象用于响应


void _handleDragUpdate(DragUpdateDetails details) {
if (_activeScrollController == _listScrollController &&

///手指向上移动,也就是快要显示出底部 PageView
details.primaryDelta! < 0 &&

///到了底部,切换到 PageView
_activeScrollController?.position.pixels ==
_activeScrollController?.position.maxScrollExtent) {
///切换相应的控制器
_activeScrollController = _pageController;
_drag?.cancel();

///参考 Scrollable 里
///因为是切换控制器,也就是要更新 Drag
///拖拽流程要切换到 PageView 里,所以需要 DragStartDetails
///所以需要把 DragUpdateDetails 变成 DragStartDetails
///提取出 PageView 里的 Drag 相应 details
_drag = _pageController?.position.drag(
DragStartDetails(
globalPosition: details.globalPosition,
localPosition: details.localPosition),
_disposeDrag);
}
_drag?.update(details);
}


这里有个小知识点:如上代码所示,我们可以简单通过 details.primaryDelta 判断滑动方向和移动的是否是主轴



最后如下 GIF 所示,可以看到 PageView 嵌套 ListView 同方向滑动可以正常运行了,但是目前还有个两个小问题,从图示可以看到:



  • 在切换之后 ListView 的位置没有保存下来

  • 产品要求去除 ListView 的边缘溢出效果


7777777777777


所以我们需要对 ListView 做一个 KeepAlive ,然后用简单的方法去除 Android 边缘滑动的 Material 效果:



  • 通过 with AutomaticKeepAliveClientMixinListView 在切换之后也保持滑动位置

  • 通过 ScrollConfiguration.of(context).copyWith(overscroll: false) 快速去除 Scrollable 的边缘 Material 效果


child: PageView(
controller: _pageController,
scrollDirection: Axis.vertical,
///去掉 Android 上默认的边缘拖拽效果
scrollBehavior:
ScrollConfiguration.of(context).copyWith(overscroll: false),


///对 PageView 里的 ListView 做 KeepAlive 记住位置
class KeepAliveListView extends StatefulWidget {
final ScrollController? listScrollController;
final int itemCount;

KeepAliveListView({
required this.listScrollController,
required this.itemCount,
});

@override
KeepAliveListViewState createState() => KeepAliveListViewState();
}

class KeepAliveListViewState extends State<KeepAliveListView>
with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return ListView.builder(
controller: widget.listScrollController,

///屏蔽默认的滑动响应
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return ListTile(title: Text('List Item $index'));
},
itemCount: widget.itemCount,
);
}

@override
bool get wantKeepAlive => true;
}

所以这里我们有解锁了另外一个小技巧:通过 ScrollConfiguration.of(context).copyWith(overscroll: false) 快速去除 Android 滑动到边缘的 Material 2效果,为什么说 Material2, 因为 Material3 上变了,具体可见: Flutter 3 下的 ThemeExtensions 和 Material3


000000000



本小节源码可见: github.com/CarGuo/gsy_…



同方向 ListView 嵌套 PageView


那还有没有更非常规的?答案是肯定的,毕竟产品的小脑袋,怎么会想不到在垂直滑动的 ListView 里嵌套垂直切换的 PageView 这种需求。


有了前面的思路,其实实现这个逻辑也是异曲同工:PageViewListView 的滑动禁用,然后通过 RawGestureDetector 自己管理,不同的就是手势方法分发的差异。


RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
instance
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
})
},
behavior: HitTestBehavior.opaque,
child: ListView.builder(
///屏蔽默认的滑动响应
physics: NeverScrollableScrollPhysics(),
controller: _listScrollController,
itemCount: 5,
itemBuilder: (context, index) {
if (index == 0) {
return Container(
height: 300,
child: KeepAlivePageView(
pageController: _pageController,
itemCount: itemCount,
),
);
}
return Container(
height: 300,
color: Colors.greenAccent,
child: Center(
child: Text(
"Item $index",
style: TextStyle(fontSize: 40, color: Colors.blue),
),
));
}),
)

同样是在 _handleDragStart 方法里,这里首先需要判断:



  • ListView 如果已经滑动过,就不响应顶部 PageView 的事件

  • 如果此时 ListView 处于顶部未滑动,判断手势位置是否在 PageView 里,如果是响应 PageView 的事件


  void _handleDragStart(DragStartDetails details) {
///只要不是顶部,就不响应 PageView 的滑动
///所以这个判断只支持垂直 PageView 在 ListView 的顶部
if (_listScrollController.offset > 0) {
_activeScrollController = _listScrollController;
_drag = _listScrollController.position.drag(details, _disposeDrag);
return;
}

///此时处于 ListView 的顶部
if (_pageController.hasClients) {
///获取 PageView
final RenderBox renderBox =
_pageController.position.context.storageContext.findRenderObject()
as RenderBox;

///判断触摸范围是不是在 PageView
final isDragPageView = renderBox.paintBounds
.shift(renderBox.localToGlobal(Offset.zero))
.contains(details.globalPosition);

///如果在 PageView 里就切换到 PageView
if (isDragPageView) {
_activeScrollController = _pageController;
_drag = _activeScrollController.position.drag(details, _disposeDrag);
return;
}
}

///不在 PageView 里就继续响应 ListView
_activeScrollController = _listScrollController;
_drag = _listScrollController.position.drag(details, _disposeDrag);
}

接着在 _handleDragUpdate 方法里,判断如果 PageView 已经滑动到最后一页,也将滑动事件切换到 ListView


void _handleDragUpdate(DragUpdateDetails details) {
var scrollDirection = _activeScrollController.position.userScrollDirection;

///判断此时响应的如果还是 _pageController,是不是到了最后一页
if (_activeScrollController == _pageController &&
scrollDirection == ScrollDirection.reverse &&

///是不是到最后一页了,到最后一页就切换回 pageController
(_pageController.page != null &&
_pageController.page! >= (itemCount - 1))) {
///切换回 ListView
_activeScrollController = _listScrollController;
_drag?.cancel();
_drag = _listScrollController.position.drag(
DragStartDetails(
globalPosition: details.globalPosition,
localPosition: details.localPosition),
_disposeDrag);
}
_drag?.update(details);
}

当然,同样还有 KeepAlive 和去除列表 Material 边缘效果,最后运行效果如下 GIF 所示。


22222222222



本小节源码可见:github.com/CarGuo/gsy_…



最后再补充一个小技巧:如果你需要 Flutter 打印手势竞技的过程,可以配置 debugPrintGestureArenaDiagnostics = true;来让 Flutter 输出手势竞技的处理过程


import 'package:flutter/gestures.dart';
void main() {
debugPrintGestureArenaDiagnostics = true;
runApp(MyApp());
}

image-20220613115808538


最后


最后总结一下,本篇介绍了如何通过 Darg 解决各种因为嵌套而导致的手势冲突,相信大家也知道了如何利用 ControllerDarg 来快速自定义一些滑动需求,例如 ListView 联动 ListView 的差量滑动效果:


///listView 联动 listView
class ListViewLinkListView extends StatefulWidget {
@override
_ListViewLinkListViewState createState() => _ListViewLinkListViewState();
}

class _ListViewLinkListViewState extends State<ListViewLinkListView> {
ScrollController _primaryScrollController = ScrollController();
ScrollController _subScrollController = ScrollController();

Drag? _primaryDrag;
Drag? _subDrag;

@override
void initState() {
super.initState();
}

@override
void dispose() {
_primaryScrollController.dispose();
_subScrollController.dispose();
super.dispose();
}

void _handleDragStart(DragStartDetails details) {
_primaryDrag =
_primaryScrollController.position.drag(details, _disposePrimaryDrag);
_subDrag = _subScrollController.position.drag(details, _disposeSubDrag);
}

void _handleDragUpdate(DragUpdateDetails details) {
_primaryDrag?.update(details);

///除以10实现差量效果
_subDrag?.update(DragUpdateDetails(
sourceTimeStamp: details.sourceTimeStamp,
delta: details.delta / 30,
primaryDelta: (details.primaryDelta ?? 0) / 30,
globalPosition: details.globalPosition,
localPosition: details.localPosition));
}

void _handleDragEnd(DragEndDetails details) {
_primaryDrag?.end(details);
_subDrag?.end(details);
}

void _handleDragCancel() {
_primaryDrag?.cancel();
_subDrag?.cancel();
}

void _disposePrimaryDrag() {
_primaryDrag = null;
}

void _disposeSubDrag() {
_subDrag = null;
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("ListViewLinkListView"),
),
body: RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
instance
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
})
},
behavior: HitTestBehavior.opaque,
child: ScrollConfiguration(
///去掉 Android 上默认的边缘拖拽效果
behavior:
ScrollConfiguration.of(context).copyWith(overscroll: false),
child: Row(
children: [
new Expanded(
child: ListView.builder(

///屏蔽默认的滑动响应
physics: NeverScrollableScrollPhysics(),
controller: _primaryScrollController,
itemCount: 55,
itemBuilder: (context, index) {
return Container(
height: 300,
color: Colors.greenAccent,
child: Center(
child: Text(
"Item $index",
style: TextStyle(
fontSize: 40, color: Colors.blue),
),
));
})),
new SizedBox(
width: 5,
),
new Expanded(
child: ListView.builder(

///屏蔽默认的滑动响应
physics: NeverScrollableScrollPhysics(),
controller: _subScrollController,
itemCount: 55,
itemBuilder: (context, index) {
return Container(
height: 300,
color: Colors.deepOrange,
child: Center(
child: Text(
"Item $index",
style:
TextStyle(fontSize: 40, color: Colors.white),
),
),
);
}),
),
],
),
),
));
}
}

44444444444444


作者:恋猫de小郭
链接:https://juejin.cn/post/7116267156655833102
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

环信IM重大更新:新SDK+新场景+质量洞察+内容审核+出海

即时通讯IM是人与人沟通的基础服务,随着线上场景的进一步丰富,用户对于IM的能力要求日益提升。IM本质是一项服务,用户对于体验质量的要求异常严苛,掌握终端用户质量体验的变化和趋势,能够快速发现问题及根因,成为开发者核心关注的问题。近日,环信IM发布重大更新,包...
继续阅读 »

即时通讯IM是人与人沟通的基础服务,随着线上场景的进一步丰富,用户对于IM的能力要求日益提升。IM本质是一项服务,用户对于体验质量的要求异常严苛,掌握终端用户质量体验的变化和趋势,能够快速发现问题及根因,成为开发者核心关注的问题。近日,环信IM发布重大更新,包括:新SDK+新场景+水晶球+内容审核+出海等重磅特性。

WechatIMG14814.png

环信IM在已经推出的实时热点数据、消息投递查询、用户连接状态查询的基础上,

1、增加水晶球质量洞察能力对终端用户体验数据进行统计分析,帮助开发者实时掌控用户体验;

2、IM中传递的是用户产生的信息,对于所有终端用户而言,有一个安全、干净的聊天环境至关重要,环信IM提供了多种维度的内容审核管理能力,帮助开发者有效地对内容进行管理;

3、IM作为基础的沟通服务,在聊天的基础上衍生出了多种沟通的方式随着元宇宙等新场景的爆发,多人沟通的Discord社区模型受到了游戏玩家的青睐,环信IM提供了一组新的特性对新场景进行支持;

4、2022出海依旧是互联网的一个风口,随着越来越多的企业扬帆出海,环信IM提供的全球加速网络、安全合规、翻译都将助力出海客户快速构建符合当地用户使用习惯的应用,环信是IM行业首家通过全球最严苛安全GDPR认证的厂商。同时,环信IM提供业界最全的SDK矩阵,支持业界最全的小程序生态;

业界最全SDK矩阵提升开发体验

随着新技术、新平台的发展,环信IM响应开发者的需求,提供更多的SDK支持。在跨平台的支持上,提供Flutter、React Native、Unity、Uni-App。同时增加了原生Windows SDK的支持。 

环信IM支持Android、iOS、macOS、Windows、Linux、Web、Flutter、Unity、Electron、React Native、Uni-App、ApiCloud等12大SDK。

同时支持业界最全的小程序生态,包括:微信/QQ小程序、支付宝小程序、字节跳动小程序、快手小程序、百度小程序、360小程序等。

水晶球-质量洞察

实现从质量数据主动监控、异常问题实时告警、消息投递调查分析到历史数据回溯洞察是 IM质量监控功能的闭环。本期增加的质量数据主动监控为开发者提供了全景的用户IM使用质量数据。

终端数据:提供全球终端用户的登录、消息收发、好友管理、用户管理、群组管理、聊天室管理监控数据

image.png

Server API数据:提供全球服务端token获取、用户操作、文件操作、发送消息、群组管理、聊天室管理、用户属性操作的监控数据。

image.png

多维度内容审核能力

互联网不是法外之地!聊天内容的治理一直是即时通讯的核心能力,伴随着个人隐私保护法的实施,为用户提供一个安全、干净的聊天环境愈发重要。环信IM提供多维度多层次的内容审核能力,帮助开发者应对内容治理的挑战。

丰富的用户管理手段

作为开发者在应用中拥有最高权限,可以对用户进行APP级别的封禁、强制下线、删除等操作;在群组和聊天室中,可以对用户进行踢出、拉黑、禁言等操作。

消息举报:终端用户对自己接收的不良内容进行举报,开发者在console中对于发送者和消息进行处理。

image.png基于AI模型的文本、图片、语音、视频消息的审核能力:提供多种违规模型的不同消息类型的审核服务

image.png

社区多人沟通新场景

随着国外Discord爆红出圈,对于多人沟通的社区场景,环信IM增加了消息表情回复、子区等特性。

消息表情回复:用户可以在单聊和群聊中对消息添加、删除表情。表情可以直观地表达情绪,使用表情回复增加用户互动,提升用户使用体验。同时在群组中,利用表情回复可以发起投票,根据不同表情的追加数量来确认投票。

image.png消息子区:子区是群组成员的子集,是支持多人沟通的即时通讯系统,提供子区创建删除、成员管理等能力。

image.png

愈加成熟的全球服务

作为互联网底层通讯服务提供商,环信IM服务的海外客户已经遍布全球各地,随着海外服务经验的积累,环信IM的全球化服务也愈加成熟。环信是IM行业首家通过全球最严苛安全GDPR认证的厂商,帮助出海开发者免除安全后顾之忧。基于环信全球加速网络SD-GMN服务,全球端到端消息发送平均时延低于100ms,保证跨国跨区域沟通的用户体验。

环信5大数据中心覆盖全球200多个国家和地区;集团自建上万台服务器,部署全球300多个补充加速节点,实现低延迟;FPA加速与AWS加速智能切换,确保通信质量和高可用能力;On-Demand就近接入节点,全球加速网络SD-GMN服务,实测北美数据:30-40毫秒、欧洲:20-30毫秒、东南亚/日韩:30-40毫秒、北非:45毫秒、澳洲:50毫秒、中东:70毫秒、南美和南非:90毫秒;

image.png

image.pngimage.png

- SD-GMN实测数据展示 -

翻译功能:文本消息支持翻译功能,包含按需翻译和自动翻译。

1.按需翻译:收到消息时,接收方将消息内容翻译成目标语言。

2.自动翻译:用户发送消息时,SDK 根据设置的目标语言自动翻译消息内容,然后将消息原文和译文一并发送给消息接收方

image.png

结语

除了环信IM的大招,环信PUSH、环信MQTT也火力全开,环信正经历从即时通讯云到全球消息云的生态演化:1、IM平台向下深化和泛化。从Person to Person IM 发展为更通用的消息云平台,应用和应用之间,设备和云之间消息等,以及MQTT物联网消息新机会。2、IM平台向上层场景拓展。包括新场景产品,全渠道通知(IM、企业微信、小程序、短信等)。3、消息领域拓展。包括互联网消息外增加电信类消息,以及5G消息、短信、闪验等。环信全球消息云生态将覆盖包括:chat 聊天、notification 通知、push推送、营销、iot、短信验证码等丰富场景。

未来已来,环信已经准备好了!

即刻免费体验环信IM新特性:https://console.easemob.com/user/register

收起阅读 »

跟我学企业级flutter项目:简化框架demo参考

前言最近很多人在问我,没有一个不错的demo,不会如何做单工程模式,如何封装网络请求,如何去做网络持久化。那么今天我将demo分享出来。现阶段还无法把我构建的flutter快速开发框架开源出来。暂时用简化demo来展示。 相关文章: 跟我学企业级fl...
继续阅读 »

前言

最近很多人在问我,没有一个不错的demo,不会如何做单工程模式,如何封装网络请求,如何去做网络持久化。那么今天我将demo分享出来。现阶段还无法把我构建的flutter快速开发框架开源出来。暂时用简化demo来展示。 相关文章: 跟我学企业级flutter项目:flutter模块化,单工程架构模式构思与实践

跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层

跟我学企业级flutter项目:dio网络框架增加公共请求参数&header

demo地址:

github.com/smartbackme…

为了大家更清楚的使用,我将对目录结构进行说明:

目录结构

在这里插入图片描述

以模块一来说明

在这里插入图片描述 模块一启动配置:

class MyConfiger extends ICommentConfiger{

@override
Widget getRouter(RouteSettings settings) {
var router = RouterPage.getRouter(settings);
if(router!=null){
return router;
}
return const NullRouter();
}

}
void main() {
Application.init(AppInit(MyConfiger()));
runApp(const MyApp());
}

公共模块说明

在这里插入图片描述

主工程启动说明

import 'package:commonmodule/commonmodule.dart';
import 'package:commonmodule/config.dart';
import 'package:flutter/material.dart';
import 'package:commonmodule/router_name.dart' as common;
import 'package:kg_density/kg_density.dart';
import 'package:myflutter/page/home.dart';
import 'package:onemodule/router_page.dart' as onemodule;
import 'package:twomodule/router_page.dart' as twomodule;

// 路由分配管理
class MyCommentConfiger extends ICommentConfiger{
@override
Widget getRouter(RouteSettings settings) {
if(settings.name == common.RouterName.home){
return const HomePage();
}
var teachertRouter = onemodule.RouterPage.getRouter(settings);
if(teachertRouter!=null){
return teachertRouter;
}
var clientRouter = twomodule.RouterPage.getRouter(settings);
if(clientRouter!=null){
return clientRouter;
}
return const NullRouter();

}


}

//启动初始化
void main() async {
MyFlutterBinding.ensureInitialized();
KgDensity.initKgDensity(designWidth : 375);
await SpSotre.instance.init();
ULogManager.init();
Application.init(AppInit(MyCommentConfiger()));
runApp(const MyApp());
}

//WidgetsFlutterBinding 配置
class MyFlutterBinding extends WidgetsFlutterBinding with KgFlutterBinding {

static WidgetsBinding ensureInitialized() {
if (WidgetsBinding.instance == null) MyFlutterBinding();
return WidgetsBinding.instance!;
}
}


作者:王二蛋与他的张大花
链接:https://juejin.cn/post/7115236177136844808/
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密

今天这篇文章的目的是补全大家对于 MediaQuery 和对应 rebuild 机制的基础认知,相信本篇内容对你优化性能和调试 bug 会很有帮助。 Flutter 里大家应该都离不开 MediaQuery ,比如通过 MediaQuery.of(cont...
继续阅读 »

今天这篇文章的目的是补全大家对于 MediaQuery 和对应 rebuild 机制的基础认知,相信本篇内容对你优化性能和调试 bug 会很有帮助


Flutter 里大家应该都离不开 MediaQuery ,比如通过 MediaQuery.of(context).size 获取屏幕大小 ,或者通过 MediaQuery.of(context).padding.top 获取状态栏高度,那随便使用 MediaQuery.of(context) 会有什么问题吗?


首先我们需要简单解释一下,通过 MediaQuery.of 获取到的 MediaQueryData 里有几个很类似的参数:



  • viewInsets被系统用户界面完全遮挡的部分大小,简单来说就是键盘高度

  • padding简单来说就是状态栏和底部安全区域,但是 bottom 会因为键盘弹出变成 0

  • viewPadding padding 一样,但是 bottom 部分不会发生改变


举个例子,在 iOS 上,如下图所示,在弹出键盘和未弹出键盘的情况下,可以看到 MediaQueryData 里一些参数的变化:



  • viewInsets 在没有弹出键盘时是 0,弹出键盘之后 bottom 变成 336

  • padding 在弹出键盘的前后区别, bottom 从 34 变成了 0

  • viewPadding 在键盘弹出前后数据没有发生变化


image-20220624115935998



可以看到 MediaQueryData 里的数据是会根据键盘状态发生变化,又因为 MediaQuery 是一个 InheritedWidget ,所以我们可以通过 MediaQuery.of(context) 获取到顶层共享的 MediaQueryData



那么问题来了,InheritedWidget 的更新逻辑,是通过登记的 context 来绑定的,也就是 MediaQuery.of(context) 本身就是一个绑定行为,然后 MediaQueryData 又和键盘状态有关系,所以:键盘的弹出可能会导致使用 MediaQuery.of(context) 的地方触发 rebuild,举个例子:


如下代码所示,我们在 MyHomePage 里使用了 MediaQuery.of(context).size 并打印输出,然后跳转到 EditPage 页面,弹出键盘 ,这时候会发生什么情况?



class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
print("######### MyHomePage ${MediaQuery.of(context).size}");
return Scaffold(
body: Container(
alignment: Alignment.center,
child: InkWell(
onTap: () {
Navigator.of(context).push(CupertinoPageRoute(builder: (context) {
return EditPage();
}));
},
child: new Text(
"Click",
style: TextStyle(fontSize: 50),
),
),
),
);
}
}

class EditPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: new Text("ControllerDemoPage"),
),
extendBody: true,
body: Column(
children: [
new Spacer(),
new Container(
margin: EdgeInsets.all(10),
child: new Center(
child: new TextField(),
),
),
new Spacer(),
],
),
);
}
}

如下图 log 所示 , 可以看到在键盘弹起来的过程,因为 bottom 发生改变,所以 MediaQueryData 发生了改变,从而导致上一级的 MyHomePage 虽然不可见,但是在键盘弹起的过程里也被不断 build 。


image-20220624121917686



试想一下,如果你在每个页面开始的位置都是用了 MediaQuery.of(context) ,然后打开了 5 个页面,这时候你在第 5 个页面弹出键盘时,也触发了前面 4 个页面 rebuild,自然而然可能就会出现卡顿。



那么如果我不在 MyHomePage 的 build 方法直接使用 MediaQuery.of(context) ,那在 EditPage 里弹出键盘是不是就不会导致上一级的 MyHomePage 触发 build



答案是肯定的,没有了 MediaQuery.of(context).size 之后, MyHomePage 就不会因为 EditPage 里的键盘弹出而导致 rebuild。



所以小技巧一:要慎重在 Scaffold 之外使用 MediaQuery.of(context) ,可能你现在会觉得奇怪什么是 Scaffold 之外,没事后面继续解释。


那到这里有人可能就要说了:我们通过 MediaQuery.of(context) 获取到的 MediaQueryData ,不就是对应在 MaterialApp 里的 MediaQuery 吗?那它发生改变,不应该都会触发下面的 child 都 rebuild 吗?



这其实和页面路由有关系,也就是我们常说的 PageRoute 的实现



如下图所示,因为嵌套结构的原因,事实上弹出键盘确实会导致 MaterialApp 下的 child 都触发 rebuild ,因为设计上 MediaQuery 就是在 Navigator 上面,所以弹出键盘自然也就触发 Navigator 的 rebuild


image-20220624141749056


那正常情况下 Navigator 都触发 rebuild 了,为什么页面不会都被 rebuild 呢


这就和路由对象的基类 ModalRoute 有关系,因为在它的内部会通过一个 _modalScopeCache 参数把 Widget 缓存起来,正如注释所说:



缓存区域不随帧变化,以便得到最小化的构建




举个例子,如下代码所示:



  • 首先定义了一个 TextGlobal ,在 build 方法里输出 "######## TextGlobal"

  • 然后在 MyHomePage 里定义一个全局的 TextGlobal globalText = TextGlobal();

  • 接着在 MyHomePage 里添加 3 个 globalText

  • 最后点击 FloatingActionButton 触发 setState(() {});


class TextGlobal extends StatelessWidget {
const TextGlobal({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
print("######## TextGlobal");
return Container(
child: new Text(
"测试",
style: new TextStyle(fontSize: 40, color: Colors.redAccent),
textAlign: TextAlign.center,
),
);
}
}
class MyHomePage extends StatefulWidget {
final String? title;
MyHomePage({Key? key, this.title}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
TextGlobal globalText = TextGlobal();
@override
Widget build(BuildContext context) {
print("######## MyHomePage");
return Scaffold(
appBar: AppBar(),
body: new Container(
alignment: Alignment.center,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
globalText,
globalText,
globalText,
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {});
},
),
);
}
}

那么有趣的来了,如下图 log 所示,"######## TextGlobal" 除了在一开始构建时有输出之外,剩下 setState(() {}); 的时候都没有在触发,也就是没有 rebuild ,这其实就是上面 ModalRoute 的类似行为:弹出键盘导致了 MediaQuery 触发 Navigator 执行 rebuild,但是 rebuild 到了 ModalRoute 就不往下影响



其实这个行为也体现在了 Scaffold 里,如果你去看 Scaffold 的源码,你就会发现 Scaffold 里大量使用了 MediaQuery.of(context)


比如上面的代码,如果你给 MyHomePageScaffold 配置一个 3333 的 ValueKey ,那么在 EditPage 弹出键盘时,其实 MyHomePageScaffold 是会触发 rebuild ,但是因为其使用的是 widget.body ,所以并不会导致 body 内对象重构。




如果是 MyHomePage 如果 rebuild ,就会对 build 方法里所有的配置的 new 对象进行 rebuild;但是如果只是 MyHomePage 里的 Scaffold 内部触发了 rebuild ,是不会导致 MyHomePage 里的 body 参数对应的 child 执行 rebuild 。



是不是太抽象?举个简单的例子,如下代码所示:



  • 我们定义了一个 LikeScaffold 控件,在控件内通过 widget.body 传递对象

  • LikeScaffold 内部我们使用了 MediaQuery.of(context).viewInsets.bottom ,模仿 Scaffold 里使用 MediaQuery

  • MyHomePage 里使用 LikeScaffold ,并给 LikeScaffold 的 body 配置一个 Builder ,输出 "############ HomePage Builder Text " 用于观察

  • 跳到 EditPage 页面打开键盘


class LikeScaffold extends StatefulWidget {
final Widget body;

const LikeScaffold({Key? key, required this.body}) : super(key: key);

@override
State<LikeScaffold> createState() => _LikeScaffoldState();
}

class _LikeScaffoldState extends State<LikeScaffold> {
@override
Widget build(BuildContext context) {
print("####### LikeScaffold build ${MediaQuery.of(context).viewInsets.bottom}");
return Material(
child: new Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [widget.body],
),
);
}
}
····
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
var routeLists = routers.keys.toList();
return new LikeScaffold(
body: Builder(
builder: (_) {
print("############ HomePage Builder Text ");
return InkWell(
onTap: () {
Navigator.of(context).push(CupertinoPageRoute(builder: (context) {
return EditPage();
}));
},
child: Text(
"FFFFFFF",
style: TextStyle(fontSize: 50),
),
);
},
),
);
}
}

可以看到,最开始 "####### LikeScaffold build 0.0############ HomePage Builder Text 都正常执行,然后在键盘弹出之后,"####### LikeScaffold build 跟随键盘动画不断输出 bottom 的 大小,但是 "############ HomePage Builder Text ") 没有输出,因为它是 widget.body 实例。



所以通过这个最小例子,可以看到虽然 Scaffold 里大量使用 MediaQuery.of(context) ,但是影响范围是约束在 Scaffold 内部


接着我们继续看修改这个例子,如果在 LikeScaffold 上嵌套多一个 Scaffold ,那输出结果会是怎么样?



class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
var routeLists = routers.keys.toList();
///多加了个 Scaffold
return Scaffold(
body: new LikeScaffold(
body: Builder(
·····
),
),
);
}

答案是 LikeScaffold 内的 "####### LikeScaffold build 也不会因为键盘的弹起而输出,也就是: LikeScaffold 虽然使用了 MediaQuery.of(context) ,但是它不再因为键盘的弹起而导致 rebuild


因为此时 LikeScaffoldScaffold 的 child ,所以在 LikeScaffold 内通过 MediaQuery.of(context) 指向的,其实是 Scaffold 内部经过处理的 MediaQueryData


image-20220624150712453



Scaffold 内部有很多类似的处理,例如 body 里会根据是否有 AppbarBottomNavigationBar 来决定是否移除该区域内的 paddingTop 和 paddingBottom 。



所以,看到这里有没有想到什么?为什么时不时通过 MediaQuery.of(context) 获取的 padding ,有的 top 为 0 ,有的不为 0 ,原因就在于你获取的 context 来自哪里


举个例子,如下代码所示, ScaffoldChildPage 作为 Scaffold 的 child ,我们分别在 MyHomePageScaffoldChildPage 里打印 MediaQuery.of(context).padding


class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
print("MyHomePage MediaQuery padding: ${MediaQuery.of(context).padding}");
return Scaffold(
appBar: AppBar(
title: new Text(""),
),
extendBody: true,
body: Column(
children: [
new Spacer(),
ScaffoldChildPage(),
new Spacer(),
],
),
);
}
}
class ScaffoldChildPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
print("ScaffoldChildPage MediaQuery padding: ${MediaQuery.of(context).padding}");
return Container();
}
}

如下图所示,可以看到,因为此时 MyHomePageAppbar ,所以 ScaffoldChildPage 里获取到 paddingTop 是 0 ,因为此时 ScaffoldChildPage 获取到的 MediaQueryData 已经被 MyHomePage 里的 Scaffold 改写了。


image-20220624151522429


如果此时你给 MyHomePage 增加了 BottomNavigationBar ,可以看到 ScaffoldChildPage 的 bottom 会从原本的 34 变成 90 。


image-20220624152008795


到这里可以看到 MediaQuery.of 里的 context 对象很重要:



  • 如果页面 MediaQuery.of 用的是 Scaffold 外的 context ,获取到的是顶层的 MediaQueryData ,那么弹出键盘时就会导致页面 rebuild

  • MediaQuery.of 用的是 Scaffold 内的 context ,那么获取到的是 Scaffold 对于区域内的 MediaQueryData ,比如前面介绍过的 body ,同时获取到的 MediaQueryData 也会因为 Scaffold 的配置不同而发生改变


所以,如下动图所示,其实部分人会在 push 对应路由地方,通过嵌套 MediaQuery 来做一些拦截处理,比如设置文本不可缩放,但是其实这样会导致键盘在弹出和收起时,触发各个页面不停 rebuild ,比如在 Page 2 弹出键盘的过程,Page 1 也在不停 rebuild。


1111333


所以,如果需要做一些全局拦截,推荐通过 useInheritedMediaQuery 这种方式来做全局处理。


return MediaQuery(
data: MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(boldText: false),
child: MaterialApp(
useInheritedMediaQuery: true,
),
);

所以最后做个总结,本篇主要理清了:



  • MediaQueryDataviewInsets \ padding \ viewPadding 的区别

  • MediaQuery 和键盘状态的关系

  • MediaQuery.of 使用不同 context 对性能的影响

  • 通过 Scaffold 内的 context 获取到的 MediaQueryData 受到 Scaffold 的影响

作者:恋猫de小郭
链接:https://juejin.cn/post/7114098725600903175
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Flutter 实现背景图片毛玻璃效果

前言 继续我们绘图相关篇章,这次我们来看看如何使用 CustomPaint 实现毛玻璃背景图效果。毛玻璃背景图其实就是将图片进行一定程度的模糊,背景图经过模糊后更加虚幻,使得前景和后景就会有层次感。相比直接加蒙层的效果来说,毛玻璃看起来更加好看一些。下面是背景...
继续阅读 »

前言


继续我们绘图相关篇章,这次我们来看看如何使用 CustomPaint 实现毛玻璃背景图效果。毛玻璃背景图其实就是将图片进行一定程度的模糊,背景图经过模糊后更加虚幻,使得前景和后景就会有层次感。相比直接加蒙层的效果来说,毛玻璃看起来更加好看一些。下面是背景图处理前后的对比,我们的前景图片的透明度并没有改变,但是背景图模糊虚化后,感觉前景更加显眼了一样。
模糊前后对比.jpg
本篇涉及如下内容:



  • 使用 canvas 绘制图片。

  • 绘制图片时如何更改图片的填充范围。

  • 使用 ImageFilter 模糊图片,实现毛玻璃效果。


使用 canvas 绘制图片


Flutter 为 canvas 提供了drawImage 方法用于绘制图片,方法定义如下:


void drawImage(Image image, Offset offset, Paint paint)

其中各个参数说明如下:



  • imagedart:ui 中的 Image 对象,注意不是Widget 中的 Image,因此绘制的时候需要将图片资源转换为 ui.Image 对象。下面是转换的示例代码,fillImage 即最终得到的 ui.Image 对象。注意转换需要一定的时间,因此需要使用异步 async / await 操作。


Future<void> init() async {
final ByteData data = await rootBundle.load('images/island-coder.png');
fillImage = await loadImage(Uint8List.view(data.buffer));
}

Future<ui.Image> loadImage(Uint8List img) async {
final Completer<ui.Image> completer = Completer();
ui.decodeImageFromList(img, (ui.Image img) {
setState(() {
isImageLoaded = true;
});
return completer.complete(img);
});
return completer.future;
}


  • offset:绘制图片的起始位置。

  • paint:绘图画笔对象,在 paint 上可以应用各种处理效果,比如本篇要用到的图片模糊效果。


注意,drawImage 方法无法更改图片绘制的区域大小,默认就是按图片的实际尺寸绘制的,所以如果要想保证全屏的背景图,我们就需要使用另一个绘制图片的方法。


更改绘制图片的绘制范围


Flutter 的 canvas 为绘制图片提供了一个尺寸转换方法,即可以通过指定原绘制区域的矩形和目标区域的矩形,将图片某个区域映射到新的矩形框中绘制。也就是我们甚至可以实现绘制图片的局部区域。该方法名为 drawImageRect,定义如下:


void drawImageRect(Image image, Rect src, Rect dst, Paint paint)

方法的参数比较容易懂,我们来看看 Flutter 的文档说明。



Draws the subset of the given image described by the src argument into the canvas in the axis-aligned rectangle given by the dst argument.
翻译:通过 src 参数将给定图片的局部(subset)绘制到坐标轴对齐的目标矩形区域内。



下面是我们将源矩形框设置为实际图片的尺寸和一半宽高的对比图,可以看到取一半宽高的只绘制了左上角的1/4区域。实际我们可以定位起始位置来截取部分区域绘制。
截取原图的一半宽高.jpg


毛玻璃效果实现


毛玻璃效果实现和我们上两篇使用 paintshader属性有点类似,Paint 类提供了一个imageFilter属性专门用于图片处理,其中dart:ui 中就提供了ui.ImageFilter.blur方法构建模糊效果处理的 ImageFilter对象。方法定义如下:


factory ImageFilter.blur({ 
double sigmaX = 0.0,
double sigmaY = 0.0,
TileMode tileMode = TileMode.clamp
})

这个方法实际调用的是一个高斯模糊处理器,高斯模糊其实就是应用一个方法将像素点周边指定范围的值进行处理,进而实现模糊效果,有兴趣的可以自行百度一下。下面的 sigmaXsigmaY 分布代表横轴方向和纵轴方向的模糊程度,数值越大,模糊程度越厉害。因此我们可以通过这两个参数控制模糊程度。


return _GaussianBlurImageFilter(
sigmaX: sigmaX,
sigmaY: sigmaY,
tileMode: tileMode
);

**注意,这里 sigmaX 和 sigmaY 不能同时为0,否则会报错!**这里应该是如果同时为0会导致除0操作。
下面来看整体的绘制实现代码,如下所示:


class BlurImagePainter extends CustomPainter {
final ui.Image bgImage;
final double blur;

BlurImagePainter({
required this.bgImage,
required this.blur,
});
@override
void paint(Canvas canvas, Size size) {
var paint = Paint();
// 模糊的取值不能为0,为0会抛异常
if (blur > 0) {
paint.imageFilter = ui.ImageFilter.blur(
sigmaX: blur,
sigmaY: blur,
tileMode: TileMode.mirror,
);
}

canvas.drawImageRect(
bgImage,
Rect.fromLTRB(0, 0, bgImage.width.toDouble(), bgImage.height.toDouble()),
Offset.zero & size,
paint,
);
}

代码其实很短,就是在模糊值不为0的时候,应用 imageFilter 进行模糊处理,然后使用 drawImageRect 方法确保图片填充满整个背景。完整代码已经提交至:绘图相关代码,文件名为:blur_image_demo.dart。变换模糊值的效果如下动图所示。
背景图模糊过程.gif


总结


本篇介绍了使用 CustomPaint 实现背景图模糊,毛玻璃的效果。关键点在于 使用 Paint 对象的 imageFilter属性,使用高斯模糊应用到图片上。以后碰到需要模糊背景图的地方就可以直接上手用啦!


作者:岛上码农
链接:https://juejin.cn/post/7113131636195065892
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Native 如何快速集成 Flutter

如何 Android 项目中集成 Flutter 概述 目前flutter越来越受欢迎,但对于一些成熟的产品来说,完全摒弃原有App全面转向Flutter是不现实的。因此使用Flutter去统一Android、iOS技术栈,把它作为已有原生App的扩展能力,通...
继续阅读 »

如何 Android 项目中集成 Flutter


概述


目前flutter越来越受欢迎,但对于一些成熟的产品来说,完全摒弃原有App全面转向Flutter是不现实的。因此使用Flutter去统一Android、iOS技术栈,把它作为已有原生App的扩展能力,通过有序推进来提升移动终端的开发效率。 目前,想要在已有的原生App里嵌入一些Flutter页面主要有两种方案。一种是将原生工程作为Flutter工程的子工程,由Flutter进行统一管理,这种模式称为统一管理模式。另一种是将Flutter工程作为原生工程的子模块,维持原有的原生工程管理方式不变,这种模式被称为三端分离模式,如下图所示。
1.png
三端代码分离模式的原理是把Flutter模块作为原生工程的子模块,从而快速地接入Flutter模块,降低原生工程的改造成本。


如何在Native项目中接入flutter 模块


在原生项目中集成flutter模块有两种方式,第一种是直接在项目中新建一个flutter module,第二种将flutter项目模块打包成aar或so包集成到Native项目中。一下将详细介绍这两种方式 (以Android为例)


采用module引用的方式


直接通过Android stuido



File->New ->New Module 选择 Flutter Module 来生成一个Flutter Module.



image.png


image.png



如下图:Android studio为原生项目创建了一个module



image.png


手动创建Flutter module


假设你在 some/path/MyApp 路径下已有一个 Android 应用,并且你希望 Flutter 项目作为同级项目:


 cd some/path/
$ flutter create -t module --org com.example my_flutter

image.png


注意:



  1. 这会创建一个 some/path/my_flutter/ 的 Flutter 模块项目,其中包含一些 Dart 代码来帮助你入门以及一个隐藏的子文件夹 .android/。 .android 文件夹包含一个 Android 项目,该项目不仅可以帮助你通过 flutter run 运行这个 Flutter 模块的独立应用,而且还可以作为封装程序来帮助引导 Flutter 模块作为可嵌入的 Android 库。

  2. 为了避免 Dex 合并出现问题,flutter.androidPackage 不应与应用的包名相同


引入 Java 8


Flutter Android 引擎需要使用到 Java 8 中的新特性。


在尝试将 Flutter 模块项目集成到宿主 Android 应用之前,请先确保宿主 Android 应用的 build.gradle 文件的 android { } 块中声明了以下源兼容性,例如:


android {
//...
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
}

采用AAR资源包的方式导入Flutter模块


flutter 工程作为独立的项目开发迭代,原生工程不直接使用Flutter项目,而是通过导入flutter 的资源包来引用Flutter 模块。



创建Flutter module 工程。



image.png



编译生成AAR包



image.png



flutter 工程会创建一个本地maven仓库和aar文件,同时在Flutter 项目也会输出指引导入的步骤文本,按照提示步骤操作即可。
为方便使用将该maven仓库拷贝到native 项目中。



image.png



提示步骤如下



Consuming the Module




  1. Open \app\build.gradle




  2. Ensure you have the repositories configured, otherwise add them:


    String storageUrl = System.env.FLUTTER_STORAGE_BASE_URL ?: "storage.googleapis.com"
    repositories {
    maven {
    url 'D:<path>\build\host\outputs\repo'
    }
    maven {
    url '$storageUrl/download.flutter.io'
    }
    }




  3. Make the host app depend on the Flutter module:




dependencies {
debugImplementation 'com.example.untitled1:flutter_debug:1.0'
profileImplementation 'com.example.untitled1:flutter_profile:1.0'
releaseImplementation 'com.example.untitled1:flutter_release:1.0'
}


  1. Add the profile build type:


android {
buildTypes {
profile {
initWith debug
}
}
}

To learn more, visit flutter.dev/go/build-aa…
Process finished with exit code 0


在 Android 应用中添加 Flutter 页面


步骤 1:在 AndroidManifest.xml 中添加 FlutterActivity


Flutter 提供了 FlutterActivity,用于在 Android 应用内部展示一个 Flutter 的交互界面。和其他的 Activity 一样,FlutterActivity 必须在项目的 AndroidManifest.xml 文件中注册。将下边的 XML 代码添加到你的 AndroidManifest.xml 文件中的 application 标签内:


<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
/>

上述代码中的 @style/LaunchTheme 可以替换为想要在你的 FlutterActivity 中使用的其他 Android 主题。主题的选择决定 Android 系统展示框架所使用的颜色,例如 Android 的导航栏,以及 Flutter UI 自身的第一次渲染前 FlutterActivity 的背景色。


步骤 2:加载 FlutterActivity


在你的清单文件中注册了 FlutterActivity 之后,根据需要,你可以在应用中的任意位置添加打开 FlutterActivity 的代码。下边的代码展示了如何在 OnClickListener 的点击事件中打开 FlutterActivity


myButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
startActivity(
FlutterActivity.createDefaultIntent(currentActivity)
);
}
});

Flutter 启动优化


每一个 FlutterActivity 默认会创建它自己的 FlutterEngine。每一个 FlutterEngine 会有一个明显的预热时间。这意味着加载一个标准的 FlutterActivity 时,在你的 Flutter 交互页面可见之前会有一个短暂的延迟。想要最小化这个延迟时间,你可以在抵达你的 FlutterActivity 之前,初始化一个 FlutterEngine,然后使用这个已经预热好的 FlutterEngine
如果直接启动FlutterActivity则无法避免预热时间,用户会感受到一个较长时间的白屏等待。


优化


提前初始化一个  FlutterEngine,启动的FlutterActivty时直接使用已经初始化的FlutterEngine.



提前初始化



public class MyApplication extends Application {
public FlutterEngine flutterEngine;

@Override
public void onCreate() {
super.onCreate();
// Instantiate a FlutterEngine.
flutterEngine = new FlutterEngine(this);

// Start executing Dart code to pre-warm the FlutterEngine.
flutterEngine.getDartExecutor().executeDartEntrypoint(
DartEntrypoint.createDefault()
);

// Cache the FlutterEngine to be used by FlutterActivity.
FlutterEngineCache
.getInstance()
.put("my_engine_id", flutterEngine);
}
}


使用预热的FlutterEngine



myButton.addOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
startActivity(
FlutterActivity
.withCachedEngine("my_engine_id")
.build(currentActivity)
);
}
});

作者:hujian
链接:https://juejin.cn/post/7113874811675213832
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

黑科技!让Native Crash 与ANR无处发泄!

ANR
前言 高产似母猪的我,又带来了干货记录,本次是对signal的一个总结与回顾。不知道你们开发中,是否会遇到小部分的nativecrash 或者 anr,这部分往往是由第三方库导致的或者当前版本没办法修复的bug导致的,往往这些难啃的crash,对现有的cras...
继续阅读 »

前言


高产似母猪的我,又带来了干货记录,本次是对signal的一个总结与回顾。不知道你们开发中,是否会遇到小部分的nativecrash 或者 anr,这部分往往是由第三方库导致的或者当前版本没办法修复的bug导致的,往往这些难啃的crash,对现有的crash数据指标造成一定影响,同时也对这小部分crash用户不友好,那么我们有没有办法实现一套crash or anr重启机制呢?其实是有的,相信在各个大厂都有一套“安全气囊”装置,比如crash一定次数就启用轻量版本或者自动重新启动等等,下面我们来动手搞一个这样的装置!这也是我第三个s开头的开源库Signal


注意:前方高能!阅读本文最好有一点ndk开发的知识噢!没有也没关系,冲吧!


Native Crash


native crash不同于java/kotlin层的crash,在java环境中,如果程序出现了不可预期的crash(即没有捕获),就会往上抛出给最终的线程uncaghtexceptionhandler,在这里我们可以再次处理,比如屏蔽某个exception即可保持app的稳定,然后native层的crash不一样,native 层的crash大多数是“不可恢复”的,比如某个内存方面的错误,这些往往是不可处理的,需要中断当前进程,所以如果发生了native crash,我们转移到自定义的安全处理,比如自动重启后提示用户等等,就会提高用户很大的体验感(比起闪退)


信号量机制


当native 层发生异常的时候,往往是通过信号的方式发送,给相对应的信号处理器处理


image.png
我们可以从signal.h看到,大概已经定义的信号量有


/**
* #define SIGHUP 1
#define SIGINT 2
#define SIGQUIT 3
#define SIGILL 4
#define SIGTRAP 5
#define SIGABRT 6
#define SIGIOT 6
#define SIGBUS 7
#define SIGFPE 8
#define SIGKILL 9
#define SIGUSR1 10
#define SIGSEGV 11
#define SIGUSR2 12
## define SIGPIPE 13
#define SIGALRM 14
#define SIGTERM 15
#define SIGSTKFLT 16
#define SIGCHLD 17
#define SIGCONT 18
#define SIGSTOP 19
#define SIGTSTP 20
#define SIGTTIN 21
#define SIGTTOU 22
#define SIGURG 23
#define SIGXCPU 24
#define SIGXFSZ 25
#define SIGVTALRM 26
#define SIGPROF 27
#define SIGWINCH 28
#define SIGIO 29
#define SIGPOLL SIGIO
#define SIGPWR 30
#define SIGSYS 31
*/

具体的含义可自定百度或者google,相信如果开发者都能在bugly等bug平台上看到


信号量处理函数sigaction


一般的我们有很多种方式定义信号量处理函数,这里介绍sigaction
头文件:#include<signal.h>


定义函数:int sigaction(int signum,const struct sigaction *act ,struct sigaction *oldact)


函数说明:sigaction会依参数signum指定的信号编号来设置该信号的处理函数。参数signum可以指定SIGKILL和SIGSTOP以外的所有信号。如参数结构sigaction定义如下


struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};

信号处理函数可以采用void (*sa_handler)(int)或void (*sa_sigaction)(int, siginfo_t *, void *)。到底采用哪个要看sa_flags中是否设置了SA_SIGINFO位,如果设置了就采用void (*sa_sigaction)(int, siginfo_t *, void *),此时可以向处理函数发送附加信息;默认情况下采用void (*sa_handler)(int),此时只能向处理函数发送信号的数值。


sa_handler:此参数和signal()的参数handler相同,代表新的信号处理函数,其他意义请参考signal();
sa_mask:用来设置在处理该信号时暂时将sa_mask指定的信号集搁置;
sa_restorer:此参数没有使用;
sa_flags :用来设置信号处理的其他相关操作,下列的数值可用。sa_flags还可以设置其他标志:
SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFL
SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用
SA_NODEFER :一般情况下, 当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号。参考


即我们可以通过这个函数,注册我们想要的信号处理,如果当SIGABRT信号到来时,我们希望将其引到自我们自定义信号处理,即可采用以下方式


 sigaction(SIGABRT, &sigc, nullptr);

其中sigc为sigaction结构体的变量


struct sigaction sigc;
//sigc.sa_handler = SigFunc;
sigc.sa_sigaction = SigFunc;
sigemptyset(&sigc.sa_mask);
sigc.sa_flags = SA_SIGINFO;

SigFunc为我们定义处理函数的指针,我们可以设定这样一个函数,去处理我们想要拦截的信号


void SigFunc(int sig_num, siginfo *info, void *ptr) {
自定义处理
}

native crash拦截


有了前面这些基础知识,我们就开始封装我们的crash拦截吧,作为库开发者,我们希望把拦截的信号量交给上层去处理,所以我们的层次是这样的


image.png
所以我们可以有以下代码,具体细节可以看Signal
我们给出函数处理器


jobject currentObj;
JNIEnv *currentEnv = nullptr;

void SigFunc(int sig_num, siginfo *info, void *ptr) {
// 这里判空并不代表这个对象就是安全的,因为有可能是脏内存

if (currentEnv == nullptr || currentObj == nullptr) {
return;
}
__android_log_print(ANDROID_LOG_INFO, TAG, "%d catch", sig_num);
__android_log_print(ANDROID_LOG_INFO, TAG, "crash info pid:%d ", info->si_pid);
jclass main = currentEnv->FindClass("com/example/lib_signal/SignalController");
jmethodID id = currentEnv->GetMethodID(main, "callNativeException", "(I)V");
if (!id) {
return;
}
currentEnv->CallVoidMethod(currentObj, id, sig_num);
currentEnv->DeleteGlobalRef(currentObj);


}

当so库被加载的时候由系统自动调用JNI_OnLoad
extern "C" jint JNI_OnLoad(JavaVM *vm, void *reserved) {
jint result = -1;
// 直接用vm进行赋值,不然不可靠
if (vm->GetEnv((void **) &currentEnv, JNI_VERSION_1_4) != JNI_OK) {
return result;
}
return JNI_VERSION_1_4;
}

其中currentEnv代表着当前jni环境,我们在JNI_OnLoad阶段进行初始化即可,currentObj即代表我们要调用的方法对象,因为我们要回调到java层,所以native肯定需要一个java对象,具体可以看到Signal里面的处理,值得注意的是,我们在native想要在其他函数使用java对象的话,在初始函数赋值的时候,就必须采用env->NewGlobalRef方式分配一个全局变量,不然在该函数结束的时候,对象的内存就会变成脏变量(注意不是NULL)。


Spi机制的运用


如果还不明白spi机制的话,可以查看我之前写的这篇spi机制,因为我们最终会将信号信息传递给java层,所以最终会在java最后执行我们的重启处理,但是重启前我们可能会使用各种自定义的处理方案,比如弹出toast或者各种自定义操作,那么这种自定义的处理就很合适用spi接口暴露给具体的使用者即可,所以我们Signal定义了一个接口


interface CallOnCatchSignal {
fun onCatchSignal(signal: Int,context: Context)
}

外部库的调用者实现这个接口,将实现类配置在META-INF.services目录即可,如图


image.png
如此一来,我们就可以在自定义的MyHandler实现自己的重启逻辑,比如重启/自定义上报crash等等,demo可以看Signal的处理


ANR


关于anr也是一个很有趣的话题,我们可以看到anr也会导致闪退,主要是国内各个厂商都有自己的自定义化处理,比如常规的弹出anr框或者主动闪退,无论是哪一种,对于用户来说都不是一个好的体验。


ANR传递过程


以android 11为例子,最终anr被检测发生后,会调用ProcessErrorStateRecord类的appNotResponding方法,去进行dump 墓碑文件的操作,这个时候就会调用发送一个信号为Signal_Quit的信号,对应的常量为3,所以如果我们想检测到anr后去进行自定义处理的话,按照上面所说直接用sigaction可以吗?


image.png


然而如果直接用sigaction去注册Signal_Quit信号进行处理的话,会发现居然什么都没有回调!那么这发生了什么!


原因就是我们进程继承Zygote进行的时候就把主线程信号的掩码也继承了,Zygote进程把这三个信号量加入了掩码,该方法被调用在init方法中


image.png
掩码的作用就是使得当前的线程不相应这三个信号量,交给其他线程处理


那么其他线程这里指的是什么?其实就是SignalCatcher线程,通常我们发生anr的时候也能看到log输出,最终在run方法注册处理函数


image.png
最终调用WaitForSignal


image.png
调用wait方法


image.png
这个sigwait方法也是一个注册信号处理函数的方法,跟sigaction的区别可参考


取消block


经过上面的分析,相信能了解到为什么Signal_Quit监听不了了,我们也知道,zygote通过掩码把信号进行了屏蔽,那么我们有办法把这个屏蔽给打开吗?答案是有的


pthread_sigmask(SIG_UNBLOCK, &mask, &old))

sigemptyset(&mask);
sigaddset(&mask, SIGQUIT);

我们可以通过pthread_sigmask设置为非block,即参数1的标志,把要取消屏蔽的信号放入即可,如图就是把SIGQUIT取消了,这样一来我们再使用sigaction去注册SIGQUIT就可以在信号出发时执行我们的anr处理逻辑了。值得注意的是,SIGQUIT触发也不一定由anr发生,这是一个必要但不充分的条件,所以我们还要添加其他的判断,比如我们可以判断一个queue里面的当前message的when参数来判断这个消息在队列待了多久,又或者是我们自定义一个异步消息去查看这个消息什么时候回调了handler等等方法,最终判断是否是anr,当然这个不是百分百准确,目前我也没想到百分百准确的方法,因为FileObserve监听traces文件已经在android5以上不能用了,所以Signal里面没有给出具体的判断,只给了一个参考例子。


最后


上述所讲的都在Signal这个库里面有源码与注释,用起来吧!自定义处理可以用作检测crash,anr,也可以用作一个安全装置,发生crash重启等等,只要有脑洞,都可以实现!最后记得点个赞啦!


作者:Pika
链接:https://juejin.cn/post/7114181318644072479
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Kotlin 协程调度切换线程是时候解开真相了

在前面的文章里,通过比较基础的手段演示了如何开启协程、如何挂起、恢复协程。并没有涉及到如何切换线程执行,而没有切换线程功能的协程是没有灵魂的。 本篇将重点分析协程是如何切换线程执行以及如何回到原来的线程执行等知识。 通过本篇文章,你将了解到: 如何指定协程...
继续阅读 »

在前面的文章里,通过比较基础的手段演示了如何开启协程、如何挂起、恢复协程。并没有涉及到如何切换线程执行,而没有切换线程功能的协程是没有灵魂的。

本篇将重点分析协程是如何切换线程执行以及如何回到原来的线程执行等知识。

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




  1. 如何指定协程运行的线程?

  2. 协程调度器原理

  3. 协程恢复时线程的选择



1. 如何指定协程运行的线程?


Android 切换线程常用手法


常规手段


平常大家用的切换到主线程的手段:Activity.runOnUiThread(xx),View.post(xx),Handler.sendMessage(xx) 等简单方式。另外还有一些框架,如AsyncTask、RxJava、线程池等。
它们本质上是借助了Looper+Handler功能。

先看个Demo,在子线程获取学生信息,拿到结果后切换到主线程展示:


    private inner class MyHandler : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
//主线程弹出toast
Toast.makeText(context, msg.obj.toString(), Toast.LENGTH_SHORT).show()
}
}

//获取学生信息
fun showStuInfo() {
thread {
//模拟网络请求
Thread.sleep(3000)
var handler = MyHandler()
var msg = Message.obtain()
msg.obj = "我是小鱼人"
//发送到主线程执行
handler.sendMessage(msg)
}
}

我们知道Android UI 刷新是基于事件驱动的,主线程一直尝试从事件队列里拿到待执行的事件,没拿到就等待,拿到后就执行对应的事件。这也是Looper的核心功能,不断检测事件队列,而往队列里放事件即是通过Handler来操作的。



子线程通过Handler 往队列里存放事件,主线程在遍历队列,这就是一次子线程切换到主线程运行的过程。



当然了,因为主线程有消息队列,若想要抛事件到子线程执行,在子线程构造消息队列即可。


协程切换到主线程


同样的功能,用协程实现:


    fun showStuInfoV2() {
GlobalScope.launch(Dispatchers.Main) {
var stuInfo = withContext(Dispatchers.IO) {
//模拟网络请求
Thread.sleep(3000)
"我是小鱼人"
}

Toast.makeText(context, stuInfo, Toast.LENGTH_SHORT).show()
}
}

很明显,协程简洁太多。

相较于常规手段,协程无需显示构造线程,也无需显示通过Handler发送,在Handler里接收信息并展示。

我们有理由猜测,协程内部也是通过Handler+Looper实现切换到主线程运行的。


协程切换线程


当然协程不只能够从子线程切换到主线程,也可以从主线程切换到子线程,甚至在子线程之间切换。


    fun switchThread() {
println("我在某个线程,准备切换到主线程")
GlobalScope.launch(Dispatchers.Main) {
println("我在主线程,准备切换到子线程")
withContext(Dispatchers.IO) {
println("我在子线程,准备切换到子线程")
withContext(Dispatchers.Default) {
println("我在子线程,准备切换到主线程")
withContext(Dispatchers.Main) {
println("我在主线程")
}
}
}
}
}

无论是launch()函数还是withContext()函数,只要我们指定了运行的线程,那么协程将会在指定的线程上运行。


2. 协程调度器原理


指定协程运行的线程


接下来从launch()源码出发,一步步探究协程是如何切换线程的。

launch()简洁写法:


    fun launch1() {
GlobalScope.launch {
println("launch default")
}
}

launch()函数有三个参数,前两个参数都有默认值,第三个是我们的协程体,也即是 GlobalScope.launch 花括号里的内容。


#Builders.common.kt
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
//构造新的上下文
val newContext = newCoroutineContext(context)
//构造completion
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
//开启协程
coroutine.start(start, coroutine, block)
return coroutine
}

接着看newCoroutineContext 实现:


#CoroutineContext.kt
actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
//在Demo 环境里 coroutineContext = EmptyCoroutineContext
val combined = coroutineContext + context
//DEBUG = false
val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined
//没有指定分发器,默认使用的分发器为:Dispatchers.Default
//若是指定了分发器,就用指定的
return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)
debug + Dispatchers.Default else debug
}

这块涉及到CoroutineContext 一些重载运算符的操作,关于CoroutineContext 本次不会深入,只需理解其意思即可。


只需要知道:

CoroutineContext 里存放着协程的分发器。


协程有哪些分发器呢?


Dispatchers.Main



UI 线程,在Android里为主线程



Dispatchers.IO



IO 线程,主要执行IO 操作



Dispatchers.Default



主要执行CPU密集型操作,比如一些计算型任务



Dispatchers.Unconfined



不特意指定使用的线程



指定协程在主线程运行


不使用默认参数,指定协程的分发器:


    fun launch1() {
GlobalScope.launch(Dispatchers.Main) {
println("我在主线程执行")
}
}

以此为例,继续分析其源码。

上面提到过,开启协程使用coroutine.start(start, coroutine, block)函数:



#AbstractCoroutine.kt
fun <R> start(start: CoroutineStart, receiver: R, block: suspend R.() -> T) {
//start 为CoroutineStart里的函数
//最终会调用到invoke
start(block, receiver, this)
}
#CoroutineStart.kt
public operator fun <R, T> invoke(block: suspend R.() -> T, receiver: R, completion: Continuation<T>): Unit =
when (this) {
//this 指的是StandaloneCoroutine,默认走default
CoroutineStart.DEFAULT -> block.startCoroutineCancellable(receiver, completion)
CoroutineStart.ATOMIC -> block.startCoroutine(receiver, completion)
CoroutineStart.UNDISPATCHED -> block.startCoroutineUndispatched(receiver, completion)
CoroutineStart.LAZY -> Unit // will start lazily
}

CoroutineStart.DEFAULT、CoroutineStart.ATOMIC 表示的是协程的启动方式,其中DEFAULT 表示立即启动,也是默认启动方式。


接下来就是通过block去调用一系列的启动函数,这部分我们之前有详细分析过,此处再简单过一下:



block 代表的是协程体,其实际编译结果为:匿名内部类,该类继承自SuspendLambda,而SuspendLambda 间接实现了Continuation 接口。



继续看block的调用:


#Cancellable.kt
//block 的扩展函数
internal fun <R, T> (suspend (R) -> T).startCoroutineCancellable(
receiver: R, completion: Continuation<T>,
onCancellation: ((cause: Throwable) -> Unit)? = null
) =
//runSafely 为高阶函数,里边就是调用了"{}"里的内容
runSafely(completion) {
createCoroutineUnintercepted(receiver, completion).intercepted().resumeCancellableWith(Result.success(Unit), onCancellation)
}

流程流转到createCoroutineUnintercepted()函数了,在少年,你可知 Kotlin 协程最初的样子? 里有重点分析过:该函数是真正创建协程体的地方。


直接上代码:


#IntrinsicsJvm.kt
actual fun <R, T> (suspend R.() -> T).createCoroutineUnintercepted(
receiver: R,
completion: Continuation<T>
): Continuation<Unit> {
//包装completion
val probeCompletion = probeCoroutineCreated(completion)
return if (this is BaseContinuationImpl)
//创建协程体类
//receiver completion 皆为协程体对象 StandaloneCoroutine
create(receiver, probeCompletion)
else {
createCoroutineFromSuspendFunction(probeCompletion) {
(this as Function2<R, Continuation<T>, Any?>).invoke(receiver, it)
}
}
}

该函数的功能为创建一个协程体类,我们暂且称之为MyAnnoy。


class MyAnnoy extends SuspendLambda implements Function2 {
@Nullable
@Override
protected Object invokeSuspend(@NotNull Object o) {
//...协程体逻辑
return null;
}
@NotNull
@Override
public Continuation<Unit> create(@NotNull Continuation<?> completion) {
//...创建MyAnnoy
return null;
}
@Override
public Object invoke(Object o, Object o2) {
return null;
}
}

新的MyAnnoy 创建完成后,调用intercepted(xx)函数,这个函数很关键:


#Intrinsics.Jvm.kt
public actual fun <T> Continuation<T>.intercepted(): Continuation<T> =
//判断如果是ContinuationImpl,则转为ContinuationImpl 类型
//继而调用intercepted()函数
(this as? ContinuationImpl)?.intercepted() ?: this

此处为什么要将MyAnnoy 转为ContinuationImpl ?

因为它要调用ContinuationImpl里的intercepted() 函数:


#ContinuationImpl.kt
public fun intercepted(): Continuation<Any?> =
intercepted
//1、如果intercepted 为空则从context里取数据
//2、如果context 取不到,则返回自身,最后给intercepted 赋值
?: (context[ContinuationInterceptor]?.interceptContinuation(this) ?: this)
.also { intercepted = it }

先看intercepted 变量类型:


#ContinuationImpl.kt
private var intercepted: Continuation<Any?>? = null

还是Continuation 类型,初始时intercepted = null。

context[ContinuationInterceptor] 表示从CoroutineContext里取出key 为ContinuationInterceptor 的Element。

既然要取出,那么得要放进去的时候,啥时候放进去的呢?


答案是:



newCoroutineContext(context) 构造了新的CoroutineContext,里边存放了分发器。



又因为我们设定的是在主线程进行分发:Dispatchers.Main,因此context[ContinuationInterceptor] 取出来的是Dispatchers.Main。


Dispatchers.Main 定义:


#Dispatchers.kt
public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
#MainCoroutineDispatcher.kt
public abstract class MainCoroutineDispatcher : CoroutineDispatcher() {}

MainCoroutineDispatcher 继承自 CoroutineDispatcher,而它里边有个函数:


#CoroutineDispatcher.kt
public final override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
DispatchedContinuation(this, continuation)

而 Dispatchers.Main 调用的就是interceptContinuation(xx)函数。

该函数入参为Continuation 类型,也就是MyAnnoy 对象,函数的内容很简单:




  • 构造DispatchedContinuation 对象,传入的参数分别是Dispatchers.Main和MyAnnoy 对象。

  • Dispatchers.Main、MyAnnoy 分别赋值给成员变量dispatcher和continuation。



DispatchedContinuation 继承自DispatchedTask,它又继承自SchedulerTask,本质上就是Task,Task 实现了Runnable接口:


#Tasks.kt
internal abstract class Task(
@JvmField var submissionTime: Long,
@JvmField var taskContext: TaskContext
) : Runnable {
//...
}

至此,我们重点关注其实现了Runnable接口里的run()函数即可。


再回过头来看构造好DispatchedContinuation 之后,调用resumeCancellableWith()函数:


#DispatchedContinuation.kt
override fun resumeWith(result: Result<T>) {
val context = continuation.context
val state = result.toState()
//需要分发
if (dispatcher.isDispatchNeeded(context)) {
_state = state
resumeMode = MODE_ATOMIC
//调用分发器分发
dispatcher.dispatch(context, this)
} else {
executeUnconfined(state, MODE_ATOMIC) {
withCoroutineContext(this.context, countOrElement) {
continuation.resumeWith(result)
}
}
}
}

而Demo里此处的dispatcher 即为Dispatchers.Main。


好了,总结一下launch()函数的功能:



image.png


Dispatchers.Main 实现


接着来看看Dispatchers.Main 如何分发任务的,先看其实现:


#MainDispatcherLoader.java
internal object MainDispatcherLoader {

//默认true
private val FAST_SERVICE_LOADER_ENABLED = systemProp(FAST_SERVICE_LOADER_PROPERTY_NAME, true)

@JvmField
val dispatcher: MainCoroutineDispatcher = loadMainDispatcher()
//构造主线程分发
private fun loadMainDispatcher(): MainCoroutineDispatcher {
return try {
val factories = if (FAST_SERVICE_LOADER_ENABLED) {
//加载分发器工厂①
FastServiceLoader.loadMainDispatcherFactory()
} else {
...
}
//通过工厂类,创建分发器②
factories.maxByOrNull { it.loadPriority }?.tryCreateDispatcher(factories)
?: createMissingDispatcher()
} catch (e: Throwable) {
...
}
}
}

先看①:


#FastServiceLoader.kt
internal fun loadMainDispatcherFactory(): List<MainDispatcherFactory> {
val clz = MainDispatcherFactory::class.java
//...
return try {
//反射构造工厂类:AndroidDispatcherFactory
val result = ArrayList<MainDispatcherFactory>(2)
FastServiceLoader.createInstanceOf(clz,
"kotlinx.coroutines.android.AndroidDispatcherFactory")?.apply { result.add(this) }
FastServiceLoader.createInstanceOf(clz,
"kotlinx.coroutines.test.internal.TestMainDispatcherFactory")?.apply { result.add(this) }
result
} catch (e: Throwable) {
//...
}
}

该函数返回的工厂类为:AndroidDispatcherFactory。


再看②,拿到工厂类后,就该用它来创建具体的实体了:


#HandlerDispatcher.kt
internal class AndroidDispatcherFactory : MainDispatcherFactory {
//重写createDispatcher 函数,返回HandlerContext
override fun createDispatcher(allFactories: List<MainDispatcherFactory>) =
HandlerContext(Looper.getMainLooper().asHandler(async = true), "Main")
//...
}

//定义
internal class HandlerContext private constructor(
private val handler: Handler,
private val name: String?,
private val invokeImmediately: Boolean
) : HandlerDispatcher(), Delay {
}

最终创建了HandlerContext。

HandlerContext 继承自类:HandlerDispatcher


#HandlerDispatcher.kt
sealed class HandlerDispatcher : MainCoroutineDispatcher(), Delay {
//重写分发函数
override fun dispatch(context: CoroutineContext, block: Runnable) {
//抛到主线程执行,handler为主线程的Handler
handler.post(block)
}
}

很明显了,DispatchedContinuation里借助dispatcher.dispatch()进行分发,而dispatcher 是Dispatchers.Main,最终的实现是HandlerContext。

因此dispatch() 函数调用的是HandlerDispatcher.dispatch()函数,该函数里将block 抛到了主线程执行。

block 为啥是呢?

block 其实是DispatchedContinuation 对象,从上面的分析可知,它间接实现了Runnable 接口。

查看其实现:


#DispatchedTask.kt
override fun run() {
val taskContext = this.taskContext
var fatalException: Throwable? = null
try {
//delegate 为DispatchedContinuation 本身
val delegate = delegate as DispatchedContinuation<T>
//delegate.continuation 为我们的协程体 MyAnnoy
val continuation = delegate.continuation
withContinuationContext(continuation, delegate.countOrElement) {
val context = continuation.context
//...
val job = if (exception == null && resumeMode.isCancellableMode) context[Job] else null
if (job != null && !job.isActive) {
//...
} else {
if (exception != null) {
continuation.resumeWithException(exception)
} else {
//执行协程体
continuation.resume(getSuccessfulResult(state))
}
}
}
} catch (e: Throwable) {
//...
} finally {
//...
}
}

continuation 变量是我们的协程体:MyAnnoy。

MyAnnoy.resume(xx) 这函数我们很熟了,再重新熟悉一下:


#ContinuationImpl.kt
override fun resumeWith(result: Result<Any?>) {
// This loop unrolls recursion in current.resumeWith(param) to make saner and shorter stack traces on resume
var current = this
var param = result
while (true) {
with(current) {
//completion 即为开始时定义的StandaloneCoroutine
val completion = completion!! // fail fast when trying to resume continuation without completion
val outcome: Result<Any?> =
try {
//执行协程体里的代码
val outcome = invokeSuspend(param)
if (outcome === kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED) return
kotlin.Result.success(outcome)
} catch (exception: Throwable) {
kotlin.Result.failure(exception)
}
//...
}
}
}

invokeSuspend(param) 调用的是协程体里的代码,也就是launch 花括号里的内容,因此这里面的内容是主线程执行的。


再来看看launch(Dispatchers.Main)函数执行步骤如下:




  1. 分发器HandlerContext 存储在CoroutineContext(协程上下文)里。

  2. 构造DispatchedContinuation 分发器,它持有变量dispatcher=HandlerContext,continuation=MyAnnoy。

  3. DispatchedContinuation 调用dispatcher(HandlerContext) 进行分发。

  4. HandlerContext 将Runnable(DispatchedContinuation) 抛到主线程。



经过上面几步,launch(Dispatchers.Main) 任务算是完成了,至于Runnable什么时候执行与它无关了。


当Runnable 在主线程被执行后,从DispatchedContinuation 里取出continuation(MyAnnoy),并调用continuation.resume()函数,进而执行MyAnnoy.invokeSuspend()函数,最后执行了launch{}协程体里的内容。

于是协程就愉快地在主线程执行了。


老规矩,结合代码与函数调用图:



image.png


3. 协程恢复时线程的选择


以主线程为例,我们知道了协程指定线程运行的原理。

想象另一种场景:



在协程里切换了子线程执行,子线程执行完毕后还会回到主线程执行吗?



对上述Demo进行改造:


    fun launch2() {
GlobalScope.launch(Dispatchers.Main) {
println("我在主线程执行")
withContext(Dispatchers.IO) {
println("我在子线程执行")//②
}
println("我在哪个线程执行?")//③
}
}

大家先猜猜③ 的答案是什么?是主线程还是子线程?


withContext(xx)函数上篇(讲真,Kotlin 协程的挂起没那么神秘(原理篇))已经深入分析过了,它是挂起函数,主要作用:



切换线程执行协程。




image.png


MyAnnoy1 对应协程体1,为父协程体。

MyAnnoy2 对应协程体2,为子协程体。

当② 执行完成后,会切换到父协程执行,我们看看切换父协程的流程。

每个协程的执行都要经历下面这个函数:


#BaseContinuationImpl.kt
override fun resumeWith(result: Result<Any?>) {
//...
while (true) {
//..
with(current) {
val completion = completion!! // fail fast when trying to resume continuation without completion
val outcome: Result<Any?> =
try {
//执行协程体
val outcome = invokeSuspend(param)
if (outcome === kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED) return
kotlin.Result.success(outcome)
} catch (exception: Throwable) {
kotlin.Result.failure(exception)
}
releaseIntercepted() // this state machine instance is terminating
if (completion is BaseContinuationImpl) {
//...
} else {
//如果上一步的协程体不阻塞,则执行completion
completion.resumeWith(outcome)
return
}
}
}
}

此处以withContext(xx)函数协程体执行为例,它的completion 为何物?

上面提到过launch()开启协程时,它的协程体的completion 为StandaloneCoroutine,也就是说MyAnnoy1.completion = StandaloneCoroutine。

从withContext(xx)源码里得知,它的completion 为DispatchedCoroutine,DispatchedCoroutine,它继承自ScopeCoroutine,ScopeCoroutine 有个成员变量为:uCont: Continuation。

当构造DispatchedCoroutine 时,传入的协程体赋值给uCont。
也就是DispatchedCoroutine.uCont = MyAnnoy1,MyAnnoy2.completion = DispatchedCoroutine。



此时,子协程体与父协程 通过DispatchedCoroutine 关联起来了。



因此completion.resumeWith(outcome)==DispatchedCoroutine.resumeWith(outcome)。
直接查看 后者实现即可:


#AbstractCoroutine.kt
public final override fun resumeWith(result: Result<T>) {
val state = makeCompletingOnce(result.toState())
if (state === COMPLETING_WAITING_CHILDREN) return
afterResume(state)
}

#Builders.common.kt
#DispatchedCoroutine 类里
override fun afterResume(state: Any?) {
//uCont 为父协程体
uCont.intercepted().resumeCancellableWith(recoverResult(state, uCont))
}

到此就豁然开朗了,uCont.intercepted() 找到它的拦截器,因为uCont为MyAnnoy1,它的拦截器就是HandlerContext,又来了一次抛回到主线程执行。


因此,上面Demo里③ 的答案是:



它在主线程执行。



小结来看,就两步:




  1. 父协程在主线程执行,中途遇到挂起的方法切换到子线程(子协程)执行。

  2. 当子协程执行完毕后,找到父协程的协程体,继续让其按照原有规则分发。



老规矩,有代码有图有真相:



image.png


至此,切换到主线程执行的原理已经分析完毕。


好奇的小伙伴可能会问:你这举例都是子线程往主线程切换,若是子线程往子线程切换呢?

往主线程切换依靠Handler,而子线程切换依赖线程池,这块内容较多,单独拎出来分析。

既然都提到这个点了,那这里再提一个问题:


    fun launch3() {
GlobalScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Default) {
println("我在哪个线程运行")
delay(2000)
println("delay 后我在哪个线程运行")
}
println("我又在哪个线程运行")
}
}

你知道上面的答案吗?


我们下篇将重点分析协程线程池的调度原理,通过它你将会知道上面的答案。


本文基于Kotlin 1.5.3,文中完整Demo请点击


作者:小鱼人爱编程
链接:https://juejin.cn/post/7113706345190129700
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

掘金x得物公开课 - Flutter 3.0下的混合开发演进

hello 大家好,我是《Flutter 开发实战详解》的作者,Github GSY 项目的负责人郭树煜,同时也是今年新晋的 Flutter GDE,借着本次 Google I/O 之后发布的 Flutter 3.0,来和大家聊一聊 Flutter 里混合开...
继续阅读 »

hello 大家好,我是《Flutter 开发实战详解》的作者,Github GSY 项目的负责人郭树煜,同时也是今年新晋的 Flutter GDE,借着本次 Google I/O 之后发布的 Flutter 3.0,来和大家聊一聊 Flutter 里混合开发的技术演进。


为什么混合开发在 Flutter 里是特殊的存在?因为它渲染的控件是通过 Skia 直接和 GPU 交互,也就是说 Flutter 控件和平台无关,甚至连 UI 绘制线程都和原生平台 UI 线程是相互独立,所以甚至于 Flutter 在诞生之初都不支持和原生平台的控件进行混合开发,也就是不支持 WebView ,这就成了当时最大的缺陷之一


其实从渲染的角度看 Flutter 更像是一个 2D 游戏引擎,事实上 Flutter 在这次 Google I/O 也分享了基于 Flutter 的游戏开发 ToolKit 和第三方工具包 Flame ,如图所示就是本次 Google I/O 发布的 Pinball 小游戏,所以从这些角度上看都可以看出 Flutter 在混合开发的特殊性。



如果说的更形象简单一点,那就是如何把原生控件渲染到 WebView



TT


最初的社区支持


不支持 WebView 在最初可以说是 Flutter 最大的痛点之一,所以在这样窘迫的情况下,社区里涌现出一些临时的解决方法,比如 flutter_webview_plugin


类似 flutter_webview_plugin 的出现,解决了当时大部分时候 App 里打开一个网页的简单需求,如下图所示,它的思路就是:



在 Flutter 层面放一个占位控件提供大小,然后原生层在同样的位置把 WebView 添加进去,从而达到看起来把 WebView 集成进去的效果,这个思路在后续也一直被沿用



image-20220625170833702


这样的实现方式无疑成本最低速度最快,但是也带来了很多的局限性


相信大家也能想到,因为 Flutter 的所有控件都是渲染一个 FlutterView 上,也就是从原生的角度其实是一个单页面的效果,所以这种脱离 Flutter 渲染树的添加控件的方法,无疑是没办法和 Flutter 融合到一起,举个例子:



  • 如图一所示,从 Flutter 页面跳到 Native 页面的时候,打开动画无法同步,因为 AppBar 是 Flutter 的,而 Native 是原生层,它们不在同一个渲染树内,所以无法实现同步的动画效果

  • 如图二所示,比如在打开 Native 页面之后,通过 Appbar 再打开一个黄色的 Bottm Sheet ,可以看到此时黄色的 Bottm Sheet 打开了,但是却被 Native 遮挡住(Demo 里给 Native 设置了透明色),因为 Flutter 的 Bottm Sheet 是被渲染在 FlutterView 里面,而 Native UI 把 FlutterView 挡住了,所以新的 Flutter UI 自然也被遮挡

  • 如图三所示,当我们通过 reload 重刷 Flutter UI 之后,可以看到 Flutter 得 UI 都被重置了,但是此时 Native UI 还在,因为此时已经没有返回按键之类的无法关闭,这也是这种集成方式一不小心就影响开发的问题

  • 如图四通过 iOS 上的 debug 图层,我们可以更形象地看到这种方式的实现逻辑和堆叠效果



















动画不同步页面被挡reload 之后iOS
11111111222222222333333image-20220616142126589

PlatformView


随着 Flutter 的发展,官方支持混合开发势在必行,所以第一代 PlatformView 的支持还是诞生了,但是由于 Android 和 iOS 平台特性的不同,最初Android 的 AndroidView 和 iOS 的 UIKitView 实现逻辑相差甚远,以至于后面 Flutter 的 PlatformView 的每次大调整都是围绕于 Android 在做优化


Android


最初 Flutter 在 Android 上对 PlatformView 的支持是通过 VirtualDisplay 实现,VirtualDisplay 类似于一个虚拟显示区域,需要结合 DisplayManager 一起调用,VirtualDisplay 一般在副屏显示或者录屏场景下会用到,而在 Flutter 里 VirtualDisplay 会将虚拟显示区域的内容渲染在一个内存 Surface上。


在 Flutter 中通过将 AndroidView 需要渲染的内容绘制到 VirtualDisplays 中 ,然后通过 textureId 在 VirtualDisplay 对应的内存中提取绘制的纹理, 简单看实现逻辑如下图所示:


image-20220626151538054



这里其实也是类似于最初社区支持的模式:通过在 Dart 层提供一个 AndroidView ,从而获取到控件所需的大小,位置等参数,当然这里多了一个 textureId ,这个 id 主要是提交给 Flutter Engine ,通过 id Flutter 就可以在渲染时将画面从内存里提出出来。



iOS


在 iOS 平台上就不使用类似 VirtualDisplay 的方法,而是通过将 Flutter UI 分为两个透明纹理来完成组合,这种方式无疑更符合 Flutter 社区的理念,这样的好处是:



需要在 PlatformView 下方呈现的 Flutter UI 可以被绘制到其下方的纹理;而需要在 PlatformView 上方呈现的 Flutter UI 可以被绘制到其上方的纹理, 它们只需要在最后组合起来就可以了。



是不是有点抽象?


简单看下面这张图,其实就是通过在 NativeView 的不同层级设置不同的透明图层,然后把不同位置的控件渲染到不同图层,最终达到组合起来的效果。


image-20220626151526444


那明明这种方法更好,为什么 Android 不一开始也这样实现呢?


因为当时在实现思路上, VirtualDisplay 的实现模式并不支持这种模式,因为在 iOS 上框架渲染后系统会有回调通知,例如:当 iOS 视图向下移动 2px 时,我们也可以将其列表中的所有其他 Flutter 控件也向下渲染 2px


但是在 Android 上就没有任何有关的系统 API,因此无法实现同步输出的渲染。如果强行以这种方式在 Android 上使用,最终将产生很多如 AndroidView 与 Flutter UI 不同步的问题


问题


事实上 VirtualDisplay 的实现方式也带来和很多问题,简单说两个大家最直观的体会:


触摸事件


因为控件是被渲染在内存里,虽然你在 UI 上看到它就在那里,但是事实上它并不在那里,你点击到的是 FlutterView ,所以用户产生的触摸事件是直接发送到 FlutterView


所以触摸事件需要在 FlutterView 到 Dart ,再从 Dart 转发到原生,然后如果原生不处理又要转发回 Flutter ,如果中间还存在其他派生视图,事件就很容易出现丢失和无法响应,而这个过程对于 FlutterView 来说,在原生层它只有一个 View 。


所以 Android 的 MotionEvent 在转化到 Flutter 过程中可能会因为机制的不同,存在某些信息没办法完整转化的丢失。


文字输入


一般情况下 AndroidView 是无法获取到文本输入,因为 VirtualDisplay 所在的内存位置会始终被认为是 unfocused 的状态



InputConnectionsunfocused 的 View 中通常是会被丢弃。



所以 Flutter 重写了 checkInputConnectionProxy 方法,这样 Android 会认为 FlutterView 是作为 AndroidView 和输入法编辑器(IME)的代理,这样 Android 就可以从 FlutterView 中获取到 InputConnections 然后作用于 AndroidView 上面。



在 Android Q 开始又因为非全局的 InputMethodManager 需要新的兼容



当然还有诸如性能等其他问题,但是至少先有了支持,有了开始才会有后续的进阶,在 Flutter 3.0 之前, VirtualDisplay 一直默默在 PlatformView 的背后耕耘。


HybridComposition


时间来到 Flutter 1.2,Hybrid Composition 是在 Flutter 1.2 时发布的 Android 混合开发实现,它使用了类似 iOS 的实现思路,提供了 Flutter 在 Android 上的另外一种 PlatformView 的实现。


如下图是在 Dart 层使用 VirtualDisplay 切换到 HybridComposition 模式的区别,最直观的感受应该是需要写的 Dart 代码变多了。


111111


但是其实 HybridComposition 的实现逻辑是变简单了: PlatformView 是通过 FlutterMutatorView 把原生控件 addViewFlutterView 上,然后再通过 FlutterImageView 的能力去实现图层的混合



又懵了?不怕,马上你就懂了



简单来说就是 HybridComposition 模式会直接把原生控件通过 addView 添加到 FlutterView 上 。这时候大家可能会说,咦~这不是和最初的实现一样吗?怎么逻辑又回去了



其实确实是社区的进阶版实现,Flutter 直接通过原生的 addView 方法将 PlatformView 添加到 FlutterView 里,而当你还需要在 PlatformView 上渲染 Flutter 自己的 Widget 时,Flutter 就会通过再叠加一个 FlutterImageView 来承载这个 Widget 的纹理。



举一个简单的例子,如下图所示,一个原生的 TextView 被通过 HybridComposition 模式接入到 Flutter 里(NativeView),而在 Android 的显示布局边界和 Layout Inspector 上可以清晰看到: 灰色 TextView 通过 FlutterMutatorView 被添加到 FlutterView 上被直接显示出来


image-20220618152055492


所以在 HybridCompositionTextView 是直接在原生代码上被 add 到 FlutterView 上,而不是提取纹理


那如果我们看一个复杂一点的案例,如下图所示,其中蓝色的文本是原生的 TextView ,红色的文本是 Flutter 的 Text 控件,在中间 Layout Inspector 的 3D 图层下可以清晰看到:



  • 两个蓝色的 TextView 是通过 FlutterMutatorView 被添加在 FlutterView 之上,并且把没有背景色的红色 RE 遮挡住了

  • 最顶部有背景色的红色 RE 也是 Flutter 控件,但是因为它需要渲染到 TextView 之上,所以这时候多一个 FlutterImageView ,它用于承载需要显示在 Native 控件之上的纹理,从而达 Flutter 控件“真正”和原生控件混合堆叠的效果。


image-20220616165047353


可以看到 Hybrid Composition 上这种实现,能更原汁原味地保流下原生控件的事件和特性,因为从原生角度看它就是原生层面的物理堆叠,需要都一个层级就多加一个 FlutterImageView ,同一个层级的 Flutter 控件共享一个 FlutterImageView


当然,在 HybridCompositionFlutterImageView 也是一个很有故事的对象,由于篇幅原因这里就不详细展开,这里大家可以简单看这张图感受下,也就是在有 PlatformView 和没有 PlatformView 是,Flutter 的渲染会有一个转化的过程,而在这个变化过程,在 Flutter 3.0 之前可以通过 PlatformViewsService.synchronizeToNativeViewHierarchy(false); 取消


image-20220618153757996


最后,Hybrid Composition 也不少问题,比如上面的转化就是为了解决动画同步问题,当然这个行为也会产生一些性能开销,例如:



在 Android 10 之前, Hybrid Composition 需要将内存中的每个 Flutter 绘制的帧数据复制到主内存,之后再从 GPU 渲染复制回来 ,所以也会导致 Hybrid Composition 在 Android 10 之前的性能表现更差,例如在滚动列表里每个 Item 嵌套一个 Hybrid CompositionPlatformView ,就可能会变卡顿甚至闪烁。



其他还有线程同步,闪烁等问题,由于篇幅就不详细展开,如果感兴趣的可以详细看我之前发布过的 《Flutter 深入探索混合开发的技术演进》


TextureLayer


随着 Flutter 3.0 的发布,第一代 PlatformView 的实现 VirtualDisplay 被新的 TextureLayer 所替代,如下图所示,简单对比 VirtualDisplayTextureLayer 的实现差异,可以看到主要还是在于原生控件纹理的提取方式上


image-20220618154327890


从上图我们可以得知:



  • VirtualDisplayTextureLayerPlugin 的实现是可以无缝切换,因为主要修改的地方在于底层对于纹理的提取和渲染逻辑

  • 以前 Flutter 中会将 AndroidView 需要渲染的内容绘制到 VirtualDisplays ,然后在 VirtualDisplay 对应的内存中,绘制的画面就可以通过其 Surface 获取得到;现在 AndroidView 需要的内容,会通过 View 的 draw 方法被绘制到 SurfaceTexture 里,然后同样通过 TextureId 获取绘制在内存的纹理


是不是又有点蒙?简单说就是不需要绘制到副屏里,现在直接通过 override Viewdraw 方法就可以了。


TextureLayer 的实现里,同样是需要把控件添加到一个 PlatformViewWrapper 的原生布局控件里,但是这个控件通过 override 了 Viewdraw 方法,把原本的 Canvas 替换成 SurfaceTexture 在内存的 Canvas ,所以 PlatformViewWrapper 的 child 会把控件绘制到内存的 SurfaceTexture 上。



举个例子,还是之前的代码,如下图所示,这时候通过 TextureLayer 模式运行之后,通过 Layout Inspector 的 3D 图层可以看到,两个原生的 TextView 通过 PlatformViewWrapper 被添加到 FlutterView 上。


但是不同的是,在 3D 图层里看不到 TextView 的内容,因为绘制 TextView 的 Canvas 被替换了,所以 TextView 的内容被绘制到内存的 Surface 上,最终会在渲染时同步 Flutter Engine 里。



看到这里,你可能也发现了,这时候因为有 PlatformViewWrapper 的存在,点击会被 PlatformViewWrapper 内部拦截,从而也解决了触摸的问题, 而这里刚好有人提了一个问题,如下图所示:



"从图 1 Layout Inspector 看, PlatformWrapperView 是在 FlutterSurfaceView 上方,为什么如图 2 所示,点击 Flutter button 却可以不触发 native button的点击效果?"。
















图1图2
image.pngimg

思考一下,因为最直观的感受:点击不都是被 PlatformViewWrapper 拦截了吗?明明 PlatformViewWrapper 是在 FlutterSurfaceView 之上,为什么 FlutterSurfaceView 里的 FlutterButton 还能被点击到


这里简单解释一下:



  • 1、首先那个 Button 并不是真的被摆放在那里,而是通过 PlatformViewWrappersuper.draw绘制到 surface 上的,所以在那里的是 PlatformViewWrapper ,而不是 Button ,Button 的内容已经变成纹理去到了 FlutterSurfaceView 里面

  • 2、 PlatformViewWrapper 里重写了 onInterceptTouchEvent 做了拦截onInterceptTouchEvent 这个事件是从父控件开始往子控件传,因为拦截了所以不会让 Button 直接响应,然后在 PlatformViewWrapperonTouchEvent 响应里是做了点击区域的分发,响应会分发到了 AndroidTouchProcessor 之后,会打包发到 _unpackPointerDataPacket 进入 Dart

  • 3、 在 Dart 层的点击区域,如果没有 Flutter 控件响应,会是 _PlatformViewGestureRecognizer-> updateGestureRecognizers -> dispatchPointerEvent -> sendMotionEvent 又发送回原生层

  • 4、回到原生 PlatformViewsControllercreateForTextureLayer 里的 onTouch ,执行 view.dispatchTouchEvent(event);


image-20220625171101069


总结起来就是:**PlatfromViewWrapper 拦截了 Event ,通过 Dart 做二次分发响应,从而实现不同的事件响应 ** ,它和 VirtualDisplay 的不同是, VirtualDisplay 的事件响应都是在 FlutterView 上,但是TextureLayout 模式,是有独立的原生 PlatfromViewWrapper 控件来开始,所以区域效果和一致性会更好。


问题


最后这里还需要提个醒,如果你之前使用的插件使用的是 HybirdComposition ,但是没做兼容,也就是使用的还是 PlatformViewsService.initSurfaceAndroidView 的话,它也会切换成 TextureLayer 的逻辑,所以你需要切换为 PlatformViewsService.initExpensiveAndroidView ,才能继续使用原本 HybirdComposition 的效果



⚠️我也比较奇怪为什么 Flutter 3.0 没有提及 Android 这个 breaking change ,因为对于开发来说其实是无感的,不小心就掉坑里。



那你说为什么还要 HybirdComposition


前面我们说过, TextureLayer 是通过在 super.draw 替换 Canvas 的方法去实现绘制,但是它替换不了 Surface 里的一些 Canvas ,所以比如一些需要 SurfaceViewTextureView 或者有自己内部特殊 Canvas 的场景,你还是需要 HybirdComposition ,只不过可能会和官方新的 API 名字一样,它 Expensive 。


Expensive 是因为在 Flutter 3.0 正式版开始,FlutterView 在使用 HybirdComposition 时一定会 converted to FlutterImageView ,这也是 Flutter 3.0 下一个需要注意的点。


image-20220616170253242



更多内容可见 《Flutter 3.0 之 PlatformView :告别 VirtualDisplay ,拥抱 TextureLayer》



image-20220625164049356


最后


最后做个总结,可以看到 Flutter 为了混合开发做了很多的努力,特别是在 Android 上,也是因为历史埋坑的原因,由于时间关系这里没办法都详细介绍,但是相信本次之后大家对 Flutter 的 PlatformView 实现都有了全面的了解,这对大家在未来使用 Flutter 也会有很好的帮助,如果你还有什么问题,欢迎交流。


image-20220626151444011


作者:恋猫de小郭
链接:https://juejin.cn/post/7113655154347343909
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »