并发编程-线程的启动、死锁、线程安全、ThreadLocal
1 线程的启动方式
线程的启动方式只有两种。
方式1:继承Thread
,然后调用start()
启动。
private static class PrimeThread extends Thread {
@Override
public void run() {
System.out.println("thread extend Thread---name:" + Thread.currentThread().getName());
}
}
PrimeThread thread = new PrimeThread();
thread.start();
方式2:实现Runnable
,然后交给Thread
去启动。
private static class PrimeRunnable implements Runnable {
@Override
public void run() {
System.out.println("thread implements Runnable---name:" + Thread.currentThread().getName());
}
}
PrimeRunnable runnable = new PrimeRunnable();
new Thread(runnable).start();
其他的比如线程池、FutureTask
等都属于这两种的包装或封装。
并且Thread
源码的注释中也清楚的写了有两种方式创建线程:
* <p>
* There are two ways to create a new thread of execution. One is to
* declare a class to be a subclass of <code>Thread</code>. This
* subclass should override the <code>run</code> method of class
* <code>Thread</code>. An instance of the subclass can then be
* allocated and started. For example, a thread that computes primes
* larger than a stated value could be written as follows:
* <hr><blockquote><pre>
* class PrimeThread extends Thread {
* long minPrime;
* PrimeThread(long minPrime) {
* this.minPrime = minPrime;
* }
*
* public void run() {
* // compute primes larger than minPrime
* . . .
* }
* }
* </pre></blockquote><hr>
* <p>
* The following code would then create a thread and start it running:
* <blockquote><pre>
* PrimeThread p = new PrimeThread(143);
* p.start();
* </pre></blockquote>
* <p>
* The other way to create a thread is to declare a class that
* implements the <code>Runnable</code> interface. That class then
* implements the <code>run</code> method. An instance of the class can
* then be allocated, passed as an argument when creating
* <code>Thread</code>, and started. The same example in this other
* style looks like the following:
* <hr><blockquote><pre>
* class PrimeRun implements Runnable {
* long minPrime;
* PrimeRun(long minPrime) {
* this.minPrime = minPrime;
* }
*
* public void run() {
* // compute primes larger than minPrime
* . . .
* }
* }
* </pre></blockquote><hr>
* <p>
2 线程的状态
Java中线程的状态分为6种:
1、初始(NEW):新创建了一个线程,但是还没有调用start()
方法。
2、运行(RUNNABLE):Java线程中将就绪(READY)和运行中(RUNNING)两种装填笼统的称为“运行”。
线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法,该状态的线程位于可运行线程池中,获取CPU的使用权,此时处于就绪状态(READY),就绪状态的线程在获得CPU时间片后变为运行中状态(RUNNING)。
3、阻塞(BLOCKED):表示线程阻塞于锁。
4、等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
5、超时等待(TIMED_WAITING):该状态不同于WATING,它可以在指定的时间后自行返回。
6、终止(TERMINATED):表示该线程已经执行完毕。
线程生命周期如下:
3 死锁
3.1 概念
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
死锁是必然发生在多操作者(M>=2个)情况下,争夺多个资源(N>=2个,且N<=M)才会发生这种情况。很明显,单线程自然不会有死锁。
死锁还有几个要求:
- 争夺资源的顺序不对,如果争夺资源的顺序是一样的,也不会产生死锁。
- 争夺者拿到资源不放手。
3.1.1 学术定义
死锁的发生必须具备以下四个必要条件。
- 互斥条件: 指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
- 请求和保持条件: 指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
- 不剥夺条件: 指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
- 环路等待条件: 指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。
理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。只要打破四个必要条件之一就能有效预防死锁的发生。
- 打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
- 打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
- 打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
- 打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。
避免死锁常见的算法有有序资源分配法、银行家算法。
示例代码:
/**
* @Description: 死锁的产生
* @CreateDate: 2022/3/15 2:31 下午
*/
public class NormalDeadLock {
/**
* 第1个锁
*/
private static final Object LOCK_1 = new Object();
/**
* 第2个锁
*/
private static final Object LOCK_2 = new Object();
/**
* 第1个拿锁的方法 先去拿锁1,再去拿锁2
*
* @throws InterruptedException 中断异常
*/
private static void method1() throws InterruptedException {
String threadName = Thread.currentThread().getName();
synchronized (LOCK_1) {
System.out.println(threadName + " get LOCK_1");
Thread.sleep(100);
synchronized (LOCK_2) {
System.out.println(threadName + " get LOCK_2");
}
}
}
/**
* 第2个拿锁的方法 先去拿锁2,再去拿锁1,这就导致方法1和方法2各拿一个锁,然后互不相让,都不释放自己的锁,造成了互斥,就产生了死锁
*
* @throws InterruptedException 中断异常
*/
private static void method2() throws InterruptedException {
String threadName = Thread.currentThread().getName();
synchronized (LOCK_2) {
System.out.println(threadName + " get LOCK_2");
Thread.sleep(100);
synchronized (LOCK_1) {
System.out.println(threadName + " get LOCK_1");
}
}
}
/**
* 子线程PrimeThread1
*/
private static class PrimeThread1 extends Thread {
private final String name;
public PrimeThread1(String name) {
this.name = name;
}
@Override
public void run() {
Thread.currentThread().setName(name);
try {
method1();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* 子线程PrimeThread2
*/
private static class PrimeThread2 extends Thread {
private final String name;
public PrimeThread2(String name) {
this.name = name;
}
@Override
public void run() {
Thread.currentThread().setName(name);
try {
method2();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
PrimeThread1 thread1 = new PrimeThread1("PrimeThread1");
PrimeThread2 thread2 = new PrimeThread2("PrimeThread2");
thread1.start();
thread2.start();
}
}
执行后,可以看到控制台没有结束运行,看不到Process finished with exit code 0
,但是又一直处于静止状态。
PrimeThread1 get LOCK_1
PrimeThread2 get LOCK_2
3.2 危害
- 线程不工作了,但是整个程序还是活着的。
- 没有任何的异常信息可以供我们检查。
- 一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对正式已发布程序来说,这是个很严重的问题。
3.3 解决方案
关键是保证拿锁的顺序一致。
两种解决方式:
1、内部通过顺序比较,确定拿锁的顺序。
比如上述示例代码中,可以让方法1和方法2同时都先拿锁1,然后再去拿锁2,就能解决死锁问题。
private static void method1() throws InterruptedException {
String threadName = Thread.currentThread().getName();
synchronized (LOCK_1) {
System.out.println(threadName + " get LOCK_1");
Thread.sleep(100);
synchronized (LOCK_2) {
System.out.println(threadName + " get LOCK_2");
}
}
}
private static void method2() throws InterruptedException {
String threadName = Thread.currentThread().getName();
synchronized (LOCK_1) {
System.out.println(threadName + " get LOCK_1");
Thread.sleep(100);
synchronized (LOCK_2) {
System.out.println(threadName + " get LOCK_2");
}
}
}
修改后后,可以看到程序能正常执行。
PrimeThread1 get LOCK_1
PrimeThread1 get LOCK_2
PrimeThread2 get LOCK_1
PrimeThread2 get LOCK_2
Process finished with exit code 0
2、采用尝试拿锁的机制。
示例代码:
/**
* @Description: 尝试拿锁,解决死锁问题
* @CreateDate: 2022/3/15 2:57 下午
*/
public class TryGetLock {
/**
* 第1个锁
*/
private static final Lock LOCK_1 = new ReentrantLock();
/**
* 第2个锁
*/
private static final Lock LOCK_2 = new ReentrantLock();
/**
* 方法1 先尝试拿锁1,再尝试拿锁2,拿不到锁2的话连同锁1一起释放
*
* @throws InterruptedException 中断异常
*/
private static void method1() throws InterruptedException {
String threadName = Thread.currentThread().getName();
Random r = new Random();
while (true) {
if (LOCK_1.tryLock()) {
System.out.println(threadName + " get LOCK_1");
try {
if (LOCK_2.tryLock()) {
try {
System.out.println(threadName + " get LOCK_2");
System.out.println("method1 do working...");
break;
} finally {
LOCK_2.unlock();
}
}
} finally {
LOCK_1.unlock();
}
}
//注意:这里需要给个很短的间隔时间去让其他线程拿锁,不然可能会造成活锁
Thread.sleep(r.nextInt(3));
}
}
/**
* 方法2 先尝试拿锁2,再尝试拿锁1,拿不到锁1的话连同锁2一起释放
*
* @throws InterruptedException 中断异常
*/
private static void method2() throws InterruptedException {
String threadName = Thread.currentThread().getName();
Random r = new Random();
while (true) {
if (LOCK_2.tryLock()) {
System.out.println(threadName + " get LOCK_2");
try {
if (LOCK_1.tryLock()) {
try {
System.out.println(threadName + " get LOCK_1");
System.out.println("method2 do working...");
break;
} finally {
LOCK_1.unlock();
}
}
} finally {
LOCK_2.unlock();
}
}
Thread.sleep(r.nextInt(3));
}
}
private static class PrimeThread1 extends Thread {
private final String name;
public PrimeThread1(String name) {
this.name = name;
}
@Override
public void run() {
Thread.currentThread().setName(name);
try {
method1();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private static class PrimeThread2 extends Thread {
private final String name;
public PrimeThread2(String name) {
this.name = name;
}
@Override
public void run() {
Thread.currentThread().setName(name);
try {
method2();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
PrimeThread1 thread1 = new PrimeThread1("PrimeThread1");
PrimeThread2 thread2 = new PrimeThread2("PrimeThread2");
thread1.start();
thread2.start();
}
}
执行结果:
PrimeThread2 get LOCK_2
PrimeThread1 get LOCK_1
PrimeThread2 get LOCK_2
PrimeThread2 get LOCK_1
method2 do working...
PrimeThread1 get LOCK_1
PrimeThread1 get LOCK_2
method1 do working...
Process finished with exit code 0
4 其他线程安全问题
4.1 活锁
两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。
解决办法:每个线程休眠随机数,错开拿锁的时间。
如上边的尝试拿锁示例代码中,如果不加随机sleep,就会造成活锁。
4.2 线程饥饿
低优先级的线程,总是拿不到执行时间。
5 ThreadLocal
5.1 与Synchonized的比较
ThreadLocal
和synchonized
都用于解决多线程并发訪问。但是ThreadLocal
与synchronized
有本质的差别。synchronized
是利用锁的机制,使变量或代码块在某一时该仅仅能被一个线程访问。而ThreadLocal
为每个线程都提供了变量的副本,使得每个线程在某一时间访问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。
5.2 ThreadLocal的使用
ThreadLocal
类接口很简单,只有4个方法:
protected T initialValue()
返回该线程局部变量的初始值,该方法是一个protected的方法,显然是为了让子类覆盖而设计的。这个方法是一个延迟调用方法,在线程第1次调用get()
或set(Object)
时才执行,并且仅执行1次。ThreadLocal
中的缺省实现直接返回一个null。public void set(T value)
设置当前线程的线程局部变量。public T get()
返回当前线程所对应的线程局部变量。public void remove()
将当前线程局部变量的值删除,目的是为了减少内存的占用,该方法是JDK 5.0新增的方法。需要指出的是,当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显式调用该方法清除线程的局部变量并不是必须的操作,但它可以加快内存回收的速度。
public final static ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<String>();
THREAD_LOCAL
代表一个能够存放String
类型的ThreadLocal
对象。此时不论什么一个线程能够并发访问这个变量,对它进行写入、读取操作,都是线程安全的。
示例代码:
/**
* @Description: 使用ThreadLocal
* @CreateDate: 2022/3/15 3:37 下午
*/
public class UseThreadLocal {
private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();
private void startThreadArray() {
Thread[] threads = new Thread[3];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new PrimeRunnable(i));
}
for (int i = 0; i < threads.length; i++) {
threads[i].start();
}
}
private static class PrimeRunnable implements Runnable {
private final int id;
public PrimeRunnable(int id) {
this.id = id;
}
@Override
public void run() {
String threadName = Thread.currentThread().getName();
THREAD_LOCAL.set("线程" + id);
System.out.println(threadName + ":" + THREAD_LOCAL.get());
}
}
public static void main(String[] args) {
UseThreadLocal useThreadLocal = new UseThreadLocal();
useThreadLocal.startThreadArray();
}
}
5.3 ThreadLocal的内部实现
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
ThreadLocal.ThreadLocalMap threadLocals = null;
上面先取到当前线程,然后调用getMap
方法获取对应的ThreadLocalMap
,ThreadLocalMap
是ThreadLocal
的静态内部类,然后Thread
类中有一个这样类型成员,所以getMap
是直接返回Thread
的成员。
看下ThreadLocal
的内部类ThreadLocalMap
源码:
static class ThreadLocalMap {
/**
* The entries in this hash map extend WeakReference, using
* its main ref field as the key (which is always a
* ThreadLocal object). Note that null keys (i.e. entry.get()
* == null) mean that the key is no longer referenced, so the
* entry can be expunged from table. Such entries are referred to
* as "stale entries" in the code that follows.
*/
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
//类似于map的key、value结构,key就是ThreadLocal,value就是要隔离访问的变量
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
/**
* The initial capacity -- MUST be a power of two.
*/
private static final int INITIAL_CAPACITY = 16;
/**
* The table, resized as necessary.
* table.length MUST always be a power of two.
* 用数组保存了Entry,因为可能有多个变量需要线程隔离访问
*/
private Entry[] table;
可以看到有个Entry
内部静态类,它继承了WeakReference
,总之它记录了两个信息,一个是ThreadLocal<?>
类型,一个是Object
类型的值。getEntry
方法则是获取某个ThreadLocal
对应的值,set
方法就是更新或赋值相应的ThreadLocal
对应的值。
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// Android-changed: Use refersTo()
if (e != null && e.refersTo(key))
return e;
else
return getEntryAfterMiss(key, i, e);
}
private void set(ThreadLocal<?> key, Object value) {
// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
回顾get
方法,其实就是拿到每个线程独有的ThreadLocalMap
,然后再用ThreadLocal
的当前实例,拿到Map
中的相应的Entry
,然后就可以拿到相应的值返回出去。当然,如果Map
为空,还会先进行Map
的创建,初始化等工作。
作者:木水Code
链接:https://juejin.cn/post/7102969152477855780
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter入口中的runApp方法解析
前言
开发中,如果在runApp
方法执行之前设置Android沉浸式样式报错,需要先设置WidgetsFlutterBinding.ensureInitialized();
这一行代码才行,为什么,接下来看下这一行代码具体做了啥。
点进去发现这个方法在runApp
中进行了实现,并且还调用了WidgetsFlutterBinding
的另两个方法,
方法体:
void runApp(Widget app) {
WidgetsFlutterBinding.ensureInitialized()
..scheduleAttachRootWidget(app)
..scheduleWarmUpFrame();
}
接下来我们对这三个方法进行一个一个进行分析。
1、WidgetsFlutterBinding.ensureInitialized()
代码:
/// widgets框架的具体绑定,将框架绑定到Flutter引擎的中间层。
/// A concrete binding for applications based on the Widgets framework.
/// This is the glue that binds the framework to the Flutter engine.
class WidgetsFlutterBinding extends BindingBase with GestureBinding,
SchedulerBinding, ServicesBinding, PaintingBinding,
SemanticsBinding, RendererBinding, WidgetsBinding {
/// 只有需要绑定时,才需要调用这个方法,在runApp之前调用。
/// You only need to call this method if you need the binding to be
/// initialized before calling [runApp].
static WidgetsBinding ensureInitialized() {
if (WidgetsBinding.instance == null)
WidgetsFlutterBinding();
return WidgetsBinding.instance!;
}
}
可以看到,WidgetsFlutterBinding
继承了 BindingBase
,并混入了一些其他Binding
。
先看下BindingBase
类,
/// 初始化 获取唯一window
ui.SingletonFlutterWindow get window => ui.window;
/// 初始化PlatformDispatcher实例,平台消息和配置的中心入口点,负责分发事件给Flutter引擎。
ui.PlatformDispatcher get platformDispatcher => ui.PlatformDispatcher.instance;
主要是获取了window
实例和PlatformDispatcher
实例。
再看下其他Binding
解释:
GestureBinding
:处理手势相关。
SchedulerBinding
: 处理系统调度。
ServicesBinding
:处理与原生的交互。
PaintingBinding
:处理绘制相关。
SemanticsBinding
:处理语义化。
RendererBinding
:处理渲染相关。
WidgetsBinding
:Widgets
相关。
Flutter框架层的相关基础绑定。
接着我们看下改变状态栏的代码。
改变样式核心代码:
if (_pendingStyle != _latestStyle) {
// 通过和原生平台进行通信 来改变具体平台状态样式
SystemChannels.platform.invokeMethod<void>(
'SystemChrome.setSystemUIOverlayStyle',
_pendingStyle!._toMap(),
);
_latestStyle = _pendingStyle;
}
通过 ensureInitialized
的注释和修改样式的代码即可解决我们开头的疑问,因为设置状态栏样式是通过原生window
窗口进行修改的,所以这里如果需要修改状态栏,就需要进行和原生绑定才能拿到原生的window
窗口来进行修改。
从注释来看:WidgetsFlutterBinding
是widgets框架的具体绑定,将框架绑定到Flutter引擎的中间层。
通过 ensureInitialized
方法返回 一个WidgetsBinding
单例类。
至此,WidgetsFlutterBinding.ensureInitialized();的工作已经结束。
就是做了初始化引擎绑定,返回WidgetsBinding
。
..scheduleAttachRootWidget(app)
上一个方法我们知道返回了WidgetsBinding
类,那这个方法就是在WidgetsBinding
这个类里,接下来先看下这个类。
/// widgets和Flutter引擎之间的粘合剂,中间层
/// The glue between the widgets layer and the Flutter engine.
mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, RendererBinding, SemanticsBinding {
@override
void initInstances() {
super.initInstances();
_instance = this;
// Initialization of [_buildOwner] has to be done after
// [super.initInstances] is called, as it requires [ServicesBinding] to
// properly setup the [defaultBinaryMessenger] instance.
_buildOwner = BuildOwner();
buildOwner!.onBuildScheduled = _handleBuildScheduled;
/// 略
@protected
void scheduleAttachRootWidget(Widget rootWidget) {
Timer.run(() {
attachRootWidget(rootWidget);
});
}
void attachRootWidget(Widget rootWidget) {
final bool isBootstrapFrame = renderViewElement == null;
_readyToProduceFrames = true;
_renderViewElement = RenderObjectToWidgetAdapter<RenderBox>(
container: renderView,
debugShortDescription: '[root]',
child: rootWidget,
).attachToRenderTree(buildOwner!, renderViewElement as RenderObjectToWidgetElement<RenderBox>?);
if (isBootstrapFrame) {
SchedulerBinding.instance!.ensureVisualUpdate();
}
}
可以看到这是一个mixin类,创建了BuildOwner
的实例,这个类是用来管理Element
的,在attachRootWidget
方法中创建了RenderObjectToWidgetAdapter
实例,并设置了我们的runApp中的参数根节点rootWigdt
。
/// 桥接 RenderObject 到 Element
/// A bridge from a [RenderObject] to an [Element] tree.
RenderObjectToWidgetAdapter({
this.child,
required this.container,
this.debugShortDescription,
}) : super(key: GlobalObjectKey(container));
RenderObjectToWidgetAdapter
是桥接RenderObject
到Element
的,Element
是持有Widget
的具体实现,通过RenderObject
进行渲染,也就是通过这个方法实现了 Widget、Element、RenderObject
的初始及绑定关系。
..scheduleWarmUpFrame();
绑定之后,接下来就是将内容显示在屏幕上,从以下代码分别调用了handleBeginFrame
、handleDrawFrame
方法,通过hadScheduledFrame
判断是否调用handleBeginFrame
触发scheduleFrame
方法,调用 window.scheduleFrame();
最终调用 platformDispatcher.scheduleFrame();
通知引擎在合适的时机进行帧绘制。
void scheduleWarmUpFrame() {
if (_warmUpFrame || schedulerPhase != SchedulerPhase.idle)
return;
_warmUpFrame = true;
final TimelineTask timelineTask = TimelineTask()..start('Warm-up frame');
final bool hadScheduledFrame = _hasScheduledFrame;
// We use timers here to ensure that microtasks flush in between.
Timer.run(() {
assert(_warmUpFrame);
handleBeginFrame(null);
});
Timer.run(() {
assert(_warmUpFrame);
handleDrawFrame();
resetEpoch();
_warmUpFrame = false;
if (hadScheduledFrame)
scheduleFrame();
});
void scheduleFrame() {
window.scheduleFrame();
}
/// Requests that, at the next appropriate opportunity, the [onBeginFrame] and
/// [onDrawFrame] callbacks be invoked.
void scheduleFrame() => platformDispatcher.scheduleFrame();
小结
runApp中的三个方法执行的三步分别是:
1、初始化WidgetsFlutterBinding
返回WidgetBinding
实例。
2、初始化Widget、Elment、RenderObject
三棵树并确定绑定关系。
3、通知引擎合适时机进行帧绘制。更快的将内容显示到屏幕中。
作者:老李code
链接:https://juejin.cn/post/7098218181604409381
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Kotlin - 改良装饰者模式
一、前言
- 装饰者模式
- 作用:在不必改变原类文件和使用继承的情况下,动态地扩展一个对象的功能。
- 本质:该模式通过创建一个包装对象,来包裹真实的对象。
- 核心操作:
- 创建一个装饰类,包含一个被装饰类的实例
- 装饰类重写所有被装饰类的方法
- 在装饰类中对需要增强的功能进行扩展
二、使用装饰者模式
- 例子:枪支部件
- 重点:装饰器类设计(实现被装饰类相同的接口,构造器接收被装饰类的接口实例对象)
像绝地求生这种大型射击游戏,里面的枪支系统是很复杂的,有很多种枪,而且几乎每种枪上都可以装配各种各样的部件,比如消声器、八倍镜之类的,部件的作用各不相同,有的可以增加火力,有的可以提高精确度,等等,现在我们来简单设计一下这个枪支系统,枪有很多种,所以需要定义一个接口来描述枪都有哪些能力,供后续扩展各种新枪:
/**
* 枪支接口
*
* @author GitLqr
*/
interface Gun {
/**
* 攻击力
*/
fun attack(): Float
/**
* 噪音
*/
fun noise(): Float
/**
* 生产日期
*/
fun prodDate(): String
}
/**
* Ump9
*
* @author GitLqr
*/
class Ump9Gun : Gun {
override fun attack() = 100f
override fun noise() = 20f
override fun prodDate() = "2020-02-18"
}
这里只实现了 Ump9 这个型号的枪,后续还可以根据需要扩展,现在来想想枪支部件怎么设计?在 Java 中,给一个类扩展行为有两种选择:
- 设计一个继承它的子类
- 使用装饰者模式对该类进行装饰
那么枪支部件合适用继承方式来设计吗?显然不合适,因为一个部件可以装配在不只一种枪上,所以继承这种方式排除。另一种方式,使用装饰者模式有一个很大的优势,在于符合“组合优于继承”的设计原则,我们知道,部件可以和任意枪组合,显示,使用装饰者模式来设计枪支部件是一个不错的选择:
/**
* 枪支部件
*
* @author GitLqr
*/
abstract class GunPart(protected val gun: Gun) : Gun
/**
* 消声器
*
* @author GitLqr
*/
class Muffler(gun: Gun) : GunPart(gun) {
override fun attack() = gun.attack() - 5
override fun noise() = 0f
override fun prodDate() = gun.prodDate()
}
/**
* 燃烧子弹
*
* @author GitLqr
*/
class FireBullet(gun: Gun) : GunPart(gun) {
override fun attack() = gun.attack() + 200
override fun noise() = gun.noise()
override fun prodDate() = gun.prodDate()
}
程序设计时,装饰器(部件)会引用被装饰实例(枪),并实现被装饰实例的所有接口,然后在需要增强的接口方法中加入增强逻辑。因为枪支部件 GunPart
接收 Gun
类型构造参数,而且本身也是 Gun
接口的实现类,所以,可以让多种枪支部件 GunPart
嵌套修饰枪实例:
// 使用
var ump9: Gun = Ump9Gun()
println("装配前:ump9 攻击力 ${ump9.attack()},噪音 ${ump9.noise()}")
ump9 = Muffler(FireBullet(ump9)) // 装配了 燃烧子弹、消声器 的ump9
println("装配后:ump9 攻击力 ${ump9.attack()},噪音 ${ump9.noise()}")
// 输出
装配前:ump9 攻击力 100.0,噪音 20.0
装配后:ump9 攻击力 295.0,噪音 0.0
三、改良装饰者模式
- 例子:枪支部件
- 重点:类委托(
by
关键字)
在上面的例子中,装饰者模式可以很好的解决实例组合的情况,但是代码还是显得比较啰唆,因为需要重写所有的装饰对象方法,所以可能会存在大量样板代码。比如 FireBullet
只装饰增强 attack()
方法,而 noise()
、prodDate()
均不做修改,但还要是把这两个方法重写一遍。Kotlin 中有类委托特性,利用 by
关键字,将装饰类的所有方法委托给一个被装饰的类对象,然后只需覆写装饰的方法即可:
/**
* 枪支部件
*
* @author GitLqr
*/
abstract class GunPart(protected val gun: Gun) : Gun by gun
/**
* 消声器
*
* @author GitLqr
*/
class Muffler(gun: Gun) : GunPart(gun) {
override fun attack() = gun.attack() - 5
override fun noise() = 0f
}
/**
* 燃烧子弹
*
* @author GitLqr
*/
class FireBullet(gun: Gun) : GunPart(gun) {
override fun attack() = gun.attack() + 200
}
可以看到,使用类委托之后,装饰类 FireBullet
中的样板代码不用重写了,从而减少了代码量。
作者:GitLqr
链接:https://juejin.cn/post/7102612022512058376
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
<版本>Android统一依赖管理
总结:
在多module的项目中,对版本的统一管理很重要,可以避免多个版本库的冲突问题,也方便日后的统一升级等等
Android的版本依赖的统一管理,有三种方式:
- 传统
apply from
的方式 buildsrc
方式composing builds
方式
一、传统apply from
的方式
在根目录新建一个config.gradle
(或其他随意的xxx.gradle
)文件
或在根目录的build.gradle
定义一些变量
如:
ext {
android = [
compileSdkVersion: 30,
buildToolsVersion: "30",
minSdkVersion : 16,
targetSdkVersion : 28,
versionCode : 100,
versionName : "1.0.0"
]
versions = [
appcompatVersion : "1.1.0",
coreKtxVersion : "1.2.0",
supportLibraryVersion : "28.0.0",
glideVersion : "4.11.0",
okhttpVersion : "3.11.0",
retrofitVersion : "2.3.0",
constraintLayoutVersion: "1.1.3",
gsonVersion : "2.8",
//等等······
]
dependencies = [
//base
"constraintLayout" : "androidx.constraintlayout:constraintlayout:${version["constraintLayoutVersion"]}",
"appcompat" : "androidx.appcompat:appcompat:${version["appcompatVersion"]}",
"coreKtx" : "androidx.core:core-ktx:${version["coreKtxVersion"]}",
//等等······
]
}
在工程的根目录build.gradle
添加:
apply from"config.gradle"
在需要依赖的module
的build.gradle
中,依赖的方式如下:
dependencies {
...
// 添加appcompatVersion依赖
api rootProject.ext.dependencies["appcompatVersion"]
...
}
【缺点】
- 无法跟踪代码,需要手动搜索相关的依赖
- 可读性很差
二、buildsrc
方式
什么是buildsrc
当运行 gradle
时会检查项目中是否存在一个名为 buildsrc
的目录。然后 gradle 会自动编译并测试这段代码,并将其放入构建脚本的类路径中。
对于多项目构建,只能有一个 buildsrc
目录,该目录必须位于根项目目录中, buildsrc
是 gradle 项目根目录下的一个目录,它可以包含我们的构建逻辑。
与脚本插件相比,buildsrc
应该是首选,因为它更易于维护、重构和测试代码。
优缺点:
1】优点:
buildSrc
是Android默认插件,共享buildsrc
库工件的引用,全局只有这一个地方可以修改它- 支持自动补全,支持跳转。
2】缺点:
- 依赖更新将重新构建整个项目,项目越大,重新构建的时间就越长,造成不必要的时间浪费。
A change in buildSrc causes the whole project to become out-of-date. Thus, when making small incremental changes, the --no-rebuild command-line option is often helpful to get faster feedback. Remember to run a full build regularly or at least when you’re done, though.
buildSrc的更改会导致整个项目过时,因此,在进行小的增量更改时,-- --no-rebuild命令行选项通常有助于获得更快的反馈。不过,请记住要定期或至少在完成后运行完整版本。
使用方式:
参考:Kotlin + buildSrc for Better Gradle Dependency Management
在项目根目录下新建一个名为
buildSrc
的文件夹(名字必须是 buildSrc,因为运行 Gradle 时会检查项目中是否存在一个名为 buildSrc 的目录)
在 buildSrc 文件夹里创建名为
build.gradle.kts
的文件,添加以下内容
plugins {
`kotlin-dsl`
}
repositories{
jcenter()
}
- 在
buildSrc/src/main/java/包名/
目录下新建Deps.kt
文件,添加以下内容
object Versions {
......
val appcompat = "1.1.0"
......
}
object Deps {
......
val appcompat = "androidx.appcompat:appcompat:${Versions.appcompat}"
......
}
- 重启 Android Studio,项目里就会多出一个名为 buildSrc 的 module,实现效果
三、composing builds
方式
摘自 Gradle 文档:复合构建只是包含其他构建的构建. 在许多方面,复合构建类似于 Gradle 多项目构建,不同之处在于,它包括完整的 builds ,而不是包含单个 projects
- 组合通常独立开发的构建,例如,在应用程序使用的库中尝试错误修复时
- 将大型的多项目构建分解为更小,更孤立的块,可以根据需要独立或一起工作
优缺点:
1】优点:
- 支持单向跟踪
- 自动补全
- 依赖更新时,不会重新构建整个项目
2】缺点:
- 需要在每一个module中都添加相应的插件引用
使用方式:
参考Gradle文档
- 新建的 module 名称
VersionPlugin
(名字随意) - 在 versionPlugin 文件夹下的 build.gradle 文件内,添加以下内容
buildscript {
repositories {
jcenter()
}
dependencies {
// 因为使用的 Kotlin 需要需要添加 Kotlin 插件
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
}
}
apply plugin: 'kotlin'
apply plugin: 'java-gradle-plugin'
repositories {
// 需要添加 jcenter 否则会提示找不到 gradlePlugin
jcenter()
}
gradlePlugin {
plugins {
version {
// 在 app 模块需要通过 id 引用这个插件
id = 'com.yu.plugin'
// 实现这个插件的类的路径
implementationClass = 'com.yu.versionplugin.VersionPlugin'
}
}
}
- 在
VersionPlugin/src/main/java/包名/
目录下新建DependencyManager.kt
文件,添加相关的依赖配置,如:
package com.yu.versionplugin
/**
* 配置和 build相关的
*/
object BuildVersion {
const val compileSdkVersion = 29
const val buildToolsVersion = "29.0.2"
const val minSdkVersion = 17
const val targetSdkVersion = 26
const val versionCode = 102
const val versionName = "1.0.2"
}
/**
* 项目相关配置
*/
object BuildConfig {
//AndroidX
const val appcompat = "androidx.appcompat:appcompat:1.2.0"
const val constraintLayout = "androidx.constraintlayout:constraintlayout:2.0.4"
const val coreKtx = "androidx.core:core-ktx:1.3.2"
const val material = "com.google.android.material:material:1.2.1"
const val junittest = "androidx.test.ext:junit:1.1.2"
const val swiperefreshlayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
const val recyclerview = "androidx.recyclerview:recyclerview:1.1.0"
const val cardview = "androidx.cardview:cardview:1.0.0"
//Depend
const val junit = "junit:junit:4.12"
const val espresso_core = "com.android.support.test.espresso:espresso-core:3.0.2"
const val guava = "com.google.guava:guava:24.1-jre"
const val commons = "org.apache.commons:commons-lang3:3.6"
const val zxing = "com.google.zxing:core:3.3.2"
//leakcanary
const val leakcanary = "com.squareup.leakcanary:leakcanary-android:2.4"
//jetPack
const val room_runtime = "androidx.room:room-runtime:2.2.5"
const val room_compiler = "androidx.room:room-compiler:2.2.5"
const val room_rxjava2 = "androidx.room:room-rxjava2:2.2.5"
const val lifecycle_extensions = "android.arch.lifecycle:extensions:1.1.1"
const val lifecycle_compiler = "android.arch.lifecycle:compiler:1.1.1"
const val rxlifecycle = "com.trello.rxlifecycle3:rxlifecycle:3.1.0"
const val rxlifecycle_components = "com.trello.rxlifecycle3:rxlifecycle-components:3.1.0"
//Kotlin
const val kotlinx_coroutines_core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7"
//...
}
- 在
VersionPlugin/src/main/java/包名/
目录下新建VersionPlugin.kt
,实现Plugin接口,如下:
package com.yu.versionplugin
import org.gradle.api.Plugin
import org.gradle.api.Project
class VersionPlugin : Plugin<Project>{
override fun apply(p0: Project) {
}
companion object{
}
}
- 在
settings.gradle
文件内添加如下代码,并重启 Android Studio
//注意是 includeBuild
includeBuild 'VersionPlugin'
- 在
app
模块build.gradle
文件内 首行 添加以下内容
plugins{
// 这个 id 就是在 VersionPlugin 文件夹下 build.gradle 文件内定义的 id
id "com.yu.plugin"
}
// 定义的依赖地址
import com.yu.versionplugin.*
- 使用如下:
import com.yu.versionplugin.*
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'com.yu.plugin'
}
android {
compileSdk 32
defaultConfig {
applicationId "com.yu.versiontest"
minSdk BuildVersion.minSdkVersion
targetSdk BuildVersion.targetSdkVersion
versionCode BuildVersion.versionCode
versionName BuildVersion.versionName
}
//.....
}
dependencies {
implementation BuildConfig.coreKtx
implementation BuildConfig.appcompat
implementation BuildConfig.material
//......
}
作者:玉圣
链接:https://juejin.cn/post/7097431328441761800
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
如何解决Flutter的WebView白屏和视频自动播放
前言
众所周知,Flutter 的 WebView 不太友好,用起来不顺手。
我们 Flutter 开发常用的 WebView 库有2个,一个是 Flutter 官方自己出的 webview_flutter ,另一个是比较流行的 flutter_inappwebview 。这两个库其实差不多,flutter_inappwebview 功能比较丰富,封装了很多事件、方法等,但是很多问题这两个库都会遇到。本文以 webview_flutter 为基础库展开讲解相关问题以及解决方案。
问题
白屏、UI错乱
如上图所示
- 测试的时候发现部分手机(如OPPO)会出现白屏现象(左图)
- 原生与 Flutter 混编,打开页面会发现页面布局变了,顶部banner变小了(右图)
查阅网上的一些解决方案,千篇一律都是:
if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView();
但是,这样设置其实是不对的,还会出现以上问题,真正的解决方案是:
if (Platform.isAndroid) WebView.platform = AndroidWebView();
视频自动播放
由于需求需要,打开页面的时候,列表的第一个视频(YouTube/Facebook 视频)需要自动播放。
但是发现没法自动播放,如下图,会出现播放之后马上暂停的现象。
查阅资料得知,是谷歌浏览器的隐私政策导致的。
- Chrome 的自动播放策略
- 始终允许静音自动播放。
- 在以下情况下允许自动播放声音:
- 用户与域进行了交互(单击、点击等)
- 在桌面上,用户的媒体参与指数阈值已被超过,这意味着用户之前播放过有声视频。
- 用户已将网站添加到移动设备的主屏幕或在桌面设备上安装 PWA。
- 顶级框架可以将自动播放权限委托给其 iframe 以允许自动播放声音。
所以要想视频自动播放,有两种方案:
- 静音播放。
- 在 Web 端调用视频播放器的静音即可自动播放。
- 模拟点击。
- 给 WebView 设置一个 GlobalKey 。
WebView(
key: logic.state.videoGlobalKey,
......
);
}
- 然后在 WebView 的 onPageFinished 方法里,通过 GlobalKey 获取 WebView 的位置,从而进行模拟点击,就可以自动播放视频了。
var currentContext = state.videoGlobalKey.currentContext;
var offset = (currentContext?.findRenderObject() as RenderBox)
.localToGlobal(Offset.zero);
//模拟点击
var addPointer = PointerAddedEvent(
pointer: 0,
position: Offset(
offset.dx + 92.w,
offset.dy + 92.w),
);
var downPointer = PointerDownEvent(
pointer: 0,
position: Offset(
offset.dx + 92.w,
offset.dy + 92.w),
);
var upPointer = PointerUpEvent(
pointer: 0,
position: Offset(
offset.dx + 92.w,
offset.dy + 92.w),
);
GestureBinding.instance!.handlePointerEvent(addPointer);
GestureBinding.instance!.handlePointerEvent(downPointer);
GestureBinding.instance!.handlePointerEvent(upPointer);
- 给 WebView 设置一个 GlobalKey 。
这两种方案各有利弊,方案一无法播放声音(需要用户手动点击开启声音),方案二偶尔会有误触的操作。我们 APP 通过与产品商量最终选取的是方案一的解决方案。
另外 iOS 端自动播放会自动全屏,需要设置以下属性:
WebView(
key: logic.state.videoGlobalKey,
// 允许在线播放(解决iOS播放视频自动全屏)
allowsInlineMediaPlayback: true,
......
);
}
作者:未央歌
链接:https://juejin.cn/post/7102256787117572132
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
CAS以及Atomic原子操作详解
CAS以及Atomic原子操作详解
CAS
什么是CAS
- 针对一个变量,首先比较它在
内存中的值与某个期望的值是否相同
,如果相同就给它赋予新值 - 其原子性是
直接在硬件层面得到保障
的 - CAS是一种
无锁算法
,在不使用锁的情况下实现多线程之间的变量同步
- 针对一个变量,首先比较它在
底层
: CAS的底层实现
- 从JVM源码层面的看CAS
- 原子性
- 在单核处理器是通过cmpxchgl指令来保证原子性的,在多核处理器下无法保证了,通过lock前缀的加持变为lock cmpxchgl保证了原子性,这里
lock前缀指令拥有保证后续指令的原子性的作用
- 在单核处理器是通过cmpxchgl指令来保证原子性的,在多核处理器下无法保证了,通过lock前缀的加持变为lock cmpxchgl保证了原子性,这里
- 有序性
- 通过
C++关键字volatile禁止指令重排序保证有序性
,对于C++关键字volatile有两个作用一个是禁止重排序,一个是防止代码被优化
- 通过
- 其中可见性在JVM源码层面是保证的了,因为多核处理器下会加lock前缀指令,但是Java代码层面实现的CAS不能保证get加锁标记和set加锁标记的可见性,比如Atomic类中需要通过volatile修饰state保证可见性
- 原子性
- 从JVM源码层面的看CAS
缺陷
: CAS的缺陷
- 一般CAS都是配合自旋,
自旋时间过长,可能会导致CPU满载
,所以一般会选择自旋到一定次数去park - 每次
只能保证一个共享变量进行原子操作
- ABA问题
问题
: 什么是ABA问题
- 当有多个线程对一个原子类进行操作时,某个线程在这段时间内将A修改到B,又马上将其修改为A
,其他线程并不感知
,还是会被修改成功
- 当有多个线程对一个原子类进行操作时,某个线程在这段时间内将A修改到B,又马上将其修改为A
问题
: ABA问题的解决方案
- 数据库有个锁是乐观锁,是一种通过版本号方式来进行数据同步,也就是
每次更新的时候都会匹配这个版本号,只有符号才能更新成功
,同样的ABA问题也是基于这种去解决的,相应的Java也提供了对应的原子类AtomicStampedRefrence,其内部reference就是我们实际存储的变量,stamp就是版本号,每次修改可以通过加1来保证版本的唯一性
- 数据库有个锁是乐观锁,是一种通过版本号方式来进行数据同步,也就是
- 一般CAS都是配合自旋,
问题
: CAS失败自旋的操作存在什么问题
- CAS自旋时间过长不成功,会给CPU带来较大的开销
CAS的应用
CAS操作的是由Unsafe类提供支持,该类定义了三种针对不同类型变量的CAS操作
public final native boolean compareAndSwapObject(Object o, long offset,Object expected,Object x);
public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);
public final native boolean compareAndSwapLong(Object o, long offset,long expected,long x);
Atomic原子类
- 在并发编程中很容易出现并发安全的问题,比如自增操作,有可能不能获取正确的值,一般情况想到的是synchronized来保证线程安全,但是由于它是悲观锁,并不是最高效的解决方案,所以Juc提供了乐观锁的方式去提升性能
基本类型
: AtomicInteger、AtomicLong、AtomicBoolean引用类型
: AtomicReference、AtomicStampedRerence、AtomicMarkableReference数组类型
: AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray对象属性原子修改器
: AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater原子类型累加器(JDK8增加的类)
: DoubleAccumulator、DoubleAdder、LongAccumulator、LongAdder、Striped64
LongAdder和DoubleAdder
瓶颈详解
- 对于高并发场景下,
多个线程同时进行自旋操作,会出现大量失败并不断自旋的情况
,此时AtomicLong自旋会成为瓶颈,LongAdder引入解决了高并发场景,AtomicInteger、AtomicLong的自旋瓶颈问题
LongAdder原理
- AtomicLong中有个内部变量value保存着实际的值,所有的操作都是针对该变量进行,在高并发场景下,value变量其实就是一个热点,多个线程同时竞争这个热点,而这样冲突的概率就比较大了
重点
: LongAdder的基本思路就是分散热点,将value的值分散到一个数组中,不同线程会命中到这个数组的不同槽位中,各个线程只对自己槽位中的那个值进行CAS操作,这样就分散了热点,冲突的概率就小很多,如果要获取真正的值,只需要将各个槽位的值累加返回- LongAdder设计的精妙之处:
尽量减少热点冲突,不到最后万不得已,尽量将CAS操作延迟
注意
: LongAdder的sum方法会有线程安全的问题
- 高并发场景下
除非全局加锁,否则得不到程序运行中某个时刻绝对准确的值
,由于计算总和时没有对Cell数组进行加锁,所以在累加过程中可能有其他线程对于Cell数组中的值因为线程安全无法保障进行了修改,也有可能对数组进行了扩容,所以sum返回的值并不是非常精确的
,其返回值并不是一个调用sum方法的原子快照值
- 高并发场景下
LongAdder逻辑
LongAccumulator
- LongAccumulator是
LongAdder的增强版本
,LongAdder只针对数组值进行加减运算,而LongAccumulator提供了自定义的函数操作 - LongAccumulator
内部原理和LongAdder几乎完全一样
,都是利用了父类Striped64的longAccumulate方法
作者:枫度柚子
链接:https://juejin.cn/post/7101216131397976071
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
从单例谈double-check必要性,多种单例各取所需
前言
- 前面铺掉了那么多都是在讲原则,讲图例。很多同学可能都觉得和设计模式不是很搭边。虽说设计模式也是理论的东西,但是设计原则可能对我们理解而言更加的抽象。不过好在原则东西不是很多,后面我们就可以开始转讲设计模式了。
- 我的思路是按照设计模式进行分类整理。期间穿插相关的知识进行扩展从而保证我们学习的更加的全面。在正式开始前我现在这里立个Flag。争取在20周内完成我们设计模式章节的内容。期间可能会有别的学习,20周争取吧
- 相信单例模式是大家第一个使用到的设计模式吧。不管你怎么样,我第一个使用的就是单例模式。其实单例模式也是分很多种的【饿汉式】、【懒汉式】。如果在细分还有线程安全和线程不安全版本的。
饿汉式
- 顾名思义饿汉式就是对类需求很迫切。从Java角度看就是类随着JVM启动就开始创建,不管你是否使用到只要JVM启动就会创建。
public class SingleFactory
{
private static Person person = new Person();
private SingleFactory()
{
}
public static Person getInstance()
{
return person;
}
}
- 上面这段代码就是饿汉式单例模式。通过这单代码我们也能够总结出单例模式的几个特点
特点 隐藏类的创建即外部无法进行创建 内部初始化好一个完整的类 提供一个可以访问到内部实例的方法,这里指的是getInstance
- 单例模式特点还是很容易区分的。饿汉式感觉挺好的,那为什么后面还会出现懒汉式及其相关的变形呢?下面我们就来看看饿汉式有啥缺点吧。
- 首先上面我们提到饿汉式的标志性特点就是随着JVM 的启动开始生成实例对象。这是优点同时也是缺点。大家应该都用过Mybatis等框架,这些框架为了加快我们程序的启动速度纷纷推出各种懒加载机制。
- 何为懒加载呢?就是用到的时候再去初始化相关业务,将和启动不相关的部分抽离出去,这样启动速度自然就快了起来了。在回到饿汉式,你不管三七二十一就把我给创建了这无疑影响了我的程序启动速度。如果这个单例模式你使用了倒还好,假如启动之后压根就没用到这个单例模式的类,那我岂不是吃力不讨好。不仅浪费了时间还浪费了我的空间。
- 所以说,处于对性能的考虑呢?还是建议大家不要使用饿汉式单例。但是,存在即是合理的,我们不能一棒子打死一堆人。具体场景具体对待吧XDM。
🐶变形1
public class SingleFactory
{
private static Person person ;
static {
person = new Person();
}
private SingleFactory()
{
}
public static Person getInstance()
{
return person;
}
}
- 咋一看好像和上面的没啥区别哦。仔细对比你就会发现我们这里并没有立刻创建Person这个类,而是放在静态代码块中初始化实例了。
- 放在静态代码块和直接创建其实是一样的。都是通过类加载的方式来进行实例化的。基本同根同源没啥可说的 。
- 关于Static关键字我们之前也有说过,他涉及到的是类加载的顺序。我们在类加载的最后阶段就是执行我们的静态代码块
懒汉式
public class SingleFactory
{
private static Person person = null;
private SingleFactory()
{
}
public static Person getInstance()
{
try
{
Thread.sleep(30);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
if(person==null){
person=new Person();
}
return person;
}
}
- 懒汉式就是将我们的对象创建放在最后一刻进行创建。并不是跟随类加载的时候生成对象的,这样会造成一定程度的内存浪费。懒汉式更加的提高了内存的有效利用。在
getInstance
方法中我们在获取对象前判断是否已经生成过对象。如果没有在生成对象。这种行为俗称懒,所以叫做懒汉式单例模式
🐱变形1
- 上面懒汉式单例中我加入了睡眠操作。这是因为我想模拟出他的缺点。上面这种方式在高并发的场景下并不能保证系统中仅有一个实例对象。
public class SingleFactory
{
private static Person person = null;
private SingleFactory()
{
}
public static Person getIstance()
{
try
{
Thread.sleep(30);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
synchronized (SingleFactory.class)
{
if (person == null)
{
person = new Person();
}
}
return person;
}
}
- 只需要加一把锁,就能保证线性操作了。但是仔细想想难道这样就真的安全了吗。
double-check
- 在多线程下安全的单例模式应该非double-check莫属了吧。
public class OnFactory {
private static volatile OnFactory onFactory;
public static OnFactory getInstance() {
if (null == onFactory) {
synchronized (OnFactory.class) {
if (null == onFactory) {
onFactory = new OnFactory();
}
}
}
return onFactory;
}
}
- 这段代码是之前咱们学习double-check和volatile的时候写过的一段代码。在这里我们不仅在锁前后都判断了而且还加上了volatile进行内存刷新。关于volatile需要的在主页中搜索关键词即可找到。这里仅需要知道一点volatile必须存在否则线程不安全。
作者:zxhtom
链接:https://juejin.cn/post/7101496650610245646
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Kotlin - 改良责任链模式
一、前言
- 责任链模式
- 作用:避免请求的发送者和接收者之间的耦合关系,将这个对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。
- 举例:OKHttp 的拦截器、Servlet 中的 FilterChain
二、使用责任链模式
- 例子:学生会经费申请
- 重点:1 个请求会在 n 个处理器组成的处理器链上传递
以学生会经费申请会例,学生会会有一些日常开销以及活动开支,需要向学院的学生会基金申请经费,如果金额在 100 元之内,由分部长审批;如果金额在 100 到 500 元之间,由会长审批;如果金额在 500 到 1000 元之间,由学院辅导员审批;而如果金额超过 1000 元,则默认打回申请。像这种需要一层层往后传递请求的情况,非常适合采用责任链模式来设计程序:
/**
* 经费申请事件
*
* @author GitLqr
*/
data class ApplyEvent(val money: Int, val title: String)
/**
* 经费审批处理器
*
* @author GitLqr
*/
interface ApplyHandler {
val successor: ApplyHandler?
fun handleEvent(event: ApplyEvent)
}
注意:责任链模式需要将处理器对象连成一条链,最简单粗暴的方式就是让前驱处理器持有后继处理器
successor
接着,根据案例需要,编写各个角色对应的处理器类:
/**
* 部长
*
* @author GitLqr
*/
class GroupLeader(override val successor: ApplyHandler?) : ApplyHandler {
override fun handleEvent(event: ApplyEvent) {
when {
event.money <= 100 -> println("Group Leader handled application: ${event.title}.")
successor != null -> successor.handleEvent(event)
else -> println("Group Leader: This application cannot be handled.")
}
}
}
/**
* 会长
*
* @author GitLqr
*/
class President(override val successor: ApplyHandler?) : ApplyHandler {
override fun handleEvent(event: ApplyEvent) {
when {
event.money <= 500 -> println("President handled application: ${event.title}.")
successor != null -> successor.handleEvent(event)
else -> println("President: This application cannot be handled.")
}
}
}
/**
* 学院
*
* @author GitLqr
*/
class College(override val successor: ApplyHandler?) : ApplyHandler {
override fun handleEvent(event: ApplyEvent) {
when {
event.money <= 1000 -> println("College handled application: ${event.title}.")
successor != null -> successor.handleEvent(event)
else -> println("College: This application cannot be handled.")
}
}
}
最后,创建各个角色处理器实例,并按顺序组成一条链,由链头开始接收、转发需要被处理的经费申请事件:
// 使用
// val college = College(null)
// val president = President(college)
// val groupLeader = GroupLeader(president)
val groupLeader = GroupLeader(President(College(null)))
groupLeader.handleEvent(ApplyEvent(10, "buy a pen")) // 买只钢笔
groupLeader.handleEvent(ApplyEvent(200, "team building")) // 团建
groupLeader.handleEvent(ApplyEvent(600, "hold a debate match")) // 举行辩论赛
groupLeader.handleEvent(ApplyEvent(1200, "annual meeting of the college")) // 学院年会
// 输出
Group Leader handled application: buy a pen.
President handled application: team building.
College handled application: hold a debate match.
College: This application cannot be handled.
从输出结果可以看到,经费申请事件会在处理器链上传递,直到被一个合适的处理器处理并终止。
注意:这话是针对当前案例说的,责任链模式没有硬性要求一个请求只能被一个处理器处理,你可以在前面的处理器中对请求进行加工,提取数据等等操作,并且可以选择是否放行,交由后面的处理器继续处理,这需要根据实际情况,灵活应变。
三、改良责任链模式
- 例子:学生会经费申请
- 重点:偏函数
Partial Function
在对上述案例进行改良之前,我们先来了解一下偏函数是什么,在不同的编程语言中,对偏函数的理解还不一样,在 Python 中,偏函数是使用 functools.partial
把一个函数的某些参数给固定住(也就是设置默认值),返回一个新的函数,调用这个新函数会更简单。而在 Scala 中,偏函数是使用 PartialFunction
构建一个仅仅处理输入参数的部分分支的函数,换句话说,就是带有判断条件的函数,只有满足条件的参数,才会被函数处理。
以上结论来自以下两篇文章:
- 廖雪峰官方网站上的 Python 《偏函数》http://www.liaoxuefeng.com/wiki/101695…
- 数据工匠记(作者:匠人李)的 Scala 《偏函数(Partial Function)》http://www.lllpan.top/article/77
题外话:对 Scala 偏函数有兴趣的可以看一下上面的文章,写的很通透。
回过头来,责任链模式的核心机理是,整个链条上的每个处理环节都有对其输入的校验标准,当输入的参数处于某个责任链节的有效接收范围之内,该环节才能对其做出正常的处理操作。那么,我们是不是可以把链条上的每个处理环节看做是一个个的偏函数呢?是的,不过 Kotlin 中并没有内置偏函数 API,好在有一个第三方 Kotlin 函数库【funKTionale】,其中的 partialfunctions.kt
就有 Scala 中偏函数的类似实现:
// https://github.com/MarioAriasC/funKTionale/blob/master/funktionale-utils/src/main/kotlin/org/funktionale/utils/partialfunctions.kt
class PartialFunction<in P1, out R>(private val definetAt: (P1) -> Boolean, private val f: (P1) -> R) : (P1) -> R {
override fun invoke(p1: P1): R {
if (definetAt(p1)) {
return f(p1)
} else {
throw IllegalArgumentException("Value: ($p1) isn't supported by this function")
}
}
fun isDefinedAt(p1: P1) = definetAt(p1)
}
这个 PartialFunction
类第一眼看上去感觉好复杂,分成如下几步,方便理解:
PartialFunction
继承自一个函数类型(P1) -> R
,编译器会强制要求实现invoke()
方法,这意味着PartialFunction
实例对象可以像调用函数那样使用。- 构造参数 1
definetAt: (P1) -> Boolean
用于判断P1
参数是否满足被处理的条件。 - 构造参数 2
f: (P1) -> R
用于处理P1
参数并返回R
类型值。 - 成员方法
invoke
中,当 P1 满足条件时,则将 P1 交给 构造参数 2f: (P1) -> R
处理;否则抛出异常。 - 成员方法
isDefinedAt
只是构造参数 1definetAt
的拷贝。
所以,用一句话概括 PartialFunction
实例对象,就是一个带有判断条件的"函数",只有满足条件的参数,才会被"函数"处理。现在我们用一个个 PartialFunction
实例来代替处理器是完全没问题的,问题是怎么把它们链接起来呢?【funKTionale】中还为 PartialFunction
扩展了一个 orElse
函数,这就是把偏函数组合起来的关键:
// https://github.com/MarioAriasC/funKTionale/blob/master/funktionale-utils/src/main/kotlin/org/funktionale/utils/partialfunctions.kt
infix fun <P1, R> PartialFunction<P1, R>.orElse(that: PartialFunction<P1, R>): PartialFunction<P1, R> {
return PartialFunction({ this.isDefinedAt(it) || that.isDefinedAt(it) }) {
when {
this.isDefinedAt(it) -> this(it)
that.isDefinedAt(it) -> that(it)
else -> throw IllegalArgumentException("function not definet for parameter ($it)")
}
}
}
同样,也分成如下几步,方便理解:
orElse
是PartialFunction
的扩展函数,故内部可以使用this
获取原本的PartialFunction
实例(也就是receiver
)。orElse
只接收一个PartialFunction
类型参数that
,并且返回一个PartialFunction
类型实例,故orElse
可以嵌套调用。orElse
返回值是一个使用了两个PartialFunction
实例对象 (即this
和that
)组合出来的一个新的PartialFunction
实例对象,orElse
返回值的意图是,只要原本的this
和that
中有一个条件成立,那么就让条件成立的那个来处理参数 P1 ,否则抛出异常。其实,这个that
就相当于是责任链模式中的successor
。orElse
使用infix
修饰,故支持中缀表达式写法。
注意:你可能一时看不懂
PartialFunction({ xxx }){ yyy }
这个奇怪的语法,其实很简单,在创建一个PartialFunction
实例时,可以传入两个 Lambda 表达式,所以正常写法应该是这样的PartialFunction({ xxx }, { yyy })
,不过,在 Kotlin 中,当 Lambda 表达式作为最后一个参数传入时,可以写到函数外部,所以就出现了PartialFunction({ xxx }){ yyy }
这种写法。
好了,现在用 PartialFunction
来改良原本的责任链模式代码:
/**
* 使用自运行Lambda来构建一个个 PartialFunction 实例:部长、会长、学院
*
* @author GitLqr
*/
val groupLeader = {
val definetAt: (ApplyEvent) -> Boolean = { it.money <= 200 }
val handler: (ApplyEvent) -> Unit = { println("Group Leader handled application: ${it.title}.") }
PartialFunction(definetAt, handler)
}()
val president = {
val definetAt: (ApplyEvent) -> Boolean = { it.money <= 500 }
val handler: (ApplyEvent) -> Unit = { println("President handled application: ${it.title}.") }
PartialFunction(definetAt, handler)
}()
val college = {
val definetAt: (ApplyEvent) -> Boolean = { true }
val handler: (ApplyEvent) -> Unit = {
when {
it.money <= 1000 -> println("College handled application: ${it.title}.")
else -> println("College: This application is refused.")
}
}
PartialFunction(definetAt, handler)
}()
注意:自运行 Lambda 相当于是 js 中的立即执行函数。
接下来就是用 orElse
将一个个 PartialFunction
实例链接起来:
// 使用
// val applyChain = groupLeader.orElse(president.orElse(college))
val applyChain = groupLeader orElse president orElse college // 中缀表达式
applyChain(ApplyEvent(10, "buy a pen")) // 买只钢笔
applyChain(ApplyEvent(200, "team building")) // 团建
applyChain(ApplyEvent(600, "hold a debate match")) // 举行辩论赛
applyChain(ApplyEvent(1200, "annual meeting of the college")) // 学院年会
// 输出
Group Leader handled application: buy a pen.
Group Leader handled application: team building.
College handled application: hold a debate match.
College: This application is refused.
使用 PartialFunction
之后,不仅可以不幅度减少代码量,结合 orElse
能获得更好的语法表达。以上,就是使用偏函数改良责任链模式的全部内容了。为了加深对偏函数的理解,这里引用数据工匠记的 Scala 《偏函数(Partial Function)》原文中的话:
为什么要用偏函数呢?以我个人愚见,还是一个重用粒度的问题。函数式的编程思想是以一种“演绎法”而非“归纳法”去寻求解决空间。也就是说,它并不是要去归纳问题然后分解问题并解决问题,而是看透问题本质,定义最原初的操作和组合规则,面对问题时,可以通过组合各种函数去解决问题,这也正是“组合子(combinator)”的含义。偏函数则更进一步,将函数求解空间中各个分支也分离出来,形成可以被组合的偏函数。
作者:GitLqr
链接:https://juejin.cn/post/7100562422376693797
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter自绘组件:扇形图
简介
在开发过程中通常会遇到一些不规则的UI,比如不规则的线条,多边形,统计图表等等,用那些通用组件通过组合的方式无法进行实现,这就需要我们自己进行绘制。可以通过使用CuntomPaint
组件并结合画笔CustomPainter
去进行手动绘制各种图形。
CustomPaint介绍
CustomPaint
是一个继承SingleChildRenderObjectWidget
的Widget
,这里主要介绍几个重要参数:
child
:CustomPaint
的子组件。
painter
: 画笔,绘制的图形会显示在child
后面。
foregroundPainter
:前景画笔,绘制的图形会显示在child
前面。
size
:绘制区域大小。
CustomPainter介绍
CustomPainter
是一个抽象类,通过自定义一个类继承自CustomPainter
,重写paint
和shouldRepaint
方法,具体绘制主要在paint
方法里。
paint介绍
主要两个参数:
Canvas
:画布,可以用于绘制各种图形。
Size
:绘制区域的大小。
void paint(Canvas canvas, Size size)
shouldRepaint介绍
在Widget重绘前会调用该方法确定时候需要重绘,shouldRepaint
返回ture
表示需要重绘,返回false
表示不需要重绘。
bool shouldRepaint(CustomPainter oldDelegate)
示例
这里我们通过绘制一个饼状图来演示绘制的整体流程。
使用CustomPaint
首先,使用CustomPaint
,绘制大小为父组件最大值,传入自定义painter
。
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size.infinite,
painter: PieChartPainter(),
);
}
自定义Painter
自定义PieChartPainter
继承CustomPainter
class PieChartPainters extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate != this;
}
}
绘制
接着我们来实现paint
方法进行绘制
@override
void paint(Canvas canvas, Size size) {
//移动到中心点
canvas.translate(size.width / 2, size.height / 2);
//绘制饼状图
_drawPie(canvas, size);
//绘制扇形分割线
_drawSpaceLine(canvas);
//绘制中心圆
_drawHole(canvas, size);
}
绘制饼状图
我们以整个画布的中点为圆点,然后计算出每个扇形的角度区域,通过canvas.drawArc
绘制扇形。
void _drawPie(Canvas canvas, Size size) {
var startAngle = 0.0;
var sumValue = models.fold<double>(0.0, (sum, model) => sum + model.value);
for (var model in models) {
Paint paint = Paint()
..style = PaintingStyle.fill
..color = model.color;
var sweepAngle = model.value / sumValue * 360;
canvas.drawArc(Rect.fromCircle(radius: model.radius, center: Offset.zero),
startAngle * pi / 180, sweepAngle * pi / 180, true, paint);
//为每一个区域绘制延长线和文字
_drawLineAndText(
canvas, size, model.radius, startAngle, sweepAngle, model);
startAngle += sweepAngle;
}
}
绘制延长线以及文本
延长线的起点为扇形区域边缘中点位置,长度为一个固定的长度,转折点坐标通过半径加这个固定长度和三角函数进行计算,然后通过转折点的位置决定横线终点的方向,而横线的长度则根据文字的宽度决定,然后通过canvas.drawLine
进行绘制直线。
文本绘制使用TextPainter.paint
进行绘制,paint
方法里面最终是通过canvas.drawParagraph
进行绘制的。
最后再在文字的前面通过canvas.drawCircle
绘制一个小圆点。
void _drawLineAndText(Canvas canvas, Size size, double radius,
double startAngle, double sweepAngle, PieChartModel model) {
var ratio = (sweepAngle / 360.0 * 100).toStringAsFixed(2);
var top = Text(model.name);
var topTextPainter = getTextPainter(top);
var bottom = Text("$ratio%");
var bottomTextPainter = getTextPainter(bottom);
// 绘制横线
// 计算开始坐标以及转折点的坐标
var startX = radius * (cos((startAngle + (sweepAngle / 2)) * (pi / 180)));
var startY = radius * (sin((startAngle + (sweepAngle / 2)) * (pi / 180)));
var firstLine = radius / 5;
var secondLine =
max(bottomTextPainter.width, topTextPainter.width) + radius / 4;
var pointX = (radius + firstLine) *
(cos((startAngle + (sweepAngle / 2)) * (pi / 180)));
var pointY = (radius + firstLine) *
(sin((startAngle + (sweepAngle / 2)) * (pi / 180)));
// 计算坐标在左边还是在右边
// 并计算横线结束坐标
// 如果结束坐标超过了绘制区域,则改变结束坐标的值
var marginOffset = 20.0; // 距离绘制边界的偏移量
var endX = 0.0;
if (pointX - startX > 0) {
endX = min(pointX + secondLine, size.width / 2 - marginOffset);
secondLine = endX - pointX;
} else {
endX = max(pointX - secondLine, -size.width / 2 + marginOffset);
secondLine = pointX - endX;
}
Paint paint = Paint()
..style = PaintingStyle.fill
..strokeWidth = 1
..color = Colors.grey;
// 绘制延长线
canvas.drawLine(Offset(startX, startY), Offset(pointX, pointY), paint);
canvas.drawLine(Offset(pointX, pointY), Offset(endX, pointY), paint);
// 文字距离中间横线上下间距偏移量
var offset = 4;
var textWidth = bottomTextPainter.width;
var textStartX = 0.0;
textStartX =
_calculateTextStartX(pointX, startX, textWidth, secondLine, textStartX, offset);
bottomTextPainter.paint(canvas, Offset(textStartX, pointY + offset));
textWidth = topTextPainter.width;
var textHeight = topTextPainter.height;
textStartX =
_calculateTextStartX(pointX, startX, textWidth, secondLine, textStartX, offset);
topTextPainter.paint(canvas, Offset(textStartX, pointY - offset - textHeight));
// 绘制文字前面的小圆点
paint.color = model.color;
canvas.drawCircle(
Offset(textStartX - 8, pointY - 4 - topTextPainter.height / 2),
4,
paint);
}
绘制扇形分割线
在绘制完扇形之后,然后在扇形的开始的那条边上绘制一条直线,起点为圆点,长度为扇形半径,终点的位置根据半径和扇形开始的那条边的角度用三角函数进行计算,然后通过canvas.drawLine
进行绘制。
void _drawSpaceLine(Canvas canvas) {
var sumValue = models.fold<double>(0.0, (sum, model) => sum + model.value);
var startAngle = 0.0;
for (var model in models) {
_drawLine(canvas, startAngle, model.radius);
startAngle += model.value / sumValue * 360;
}
}
void _drawLine(Canvas canvas, double angle, double radius) {
var endX = cos(angle * pi / 180) * radius;
var endY = sin(angle * pi / 180) * radius;
Paint paint = Paint()
..style = PaintingStyle.fill
..color = Colors.white
..strokeWidth = spaceWidth;
canvas.drawLine(Offset.zero, Offset(endX, endY), paint);
}
绘制内部中心圆
这里可以通过传入的参数判断是否需要绘制这个圆,使用canvas.drawCircle
进行绘制一个与背景色一致的圆。
void _drawHole(Canvas canvas, Size size) {
if (isShowHole) {
holePath.reset();
Paint paint = Paint()
..style = PaintingStyle.fill
..color = Colors.white;
canvas.drawCircle(Offset.zero, holeRadius, paint);
}
}
触摸事件处理
接下来我们来处理点击事件,当我们点击某一个扇形区域时,此扇形需要突出显示,如下图:
重写hitTest
方法
注意
这个方法的返回值决定是否响应事件。
默认情况下返回null
,事件不会向下传递,也不会进行处理;
如果返回true
则当前组件进行处理事件;
如果返回false
则当前组件不会响应点击事件,会向下一层传递;
我直接在这里处理点击事件,通过该方法传入的offset
确定点击的位置,如果点击位置是在圆形区域内并且不在中心圆内则处理事件同时判断所点击的具体是哪个扇形,反之则恢复默认状态。
@override
bool? hitTest(Offset offset) {
if (oldTapOffset.dx==offset.dx && oldTapOffset.dy==offset.dy) {
return false;
}
oldTapOffset = offset;
for (int i = 0; i < paths.length; i++) {
if (paths[i].contains(offset) &&
!holePath.contains(offset)) {
onTap?.call(i);
oldTapOffset = offset;
return true;
}
}
onTap?.call(-1);
return false;
}
至此,我们通过onTap
向上传递出点击的是第几个扇形,然后进行处理,更新UI就可以了。
动画实现
这里通过Widget
继承ImplicitlyAnimatedWidget
来实现,ImplicitlyAnimatedWidget
是一个抽象类,继承自StatefulWidget
,既然是StatefulWidget
那肯定还有一个State
,State
继承AnimatedWidgetBaseState
(此类继承自ImplicitlyAnimatedWidgetState
),感兴趣的小伙伴可以直接去看源码
实现AnimatedWidgetBaseState
里面的forEachTween
方法,主要是用于来更新Tween的初始值。
@override
void forEachTween(TweenVisitor<dynamic>visitor) {
customPieTween = visitor(customPieTween, end, (dynamic value) {
return CustomPieTween(begin: value, end: end);
}) as CustomPieTween;
}
自定义CustomPieTween
继承自Tween
,重写lerp
方法,对需要做动画的参数进行处理
class CustomPieTween extends Tween<List<PieChartModel>> {
CustomPieTween({List<PieChartModel>? begin, List<PieChartModel>? end})
: super(begin: begin, end: end);
@override
List<PieChartModel> lerp(double t) {
List<PieChartModel> list = [];
begin?.asMap().forEach((index, model) {
list.add(model
..radius = lerpDouble(model.radius, end?[index].radius ?? 100.0, t));
});
return list;
}
double lerpDouble(double radius, double radius2, double t) {
if (radius == radius2) {
return radius;
}
var d = (radius2 - radius) * t;
var value = radius + d;
return value;
}
}
完整代码
感兴趣的小伙伴可以直接看源码
GitHub:chart_view
作者:Eau_z
链接:https://juejin.cn/post/7098140878945927175
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
跟我学flutter:细细品Widget(五)Element
前言
跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
跟我学flutter:在国内如何发布自己的Plugin 或者 Package
跟我学flutter:Flutter雷达图表(一)如何使用kg_charts
跟我学flutter:细细品Widget(一)Widget&Element初识
跟我学flutter:细细品Widget(二)StatelessWidget&StatefulWidget
跟我学flutter:细细品Widget(三)ProxyWidget,InheritedWidget
跟我学flutter:细细品Widget(四)Widget 渲染过程 与 RenderObjectWidget
跟我学flutter:细细品Widget(五)Element
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget
之前的文章都有简述Element,这篇将着重去讲Element
Widget是描述一个UI元素的配置数据,Element才真正代表屏幕显示元素
分类
如上图所示Element分为两类
ComponentElement : 组合类Element。这类Element主要用来组合其他更基础的Element,得到功能更加复杂的Element。开发时常用到的StatelessWidget和StatefulWidget相对应的Element:StatelessElement和StatefulElement,即属于ComponentElement。
RenderObjectElement : 渲染类Element,对应Renderer Widget,是框架最核心的Element。RenderObjectElement主要包括LeafRenderObjectElement(叶子无节点),SingleChildRenderObjectElement(单child),和MultiChildRenderObjectElement(多child)。
Element生命周期
Element有4种状态:initial,active,inactive,defunct。其对应的意义如下:
- initial:初始状态,Element刚创建时就是该状态。
- active:激活状态。此时Element的Parent已经通过mount将该Element插入Element Tree的指定的插槽处(Slot),Element此时随时可能显示在屏幕上。
- inactive:未激活状态。当Widget Tree发生变化,Element对应的Widget发生变化,同时由于新旧Widget的Key或者的RunTimeType不匹配等原因导致该Element也被移除,因此该Element的状态变为未激活状态,被从屏幕上移除。并将该Element从Element Tree中移除,如果该Element有对应的RenderObject,还会将对应的RenderObject从Render Tree移除。但是,此Element还是有被复用的机会,例如通过GlobalKey进行复用。
- defunct:失效状态。如果一个处于未激活状态的Element在当前帧动画结束时还是未被复用,此时会调用该Element的unmount函数,将Element的状态改为defunct,并对其中的资源进行清理。
Element4种状态间的转换关系如下图所示:
ComponentElement
State和StatefulElement是一一对应的,只有在初始化StatefulElement时,才会初始化对应的State并将其绑定到StatefulElement上
核心流程
一个Element的核心操作流程有,创建、更新、销毁三种,下面将分别介绍这三个流程。
创建
ComponentElement的创建起源与父Widget调用inflateWidget,然后通过mount将该Element挂载至Element Tree,并递归创建子节点。
更新
由父Element执行更新子节点的操作(updateChild),由于新旧Widget的类型和Key均未发生变化,因此触发了Element的更新操作,并通过performRebuild将更新操作传递下去。其核心函数updateChild之后会详细介绍。
销毁
由父Element或更上级的节点执行更新子节点的操作(updateChild),由于新旧Widget的类型或者Key发生变化,或者新Widget被移除,因此导致该Element被转为未激活状态,并被加入未激活列表,并在下一帧被失效。
核心函数
- inflateWidget
Element inflateWidget(Widget newWidget, dynamic newSlot) {
final Key key = newWidget.key;
//复用GlobalKey对应的Element
if (key is GlobalKey) {
final Element newChild = _retakeInactiveElement(key, newWidget);
if (newChild != null) {
newChild._activateWithParent(this, newSlot);
final Element updatedChild = updateChild(newChild, newWidget, newSlot);
return updatedChild;
}
}
//创建Element,并挂载至Element Tree
final Element newChild = newWidget.createElement();
newChild.mount(this, newSlot);
return newChild;
}
复制代码
- 判断新Widget是否有GlobalKey,如果有GlobalKey,则从Inactive Elements列表中找到对应的Element并进行复用。(可能从树的另一个位置嫁接或重新激活)
- 无可复用Element,则根据新Widget创建对应的Element,并将其挂载至Element Tree。
- mount
void mount(Element parent, dynamic newSlot) {
//更新_parent等属性,将元素加入Element Tree
_parent = parent;
_slot = newSlot;
_depth = _parent != null ? _parent.depth + 1 : 1;
_active = true;
if (parent != null) // Only assign ownership if the parent is non-null
_owner = parent.owner;
//注册GlobalKey
final Key key = widget.key;
if (key is GlobalKey) {
key._register(this);
}
_updateInheritance();
}
复制代码
- 将给Element加入Element Tree,更新_parent,_slot等树相关的属性。
- 如果新Widget有GlobalKey,将该Element注册进GlobalKey中,其作用下文会详细分析。
- ComponentElement的mount函数会调用_firstBuild函数,触发子Widget的创建和更新。
- performRebuild
@override
void performRebuild() {
//调用build函数,生成子Widget
Widget built;
built = build();
//根据新的子Widget更新子Element
_child = updateChild(_child, built, slot);
}
复制代码
- 调用build函数,生成子Widget。
- 根据新的子Widget更新子Element。
- update
@mustCallSuper
void update(covariant Widget newWidget) {
_widget = newWidget;
}
复制代码
- 将对应的Widget更新为新的Widget。
- 在ComponentElement的各种子类中,还会调用rebuild函数触发对子Widget的重建。
- updateChild
@protected
Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
if (newWidget == null) {
//新的Child Widget为null,则返回null;如果旧Child Widget,使其未激活
if (child != null)
deactivateChild(child);
return null;
}
Element newChild;
if (child != null) {
//新的Child Widget不为null,旧的Child Widget也不为null
bool hasSameSuperclass = true;
if (hasSameSuperclass && child.widget == newWidget) {
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
newChild = child;
} else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)){
//Key和RuntimeType相同,使用update更新
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
child.update(newWidget);
newChild = child;
} else {
//Key或RuntimeType不相同,使旧的Child Widget未激活,并对新的Child Widget使用inflateWidget
deactivateChild(child);
newChild = inflateWidget(newWidget, newSlot);
}
} else {
//新的Child Widget不为null,旧的Child Widget为null,对新的Child Widget使用inflateWidget
newChild = inflateWidget(newWidget, newSlot);
}
return newChild;
}
复制代码
根据新的子Widget,更新旧的子Element,或者得到新的子Element。
逻辑如下(伪代码):
if(newWidget == null){
if(Child == null){
return null;
}else{
移除旧的子Element,返回null
}
}else{
if(Child == null){
返回新Element
}else{
如果Widget能更新,更新旧的子Element,并返回之;否则创建新的子Element并返回。
}
}
复制代码
该逻辑概括如下:
- 如果newWidget为null,则返回null,同时如果有旧的子Element则移除之。
- 如果newWidget不为null,旧Child为null,则创建新的子Element,并返回之。
- 如果newWidget不为null,旧Child不为null,新旧子Widget的Key和RuntimeType等都相同,则调用update方法更新子Element并返回之。
- 如果newWidget不为null,旧Child不为null,新旧子Widget的Key和RuntimeType等不完全相同,则说明Widget Tree有变动,此时移除旧的子Element,并创建新的子Element,并返回之。
RenderObjectElement
RenderObjectElement同核心元素Widget及RenderObject之间的关系如下图所示:
如图:
RenderObjectElement持有Parent Element,但是不一定持有Child Element,有可能无Child Element,有可能持有一个Child Element(Child),有可能持有多个Child Element(Children)。
RenderObjectElement持有对应的Widget和RenderObject,将Widget、RenderObject串联起来,实现了Widget、Element、RenderObject之间的绑定。
核心流程
如ComponentElement一样,RenderObjectElement的核心操作流程有,创建、更新、销毁三种,接下来会详细介绍这三种流程。
- 创建
-
RenderObjectElement的创建流程和ComponentElement的创建流程基本一致,其最大的区别是ComponentElement在mount后,会调用build来创建子Widget,而RenderObjectElement则是create和attach其RenderObject。
- 更新
RenderObjectElement的更新流程和ComponentElement的更新流程也基本一致,其最大的区别是ComponentElement的update函数会调用build函数,重新触发子Widget的构建,而RenderObjectElement则是调用updateRenderObject对绑定的RenderObject进行更新。
- 销毁
RenderObjectElement的销毁流程和ComponentElement的销毁流程也基本一致。也是由父Element或更上级的节点执行更新子节点的操作(updateChild),导致该Element被停用,并被加入未激活列表,并在下一帧被失效。其不一样的地方是在unmount Element的时候,会调用didUnmountRenderObject失效对应的RenderObject。
核心函数
- inflateWidget
该函数和ComponentElement的inflateWidget函数完全一致,此处不再复述。
- mount
void mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
_renderObject = widget.createRenderObject(this);
attachRenderObject(newSlot);
_dirty = false;
}
复制代码
该函数的调用时机和ComponentElement的一致,当Element第一次被插入Element Tree的时候,该方法被调用。其主要职责也和ComponentElement的一致,此处只列举不一样的职责,职责如下:
- 调用createRenderObject创建RenderObject,并使用attachRenderObject将RenderObject关联到Element上。
- SingleChildRenderObjectElement会调用updateChild更新子节点,MultiChildRenderObjectElement会调用每个子节点的inflateWidget重建所有子Widget。
- performRebuild
@override
void performRebuild() {
//更新renderObject
widget.updateRenderObject(this, renderObject);
_dirty = false;
}
复制代码
performRebuild的主要职责如下:
调用updateRenderObject更新对应的RenderObject。
- update
@override
void update(covariant RenderObjectWidget newWidget) {
super.update(newWidget);
widget.updateRenderObject(this, renderObject);
_dirty = false;
}
复制代码
update的主要职责如下:
- 将对应的Widget更新为新的Widget。
- 调用updateRenderObject更新对应的RenderObject。
- updateChild
@protected
List updateChildren(List oldChildren, List newWidgets, { Set forgottenChildren }) {
int newChildrenTop = 0;
int oldChildrenTop = 0;
int newChildrenBottom = newWidgets.length - 1;
int oldChildrenBottom = oldChildren.length - 1;
final List newChildren = oldChildren.length == newWidgets.length ?
oldChildren : List(newWidgets.length);
Element previousChild;
// 从顶部向下更新子Element
// Update the top of the list.
while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) {
final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
final Widget newWidget = newWidgets[newChildrenTop];
if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
break;
final Element newChild = updateChild(oldChild, newWidget, IndexedSlot(newChildrenTop, previousChild));
newChildren[newChildrenTop] = newChild;
previousChild = newChild;
newChildrenTop += 1;
oldChildrenTop += 1;
}
// 从底部向上扫描子Element
// Scan the bottom of the list.
while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) {
final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenBottom]);
final Widget newWidget = newWidgets[newChildrenBottom];
if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
break;
oldChildrenBottom -= 1;
newChildrenBottom -= 1;
}
// 扫描旧的子Element列表里面中间的子Element,保存Widget有Key的Element到oldKeyChildren,其他的失效
// Scan the old children in the middle of the list.
final bool haveOldChildren = oldChildrenTop <= oldChildrenBottom;
Map oldKeyedChildren;
if (haveOldChildren) {
oldKeyedChildren = {};
while (oldChildrenTop <= oldChildrenBottom) {
final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
if (oldChild != null) {
if (oldChild.widget.key != null)
oldKeyedChildren[oldChild.widget.key] = oldChild;
else
deactivateChild(oldChild);
}
oldChildrenTop += 1;
}
}
// 根据Widget的Key更新oldKeyChildren中的Element。
// Update the middle of the list.
while (newChildrenTop <= newChildrenBottom) {
Element oldChild;
final Widget newWidget = newWidgets[newChildrenTop];
if (haveOldChildren) {
final Key key = newWidget.key;
if (key != null) {
oldChild = oldKeyedChildren[key];
if (oldChild != null) {
if (Widget.canUpdate(oldChild.widget, newWidget)) {
// we found a match!
// remove it from oldKeyedChildren so we don't unsync it later
oldKeyedChildren.remove(key);
} else {
// Not a match, let's pretend we didn't see it for now.
oldChild = null;
}
}
}
}
final Element newChild = updateChild(oldChild, newWidget, IndexedSlot(newChildrenTop, previousChild));
newChildren[newChildrenTop] = newChild;
previousChild = newChild;
newChildrenTop += 1;
}
newChildrenBottom = newWidgets.length - 1;
oldChildrenBottom = oldChildren.length - 1;
// 从下到上更新底部的Element。.
while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) {
final Element oldChild = oldChildren[oldChildrenTop];
final Widget newWidget = newWidgets[newChildrenTop];
final Element newChild = updateChild(oldChild, newWidget, IndexedSlot(newChildrenTop, previousChild));
newChildren[newChildrenTop] = newChild;
previousChild = newChild;
newChildrenTop += 1;
oldChildrenTop += 1;
}
// 清除旧子Element列表中其他所有剩余Element
// Clean up any of the remaining middle nodes from the old list.
if (haveOldChildren && oldKeyedChildren.isNotEmpty) {
for (final Element oldChild in oldKeyedChildren.values) {
if (forgottenChildren == null || !forgottenChildren.contains(oldChild))
deactivateChild(oldChild);
}
}
return newChildren;
}
复制代码
该函数的主要职责如下:
- 复用能复用的子节点,并调用updateChild对子节点进行更新。
- 对不能更新的子节点,调用deactivateChild对该子节点进行失效。
其步骤如下:
- 从顶部向下更新子Element。
- 从底部向上扫描子Element。
- 扫描旧的子Element列表里面中间的子Element,保存Widget有Key的Element到oldKeyChildren,其他的失效。
- 对于新的子Element列表,如果其对应的Widget的Key和oldKeyChildren中的Key相同,更新oldKeyChildren中的Element。
- 从下到上更新底部的Element。
- 清除旧子Element列表中其他所有剩余Element。
收起阅读 »
跟我学flutter:细细品Widget(四)Widget 渲染过程 与 RenderObjectWidget
前言
跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
跟我学flutter:在国内如何发布自己的Plugin 或者 Package
跟我学flutter:Flutter雷达图表(一)如何使用kg_charts
跟我学flutter:细细品Widget(一)Widget&Element初识
跟我学flutter:细细品Widget(二)StatelessWidget&StatefulWidget
跟我学flutter:细细品Widget(三)ProxyWidget,InheritedWidget
跟我学flutter:细细品Widget(四)Widget 渲染过程 与 RenderObjectWidget
跟我学flutter:细细品Widget(五)Element
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget
StatelessWidget 和 StatefulWidget 只是用来组装控件的容器,并不负责组件最后的布局和绘制。在 Flutter 中,布局和绘制工作实际上是在 Widget 的另一个子类 RenderObjectWidget 内完成的。
RenderObjectWidget为RenderObjectElement提供配置信息。
RenderObjectElement包装了RenderObject,RenderObject为应用程序提供真正的渲染。
源码
abstract class RenderObjectWidget extends Widget {
const RenderObjectWidget({ Key? key }) : super(key: key);
@override
@factory
RenderObjectElement createElement();
@protected
@factory
RenderObject createRenderObject(BuildContext context);
@protected
void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { }
@protected
void didUnmountRenderObject(covariant RenderObject renderObject) { }
}
- createElement 需要返回一个继承RenderObjectElement的类
- createRenderObject 创建 Render Widget 对应的 Render Object,同样子类需要重写该方法。该方法在对应的 Element 被挂载到树上时调用(Element.mount),即在 Element 挂载过程中同步构建了「Render Tree」
- updateRenderObject 在 Widget 更新后,修改对应的 Render Object。该方法在首次 build 以及需要更新 Widget 时都会调用;
- didUnmountRenderObject 「Render Object」从「Render Tree」上移除时调用该方法。
RenderObjectElement 源码
abstract class RenderObjectElement extends Element {
RenderObject _renderObject;
@override
void mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
_renderObject = widget.createRenderObject(this);
attachRenderObject(newSlot);
_dirty = false;
}
@override
void update(covariant RenderObjectWidget newWidget) {
super.update(newWidget);
widget.updateRenderObject(this, renderObject);
_dirty = false;
}
...
}
- mount: RenderObject 对象的创建,以及与渲染树的插入工作,插入到渲染树后的 Element 就可以显示到屏幕中了。
- update: 如果 Widget 的配置数据发生了改变,那么持有该 Widget 的 Element 节点也会被标记为 dirty。在下一个周期的绘制时,Flutter 就会触发 Element 树的更新,并使用最新的 Widget 数据更新自身以及关联的 RenderObject 对象,接下来便会进入 Layout 和 Paint 的流程。而真正的绘制和布局过程,则完全交由 RenderObject 完成。
RenderObject 主要处理一些固定的操作,如:布局、绘制和 Hit testing。 与ComponentElement一样RenderObjectElement也是抽象类,不同的是ComponentElement不会直接创建RenderObject,而是间接通过创建其他Element创建RenderObject。
RenderObjectElement主要有三个系统的子类,分别处理renderObject作为child时的不同情况。
- LeafRenderObjectElement:叶子渲染对象对应的元素,处理没有children的renderObject。
- SingleChildRenderObjectElement:处理只有单个child的renderObject。
- MultiChildRenderObjectElement: 处理有多个children的渲染对象
有时RenderObject的child模型更复杂一些,比如多维数组的形式,则可能需要基于RenderObjectElement实现一个新的子类。
RenderObjectElement 充当widget与renderObject之间的中介者。需要进行方法覆盖,以便它们返回元素期望的特定类型,例如:
class FooElement extends RenderObjectElement {
@override
Foo get widget => super.widget;
@override
RenderFoo get renderObject => super.renderObject;
}
widget返回Foo,renderObject 返回RenderFoo
系统常用组件与RenderObjectElement:
常用组件 | Widget(父级) | Element |
---|---|---|
Flex/Wrap/Flow/Stack | MultiChildRenderObjectWidget | MultiChildRenderObjectElement |
RawImage(Imaget)/ErrorWidget | LeafRenderObjectWidget | LeafRenderObjectElement |
Offstage/SizedBox/Align/Padding | SingleChildRenderObjectWidget | SingleChildRenderObjectElement |
RenderObject源码
abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
...
void layout(Constraints constraints, { bool parentUsesSize = false }) {...}
void paint(PaintingContext context, Offset offset) { }
}
布局和绘制完成后,接下来的事情就交给 Skia 了。在 VSync 信号同步时直接从渲染树合成 Bitmap,然后提交给 GPU。
跟我学flutter:细细品Widget(三)ProxyWidget,InheritedWidget
前言
跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
跟我学flutter:在国内如何发布自己的Plugin 或者 Package
跟我学flutter:Flutter雷达图表(一)如何使用kg_charts
跟我学flutter:细细品Widget(一)Widget&Element初识
跟我学flutter:细细品Widget(二)StatelessWidget&StatefulWidget
跟我学flutter:细细品Widget(三)ProxyWidget,InheritedWidget
跟我学flutter:细细品Widget(四)Widget 渲染过程 与 RenderObjectWidget
跟我学flutter:细细品Widget(五)Element
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget
ProxyWidget作为抽象基类本身没有任何功能,但他有两个实现类ParentDataWidget & InheritedElement
源码
abstract class ProxyWidget extends Widget {
const ProxyWidget({ Key? key, required this.child }) : super(key: key);
final Widget child;
}
InheritedWidget
InheritedWidget 用于在树上向下传递数据。
通过BuildContext.dependOnInheritedWidgetOfExactType可以获取最近的「Inherited Widget」,需要注意的是通过这种方式获取「Inherited Widget」时,当「Inherited Widget」状态有变化时,会导致该引用方 rebuild。
通常,为了使用方便会「Inherited Widget」会提供静态方法of,在该方法中调用BuildContext.dependOnInheritedWidgetOfExactType。of方法可以直接返回「Inherited Widget」,也可以是具体的数据。
有时,「Inherited Widget」是作为另一个类的实现细节而存在的,其本身是私有的(外部不可见),此时of方法就会放到对外公开的类上。最典型的例子就是Theme,其本身是StatelessWidget类型,但其内部创建了一个「Inherited Widget」:_InheritedTheme,of方法就定义在上Theme上:
static ThemeData of(BuildContext context) {
final _InheritedTheme? inheritedTheme = context.dependOnInheritedWidgetOfExactType<_InheritedTheme>();
final MaterialLocalizations? localizations = Localizations.of(context, MaterialLocalizations);
final ScriptCategory category = localizations?.scriptCategory ?? ScriptCategory.englishLike;
final ThemeData theme = inheritedTheme?.theme.data ?? _kFallbackTheme;
return ThemeData.localize(theme, theme.typography.geometryThemeFor(category));
}
该of方法返回的是ThemeData类型的具体数据,并在其内部首先调用了BuildContext.dependOnInheritedWidgetOfExactType。
我们经常使用的「Inherited Widget」莫过于MediaQuery,同样提供了of方法:
static MediaQueryData of(BuildContext context) {
assert(context != null);
assert(debugCheckHasMediaQuery(context));
return context.dependOnInheritedWidgetOfExactType()!.data;
}
源码
abstract class InheritedWidget extends ProxyWidget {
const InheritedWidget({ Key? key, required Widget child })
: super(key: key, child: child);
@override
InheritedElement createElement() => InheritedElement(this);
@protected
bool updateShouldNotify(covariant InheritedWidget oldWidget);
}
createElement
「Inherited Widget」对应的 Element 为InheritedElement,一般情况下InheritedElement子类不用重写该方法;
updateShouldNotify
「Inherited Widget」rebuilt 时判断是否需要 rebuilt 那些依赖它的 Widget;
如下是MediaQuery.updateShouldNotify的实现,在新老Widget.data 不相等时才 rebuilt 那依赖的 Widget。
@override
bool updateShouldNotify(MediaQuery oldWidget) => data != oldWidget.data;
依赖了 InheritedWidget 在数据变动的情况下 didChangeDependencies 会被调用,
依赖的意思是 使用 return context.dependOnInheritedWidgetOfExactType()
如果使用context.getElementForInheritedWidgetOfExactType().widget的话,只会用其中的数据,而不会重新rebuild
@override
InheritedElement getElementForInheritedWidgetOfExactType() {
final InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[T];
return ancestor;
}
@override
InheritedWidget dependOnInheritedWidgetOfExactType({ Object aspect }) {
assert(_debugCheckStateIsActiveForAncestorLookup());
final InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[T];
//多出的部分
if (ancestor != null) {
return dependOnInheritedElement(ancestor, aspect: aspect) as T;
}
_hadUnsatisfiedDependencies = true;
return null;
}
我们可以看到,dependOnInheritedWidgetOfExactType() 比 getElementForInheritedWidgetOfExactType()多调了dependOnInheritedElement方法,dependOnInheritedElement源码如下:
@override
InheritedWidget dependOnInheritedElement(InheritedElement ancestor, { Object aspect }) {
assert(ancestor != null);
_dependencies ??= HashSet();
_dependencies.add(ancestor);
ancestor.updateDependencies(this, aspect);
return ancestor.widget;
}
可以看到dependOnInheritedElement方法中主要是注册了依赖关系!看到这里也就清晰了,调用dependOnInheritedWidgetOfExactType() 和 getElementForInheritedWidgetOfExactType()的区别就是前者会注册依赖关系,而后者不会,所以在调用dependOnInheritedWidgetOfExactType()时,InheritedWidget和依赖它的子孙组件关系便完成了注册,之后当InheritedWidget发生变化时,就会更新依赖它的子孙组件,也就是会调这些子孙组件的didChangeDependencies()方法和build()方法。而当调用的是 getElementForInheritedWidgetOfExactType()时,由于没有注册依赖关系,所以之后当InheritedWidget发生变化时,就不会更新相应的子孙Widget。
收起阅读 »
金三银四必备,全面总结 Kotlin 面试知识点
「Offer 驾到,掘友接招!我正在参与2022春招系列活动-经验复盘,点击查看 活动详情 即算参赛
你的支持对我意义重大!
🔥 Hi,我是旭锐。本文已收录到 GitHub · Android-NoteBook 中。这里有 Android 进阶成长路线笔记 & 博客,有志同道合的朋友,欢迎跟着我一起成长。(联系方式 & 入群方式在 GitHub)
前言
- 在 Android 面试中很重视基础知识的考察,其中语言基础主要包括 Java、Kotlin、C/C++ 三种编程语言。在小彭面试的经验中,发现很多同学的 Kotlin 语言能力只是停留在一些非常入门的语法使用上;
- 在这篇文章里,我将为你浓缩总结 Kotlin 中最常用的知识点和原理。希望通过这篇文章能够帮助你扫除支持盲区,对于一些语法背后的原理也有所涉猎。
1. 为什么要使用 Kotlin?
面试官问这个问题一方面可能是先想引入 Kotlin 这个话题,另一方面是想考察你的认知能力,是不是真的有思考过 Kotlin 的优势 / 价值,还是随波逐流别人用我也跟着用。你可以这么回答:
在 Android 生态中主要有 C++、Java、Kotlin 三种语言 ,它们的关系不是替换而是互补。其中,C++ 的语境是算法和高性能,Java 的语境是平台无关和内存管理,而 Kotlin 则融合了多种语言中的优秀特性,带来了一种更现代化的编程方式。 例如简化异步编程的协程(coroutines),提高代码质量的可空性(nullability),lambda 表达式等。
2. 语法糖的味道
== 和 equal() 相同,=== 比较内存地址
顶级成员(函数 & 属性)的原理: Kotlin 顶级成员的本质是 Java 静态成员,编译后会自动生成
文件名Kt
的类,可以使用@Jvm:fileName
注解修改自动生成的类名。
默认参数的原理: Kotlin 默认参数的本质是将默认值 固化 到调用位置,所以在 Java 中无法直接调用带默认参数的函数,需要在 Kotlin 函数上增加
@JvmOverloads
注解,指示编译器生成重载方法(@JvmOverloads
会为默认参数提供重载方法)。
解构声明的原理: Kotlin 解构声明可以把一个对象的属性分解为一组变量,所以解构声明的本质是局部变量。
举例:
val (name, price) = Book("Kotlin入门", 66.6f)
println(name)
println(price)
-------------------------------------------
Kotlin 类需要声明`operator fun componentN()`方法来实现解构功能,否则是不具备解构声明的功能的,例如:
class Book(var name: String, var price: Float) {
operator fun component1(): String { // 解构的第一个变量
return name
}
operator fun component2(): Float { // 解构的第二个变量
return price
}
}
Sequences 序列的原理: Sequences 提升性能的关键在于多个操作共享同一个 Iterator 迭代器,只需要一次循环就可以完成数据操作。Sequences 又是懒惰的,需要遇到终端操作才会开始工作。
扩展函数的原理: 扩展函数的语义是在不修改类 / 不继承类的情况下,向一个类添加新函数或者新属性。本质是静态函数,静态函数的第一个参数是接收者类型,调用扩展时不会创建适配对象或者任何运行时的额外消耗。在 Java 中,我们只需要像调用普通静态方法那样调用扩展即可。相关资料:Kotlin | 扩展函数(终于知道为什么 with 用 this,let 用 it)
let、apply、with 的区别和应用场景: let、with、apply 都是标准库函数,它们的主要区别在 lambda 参数类型定义不同。apply、with 的 lambda 参数是 T 的扩展函数,因此在 lambda 内使用 this 引用接收者对象,而 let 的 lambda 参数是参数为 T 的高阶函数,因此 lambda 内使用 it 引用唯一参数。
委托机制的原理: Kotlin 委托的语法关键字是 by,其本质上是面向编译器的语法糖,三种委托(类委托、对象委托和局部变量委托)在编译时都会转化为 “无糖语法”。例如类委托:编译器会实现基础接口的所有方法,并直接委托给基础对象来处理。例如对象委托和局部变量委托:在编译时会生成辅助属性(prop$degelate),而属性 / 变量的 getter() 和 setter() 方法只是简单地委托给辅助属性的 getValue() 和 setValue() 处理。相关资料:Kotlin | 委托机制 & 原理 & 应用
中缀函数: 声明 infix 关键字的函数是中缀函数,调用中缀函数时可以省略圆点以及圆括号等程序符号,让语句更自然。
中缀函数的要求:
- 1、成员函数或扩展函数
- 2、函数只有一个参数
- 3、不能使用可变参数或默认参数
举例:
infix fun String.吃(fruit: String): String {
return "${this}吃${fruit}"
}
调用: "小明" 吃 "苹果"
3. 类型系统
数值类型: Kotlin 将基本数据类型和引用型统一为:Byte、Short、Int、Long、Float、Double、Char 和 Boolean。需要注意的是,类型的统一并不意味着 Kotlin 所有的数值类型都是引用类型,大多数情况下,它们在编译后会变成基本数据类型,类型参数会被编译为引用类型。
隐式转换: Kotlin 不存在隐式类型转换,即时是低级类型也需要显式转换为高级类型:
//隐式转换,编译器会报错
val anInt: Int = 5
val ccLong: Long = anInt
//需要去显式的转换,下面这个才是正确的
val ddLong: Long = anInt.toLong()
平台类型: 当可空性注解不存在时,Java 类型会被转换为 Kotlin 的平台类型。平台类型本质上是 Kotlin 编译器无法确定其可空信息,既可以把它当作可空类型,也可以把它当作非空类型。
如果所有来自 Java 的值都被看成非空是不合理的,反之把 Java 值都当作可空的,由会引出大量 Null 检查。综合考量,平台类型是 Kotlin 为开发者选择的折中的设计方案。
类型转换: 较小类型并不是较大类型的子类型,较小的类型不能隐式转换为较大的类型。
val b: Byte = 1 // OK
val i: Int = b // 编译错误
val i: Int = b.toInt() // OK
只读集合和可变集合: 只读集合只可读,而可变集合可以增删该差(例如 List 只读,MutableList 可变)。需要注意,只读集合引用指向的集合不一定是不可变的,因为你使用的变量可能是众多指向同一个集合的其中一个。
Array 和 IntArray 的区别: Array 相当于引用类型数组 Integer[],IntArray 相当于数值类型数组 int[]。
Unit: Any 的子类,作为函数返回值时表示没有返回值,可以省略,与 Java void 类似。
Nothing: 表示表达式或者函数永远不会返回,Nothing? 唯一允许的值是 null。
Java Void: void 的包装类,与 void 类似表示一个函数没有有效的返回值,返回值只能是 null。
4. 面向对象
类修饰符: Kotlin 类 / 方法默认是 final 的,如果想让继承类 / 重写方法,需要在基类 / 基方法添加 open 修饰符。
final:不允许继承或重写
open:允许继承或重写
abstract:抽象类 / 抽象方法
访问修饰符: Java 默认的访问修饰符是 protected,Kotlin 默认的访问修饰符是 public。
public:所有地方可见
internal:模块中可见,一个模块就是一组编译的 Kotlin 文件
protected:子类中可见(与 Java 不同,相同包不可见,Kotlin 没有 default 包可见)
private:类中可见
构造函数:
- 默认构造函数: class 默认有一个无参主构造函数,如果显式声明了构造函数,则默认的无参主构造函数失效;
- 主构造函数: 声明在 class 关键字后,其中 constructor 关键词可以省略;
- 次级构造函数: 如果声明了次级构造函数,则默认的无参主构造函数会失效。如果存在主构造函数,次级构造函数需要直接或间接委托给主构造函数。
init 函数执行顺序: 主构造函数 > init > 次级构造函数
内部类: Kotlin 默认为静态内部类,如果想访问类中的成员方法和属性,需要添加 inner 关键字称为非静态内部类;Java 默认为非静态内部类。
data 关键字原理: data 关键字用于定义数据类型,编译器会自动从主构造函数中提取属性并生成一系列函数:equals()/hashCode()、toString()、componentN()、copy()。
sealed 关键字原理: 密封类用来表示受限的类继承结构,密封类可以有子类,但是所有子类都必须内嵌在该密封类中。
object 与 companion object 的区别 object 有两层语义:静态匿名内部类 + 单例对象 companion object 是伴生对象,一个类只能有一个,代表了类的静态成员(函数 / 属性)
单例: Kotlin 可以使用 Java 相似的方法实现单例,也可以采用 Kotlin 特有的语法。相关资料:Kotlin下的5种单例模式
- object
// Kotlin实现
object SingletonDemo
- by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED)
class SingletonDemo private constructor() {
companion object {
val instance: SingletonDemo by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
SingletonDemo()
}
}
}
5. lambda 表达式
lambda 表达式本质上是「可以作为值传递的代码块」。在老版本 Java 中,传递代码块需要使用匿名内部类实现,而使用 lambda 表达式甚至连函数声明都不需要,可以直接传递代码块作为函数值。
it: 当 lambda 表达式只有一个参数,可以用 it 关键字来引用唯一的实参。
lambda 表达式的种类
- 1、普通 Lambda 表达式:例如 ()->R
- 2、带接收者对象的 Lambda 表达式:例如 T.()->R
lambda 表达式访问局部变量的原理: 在 Java 中,匿名内部类访问的局部变量必须是 final 修饰的,否则需要使用数组或对象做一层包装。在 Kotlin 中,lambda 表达式可以直接访问非 final 的局部变量,其原理是提供了一层包装类,修改局部变量本质上是修改包装类中的属性。
class Ref<T>(var value:T)
复制代码
lambda 表达式编译优化: 在循环中使用 Java 8 与 Kotlin 中的 lambda 表达式时,会存在编译时优化,编译器会将 lambda 优化为一个 static 变量,除非 lambda 表达式中访问了外部的变量或函数。
inline 内联函数的原理:
内联 lambda 表达式参数(主要优点): 内联函数的参数如果是 lambda 表达式,则该参数默认也是 inline 的。lambda 表达式也会被固化的函数调用位置,从而减少了为 lambda 表达式创建匿名内部类对象的开销。当 lambda 表达式被经常调用时,可以减少内存开销。
减少入栈出栈过程(次要优点): 内联函数的函数体被固化到函数调用位置,执行过程中减少了栈帧创建、入栈和出栈过程。需要注意:如果函数体太大就不适合使用内联函数了,因为会大幅度增加字节码大小。
@PublishApi 注解: 编译器要求内联函数必须是 public 类型,使用 @PublishApi 注解可以实现 internal 等访问修饰的同时又实现内联
noinline 非内联: 如果在内联函数内部,lambda 表达式参数被其它非内联函数调用,会报编译时错误。这是因为 lambda 表达式已经被拉平而无法传递给其他非内联函数。可以给参数加上 noinline 关键字表示禁止内联。
inline fun test(noinline inlined: () -> Unit) {
otherNoinlineMethod(inlined)
}
复制代码
非局部返回(Non-local returns): 一个不带标签的 return 语句只能用在 fun 声明的函数中使用,因此在 lambda 表达式中的 return 必须带标签,指明需要 return 的是哪一级的函数:
fun song(f: (String) -> Unit) {
// do something
}
fun behavior() {
song {
println("song $it")
return //报错: 'return' is not allowed here
return@song // 局部返回
return@behavior // 非局部返回
}
}
唯一的例外是在内联函数中的 lambda 表达式参数,可以直接使用不带标签的 return,返回的是调用内联函数的外部函数,而不是内联函数本身,默认就是非局部返回。
inline fun song(f: (String) -> Unit) {
// do something
}
fun behavior() {
song {
println("song $it")
return // 非局部返回
return@song // 局部返回
return@behavior // 非局部返回
}
}
crossinline 非局部返回: 禁止内联函数的 lambda 表达式参数使用非局部返回
实化类型参数 reified: 因为泛型擦除的影响,运行期间不清楚类型实参的类型,Kotlin 中使用 带实化类型参数的内联函数 可以突破这种限制。实化类型参数在插入到调用位置时会使用类型实参的确切类型代替,因此可以确定实参类型。
在这个函数里,我们传入一个List,企图从中过滤出 T 类型的元素:
Java:
<T> List<T> filter(List list) {
List<T> result = new ArrayList<>();
for (Object e : list) {
if (e instanceof T) { // compiler error
result.add(e);
}
}
return result;
}
---------------------------------------------------
Kotlin:
fun <T> filter(list: List<*>): List<T> {
val result = ArrayList<T>()
for (e in list) {
if (e is T) { // cannot check for instance of erased type: T
result.add(e)
}
}
return result
}
调用:
val list = listOf("", 1, false)
val strList = filter<String>(list)
---------------------------------------------------
内联后:
val result = ArrayList<String>()
for (e in list) {
if (e is String) {
result.add(e)
}
}
5. DSL 领域特定语言
DSL 是专门用于解决某个问题的语言,虽然没有通用语言那么全面,但在解决特定问题时更加高效。案例:Compose 的 UI 代码也是采用了 DSL,使得 Compose 拥有了不输于 XML 的编码效率。实现 DSL 需要可以利用的 Kotlin 语法特性,相关资料:Kotlin DSL 实战:像 Compose 一样写代码
高阶函数: 使得 lambda 参数脱离圆括号,减少一个参数;
扩展函数: 传递 Receiver,减少一个参数;
Context Receivers: 传递多个 Receiver,在扩展函数的基础上减少多个参数;
中缀函数: 让语法更简洁自然;
@DSLMarker: 用于限制 lambda 中不带标签的 this 只能访问到最近的 Receiver 类型,当调用更外层的 Receiver 时必须显式指定 this@XXX。
context(View)
val Float.dp
get() = this * this@View.resources.displayMetrics.density
class SomeView : View {
val someDimension = 4f.dp
}
6. 总结
少部分比较聪明的小伙伴就会问了,你这怎么没有涉及协程、Flow 这些知识点?那是因为这些知识点比较多,小彭决定单独放在一篇文章里。一篇文章拆成两篇用,它不香吗?
作者:彭旭锐
链接:https://juejin.cn/post/7076744947440812062
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
效率翻倍!大型Flutter项目快速实现JSON转Model实战
一、前言
在原生应用开发中,我们通常会使用YYModel
、SwiftyJSON
、GSON
等库实现JSON
解析,并使用JSONConverter等类似工具实现JSON自动转模型,极大的提高工作效率。
但在Flutter开发中,却并没有类似的解析库给我们使用,因为这样的库需要使用运行时反射,这在 Flutter 中是禁用的。运行时反射会干扰 Dart 的 tree shaking,使用_tree shaking_
,可以在 release 版中“去除”未使用的代码,这可以显著优化应用程序的大小。由于反射会默认应用到所有代码,因此_tree shaking_
会很难工作,因为在启用反射时很难知道哪些代码未被使用,因此冗余代码很难剥离,所以 Flutter 中禁用了 Dart 的反射功能,而正因如此也就无法实现动态转化 Model 的功能。
二、json_serializable
虽然不能在Flutter中使用运行时反射,但官方提供了类似易于使用的API,它是基于代码生成库实现,json_serializable package,它是一个自动化的源代码生成器,可以生成JSON序列化模板,由于序列化代码无需手写和维护,将运行时产生JSON序列化异常的风险降至最低,使用方法如下:
1. 在项目中添加json_serializable
要包含json_serializable
到我们的项目中,需要一个常规和两个开发依赖项。简而言之,开发依赖项是不包含在我们的应用程序源代码中的依赖项。
通过此链接可以查看这些所需依赖项的最新版本 。
在您的项目根文件夹中运行 flutter packages get
(或者在编辑器中点击 “Packages Get”) 以在项目中使用这些新的依赖项.
2. 以json_serializable的方式创建model类
让我们看看如何将我们的User类转换为一个json_serializable。为了简单起见,我们使用前面示例中的简化JSON model。
user.dart
import 'package:json_annotation/json_annotation.dart';
// user.g.dart 将在我们运行生成命令后自动生成
part 'user.g.dart';
///这个标注是告诉生成器,这个类是需要生成Model类的
@JsonSerializable()
class User {
String name;
String email;
User(this.name, this.email);
factory User.fromJson(Map json) => _$UserFromJson(json);
Map toJson() => _$UserToJson(this);
}
有了这个设置,源码生成器将生成用于序列化name和email字段的JSON代码。
如果需要,自定义命名策略也很容易。例如,如果我们正在使用的API返回带有_snake_case_的对象,但我们想在我们的模型中使用_lowerCamelCase_, 那么我们可以使用@JsonKey标注:
@JsonKey(name: 'registration_date_millis')
final int registrationDateMillis;
3. 运行代码生成程序
- 一次性生成
通过在我们的项目根目录下运行flutter packages pub run build_runner build
,我们可以在需要时为我们的Model生成JSON序列化代码。 这触发了一次性构建,它通过我们的源文件,挑选相关的并为它们生成必要的序列化代码。
- 持续生成
虽然这非常方便,但如果我们不需要每次在model类中进行更改时都要手动运行构建命令的话会更好。
使用_watcher_可以使我们的源代码生成的过程更加方便。它会监视我们项目中文件的变化,并在需要时自动构建必要的文件。我们可以通过flutter packages pub run build_runner watch
在项目根目录下运行来启动_watcher_。
只需启动一次观察器,然后并让它在后台运行,这是安全的
4. 使用json_serializable
模型
要通过json_serializable
方式反序列化JSON字符串,我们不需要对先前的代码进行任何更改。
Map userMap = JSON.decode(json);
var user = new User.fromJson(userMap);
序列化也一样。调用API与之前相同。
String json = JSON.encode(user);
有了json_serializable
,我们只需要编写User
类文件 。源代码生成器创建一个名为user.g.dart
的文件,它具有所有必需的序列化逻辑。 现在,我们不必编写自动化测试来确保序列化的正常工作 - 这个库会确保序列化工作正常。
三、 JSONConverter
如上面所写,即便使用了json_serializable
,仍然需要手动编写模型类文件并逐一编写对应的模型属性,生产工作中一个项目可能会有几百个API, 如果全部手写依旧浪费大量摸鱼的时间,这里我们可以使用JSONConverter, 它可根据后台返回的JSON自动生成模型文件,配合json_serializable
,可以非常方便的实现接口对接,模型文件一键生成,极大节省程序员的体力。
另外JSONConverter
除了支持Flutter,还支持其他语言和第三方库,功能可能说非常丰富了。
四、总结
生产项目中推荐使用json_serializable
+ JSONConverter 完成服务端返回的JSON数据解析工作,效率翻倍!!
作者:vvkeep
链接:https://juejin.cn/post/7098890613839364127
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
ListView界面在Flutter 3.0错乱
一、 入坑
一直以来有一个口口相传的秘诀,就是Flutter版本等到*.*.3版本再升级。
大版本升级一定要慎重的。不然不知道哪个界面中就会出现未知的异常。
Flutter3.0发布了,但是选择不升级,忍了一个星期后,突然发现Flutter开始支持APPLE Silicon M1了。心动了,控制不住自己了,升吧。
然后出现了,我的小怪兽。
上拉和下拉进行刷新界面的时候,出现了诡异的现象。
二、现象
第一反应是Flutter3.0的锅,而已经无法回退版本了,只能选择一往无前。
用一休的大脑:
- 第三方库不兼容了,赶紧升级下版本。
RefreshIndicator
有新改动吗。ListView
有改动吗?Column
配合Expanded
界面混乱了。ListView
的Item用错了。- Debug模式也有这个问题吗。
。。。。。。
前前后后修改,编译,运行,修改,编译,运行。。。(还好我的M1 Max效率还是比较高的)
三、分析
询问度娘,google,gitHub。。。查看所有可能的答案。如果真的是ListView
有问题,那应该早就有人碰到了。怎么说3.0出来已经一个星期了。
Nope Nope Nope
行吧。请教同事,拉来所有同事问问,出谋划策。
No Way
排查了所有组件的可能性,把所有代码都删干净了,仅仅就一个ListView了,还是存在一样的问题。
那这个锅就是你了ListView了,出来的怪兽。不要躲了。
四、解决
为了确定是Flutter 3.0的锅,然后甩给Flutter,搞一个Demo吧。风风火火。。。
Demo竟然没有没有没有问题。Flutter表示这个锅它不背啊。
啊啊啊啊 流失了一天宝贵时光,此处省去1万字...
终于在比较了所有代码后,发现了它,那个引起问题的代码。
五、原因
是的。
<item name="android:fitsSystemWindows">true</item>
就是它,删除后,出现了,出现了。
那么舒服的感觉,我很喜欢。
六、后记
一定要记得*.*.3版本再升级,不能TiMi时间浪费在编译上了。
作者:_阿南_
链接:https://juejin.cn/post/7098909224612134942
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
跟我学企业级flutter项目:flutter模块化,单工程架构模式构思与实践
前言
在原生Android开发的时候,我们经常会用到ARouter,来设计我们项目的整体架构。因为ARouter会帮助我们实现模块化的思想,那么在Flutter中如何去构建模块化的架构呢?再深入一点,如何去运行一个单一模块,不去跑整体项目呢?本篇文章将会带你学习Flutter版本下的单工程架构模式。
应用场景
两模块业务有较大的隔离性,业务有迁移不同项目的可能性
开始
展示效果
那我们在开始前,看下我项目的展示效果:
整体项目运行
单工程模式下运行
架构分析
本项目有三个独立工程
- 家长端工程
- 教师端工程
- 公用lib工程
一个应用飞阅应用中包含三个独立工程,三个独立工程可单独通过壳来运行
如何构建单工程架构
Flutter具有天然的模块化思想,是可以不借助其他工具来实现单工程构建。
事前准备
- Android stuido
步骤1
构建一个Flutter plugin
这个plugin就是你的单工程,构建好的插件如图所示
你需要构建几个plugin呢?简单分析一下,我们公司有两个业务端,需要合并在一个项目里做,那么至少需要两个plugin,但是由于有公用的页面,这时候需要提取出一个公用的模块。那么由此分析,我公司需要三个plugin,那么就需要按照如上步骤在建立两个plugin
步骤二
建立好的plugin进行关联
- 公用工程yaml
name: commonlib
description: 阅卷公用lib
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
#####本地库
flutter_base_lib:
path: ../flutter_baselib/flutter_base_lib
#####本UI地库
flutter_base_ui:
path: ../flutter_baselib/flutter_base_ui
- 老师工程yaml
name: teacher
description: 老师端
dependencies:
flutter:
sdk: flutter
commonlib:
path: ../commonlib
- 家长工程yaml
name: client
description: 学生&家长工程
dependencies:
flutter:
sdk: flutter
commonlib:
path: ../commonlib
- 总工程yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
teacher:
path: ./teacher
client:
path: ./client
commonlib:
path: ./commonlib
步骤三
业务拆封
- 将登录与注册等业务拆分到commonlib
- 老师端独有业务拆分
- 家长端独有业务拆分
- 路由拆分
- 启动代码重新构建
部分示例代码:
家长端路由代码
class ClientRouterPage{
static Widget? getRouter(RouteSettings settings){
//判断家长权限
if(UserStore().getIdentityType() == CommonlibConfig.ruleParent){
String? routerName = settings.name;
//跳转家长业务页面
switch (routerName) {
case RouterName.home:
return HomePage();
case RouterName.bind_student:
return BindStudentPage();
}
}
}
主工程全部代码(只有一个类,只有如下代码)
class MyCommentConfiger extends ICommentConfiger{
@override
Widget getRouter(RouteSettings settings) {
//通过登录用户类型来跳转不同业务
//老师跳转
var teachertRouter = RouterPage.getRouter(settings);
if(teachertRouter!=null){
return teachertRouter;
}
//家长跳转
var clientRouter = ClientRouterPage.getRouter(settings);
if(clientRouter!=null){
return clientRouter;
}
//commonlib跳转
return LibRouterPage.getRouter(settings);
}
}
//启动运行
void main() {
Application.init(
init: AppInit(MyCommentConfiger()),
syncinitFin: () {
runApp(App());
});
}
家长端壳工程全部代码
class ClientCommentConfiger extends ICommentConfiger{
@override
Widget getRouter(RouteSettings settings) {
var router = ClientRouterPage.getRouter(settings);
if(router!=null){
return router;
}
//commonlib跳转
return LibRouterPage.getRouter(settings);
}
}
//启动运行
void main() {
Application.init(
init: AppInit(ClientCommentConfiger()),
syncinitFin: () {
runApp(App());
});
}
如上就是单工程架构模式的全部内容
说明:单工程架构模式,主要适用于业务有一定的隔离性,如果你的项目有一块业务极其的独立,那么你可以采用这种模式。该块业务也可以快速移植到其他项目上。
跟我学flutter:细细品Widget(二)StatelessWidget&StatefulWidget
前言
跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
跟我学flutter:在国内如何发布自己的Plugin 或者 Package
跟我学flutter:Flutter雷达图表(一)如何使用kg_charts
跟我学flutter:细细品Widget(一)Widget&Element初识
跟我学flutter:细细品Widget(二)StatelessWidget&StatefulWidget
跟我学flutter:细细品Widget(三)ProxyWidget,InheritedWidget
跟我学flutter:细细品Widget(四)Widget 渲染过程 与 RenderObjectWidget
跟我学flutter:细细品Widget(五)Element
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget
StatelessWidget和StatefulWidget是Flutter开发必不可少的。两者的区别在于state。有状态的widget需要继承StatefulWidget无状态的需要继承StatelessWidget。
StatelessWidget
无状态Widget
源码
abstract class StatelessWidget extends Widget {
const StatelessWidget({ Key? key }) : super(key: key);
@override
StatelessElement createElement() => StatelessElement(this);
@protected
Widget build(BuildContext context);
}
createElement
StatelessWidget的对应Element 为StatelessElement
build
用于构建widget
build 在很多种情况下会被调用
- Widget第一次被加载(Widget 第一次被加入到 Widget Tree 中 ,更准确地说是其对应的 Element 被加入到 Element Tree 时,即 Element 被挂载(mount)时)
- Parent Widget 修改了其配置信息;
- 该 Widget 依赖的Inherited Widget发生变化时。
当Parent Widget或 依赖的Inherited Widget频繁变化时,build方法也会频繁被调用。因此,提升build方法的性能就显得十分重要,Flutter 官方给出了几点建议:
1.减少不必要的中间节点,即减少 UI 的层级,*如:对于「Single Child Widget」,没必要通过组合「Row」、「Column」、「Padding」、「SizedBox」等复杂的 Widget 达到某种布局的目标,或许通过简单的「Align」、「CustomSingleChildLayout」即可实现。又或者,为了实现某种复杂精细的 UI 效果,不一定要通过组合多个「Container」,再附加「Decoration」来实现,通过 「CustomPaint」自定义或许是更好的选择;
2.尽可能使用const Widget,*为 Widget 提供const构造方法;
3.可以将「Stateless Widget」重构成「Stateful Widget」,*以便可以使用「Stateful Widget」中一些特定的优化手法,如:缓存「sub trees」的公共部分,并在改变树结构时使用GlobalKey;
4.尽量减小 rebuilt 范围,*如:某个 Widget 因使用了「Inherited Widget」,导致频繁 rebuilt,可以将真正依赖「Inherited Widget」的部分提取出来,封装成更小的独立 Widget,并尽量将该独立 Widget 推向树的叶子节点,以便减小 rebuilt 时受影响的范围。
StatefulWidget
有状态 Widget
StatefulWidget本身是不可变,状态在State中。
源码
abstract class StatefulWidget extends Widget {
const StatefulWidget({ Key? key }) : super(key: key);
@override
StatefulElement createElement() => StatefulElement(this);
@protected
@factory
State createState();
}
createElement
StatefulElement的对应Element 为StatefulElement
createState
实际上是「Stateful Widget」对应的「Stateful Element」被添加到 Element Tree 时,伴随「Stateful Element」的初始化,createState方法被调用。从后文可知一个 Widget 实例可以对应多个 Element 实例 (也就是同一份配置信息 (Widget) 可以在 Element Tree 上不同位置配置多个 Element 节点),因此,createState方法在「Stateful Widget」生命周期内可能会被调用多次。
另外,需要注意的是配有GlobalKey的 Widget 对应的 Element 在整个 Element Tree 中只有一个实例。
State
生命周期:
- 在对应的(Stateful Element)被挂载 (mount) 到树上时,通过StatefulElement.constructor –> StatefulWidget.createState创建 State 实例
State._emelent就是对应的Element 实例,State与Element绑定关系一经确定,在整个生命周期内不会再变了 (Element 对应的 Widget 可能会变,但对应的 State 永远不会变),期间,Element可以在树上移动,但上述关系不会变
StatefulElement 在挂载过程中接着会调用State.initState,子类可以重写该方法执行相关的初始化操作 (此时可以引用context、widget属性);
同样在挂载过程中会调用State.didChangeDependencies,该方法在 State 依赖的对象 (如:「Inherited Widget」) 状态发生变化时也会被调用,*子类很少需要重写该方法,*除非有非常耗时不宜在build中进行的操作,因为在依赖有变化时build方法也会被调用;
State 初始化已完成,其build方法此后可能会被多次调用,在状态变化时 State 可通过setState方法来触发其子树的重建;
此时,「element tree」、「renderobject tree」、「layer tree」已构建完成,完整的 UI 应该已呈现出来。此后因为变化,「element tree」中「parent element」可能会对树上该位置的节点用新配置 (Widget) 进行重建,当新老配置 (oldWidget、newWidget)具有相同的「runtimeType」&&「key」时,framework 会用 newWidget 替换 oldWidget,并触发一系列的更新操作 (在子树上递归进行)。同时,State.didUpdateWidget方法被调用,子类重写该方法去响应 Widget 的变化;
在 UI 更新过程中,任何节点都有被移除的可能,State 也会随之移除,(如上一步中「runtimeType」||「key」不相等时)。此时会调用State.deactivate方法,由于被移除的节点可能会被重新插入树中某个新的位置上,故子类重写该方法以清理与节点位置相关的信息 (如:该 State 对其他 element 的引用)、同时,不应在该方法中做资源清理;
重新插入操作必须在当前帧动画结束之前
- 当节点被重新插入树中时,State.build方法被再次调用;
- 对于在当前帧动画结束时尚未被重新插入的节点,State.dispose方法被执行,State 生命周期随之结束,此后再调用State.setState方法将报错。子类重写该方法以释放任何占用的资源。
源码
void setState(VoidCallback fn) {
assert(fn != null);
assert(() {
if (_debugLifecycleState == _StateLifecycle.defunct) {
throw FlutterError.fromParts([
ErrorSummary('setState() called after dispose(): $this'),
ErrorDescription(
'This error happens if you call setState() on a State object for a widget that '
'no longer appears in the widget tree (e.g., whose parent widget no longer '
'includes the widget in its build). This error can occur when code calls '
'setState() from a timer or an animation callback.',
),
ErrorHint(
'The preferred solution is '
'to cancel the timer or stop listening to the animation in the dispose() '
'callback. Another solution is to check the "mounted" property of this '
'object before calling setState() to ensure the object is still in the '
'tree.',
),
ErrorHint(
'This error might indicate a memory leak if setState() is being called '
'because another object is retaining a reference to this State object '
'after it has been removed from the tree. To avoid memory leaks, '
'consider breaking the reference to this object during dispose().',
),
]);
}
if (_debugLifecycleState == _StateLifecycle.created && !mounted) {
throw FlutterError.fromParts([
ErrorSummary('setState() called in constructor: $this'),
ErrorHint(
'This happens when you call setState() on a State object for a widget that '
"hasn't been inserted into the widget tree yet. It is not necessary to call "
'setState() in the constructor, since the state is already assumed to be dirty '
'when it is initially created.',
),
]);
}
return true;
}());
final Object? result = fn() as dynamic;
assert(() {
if (result is Future) {
throw FlutterError.fromParts([
ErrorSummary('setState() callback argument returned a Future.'),
ErrorDescription(
'The setState() method on $this was called with a closure or method that '
'returned a Future. Maybe it is marked as "async".',
),
ErrorHint(
'Instead of performing asynchronous work inside a call to setState(), first '
'execute the work (without updating the widget state), and then synchronously '
'update the state inside a call to setState().',
),
]);
}
// We ignore other types of return values so that you can do things like:
// setState(() => x = 3);
return true;
}());
_element!.markNeedsBuild();
}
分析:
- _debugLifecycleState == _StateLifecycle.defunct 在State.dispose后不能调用setState
- _debugLifecycleState == _StateLifecycle.created && !mounted 在 State 的构造方法中不能调用setState
- if (result is Future) setState方法的回调函数 (fn) 不能是异步的 (返回值为Future)
通过setState方法之所以能更新 UI,是在其内部调用_element.markNeedsBuild()
若State.build方法依赖了自身状态会变化的对象,如:ChangeNotifier、Stream或其他可以被订阅的对象,需要确保在initState、didUpdateWidget、dispose
等 3 方法间有正确的订阅 (subscribe) 与取消订阅 (unsubscribe) 的操作:
1.在initState中执行 subscribe;
2.如果关联的「Stateful Widget」与订阅有关,在didUpdateWidget中先取消旧的订阅,再执行新的订阅;
3.在dispose中执行 unsubscribe。
在State.initState方法中不能调用BuildContext.dependOnInheritedWidgetOfExactType,但State.didChangeDependencies会随之执行,在该方法中可以调用。
收起阅读 »
跟我学企业级flutter项目:如何将你的项目简单并且快速屏幕自适应
前言
跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
跟我学flutter:在国内如何发布自己的Plugin 或者 Package
跟我学flutter:Flutter雷达图表(一)如何使用kg_charts
跟我学flutter:细细品Widget(一)Widget&Element初识
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget
你的flutter在小屏幕手机上出现文字丢失了么? 你的flutter应用在小屏幕手机上排版出错了么? 你的flutter应用在大屏幕手机上布局错乱了么? 你在用flutter_screenutil做屏幕自适应么? 今天我来给大家介绍一款简单不侵入代码的自适应。 如果你有如下需求:
- 旧的flutter想快速屏幕适应各种手机
- 页面代码中不想增加关于适配屏幕的代码
kg_density
kg_density 是一个极简的屏幕适配方案,可以快速的帮助已经开发好的项目适配屏幕
开始集成
dependencies:
kg_density: ^0.0.1
以下机型来自 iphone5s
登录适配之前
登录适配之后
图表页面适配之前
图表页面适配之后
其他页面适配之前
其他页面适配之后
使用方法:
- 创建 FlutterBinding
class MyFlutterBinding extends WidgetsFlutterBinding with KgFlutterBinding {
static WidgetsBinding ensureInitialized() {
if (WidgetsBinding.instance == null) MyFlutterBinding();
return WidgetsBinding.instance!;
}
}
- MaterialApp 配置
MaterialApp(
///定义主题
theme: ThemeData(),
builder: KgDensity.initSize(),
);
- 启动前的配置
void main() {
///初始化
KgDensity.initKgDensity(375);
MyFlutterBinding.ensureInitialized();
///运行
runApp(App());
}
注意说明:
- KgDensity.initSize(builder: ??)
为了方便其他builder配置,代码中专门增加其他builder
使用方法
builder: KgDensity.initSize(builder: EasyLoading.init()),
- KgDensity.initKgDensity(375)
数字配置的是按照设计稿件的最窄边来配置的
若不使用KgDensity 进行适配,请不要init
- 正确获取size
MediaQuery.of(context).size
请不要使用 window.physicalSize,MediaQueryData.fromWindow(window)
收起阅读 »Dart 语言的7个很酷的特点
正文
今天的文章简短地揭示了 Dart 语言所提供的很酷的特性。更多时候,这些选项对于简单的应用程序是不必要的,但是当你想要通过简单、清晰和简洁来改进你的代码时,这些选项是一个救命稻草。
考虑到这一点,我们走吧。
Cascade 级联
Cascades (..
, ?..
) 允许你对同一个对象进行一系列操作。这通常节省了创建临时变量的步骤,并允许您编写更多流畅的代码。
var paint = Paint();
paint.color = Colors.black;
paint.strokeCap = StrokeCap.round;
paint.strokeWidth = 5.0;
//above block of code when optimized
var paint = Paint()
..color = Colors.black
..strokeCap = StrokeCap.round
..strokeWidth = 5.0;
Abstract 抽象类
使用 abstract
修饰符定义一个 _abstract
抽象类(无法实例化的类)。抽象类对于定义接口非常有用,通常带有一些实现。
// This class is declared abstract and thus
// can't be instantiated.
abstract class AbstractContainer {
// Define constructors, fields, methods...
void updateChildren(); // Abstract method.
}
Factory constructors 工厂建造者
在实现不总是创建类的新实例的构造函数时使用 factory
关键字。
class Logger {
String name;
Logger(this.name);
factory Logger.fromJson(Map<String, Object> json) {
return Logger(json['name'].toString());
}
}
Named 命名构造函数
使用命名构造函数为一个类实现多个构造函数或者提供额外的清晰度:
class Points {
final double x;
final double y;
//unnamed constructor
Points(this.x, this.y);
// Named constructor
Points.origin(double x,double y)
: x = x,
y = y;
// Named constructor
Points.destination(double x,double y)
: x = x,
y = y;
}
Mixins 混合物
Mixin 是在多个类层次结构中重用类代码的一种方法。
要实现 implement mixin,创建一个声明没有构造函数的类。除非您希望 mixin
可以作为常规类使用,否则请使用 mixin
关键字而不是类。
若要使用 mixin,请使用后跟一个或多个 mixin 名称的 with 关键字。
若要限制可以使用 mixin 的类型,请使用 on 关键字指定所需的超类。
class Musician {}
//creating a mixin
mixin Feedback {
void boo() {
print('boooing');
}
void clap() {
print('clapping');
}
}
//only classes that extend or implement the Musician class
//can use the mixin Song
mixin Song on Musician {
void play() {
print('-------playing------');
}
void stop() {
print('....stopping.....');
}
}
//To use a mixin, use the with keyword followed by one or more mixin names
class PerformSong extends Musician with Feedback, Song {
//Because PerformSong extends Musician,
//PerformSong can mix in Song
void awesomeSong() {
play();
clap();
}
void badSong() {
play();
boo();
}
}
void main() {
PerformSong().awesomeSong();
PerformSong().stop();
PerformSong().badSong();
}
Typedefs
类型别名ー是指代类型的一种简明方式。通常用于创建在项目中经常使用的自定义类型。
typedef IntList = List<int>;
List<int> i1=[1,2,3]; // normal way.
IntList i2 = [1, 2, 3]; // Same thing but shorter and clearer.
//type alias can have type parameters
typedef ListMapper<X> = Map<X, List<X>>;
Map<String, List<String>> m1 = {}; // normal way.
ListMapper<String> m2 = {}; // Same thing but shorter and clearer.
Extension 扩展方法
在 Dart 2.7 中引入的扩展方法是一种向现有库和代码中添加功能的方法。
//extension to convert a string to a number
extension NumberParsing on String {
int customParseInt() {
return int.parse(this);
}
double customParseDouble() {
return double.parse(this);
}
}
void main() {
//various ways to use the extension
var d = '21'.customParseDouble();
print(d);
var i = NumberParsing('20').customParseInt();
print(i);
}
可选的位置参数
通过将位置参数包装在方括号中,可以使位置参数成为可选参数。可选的位置参数在函数的参数列表中总是最后一个。除非您提供另一个默认值,否则它们的默认值为 null。
String joinWithCommas(int a, [int? b, int? c, int? d, int e = 100]) {
var total = '$a';
if (b != null) total = '$total,$b';
if (c != null) total = '$total,$c';
if (d != null) total = '$total,$d';
total = '$total,$e';
return total;
}
void main() {
var result = joinWithCommas(1, 2);
print(result);
}
unawaited_futures
当您想要启动一个 Future
时,建议的方法是使用 unawaited
否则你不加 async 就不会执行了
import 'dart:async';
Future doSomething() {
return Future.delayed(Duration(seconds: 5));
}
void main() async {
//the function is fired and awaited till completion
await doSomething();
// Explicitly-ignored
//The function is fired and forgotten
unawaited(doSomething());
}
end.
作者:会煮咖啡的猫
链接:https://juejin.cn/post/7095177614024769566
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
贝塞尔曲线动起来会是什么样?
彩虹系列
通过动画控制绘制的结束点,就可以让贝塞尔曲线动起来。例如下面的动图展示的效果,看起来像搭了一个滑滑梯一样。实际上就是用7条贝塞尔曲线实现的,我们使用了 Animation
对象的值来控制绘制的结束点,从而实现了对应的动画效果。
具体源码如下,其中控制绘制结束点就是在动画过程中修改循环的次数,即t <= (100 * animationValue).toInt();
这句代码,其中 animationValue 是动画控制器当前值,范围时从0-1。
class AnimationBezierPainter extends CustomPainter {
AnimationBezierPainter({required this.animationValue});
final double animationValue;
@override
void paint(Canvas canvas, Size size) {
final lineWidth = 6.0;
paint.strokeWidth = lineWidth;
paint.style = PaintingStyle.stroke;
final colors = [
Color(0xFFE05100),
Color(0xFFF0A060),
Color(0xFFE0E000),
Color(0xFF10F020),
Color(0xFF2080F5),
Color(0xFF104FF0),
Color(0xFFA040E5),
];
final lineNumber = 7;
for (var i = 0; i < lineNumber; ++i) {
paint.color = colors[i % colors.length];
_drawAnimatedLines(canvas, paint, size, size.height / 4 + i * lineWidth);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
_drawRainbowLines(Canvas canvas, Paint paint, Size size, yPos) {
var yGap = 60.0;
var p0 = Offset(0, yPos - yGap / 2);
var p1 = Offset(size.width * 2 / 3, yPos - yGap);
var p2 = Offset(size.width / 3, yPos + yGap);
var p3 = Offset(size.width, yPos + yGap * 1.5);
var path = Path();
path.moveTo(p0.dx, p0.dy);
for (var t = 1; t <= (100 * animationValue).toInt(); t += 1) {
var curvePoint =
BezierUtil.get3OrderBezierPoint(p0, p1, p2, p3, t / 100.0);
path.lineTo(curvePoint.dx, curvePoint.dy);
}
canvas.drawPath(path, paint);
}
}
我们修改曲线的控制点还可以实现下面的效果,大家有兴趣可以自己尝试一下。
弹簧动画
用多个贝塞尔曲线首尾相接,在垂直方向叠起来就能画出一条弹簧了,然后我们更改弹簧的间距和高度(曲线的数量)就能做出弹簧压下去和弹起来的动画效果了。
这部分的代码如下所示:
@override
void paint(Canvas canvas, Size size) {
var paint = Paint()..color = Colors.black54;
final lineWidth = 2.0;
paint.strokeWidth = lineWidth;
paint.style = PaintingStyle.stroke;
final lineNumber = 20;
// 弹簧效果
final yGap = 2.0 + 16.0 * animationValue;
for (var i = 0; i < (lineNumber * animationValue).toInt(); ++i) {
_drawSpiralLines(
canvas, paint, size, size.width / 2, size.height - i * yGap, yGap);
}
}
_drawSpiralLines(Canvas canvas, Paint paint, Size size, double xPos,
double yPos, double yGap) {
final xWidth = 160.0;
var p0 = Offset(xPos, yPos);
var p1 = Offset(xPos + xWidth / 2 + xWidth / 4, yPos - yGap);
var p2 = Offset(xPos + xWidth / 2 - xWidth / 4, yPos - 3 * yGap);
var p3 = Offset(xPos, yPos - yGap);
var path = Path();
path.moveTo(p0.dx, p0.dy);
for (var t = 1; t <= 100; t += 1) {
var curvePoint =
BezierUtil.get3OrderBezierPoint(p0, p1, p2, p3, t / 100.0);
path.lineTo(curvePoint.dx, curvePoint.dy);
}
canvas.drawPath(path, paint);
}
复杂立体感动画
通过多条贝塞尔图形组成的曲线往往会有立体的效果,而立体的效果动起来的时候就会感觉是3D 动画一样,实际上通过贝塞尔曲线是能够绘制出一些3D 效果的动画的,比如下面这个效果,就感觉像在三维空间飞行一样(如果配上背景图移动会更逼真)。这里实际使用了4组贝塞尔曲线来实现,当然实际还可以画一些有趣的图形,比如说画一条鱼。这个源码比较长,就不贴了,有兴趣的可以自行去下载源码(注:本篇之后的 Flutter版本升级到了2.10.3):绘图相关源码。
总结
可以看到,通过动画控制贝赛尔曲线动起来的效果还是挺有趣的。而且,我们还可以根据之前动画相关的篇章做一些更有趣的效果出来。这种玩法可以用在一些特殊的加载动画或是做一些比较酷炫的特效上面,增添 App 的趣味性。
作者:岛上码农
链接:https://juejin.cn/post/7095735501793001479
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
关于Kotlin的一些小事
一、碎碎念
说实话,原本是没有这个系列的,或者说是没想过去建立这个系列。
虽然,但是,所以就有了(别问为什么?)
val var 声明变量
- 被 val 修饰的变量:被 final 修饰,且只会为其提供 getter() 而不会提供 setter() 方法。
- 因为被 final 修饰的值,只能被赋值一次;所以不会有 setter()。
- 是否添加了"?":声明变量的时候会根据是否有"?",将变量添加 NotNull 或者 Nullable 注解。
- 被 var 修饰的变量:普通定义变量的方式,且会同时提供 setter()、getter() 方法。
- 是否添加了"?":如果没有?,则setter()方法的入参会被标记位NotNull;如果有?,则setter()方法的入参会被标记为Nullable。
?. 操作符
- 对于声明为 var 的变量,在调用方法时会需要加上 ?. 操作符来进行判空处理,避免空指针。实现空安全。
- 实现原理:通过在方法内部构造一个局部变量,然后赋值为该数据,紧接着通过判断局部变量是否为空?如果为空,则进行预设的处理;如果不为空,则直接进行方法调用。
声明变量的方式,能否全部声明为可空来避免空指针?为什么?
- 猜测:这里涉及到一个 java 和 Kt 互调的问题。
- 假设1:【Java 调 Kotlin 方法,在于调用】java 用一个可能为空的数据作为方法参数去调用 kt 方法,如果此时入参为空,但 kt 方法将方法参数配置为不可空的数据类型,那么此时就会直接报空指针异常。
- 因为 kt 会对那些入参不可空的对象先进行空指针判断再执行方法操作。
- 假设2:【Kotlin 调 Java 方法,在于接收】kt 用一个不可空的变量来接收 java 方法调用得到的返回值,如果此时 java 方法返回一个空,那么此时就会直接报空指针异常。
- 假设1:【Java 调 Kotlin 方法,在于调用】java 用一个可能为空的数据作为方法参数去调用 kt 方法,如果此时入参为空,但 kt 方法将方法参数配置为不可空的数据类型,那么此时就会直接报空指针异常。
单例的实现方式
- 后面新建文章再说
data class
- data class,编译之后变成 public final class;声明的所有参数会作为构造函数的入参。
- ① 声明为 val 的参数,只会被提供 getter() 方法;而声明为 var 的参数,会被同时提供 setter()/gettter() 方法。
- ② 带了 ? 标记的参数,即标明为可空的参数,在构造函数中会被检测是否为空并抛出异常。
by lazy 和 lateinit var
- 【作用对象不同】
- lateinit 只能用在 var 声明变量且数据类型不能为空。
- by lazy {} 只能用在 val 声明变量。
- 【初始化数据的时机不同】
- 使用 lateinit 标记的变量,认定了开发者自己在使用该变量之前一定会先为其赋值,所以在访问的时候,会先进行判空处理。如果为空则直接crash。
- 这也证实了 lateinit 只能对数据类型不为空的变量进行修饰。
- 通过 by lazy 声明的变量,会为该变量提供私有的 getter() 方法并通过该方法来访问变量,而真正保存数据的位置,是类中一个声明为 final 的数据类型为 Lazy 的私有代理对象,将其作为访问入口,通过 Lazy 的 value 属性来获取数据。Lazy.getValue() 会通过执行初始化函数 initializer 来进行初始化。
- 详见:链接,下面会接着说。
- 使用 lateinit 标记的变量,认定了开发者自己在使用该变量之前一定会先为其赋值,所以在访问的时候,会先进行判空处理。如果为空则直接crash。
- 其他方面:看一下对比
by lazy - val
by lazy{} 的使用
- by lazy{} 入参:需要传入一个初始化数据的函数 initializer: () -> T。
- by lazy{} 返回值:会通过 initializer 函数作为方法参数,构造并返回一个 SynchronizedLazyImpl:Lazy 对象。
如何获取数据?
- 可见,访问 by lazy 的变量,会通过其 getter() 方法来获取数据。
- 而此时可以看到 getter() 方法是通过访问数据类型为 Lazy 的代理对象的 getValue() 方法获取数据;由上述可知,此时得到的代理对象是一个 SynchronizedLazyImpl:Lazy 对象。
- 立下一个 Flag:后续再对所有 Lazy 实现类新建文章看看?
SynchronizedLazyImpl:Lazy
- 【关于数据 _value:Any? 】
- 初始值为一个单例对象 internal object UNINITIALIZED_VALUE,表示当前未初始化。
- 因此 _value 的数据类型为 Any。
- 【getVaule() 方法】
- ① 首先会判断当前保存的数据 _value是否为这个单例对象 UNINITIALIZED_VALUE?如果不是,则直接通过 as 强装为返回值类型并返回。如果数据未初始化,那么
- ② 进入一个同步块 synchronized(lock),在同步块中,再次判断 _value是否为这个单例对象 UNINITIALIZED_VALUE?这里的流程就类似于 double check lock。如果已经初始化,则同样通过 as 强装为返回值类型并返回。如果数据未初始化,那么
- ③ 执行初始化函数 initializer 获取数据并赋值给 _value,从而保证下次获取数据时直接返回该数据。此时,还会将初始化函数 initializer 置空。然后返回数据。
- 【关于 initializer 函数】
- 由上述可见,我们传递给 lazy 的 Lambda ,会被编译成为一个静态内部类。
- 静态内部类:继承了 FunctionN,且是一个单例类。invoke() 方法的方法体就是我们在 lambda 中的操作,并且返回值为最后一句。
- 因此可以知道,在执行初始化函数的时候,实际上就是执行我们传递给 lazy{} 的 lambda 中的执行指令。
- 【关于线程安全】
- SynchronizedLazyImpl 接受一个锁对象 lock:Any?=null ,这个锁对象可以是任意类型的对象,当然也可以为空,那么默认使用的就是当前实例对象作为锁对象来进行加锁。
- 在执行初始化函数 initializer 为数据赋值的时候,正是通过加锁来保证线程安全。
lateinit var 对比 by lazy
- 关于线程安全
- by lazy {} 的初始化默认是线程安全的,默认是 SynchronizedLazyImpl:Lazy 实现。并且能保证初始化函数 initializer 只会被调用一次,在数据未初始化时进行调用 且 调用完毕后会置空。
- lateint 默认是不保证线程安全的。
- 关于内存泄漏
- 由上述可知,传递给 lazy 的 Lambda ,会被编译成为一个静态内部类。
- 在使用 by lazy{} 的时候,如果在 lambda 里面使用了类中的成员变量,那么这个引用会一直被持有,直到该初始化函数执行,即该变量被初始化了才会释放(因为初始化函数执行完毕之后会被置空,断开引用链)。
- 而这里就很可能会导致内存泄漏。
二、各种函数?
- Flag 立下来:
- T.let
- T.run
- T.also
- T.apply
- with
- run
- 扩展函数
- 高阶函数
- inline noinline crossinline
作者:冰美式上瘾患者
链接:https://juejin.cn/post/7085965272510627877
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Dart中的extends, with, implements, on关键字详解
Dart中类的类型
Dart
是支持基于mixin
继承机制的面向对象语言,所有对象都是一个类的实例,而除了 Null
以外的所有的类都继承自Object
类。 基于mixin
的继承意味着尽管每个类(top class Object? 除外)都只有一个超类,一个类的代码可以在其它多个类继承中重复使用。
以上这段是官方文档的说明,在实际使用中,由于mixin
的加入,使得Dart
中类的使用和其它语言有所不同。Dart中类的类型有三种,分别是:
class
:声明一个类,提供具体的成员变量和方法实现。abstract class
:声明一个抽象类,抽象类将无法被实例化。抽象类常用于声明接口方法、有时也会有具体的方法实现。mixin
:声明一个Mixin
类,与抽象类一样无法被实例化,是一种在多重继承中复用某个类中代码的方法模式,可以声明接口方法或有具体的方法实现。
- 每一个类都隐式地定义了一个接口并实现了该接口,这个接口包含所有这个类的成员变量以及这个类所实现的其它接口。
- 如果想让抽象类同时可被实例化,可以为其定义
工厂构造函数
。具体内容可以参考:抽象类的实例化
mixin
关键字在Dart 2.1
中才被引用支持。早期版本中的代码通常使用abstract class
代替
从上述内容可以看出,mixin
是后面才被引入的,与abstract class
有些通用的地方,可以理解为abstract class
的升级版。它相对于abstract class
说,可以同时引入多个Mixin
,并且可以通过on
关键字来限制使用范围。
类相关关键字的使用
而对上述这些类型的使用,又有extends
, with
, implements
, on
这几个关键字:
extends
:继承,和其它语言的继承没什么区别。with
:使用Mixin
模式混入一个或者多个Mixin类
。implements
:实现一个或多个接口并实现每个接口定义的API。on
:限制Mixin
的使用范围。
针对这几个关键字的使用,我做了一张表进行总结:
样例说明
针对上面的内容,我举几个例子,可以复制代码到DartPad中进行验证:
类混入类或者抽象类(class with class)
class Animal {
String name = "Animal";
}
abstract class Flyer {
String name = "Flyer";
void fly() => print('$name can fly!');
}
abstract class Eater extends Animal {
void eat() => print('I can Eat!');
}
// 同时混入class和abstract class
abstract class Bird with Animal, Flyer {}
class Bird1 with Animal, Flyer {}
// 只支持无任何继承和混入的类,Eater继承自Animal,所以它不支持被混入。
// 报错:The class 'Eater' can't be used as a mixin because it extends a class other than 'Object'.
// class Bird with Eater {
// }
main() {
Bird1().fly(); // Flyer can fly!
}
类继承抽象类并混入Mixin
class Animal {
String name = "Animal";
}
mixin Flyer {
String name = "Flyer";
void fly() => print('$name can fly!');
}
abstract class Eater extends Animal {
@override
String get name => "Eater";
void eat() => print('$name can Eat!');
}
// 类继承抽象类并混入Mixin
class Bird extends Eater with Flyer { }
main() {
// 因为with(混入)的优先级比extends(继承)更高,所以打印出来的是Flyer而不是Eater
Bird().fly(); // Flyer can fly!
Bird().eat(); // Flyer can Eat!
}
类继承抽象类并混入Mixin的同时实现接口
class Biology {
void breathe() => print('I can breathe');
}
class Animal {
String name = "Animal";
}
// 这里设置实现了Biology接口,但是mixin与abstract class一样并不要求实现接口,声明与实现均可。
// on关键字限制混入Flyer的类必须继承自Animal或它的子类
mixin Flyer on Animal implements Biology {
@override
String get name => "Flyer";
void fly() => print('$name can fly!');
}
abstract class Eater extends Animal {
@override
String get name => "Eater";
void eat() => print('$name can Eat!');
}
// 类继承抽象类并混入Mixin的同时实现接口
// 注意关键字的使用顺序,依次是extends -> with -> implements
class Bird extends Eater with Flyer implements Biology {
// 后面使用了`implements Biology`,所以子类必须要实现这个类的接口
@override
void breathe() => print('Bird can breathe!');
}
main() {
// 因为with(混入)的优先级比extends(继承)更高,所以打印出来的是Flyer而不是Eater
Bird().fly(); // Flyer can fly!
Bird().eat(); // Flyer can Eat!
Bird().breathe(); // Bird can breathe!
}
混入mixin的顺序问题
abstract class Biology {
void breathe() => print('I can breathe');
}
mixin Animal on Biology {
String name = "Animal";
@override
void breathe() {
print('$name can breathe!');
super.breathe();
}
}
mixin Flyer on Animal {
@override
String get name => "Flyer";
void fly() => print('$name can fly!');
}
/// mixin的顺序问题:
/// with后面的Flyer必须在Animal后面,否则会报错:
/// 'Flyer' can't be mixed onto 'Biology' because 'Biology' doesn't implement 'Animal'.
class Bird extends Biology with Animal, Flyer {
@override
void breathe() {
print('Bird can breathe!');
super.breathe();
}
}
main() {
Bird().breathe();
/*
* 上述代码执行,依次输出:
* Bird can breathe!
* Flyer can breathe!
* I can breathe
* */
}
这里的顺序问题和运行出来的结果会让人有点费解,但是可以这样理解:Mixin
语法糖, 本质还是类继承. 继承可以复用代码, 但多继承会导致代码混乱。 Java
为了解决多继承的问题, 用了interface
, 只有函数声明而没有实现(后面加的default
也算语法糖了)。以A with B, C, D
为例,实际是A extends D extends C extends B
, 所以上面的Animal
必须在Flyer
前面,否则就变成了Animal extends Flyer
,会出现儿子给爹当爹的混乱问题。
mixin的底层本质只是猜测,并没有查看语言底层源码进行验证.
总结
从上述样例可以看出,三种类结构可以同时存在,关键字的使用有前后顺序:extends -> mixins -> implements
。
另外需要注意的是相同方法的优先级问题,这个有两种情况:
- 同时被
extends
,with
,implements
时,混入(with
)的优先级比继承(extends
)要高,而implements
只提供接口,不会被调用。 with
多个Mixin
时,则会调用距离with
关键字最远Mixin
中的方法。
当然,如果当前使用类重写了该方法,就会优先调用当前类中的方法。
参考资料
- Dart官方文档
- Dart之Mixin详解
- Flutter 基础 | Dart 语法 mixin:对
mixin
的使用场景进行了很好的说明
作者:星的天空
链接:https://juejin.cn/post/7094642592880525320
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter Modular使用教程
什么是Flutter Modular?
随着应用项目发展和变得越来越复杂,保持代码和项目结构可维护和可复用越来越难。Modular提供了一堆适配Flutter的解决方案来解决这些问题,比如依赖注入,路由系统和“一次性单例”系统(也就是说,当注入模块超出范围时,模块化自动配置注入模块)。
Modular的依赖注入为任何状态管理系统提供了开箱即用的支持,管理你应用的内存。
Modular也支持动态路由和相对路由,像在Web一样。
Modular结构
Modular结构由分离和独立的模块组成,这些模块将代表应用程序的特性。
每个模块都位于自己的目录中,并控制自己的依赖关系、路由、页面、小部件和业务逻辑。因此,您可以很容易地从项目中分离出一个模块,并在任何需要的地方使用它。
Modular支柱
这是Modular关注的几个方面:
- 自动内存管理
- 依赖注入
- 动态和相对路由
- 代码模块化
在项目中使用Modular
安装
打开你项目的pubspec.yaml
并且添加flutter_modular
作为依赖:
dependencies:
flutter_modular: any
在一个新项目中使用
为了在新项目中使用Modular,你必须做一些初始化步骤:
用
MaterialApp
创建你的main widget并且调用MaterialApp().modular()
方法。
// app_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
class AppWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
initialRoute: "/",
).modular();
}
}
创建继承自
Module
的你项目的main module文件:
// app_module.dart
class AppModule extends Module {
// Provide a list of dependencies to inject into your project
@override
final Listbinds = [];
// Provide all the routes for your module
@override
final Listroutes = [];
}
在
main.dart
文件中,将main module包裹在ModularApp
中以使Modular初始化它:
// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'app/app_module.dart';
void main() => runApp(ModularApp(module: AppModule(), child: AppWidget()));
完成!你的应用已经设置完成并且准备好和Modular一起工作!
创建child modules
你可以在你的项目中创建任意多module:
class HomeModule extends Module {
@override
final List binds = [
Bind.singleton((i) => HomeBloc()),
];
@override
final List routes = [
ChildRoute('/', child: (_, args) => HomeWidget()),
ChildRoute('/list', child: (_, args) => ListWidget()),
];
}
你可以通过module
参数将子模块传递给你main module中的一个Route
。
class AppModule extends Module {
@override
final List routes = [
ModuleRoute('/home', module: HomeModule()),
];
}
我们建议你讲代码分散到不同模块中,例如一个AuthModule
,并将与此模块相关的所有路由放入其中。通过这样做,维护和与其他项目分享你的代码将变得更加容易。
**注意:**使用ModuleRoute对象创建复杂的路由。
添加路由
模块路由是通过覆盖routes
属性来提供的。
// app_module.dart
class AppModule extends Module {
// Provide a list of dependencies to inject into your project
@override
final List binds = [];
// Provide all the routes for your module
@override
final List routes = [
// Simple route using the ChildRoute
ChildRoute('/', child: (_, __) => HomePage()),
ChildRoute('/login', child: (_, __) => LoginPage()),
];
}
**注意:**使用
ChildRoute
对象来创建简单路由。
动态路由
你可以使用动态路由系统来提供参数给你的Route
:
// 使用 :参数名 语法来为你的路由提供参数。
// 路由参数可以通过' args '获得,也可以在' params '属性中访问,
// 使用方括号符号 (['参数名']).
@override
final List routes = [
ChildRoute(
'/product/:id',
child: (_, args) => Product(id: args.params['id']),
),
];
当调用给定路由时,参数将是模式匹配的。例如:
// In this case, `args.params['id']` will have the value `1`.
Modular.to.pushNamed('/product/1');
你也可以在多个界面中使用它。例如:
@override
final List routes = [
// We are sending an ID to the DetailPage
ChildRoute(
'/product/:id/detail',
child: (_, args) => DetailPage(id: args.params['id']),
),
// We are sending an ID to the RatingPage
ChildRoute(
'/product/:id/rating',
child: (_, args) => RatingPage(id: args.params['id']),
),
];
与第一个实例相同,我们只需要调用这个路由。例如:
// In this case, modular will open the page DetailPage with the id of the product equals 1
Modular.to.navigate('/product/1/detail');
// We can use the pushNamed too
// The same here, but with RatingPage
Modular.to.navigate('/product/1/rating');
然而,这种表示法只对简单的文字有效。
发送对象
如果你想传递一个复杂对象给你的路由,通过arguments
参数传递给它::
Modular.to.navigate('/product', arguments: ProductModel());
并且,它将通过args.data
属性提供而不是args.params
:
@override
final List routes = [
ChildRoute(
'/product',
child: (_, args) => Product(model: args.data),
),
];
你可以直接通过binds来找回这些参数:
@override
final List binds = [
Bind.singleton((i) => MyController(data: i.args.data)),
];
路由泛型类型
你可以从导航返回一个值,就像.pop
。为了实现这个,将你期望返回的参数作为类型参数传递给Route
:
@override
final List routes = [
// This router expects to receive a `String` when popped.
ChildRoute('/event', child: (_, __) => EventPage()),
]
现在,使用.pop
就像你使用Navigator.pop
:
// Push route
String name = await Modular.to.pushNamed('/event');
// And pass the value when popping
Modular.to.pop('banana');
路由守卫
路由守卫是一种类似中间件的对象,允许你从其它路由控制给定路由的访问权限。你通过让一个类implements RouteGuard
可以实现一个路由守卫.
例如,下面的类只允许来自/admin
的路由的重定向:
class MyGuard implements RouteGuard {
@override
Future canActivate(String url, ModularRoute route) {
if (url != '/admin'){
// Return `true` to allow access
return Future.value(true);
} else {
// Return `false` to disallow access
return Future.value(false);
}
}
}
要在路由中使用你的RouteGuard
,通过guards
参数传递:
@override
final List routes = [
final ModuleRoute('/', module: HomeModule()),
final ModuleRoute(
'/admin',
module: AdminModule(),
guards: [MyGuard()],
),
];
如果你设置到module route上,RouteGuard
将全局生效。
如果RouteGuard
验证失败,添加guardedRoute
属性来添加路由选择路由:
@override
final List routes = [
ChildRoute(
'/home',
child: (context, args) => HomePage(),
guards: [AuthGuard()],
guardedRoute: '/login',
),
ChildRoute(
'/login',
child: (context, args) => LoginPage(),
),
];
什么时候和如何使用navigate或pushNamed
你可以在你的应用中使用任何一个,但是需要理解每一个。
pushNamed
无论何时使用,这个方法都将想要的路由放在当前路由的上面,并且您可以使用AppBar
上的后退按钮返回到上一个页面。 它就像一个模态,它更适合移动应用程序。
假设你需要深入你的路线,例如:
// Initial route
Modular.to.pushNamed('/home');
// User route
Modular.to.pushNamed('/home/user');
// User profile route
Modular.to.pushNamed('/home/user/profile');
最后,您可以看到返回到前一页的back按钮,这加强了模态页面在前一页上面的想法。
navigate
它删除堆栈中先前的所有路由,并将新路由放到堆栈中。因此,在本例中,您不会在AppBar
中看到后退按钮。这更适合于Web应用程序。
假设您需要为移动应用程序创建一个注销功能。这样,您需要从堆栈中清除所有路由。
// Initial route
Modular.to.pushNamed('/home');
// User route
Modular.to.pushNamed('/home/user');
// User profile route
Modular.to.pushNamed('/home/user/profile');
// Then you need to go again to the Login page, only use the navigation to clean all the stack.
Modular.to.navigate('/login');
Relative Navigation
要在页面之间导航,请使用Modular.to.navigate
。
Modular.to.navigate('/login');
你可以使用相对导航来导航,就像在web程序一样:
// Modules Home → Product
Modular.to.navigate('/home/product/list');
Modular.to.navigate('/home/product/detail/3');
// Relative Navigation inside /home/product/list
Modular.to.navigate('detail/3'); // it's the same as /home/product/detail/3
Modular.to.navigate('../config'); // it's the same as /home/config
您仍然可以使用旧的Navigator API来堆叠页面。
Navigator.pushNamed(context, '/login');
或者,您可以使用Modular.to.pushhnamed
,你不需要提供BuildContext
:
Modular.to.pushNamed('/login');
Flutter Web URL routes (Deeplink-like)
路由系统可以识别URL中的内容,并导航到应用程序的特定部分。动态路由也适用于此。例如,下面的URL将打开带有参数的Product视图。args.params['id']
设置为1。
https://flutter-website.com/#/product/1
它也可以处理查询参数或片段:
https://flutter-website.com/#/product?id=1
路由过渡动画
通过设置Route的转换参数,提供一个TransitionType,您可以选择在页面转换中使用的动画类型。
ModuleRoute('/product',
module: AdminModule(),
transition: TransitionType.fadeIn,
), //use for change transition
如果你在一个Module
中指定了一个过渡动画,那么该Module
中的所有路由都将继承这个过渡动画。
自定义过渡动画路由
你也可以通过将路由器的transition
和customTransition
参数分别设置为TransitionType.custom
和你的CustomTransition
来使用自定义的过渡动画:
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
CustomTransition get myCustomTransition => CustomTransition(
transitionDuration: Duration(milliseconds: 500),
transitionBuilder: (context, animation, secondaryAnimation, child){
return RotationTransition(turns: animation,
child: SlideTransition(
position: Tween(
begin: const Offset(-1.0, 0.0),
end: Offset.zero,
).animate(animation),
child: ScaleTransition(
scale: Tween(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: animation,
curve: Interval(
0.00,
0.50,
curve: Curves.linear,
),
),
),
child: child,
),
),
)
;
},
);
依赖注入
可以通过重写Module
的binds
的getter将任何类注入到Module
中。典型的注入例子有BLoCs、ChangeNotifier
实例或(MobX)。
一个Bind
对象负责配置对象注入。我们有4个Bind
工厂类型和一个AsyncBind
。
class AppModule extends Module {
// Provide a list of dependencies to inject into your project
@override
List get binds => [
Bind((i) => AppBloc()),
Bind.factory((i) => AppBloc()),
Bind.instance(myObject),
Bind.singleton((i) => AppBloc()),
Bind.lazySingleton((i) => AppBloc()),
AsyncBind((i) => SharedPreferences.getInstance())
];
...
}
Factory
每当调用类时实例化它。
@override
List get binds => [
Bind.factory((i) => AppBloc()),
];
Instance
使用已经实例化的对象。
@override
List get binds => [
Bind.instance((i) => AppBloc()),
];
Singleton
创建一个类的全局实例。
@override
List get binds => [
Bind.singleton((i) => AppBloc()),
];
LazySingleton
只在第一次调用类时创建一个全局实例。
@override
List get binds => [
Bind.lazySingleton((i) => AppBloc()),
];
AsyncBind
若干类的一些方法返回一个Future。要注入那些特定方法返回的实例,你应该使用AsyncBind
而不是普通的同步绑定。使用Modular.isModuleReady
等待所有AsyncBinds
解析,以便放开Module
供使用。
重要:如果有其他异步绑定的相互依赖,那么
AsyncBind
的顺序很重要。例如,如果有两个AsyncBind
,其中A依赖于B,AsyncBind
B必须在A之前声明。注意这种类型的顺序!
import 'package:flutter_modular/flutter_modular.dart' show Disposable;
// In Modular, `Disposable` classes are automatically disposed when out of the module scope.
class AppBloc extends Disposable {
final controller = StreamController();
@override
void dispose() {
controller.close();
}
}
isModuleReady
如果你想确保所有的AsyncBinds
都在Module
加载到内存之前被解析,isModuleReady
是一个方法。使用它的一种方法是使用RouteGuard
,将一个AsyncBind
添加到你的AppModule
中,并将一个RouteGuard
添加到你的ModuleRoute
中。
class AppModule extends Module {
@override
List get binds => [
AsyncBind((i)=> SharedPreferences.getInstance()),
];
@override
List get routes => [
ModuleRoute(Modular.initialRoute, module: HomeModule(), guards: [HomeGuard()]),
];
}
然后,像下面这样创建一个RouteGuard
。这样,在进入HomeModule
之前,模块化会评估你所有的异步依赖项。
import 'package:flutter_modular/flutter_modular.dart';
class HomeGuard extends RouteGuard {
@override
Future canActivate(String path, ModularRoute router) async {
await Modular.isModuleReady();
return true;
}
}
在视图中检索注入的依赖项
让我们假设下面的BLoC已经定义并注入到我们的模块中(就像前面的例子一样):
import 'package:flutter_modular/flutter_modular.dart' show Disposable;
// In Modular, `Disposable` classes are automatically disposed when out of the module scope.
class AppBloc extends Disposable {
final controller = StreamController();
@override
void dispose() {
controller.close();
}
}
注意:Modular自动调用这些
Binds
类型的销毁方法:Sink/Stream, ChangeNotifier和[Store/Triple]
有几种方法可以检索注入的AppBloc
。
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// You can use the object Inject to retrieve..
final appBloc = Modular.get();
//or for no-ready AsyncBinds
final share = Modular.getAsync();
}
}
使用Modular
小部件检索实例
ModularState
在本例中,我们将使用下面的MyWidget
作为页面,因为这个页面需要是StatefulWidget
。
让我们来了解一下ModularState
的用法。当我们定义类_MyWidgetState
扩展ModularState
时,我们正在为这个小部件(在本例中是HomeStore
)将Modular与我们的Store链接起来。当我们进入这个页面时,HomeStore
将被创建,store/controller
变量将被提供给我们,以便在MyWidget
中使用。
在此之后,我们可以使用存储/控制器而没有任何问题。在我们关闭页面后,模块化将自动处理HomeStore
。
class MyWidget extends StatefulWidget {
@override
_MyWidgetState createState() => _MyWidgetState();
}
class _MyWidgetState extends ModularState {
store.myVariableInsideStore = 'Hello!';
controller.myVariableInsideStore = 'Hello!';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Modular"),
),
body: Center(child: Text("${store.counter}"),),
);
}
}
WidgetModule
WidgetModule
具有与Module
相同的结构。如果你想要一个带有Modular页面的TabBar
,这是非常有用的。
class TabModule extends WidgetModule {
@override
List binds => [
Bind((i) => TabBloc(repository: i())),
Bind((i) => TabRepository()),
];
final Widget view = TabPage();
}
Mock导航系统
我们认为,在使用Modular.to
和Modular.link
时,提供一种native方式来mock导航系统会很有趣。要做到这一点,您只需实现IModularNavigator
并将您的实现传递给Modular.navigatorDelegate
。
使用 Mockito示例:
main() {
var navigatorMock = MyNavigatorMock();
// Modular.to and Modular.link will be called MyNavigatorMock implements!
Modular.navigatorDelegate = navigatorMock;
test('test navigator mock', () async {
when(navigatorMock.pushNamed('/test')).thenAnswer((_) async => {});
Modular.to.pushNamed('/test');
verify(navigatorMock.pushNamed('/test')).called(1);
});
}
class MyNavigatorMock extends Mock implements IModularNavigator {
@override
Future pushNamed(String? routeName, {Object? arguments, bool? forRoot = false}) =>
(super.noSuchMethod(Invocation.method(#pushNamed, [routeName], {#arguments: arguments, #forRoot: forRoot}), returnValue: Future.value(null)) as Future);
}
本例使用手动实现,但您也可以使用 代码生成器来创建模拟。
RouterOutlet
每个ModularRoute
都可以有一个ModularRoute
列表,这样它就可以显示在父ModularRoute
中。反映这些内部路由的小部件叫做RouterOutlet
。每个页面只能有一个RouterOutlet
,而且它只能浏览该页面的子页面。
class StartModule extends Module {
@override
List get binds => [];
@override
List get routes => [
ChildRoute(
'/start',
child: (context, args) => StartPage(),
children: [
ChildRoute('/home', child: (_, __) => HomePage()),
ChildRoute('/product', child: (_, __) => ProductPage()),
ChildRoute('/config', child: (_, __) => ConfigPage()),
],
),
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: RouterOutlet(),
bottomNavigationBar: BottomNavigationBar(
onTap: (id) {
if (id == 0) {
Modular.to.navigate('/start/home');
} else if (id == 1) {
Modular.to.navigate('/start/product');
} else if (id == 2) {
Modular.to.navigate('/start/config');
}
},
currentIndex: currentIndex,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.control_camera),
label: 'product',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Config',
),
],
),
);
}
作者:牛奶燕麦
链接:https://juejin.cn/post/6998910339882418189
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
jetpack compose实战——基本框架搭建
前言
- 项目地址:github.com/Peakmain/Co…
- 网上现在有不少jetpack compose的文章和教程,但是实战项目不多。
- 项目接口基于玩Android,这里也非常感谢大佬提供的免费接口
建议
先学习kotlin语言,最好有Android App开发经验
项目结构
新建项目New Project->选择 Empty Compose Activity
项目结构
新建项目New Project->选择 Empty Compose Activity
填写必要信息,完成项目创建
Compose和Android View的区别
Android View | compose |
---|---|
Button | Button |
TextView | Text |
EditText | TextField |
ImageView | Image |
LinearLayout(horizontally) | Row |
LinearLayout(vertically) | Column |
FrameLayout | Box |
RecyclerView | LazyColumn |
RecyclerView(horizontally) | LazyRow |
Snackbar | Snackbar |
一些基础知识
Scaffold
@Composable
fun Scaffold(
modifier: Modifier = Modifier,
scaffoldState: ScaffoldState = rememberScaffoldState(),
topBar: @Composable () -> Unit = {},
bottomBar: @Composable () -> Unit = {},
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
floatingActionButton: @Composable () -> Unit = {},
floatingActionButtonPosition: FabPosition = FabPosition.End,
isFloatingActionButtonDocked: Boolean = false,
drawerContent: @Composable (ColumnScope.() -> Unit)? = null,
drawerGesturesEnabled: Boolean = true,
drawerShape: Shape = MaterialTheme.shapes.large,
drawerElevation: Dp = DrawerDefaults.Elevation,
drawerBackgroundColor: Color = MaterialTheme.colors.surface,
drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
drawerScrimColor: Color = DrawerDefaults.scrimColor,
backgroundColor: Color = MaterialTheme.colors.background,
contentColor: Color = contentColorFor(backgroundColor),
content: @Composable (PaddingValues) -> Unit
)
Scaffold主要用于快速搭建一个项目的结构,包含:
- topBar:通常是TopAppBar
- bottomBar 通常是一个 BottomNavigation,里面每个item是BottomNavigationItem
- floatingActionButton 悬浮按钮
- floatingActionButtonPosition 悬浮按钮位置
- isFloatingActionButtonDocked 悬浮按钮是否贴到 bottomBar 上
- drawerContent 侧滑菜单
- content:内容区域
状态
状态和组合
由于 Compose 是声明式工具集,因此更新它的唯一方法是通过新参数调用同一可组合项。这些参数是界面状态的表现形式。每当状态更新时,都会发生重组。因此,TextField 不会像在基于 XML 的命令式视图中那样自动更新。可组合项必须明确获知新状态,才能相应地进行更新
@Composable
fun HelloContent() {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Hello!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
OutlinedTextField(
value = "",
onValueChange = { },
label = { Text("Name") }
)
}
}
- OutlinedTextField与 TextField 只是样式不同
- 如果运行此代码,您将不会看到任何反应。这是因为,TextField 不会自行更新,但会在其 value 参数更改时更新。
Compose中的状态
- Composable中可以使用remember来记住单个对象。
- 系统会在初始化由 remember计算的值存储在Composable中,并在重组的时候返回存储的值
- remember既可以存储可变对象,也可以存储不可变对象。
注意:remember 会将对象存储在组合中,当调用 remember 的可组合项从组合中移除后,它会忘记该对象。
mutableStateOf 会创建可观察的 MutableState,后者是与 Compose 运行时集成的可观察类型。
interface MutableState<T> : State<T> {
override var value: T
}
value 如有任何更改,系统会安排重组读取 value 的所有可组合函数。
在可组合项中声明 MutableState 对象的方法有三种:
- val mutableState = remember { mutableStateOf(default) }
- var value by remember { mutableStateOf(default) }
- val (value, setValue) = remember { mutableStateOf(default) }
这些声明是等效的,以语法糖的形式针对状态的不同用法提供。您选择的声明应该能够在您编写的可组合项中生成可读性最高的代码。
所以上面代码的解决办法
@Composable
fun HelloContent() {
Column(modifier = Modifier.padding(16.dp)) {
var name by remember { mutableStateOf("") }//👈🏻定义状态
if (name.isNotEmpty()) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
}
OutlinedTextField(
value = name,//👈🏻要显示的当前值
onValueChange = { name = it },//👈🏻请求更改值的事件,其中 T 是建议的新值
label = { Text("Name") }
)
}
}
小技巧:Compose的代码模板
在搭建基本框架之前,我们先来定义一个模板,方便大家开发(我的是Mac电脑)
- 1、Android Studio-> Preferences->Editor->File and Code Templates
- 2、点击➕号
- 3、使用,右击选择New->kotlin compose
基本框架搭建
效果图
- 1、新建项目,修改MainActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeProjectTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
MainFrame()
}
}
}
}
}
- 2、MainFrame
@Composable
fun MainFrame() {
val navigationItems = listOf(
NavigationItem("首页", Icons.Default.Home),
NavigationItem("项目", Icons.Default.Article),
NavigationItem("分类", Icons.Default.Category),
NavigationItem("我的", Icons.Default.Person)
)
var currentNavigationIndex by remember {
mutableStateOf(0)
}
Scaffold(
bottomBar = {
BottomNavigation(backgroundColor = MaterialTheme.colors.surface) {
navigationItems.forEachIndexed { index, navigationItem ->
BottomNavigationItem(
selected = currentNavigationIndex == index,
onClick = { currentNavigationIndex = index },
icon = {
Icon(imageVector = navigationItem.icon, contentDescription = null)
},
label = {
Text(text = navigationItem.title)
},
selectedContentColor = Color_149EE7,
unselectedContentColor = Color_999999
)
}
}
},
) {
when (currentNavigationIndex) {
0 -> HomeFragment()
1 -> ProjectFragment()
2 -> TypeFragment()
else -> MineFragment()
}
}
}
代码其实很简单,主要通过Scaffold来搭建一个项目结构,用remember+ mutableStateOf来记住状态。内容区域通过选中的index来展示不同的Fragment
- 3、这里运用到Google的一个图标库
- 图标地址:fonts.google.com/icons
- 集成依赖
implementation "androidx.compose.material:material-icons-extended:$compose_version"
总结
到这里呢,基本框架已经搭完了,其实还是比较简单的。有不动的呢,可以多看看Google官方文档:developer.android.google.cn/jetpack/com…
作者:peakmain
链接:https://juejin.cn/post/7093341380549804045
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
重复setContentView后fitsSystemWindows失效
项目中有个沉浸式的activity,在调用setContentView
切换布局的时候fitsSystemWindows
失效了,效果如图:
Activity代码:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
immerse()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
private fun immerse() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val decorView = window.decorView
decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.statusBarColor = Color.TRANSPARENT
}
}
fun reload(view: View) {
setContentView(R.layout.activity_main)
}
}
布局代码:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:fitsSystemWindows="true"
android:gravity="center_horizontal"
android:orientation="vertical"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<ImageView
android:scaleType="centerCrop"
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="400dp"
android:src="@drawable/avatar"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:layout_marginTop="20dp"
android:onClick="reload"
android:gravity="center"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="reload"
android:textAllCaps="false"/>
</LinearLayout>
首先看下fitsSystemWindows起到的作用
<!-- Boolean internal attribute to adjust view layout based on
system windows such as the status bar.
If true, adjusts the padding of this view to leave space for the system windows.
Will only take effect if this view is in a non-embedded activity. -->
<attr name="fitsSystemWindows" format="boolean" />
这个属性用于根据系统窗口(如状态栏)来调整视图的布局。如果为true,则调整此视图的padding来为系统窗口留出空间,也就是说视图布局的内容不会扩展到任务栏中
正常情况下,什么时候会触发fitsSystemWindows
的padding调整?
ViewRootImpl
首次绘制的时候会调用dispatchApplyInsets
方法,将WindowInset
(窗口内容的插入,包括状态栏,导航栏,键盘等,可以理解为这些它们所占窗口的大小)分发给decorView,最终会分发到到上述布局中的根布局LinearLayout的fitSystemWindowsInt
方法完成padding的设置,LinearLayout没有重写此方法,最终调用的还是View的fitSystemWindowsInt
ViewRootImpl
private void performTraversals() {
......
//首次绘制判断,host为decorView
if (mFirst) {
......
dispatchApplyInsets(host);
}
......
//其他条件触发,这个标记位在下文会用到
if (...... || mApplyInsetsRequested){
dispatchApplyInsets(host)
}
......
}
public void dispatchApplyInsets(View host) {
......
WindowInsets insets = getWindowInsets(true);
host.dispatchApplyWindowInsets(insets);
......
}
View
private boolean fitSystemWindowsInt(Rect insets) {
//判断fitSystemWindows是否为true
if ((mViewFlags & FITS_SYSTEM_WINDOWS) == FITS_SYSTEM_WINDOWS) {
Rect localInsets = sThreadLocal.get();
boolean res = computeFitSystemWindows(insets, localInsets);
applyInsets(localInsets);
return res;
}
return false;
}
private void applyInsets(Rect insets) {
mUserPaddingStart = UNDEFINED_PADDING;
mUserPaddingEnd = UNDEFINED_PADDING;
mUserPaddingLeftInitial = insets.left;
mUserPaddingRightInitial = insets.right;
internalSetPadding(insets.left, insets.top, insets.right, insets.bottom);
}
protected void internalSetPadding(int left, int top, int right, int bottom) {
......
//设置padding
......
//如果padding改变了,重新布局
if (changed) {
requestLayout();
invalidateOutline();
}
}
为什么重新setContentView
之后没有为新的视图设置padding?
当我们调用setContentView
重新设置布局时,activity对应的window已经被添加到WindowManager
中了,ViewRootImpl
不会重新创建,但是布局是重新加载并实例化视图了。此时ViewRootImpl
的首次绘制判断不成立,不会将WindowInset
分发给新加载的布局,因此新的视图没有设置顶部的padding,绘制的时候也就跑到了状态栏中去了
ActivityThread
public void handleResumeActivity(ActivityClientRecord r, boolean finalStateRequest,
boolean isForward, String reason) {
......
if (r.window == null && !a.mFinished && willBeVisible) {
......
if (!a.mWindowAdded) {
a.mWindowAdded = true;
//ViewRootImpl创建的起点
wm.addView(decor, l);
}
......
}
......
}
应该怎样让ViewRootImpl
重新分发WindowInset
?
从上文中ViewRootImpl
调用dispatchApplyInsets
的地方可以看到,mApplyInsetsRequested
也能影响是否调用该方法,可以从这个标志位入手。分析代码发现,调用View
的requestFitSystemWindows
或requestApplyInsets
方法可以向上调用到ViewRootImpl
的同名方法中,在这个方法中会将mApplyInsetsRequested
设为true,并调用scheduleTraversals
触发界面绘制。
View
@Deprecated
public void requestFitSystemWindows() {
//最终会调用到ViewRootImpl中去
if (mParent != null) {
mParent.requestFitSystemWindows();
}
}
public void requestApplyInsets() {
requestFitSystemWindows();
}
ViewRootImpl
public void requestFitSystemWindows() {
checkThread();
mApplyInsetsRequested = true;
scheduleTraversals();
}
demo中的reload方法修改为如下可以解决此问题
fun reload(view: View) {
val root = layoutInflater.inflate(R.layout.activity_main, null)
setContentView(root)
//使用此方法做版本兼容,最终还是会调用到 View.requestFitSystemWindows()
ViewCompat.requestApplyInsets(root)
}
作者:今天想吃什么
链接:https://juejin.cn/post/7091260989504815112
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter启动页白屏处理
前言
在上篇实现了一个Nike的加载页,但有一些遗留问题,其中之一就是启动时的白屏处理。如下:
启动页
几乎所有App都会设计一个启动页,Flutter项目如果不做处理的话,在点开时都会有这个白色的闪屏。其实这个启动页在项目文件就可以进行更改。
安卓
1.打开AndroidManifes.xml
文件,可以看到启动屏数据源指向了drawable
中的launch_background
。
2.打开drawable/launch_background
文件,就会发现现在的启动页背景是白色。
3.若要设置图片样式的启动页,则需要将下面注释的内容放开。
4. 默认情况下是没有launch_image
的,将启动页图片的名字设置为launch_image
,然后放到drawable
文件下,启动页就设置好啦。
iOS
打开下面文件,将LaunchImag.png
、LaunchImag@2x.png
、LaunchImag@3x.png
替换为我们自己的图片即可。
虚假の示例
这里以之前完成的启动图为例来试一下效果。
1.首先随便掏出个画图软件做一张启动页图片:
2.然后将上面所说项目中的图片替换为我们自己的图片看下效果:
。。。
这是什么鬼,难道图片尺寸必须跟屏幕保持一致才可以吗... 非也,其实用这种方式设置启动图并非上策,因为不同尺寸的屏幕间很难做适配,特别是示例中需要启动页中的logo与启动页消失后的logo大小保持一致的情况,所以需要尝试其他方法:
真正の示例
1.以iOS为例,使用Xcode打开项目,在Asset中我们看到了刚才拖入的图片。
2.点击LaunchScreen,这是iOSApp启动时展示的屏幕窗口,可以看到我们拖入的图片展示在一个imageView中。
3.那如果把LaunchImage的约束重新设置一下呢
4.再来看一下效果,这次似乎像那么回事儿了,但还是能发现logo大小不一样的情况(虽然这是我随手做的一张启动页图片,但既然我们的需求是根据代码,让启动页在所有屏幕上的显示效果都一样的话就不该止步于此)。
5.终极解决方案:设置背景底色,为盛放logo的imageView设置约束(在上一篇文章中,我们设置logo的初始大小为屏幕宽度的1/3,位置为屏幕的中心),那么我们为imageView设置同样的约束,然后就有了⬇️
6.最后看一下效果:
完美 🎉🎉🎉
结语
最后说一些题外话吧,其实看过苹果的App设计规范就会了解到,其实启动页被设计的初衷就是起一个过渡的作用,让用户在使用感受上不回觉得太过突兀,比如iOS系统自带的天气app,启动页只是一张简单的渐变图片,是不建议添加产品Logo或者其他一些花里胡哨的广告的,否则审核有可能会因此被拒,大家随手打开几个App感受下就不难发现,会这样做的产品少之又少,在不知不觉中就被消费了体验,也许是已经习惯了。不过毕竟咱也不是产品,在下随口说说,诸君随意听听就好 ~
作者:晚夜
链接:https://juejin.cn/post/7092371526111985694
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
跟我学flutter:细细品Widget(一)Widget&Element初识
前言
跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
跟我学flutter:在国内如何发布自己的Plugin 或者 Package
跟我学flutter:Flutter雷达图表(一)如何使用kg_charts
跟我学flutter:细细品Widget(一)Widget&Element初识
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget
Everything's a widget!
Widget
Flutter 中 Widget是一个“描述一个UI元素的配置信息”,Widget就是接受元素,而不是真是绘制的显示元素。 类比原生的Android开发,Widget更像是负责UI配置的xml文件,而非负责绘制组件的View。 当一个Widget状态发生变化时,Widget就会重新调用build()函数来返回控件的描述,过程中Flutter框架会与之前的Widget进行比较,确保实现渲染树中最小的变动来保证性能和稳定性。换句话说,当Widget发生改变时,渲染树只会更新其中的一小部分而非全部重新渲染。
源码
@immutable
abstract class Widget extends DiagnosticableTree {
const Widget({ this.key });
final Key? key;
@protected
@factory
Element createElement();
@override
String toStringShort() {
final String type = objectRuntimeType(this, 'Widget');
return key == null ? type : '$type-$key';
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.defaultDiagnosticsTreeStyle = DiagnosticsTreeStyle.dense;
}
@override
@nonVirtual
bool operator ==(Object other) => super == other;
@override
@nonVirtual
int get hashCode => super.hashCode;
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
...
}
图:
@immutable
@immutable widget中的属性时不可变的,如果有可变的你需要放在state中。
如果属性发生变更flutter则会重新构建Widget树,一旦 Widget 自己的属性变了自己就会被替换。
如你在开发过程中会有如下提示:This class (or a class that this class inherits from) is marked as '@immutable', but one or more of its instance fields aren't final:
key
主要用于控制当 Widget 更新时,对应的 Element 如何处理 (是更新还是新建)。若某 Widget 是其「Parent Widget」唯一的子节点时,一般不用设置 key
LocalKey
LocalKey是diff算法的核心所在,用做Element和Widget的比较。常用子类有以下几个:
ValueKey:以一个数据为作为key,比如数字、字符等。 ObjectKey:以Object对象作为Key。 UniqueKey:可以保证key的唯一性,如果使用这个类型的key,那么Element对象将不会被复用。 PageStorageKey:用于存储页面滚动位置的key。
GlobalKey
每个globalkey都是一个在整个应用内唯一的key。globalkey相对而言是比较昂贵的,如果你并不需要globalkey的某些特性,那么可以考虑使用Key、ValueKey、ObjectKey或UniqueKey。 他有两个用途:
- 允许widget在应用程序中的任何位置更改其parent而不丢失其状态。应用场景:在两个不同的屏幕上显示相同的widget,并保持状态相同。
- 可以获取对应Widget的state对象:
createElement
一个 widget 可以对应多个Element
canUpdate
控制一个widget如何替换树中另一个widget。如果两个widget的runtimeType与key相同,则表示新的widget将替换旧的widget,并调用Element.update更新Element;否则旧的element将从树中移出,新的element插入树中。
Widget在重新build的时候,是增量更新的,而不是全部更新 runtimeType就是这个widget的类型
Widget类大家族
简述(后面文章将展开讲解):
- StatelessWidget:无状态Widget
- StatefulWidget:有状态Widget,值得注意的是StatefulWidget是不可变的,变化的状态在。
- ProxyWidget:其有2个比较重要的子类, ParentDataWidget和InheritedWidget
- RenderObjectWidget:持有RenderObject对象的Widget,RenderObject是完成界面的布局、测量与绘制,像Padding,Table,Align都是它的子类
Widget的创建可以做到复用,通过const修饰,否则setState后,Widget重新被创建了(Element不会重建)
Element
通过Widget Tree,会生成一系列Element Tree,其主要功能如下:
- 维护这棵Element Tree,根据Widget Tree的变化来更新Element Tree,包括:节点的插入、更新、删除、移动等
- Element 是 Widget 和 RenderObject 的粘合剂,根据 Element 树生成 Render 树(渲染树)
Element类大家族
两大类:
简述(后面文章将展开讲解):
ComponentElement
组合类Element。这类Element主要用来组合其他更基础的Element,得到功能更加复杂的Element。开发时常用到的StatelessWidget和StatefulWidget相对应的Element:StatelessElement和StatefulElement,即属于ComponentElement。
RenderObjectElement
渲染类Element,对应Renderer Widget,是框架最核心的Element。RenderObjectElement主要包括LeafRenderObjectElement,SingleChildRenderObjectElement,和MultiChildRenderObjectElement。其中,LeafRenderObjectElement对应的Widget是LeafRenderObjectWidget,没有子节点;SingleChildRenderObjectElement对应的Widget是SingleChildRenderObjectWidget,有一个子节点;MultiChildRenderObjectElement对应的Widget是MultiChildRenderObjecWidget,有多个子节点。
Element生命周期
Element有4种状态:initial,active,inactive,defunct。其对应的意义如下:
- initial:初始状态,Element刚创建时就是该状态。
- active:激活状态。此时Element的Parent已经通过mount将该Element插入Element Tree的指定的插槽处(Slot),Element此时随时可能显示在屏幕上。
- inactive:未激活状态。当Widget Tree发生变化,Element对应的Widget发生变化,同时由于新旧Widget的Key或者的RunTimeType不匹配等原因导致该Element也被移除,因此该Element的状态变为未激活状态,被从屏幕上移除。并将该Element从Element Tree中移除,如果该Element有对应的RenderObject,还会将对应的RenderObject从Render Tree移除。但是,此Element还是有被复用的机会,例如通过GlobalKey进行复用。
- defunct:失效状态。如果一个处于未激活状态的Element在当前帧动画结束时还是未被复用,此时会调用该Element的unmount函数,将Element的状态改为defunct,并对其中的资源进行清理。
Element4种状态间的转换关系如下图所示:
跟我学flutter:Flutter雷达图表(一)如何使用kg_charts
前言
跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
跟我学flutter:在国内如何发布自己的Plugin 或者 Package
跟我学flutter:Flutter雷达图表(一)如何使用kg_charts
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget
本节主要讲如何使用kg_charts中的雷达图表,来绘制一个雷达图,下一章节则会对如何绘制一个可点击雷达图表进行详细说明。 最近我在开发有关雷达图表的的业务,但的确在线上找不到可以快速集成的雷达图表,找到一篇文章(Flutter雷达图package)但不是很好定制化我们的业务,但其中的代码有比较好的借鉴。然后我借鉴了部分代码,进行了kg_charts的开发。
集成方式
dependencies:
kg_charts: ^0.0.1
展示效果
1、圆形雷达图表
2、方形雷达图表
3、方形可点击雷达图表(点击效果为气泡)
4、方形多绘制区域图表(自定义展示文字)
4、方形多绘制区域图表(无自定义展示文字)
参数说明
参数 | 类型 | 是否必要 | 说明 |
---|---|---|---|
radarMap | RadarMapModel | 是 | 包含 图例,雷达点,雷达数据,半径 ,雷达种类(圆形,方形),文字最大宽度,内部画几条线(LineModel中包含绘制线颜色,文字大小等) |
textStyle | style | 否 | 外部绘制文字颜色与大小 |
isNeedDrawLegend | bool | 否 | 默认为true |
lineText | fun | 否 | 内部线上画的文字,根据数据动态生成,如果为空则不展示 |
dilogText | fun | 否 | 点击出现的dialog,根据数据动态生成,如果为空则不展示 |
outLineText | fun | 否 | 外部线上画的文字,根据数据动态生成,如果为空则不展示 |
详细使用说明
图片说明
代码使用说明
1、图例
legend: [
LegendModel('10/10',const Color(0XFF0EBD8D)),
LegendModel('10/11',const Color(0XFFEAA035)),
]
2、维度数据 如上代码所示,假设目前有两个日期维度,(业务假设是两天的考试)制定两个维度。
data: [
MapDataModel([100,90,90,90,10,20]),
MapDataModel([90,90,90,90,10,20]),
],
两个维度需要配置两套数据
维度和数据必须对应,两个维度必须是两套数据
3、数据组
indicator: [
IndicatorModel("English",100),
IndicatorModel("Physics",100),
IndicatorModel("Chemistry",100),
IndicatorModel("Biology",100),
IndicatorModel("Politics",100),
IndicatorModel("History",100),
]
数据的长短必须与数据的参数一致,比如说是六个科目,那么每套数据必须是6个数据,这个数据设置一个最大数据值,而且数据组中的值不能比该数据大。
4、RadarMapModel中其他基本参数
radius: 130,
shape: Shape.square,
maxWidth: 70,
line: LineModel(4),
radius 半径 shape 圆形的图还是方形的图 maxWidth 展示外环文字最大宽度 line 内环有几个环(还可配置内环文字大小和颜色)
5、其他基本配置
textStyle: const TextStyle(color: Colors.black,fontSize: 14),
isNeedDrawLegend: true,
lineText: (p,length) => "${(p*100~/length)}%",
dilogText: (IndicatorModel indicatorModel,List legendModels,List mapDataModels) {
StringBuffer text = StringBuffer("");
for(int i=0;i "${data*100~/max}%",
textStyle : 外环文字颜色,大小 isNeedDrawLegend:是否需要图例 lineText : 线上标注的文字(动态) 如上代码所示是转换为% dilogText:点击后弹出的浮动框(动态) 如上代码所示把日期都输出 outLineText:区域外环是否展示文字(动态) 如上代码所示是转换为%
整体代码展示
RadarWidget(
radarMap: RadarMapModel(
legend: [
LegendModel('10/10',const Color(0XFF0EBD8D)),
LegendModel('10/11',const Color(0XFFEAA035)),
],
indicator: [
IndicatorModel("English",100),
IndicatorModel("Physics",100),
IndicatorModel("Chemistry",100),
IndicatorModel("Biology",100),
IndicatorModel("Politics",100),
IndicatorModel("History",100),
],
data: [
// MapDataModel([48,32.04,1.00,94.5,19,60,50,30,19,60,50]),
// MapDataModel([42.59,34.04,1.10,68,99,30,19,60,50,19,30]),
MapDataModel([100,90,90,90,10,20]),
MapDataModel([90,90,90,90,10,20]),
],
radius: 130,
duration: 2000,
shape: Shape.square,
maxWidth: 70,
line: LineModel(4),
),
textStyle: const TextStyle(color: Colors.black,fontSize: 14),
isNeedDrawLegend: true,
lineText: (p,length) => "${(p*100~/length)}%",
dilogText: (IndicatorModel indicatorModel,List legendModels,List mapDataModels) {
StringBuffer text = StringBuffer("");
for(int i=0;i "${data*100~/max}%",
),
收起阅读 »
跟我学flutter:在国内如何发布自己的Plugin 或者 Package
前言
跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
跟我学flutter:在国内如何发布自己的Plugin 或者 Package
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget
平时在做flutter Plugin或者 Package的时候,如果觉得自己做的还不错,想要分享到PUB库上如何操作?虽然官方已经告诉我们如何操作,但是呢由于一些特殊的原因,采用官方的方式并不能上传到PUB库上,今天就跟着我学习一下如何上传pub库吧。
准备开始
开始前需要你已经有一个已经开发好的库来进行提交了。 比如我的这个
如图红色箭头表示的是必须要存在的两个文件,如果没有的话,需要添加你的开源协议。编写你的README文档。
开源协议和README我就不做介绍了,咱们来看看yaml文件需要什么内容呢?
红色的箭头分别说明了需要的内容
- name 库名
- description 描述
- version 版本号
- homepage 开源项目地址
注意:你必须先拥有google 账户
按照官方尝试
第一步: flutter packages pub publish --dry-run
Package has 0 warnings 没得问题:(如果有问题的话,会在输出的最后几行提示你缺什么)
第二步: flutter packages pub publish
输入完命令后会先检查项目结构,然后会问题是否准备好要发布了么?当然你需要输入Y
之后经过漫长的等待他会告诉你链接超时
第一次上传的话,必须登录谷歌账号。界面上会展示一个url,这时候你需要去复制URL,到你的浏览器。
哈哈哈好了,到此结束。你的电脑访问不了。就戛然而止了。当然作为一个开发者需要具备一定的访问外网能力。这里我给大家介绍我用的这款外网能力软件。
开始我们的外网之路
首先页面如图所示:
我需要记录配置中的一个关键参数:
HTTP(S)代理服务器
关键步骤
1、在你的CMD命令模式下输入如下命令
set http_proxy=http://127.0.0.1:49256
set https_proxy=https://127.0.0.1:49256
配置完成后执行**flutter packages pub publish
** 与官方的步骤一致
第一次上传的话,必须登录谷歌账号。界面上会展示一个url,这时候你需要去复制URL,到你的浏览器。
之后会提示你succeed
这样你的库就会被上传到pub库里(当然你需要等待一段时间)
当然我建议你上 pub.flutter-io.cn/ (国内网站访问更快) 查看自己的库
看发文章的过程我的库已经上线了 pub.flutter-io.cn/packages/kg…
Flutter 必知必会系列 —— mixin 和 BindingBase 的巧妙配合
前面我们已经介绍了 Flutter
的入口方法 —— main
,入口方法做了初始化、根节点生成并绑定等工作。这一节我们就详细介绍 Flutter
的初始化。
混入 mixin
混入是一个很实用的语法特性,可以让一个类在不成为某一个目标类的父类的情况下,目标类可以使用混入类的方法和属性。混入的关键字是 with
、 mixin
、on
,mixin
用来声明混入类,with
用来使用混入类,on
用来限制混入的层级。
最简单的使用如下:
首先: 声明混入类
mixin CustomerBinding {
String name = 'CustomerBinding';
void printName() {
print(name);
}
}
然后:目标类添加混入类
class TestClass with CustomerBinding {
}
我们使用 with
关键字为 TestClass
添加了混入类,那么 TestClass
中就有了 name 字段 和 printName 方法。
最后:使用目标类
void main(List<String> args) {
TestClass().printName();
}
即使 TestClass
没有明确的声明 printName
,也可以被调用到,原因就是 TestClass
的混入类中有该方法。
上面的过程就是混入的基本使用,大家可能会问到的问题是:
- 直接继承一个类不就行了么,为啥还有搞一个混入啊?
首先 看混入类和普通类的区别,混入类是不可以直接构造的,这意味着它的这一方面的功能要弱化一点点 🤏🏻。
其次 Dart 也是单继承的,就是一个类只能有一个直接的父类,而混入是可以多混入的,所以可以把不同的功能模块线性的混入到目标类中。
这就是为啥搞出来一个混入。
- 既然一个类既可以混入又可以继承,那么继承和混入的优先级谁高呢?
结论是混入高于继承,我们先看例子。
void main(List<String> args) {
var testClass = TestClass();
// 第三处
testClass.printName();
}
class TestClass extends Parent with CustomerBinding {}
class Parent {
// 第二处
void printName() {
print('Parent');
}
}
mixin CustomerBinding {
// 第一处
void printName() {
print('CustomerBinding');
}
}
第一处 和 第二处分别在混入类和父类中定义了 同名方法
第三处是使用该方法,控制台打印的是 CustomerBinding
出现这种现象的原因是:混入的实现是依靠生成中间类的方式。上面的继承关系如下:
每混入一个类都会生成一个中间类,比如上面的例子,就根据 CustomerBinding
生成一个中间类,这个类继承自 Parent
,而 TestClass
是继承自中间类。
所以 testClass
调用的就是中间类的方法,而中间类的方法就是 CustomerBinding
中的方法,所以打印了 CustomerBinding。
- 既然可以多混入,那么混入的执行顺序是什么呢?
结论:混入是线性的,后面的会覆盖前面的同名方法。
看这个例子:
void main(List<String> args) {
var testClass = TestClass();
testClass.printName();
}
class TestClass extends Parent with CustomerBinding, CustomerBinding2 {}
class Parent {
void printName() {
print('Parent');
}
}
mixin CustomerBinding {
void printName() {
print('CustomerBinding');
}
}
mixin CustomerBinding2 {
void printName() {
print('CustomerBinding2');
}
}
上面的代码会打印 CustomerBinding2
,因为 CustomerBinding2
在混入的最后面。上面形成的体系图如下:
TextClass
直接调用的就是距离它最近的父类,也就是 CustomerBinding2
中的方法,所以打印了 CustomerBinding2
。
- 既然可以多混入,那么混入可以有层级吗?就是同名不方法不覆盖,在原有逻辑的基础上实现自己的逻辑。
结论是可以的,实现的方式就是混入限定 on
既然要调到前排混入类的逻辑,首先要知道有前排的存在。 比如子类调用父类的方法,可以用 super
,前提是子类要 extends
父类。
而混入类是不知道是否有混入类存在的,这个时候就需要 on
来限定了。
看下面的例子:
void main(List<String> args) {
var testClass = TestClass();
testClass.printName();
}
class TestClass extends Parent with CustomerBinding, CustomerBinding2 {}
class Parent {
void printName() {
print('Parent');
}
}
mixin CustomerBinding on Parent{ //第一处
void printName() {
super.printName();
print('CustomerBinding');
}
}
mixin CustomerBinding2 on Parent{ //第二处
void printName() {
super.printName();
print('CustomerBinding2');
}
}
和前面的例子相比,第一处和第二处多了 on Parent,表示 CustomerBinding
和 CustomerBinding
只能用在 Parent 的子类上,所以它俩内部的 printName
就可以调用到 super
。
而且根据上面的线性规则,每次调用 super
都是向前一个混入的类调用,所以最后把三个打印语句都执行了。
小结
上面介绍了混入类、混入类的规则、大家可能会问到的混入类的问题,混入在 Flutter 中经常遇到,比如我们写动画的 TickerProviderStateMixin
、初始化的 Binding
等等,大家也可以在自己的项目用混入来封装公有逻辑,比如 Loading
等。
混入类的规则如下:
- 混入高于继承
- 混入是线性的,后面的会覆盖前面的同名方法
super
会保证混入的执行顺序为从前往后
知道了混入,下面我们来看 Flutter 是怎么用混入来实现初始化的。
Binding 初始化
前面我们讲了混入,下面我们就看看初始化中怎么使用混入的。
class WidgetsFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, ServicesBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {
static WidgetsBinding ensureInitialized() {
if (WidgetsBinding.instance == null)
WidgetsFlutterBinding();//第一处
return WidgetsBinding.instance!;
}
}
这是初始化的代码,这个地方可以看 Flutter 必知必会系列 —— runApp 做了啥 这一篇的介绍。
我们这一节的任务就是看看 WidgetsFlutterBinding()
构造方法干了啥。
WidgetsFlutterBinding
继承自 BindingBase
,并混入了 7 个类。
WidgetsFlutterBinding
没有构造方法,第一处直接调用到了父类 BindingBase
的构造方法中。如下:
BindingBase() {
//...省略代码
initInstances();//第一处
initServiceExtensions();//第二处
}
省略一些无关的代码,就剩下了第一处和第二处的代码。从名字就可以,看出来这俩方法是用来初始化的。
initInstances
用来初始化实例对象,initServiceExtensions
用来注册服务。
这里介绍一下 注册服务 是咋回事。
注册服务
Flutter 是运行在 Dart VM 上的,Flutter 应用和 Dart VM 是可以互相调用的,比如 Flutter 可以调用 Dart VM 的各种服务来获取,内存信息、类信息、调用方法等等,Dart VM 同样可以调用到 Flutter 层注册好的方法。
Flutter 和 Dart VM 的调用需要遵循 JSON 协议,详细的可以看这里 Json 协议
上面列出的方法,都是 Flutter 对 Dart VM 的调用。
Dart VM 对 Flutter 的调用也是一样的,只要注册过,名字可以匹对上就可以调用。
Flutter 的注册是 registerServiceExtension
方法。
void registerServiceExtension({
required String name,
required ServiceExtensionCallback callback,
}) {
final String methodName = 'ext.flutter.$name';
developer.registerExtension(methodName, (String method, Map<String, String> parameters) async {
// 代码省略
late Map<String, dynamic> result;
try {
result = await callback(parameters);
} catch (exception, stack) {
}
result['type'] = '_extensionType';
result['method'] = method;
return developer.ServiceExtensionResponse.result(json.encode(result));
});
}
registerServiceExtension
就是注册方法,接受的入参就是服务名字 和 回调。
服务名字:就是 Flutter
和 Dart Vm
能够认识的服务标示,方法名字就是 VM 可以调用到的名字。
回调:就是 VM
调用服务名字时,Flutter 做出的反应
。
这里注意一点,我们传递的名字会被 包装成 ext.flutter.$名字
的形式。
注册会调用 developer
的 registerExtension
方法。developer 是一个开发者包,里面有一个比较基础的 API
。
最后这个 registerExtension
会将名字和回调注册到 VM
中,这是一个 native
的方法。
external _registerExtension(String method, ServiceExtensionHandler handler);
大家感兴趣,可以从 native 看看。这里我们只需要知道 flutter
调用注册,就是为 VM
注册了一个执行 Flutter
方法的回调。
下面我们以注册的退出应用服务来验证注册过程。
registerSignalServiceExtension(
name: 'exit',
callback: _exitApplication,
);
Future<void> _exitApplication() async {
exit(0);
}
这个服务的效果是:只要 VM 调用 exit 方法,应用就退出去。
Dart VM
和 Flutter
的通信遵循 socket
的协议,只要连接上虚拟机运行的 URL
就可以了。
首先 Flutter
的 pubspec.yaml
文件中添加 vm_service
依赖
其次 Flutter
应用主动连接 vm 虚拟机
// 连接虚拟机的服务
Service.getInfo().then((value) {
String url = value.serverUri.toString();
Uri uri = Uri.parse(url);
Uri socketUri = convertToWebSocketUrl(serviceProtocolUrl: uri);
vmServiceConnectUri(socketUri.toString()).then((value) {
});
});
Service.getInfo
是获取虚拟机服务的 url,这是 Flutter 提供的 API ,这种方式更加方便。FlutterEngine
也提供了获取 url 的方法,但是需要通过插件来传递,使用不方便。
convertToWebSocketUrl
就是对 url
进行了转换,结果就是 WebSocket
可以识别的 url
。
vmServiceConnectUri
就是 Flutter
与 vmService
进行了连接
最后 我们调用一下:
Service.getInfo().then((value) {
String url = value.serverUri.toString();
Uri uri = Uri.parse(url);
Uri socketUri = convertToWebSocketUrl(serviceProtocolUrl: uri);
vmServiceConnectUri(socketUri.toString()).then((service) {
service.callServiceExtension('ext.flutter.exit',
isolateId: Service.getIsolateID(Is.Isolate.current),
args: {'enabled': true}); //第一处
});
});
第一处的代码执行之后 应用就退出去了,可以看一下效果。
Flutter DevTools
就是调用 Flutter
注册的服务来实现调试效果的,大家可以看这里:Flutter DevTools 的调试工具
上面就是 注册服务的过程和作用,下面我们来看 BaseBiding 注册了哪些服务:
void initServiceExtensions() {
if (!kReleaseMode) {
if (!kIsWeb) {
registerSignalServiceExtension(
name: 'exit',
callback: _exitApplication,
);
}
// These service extensions are used in profile mode applications.
registerStringServiceExtension(
name: 'connectedVmServiceUri',
getter: () async => connectedVmServiceUri ?? '',
setter: (String uri) async {
connectedVmServiceUri = uri;
},
);
registerStringServiceExtension(
name: 'activeDevToolsServerAddress',
getter: () async => activeDevToolsServerAddress ?? '',
setter: (String serverAddress) async {
activeDevToolsServerAddress = serverAddress;
},
);
}
}
exit
是退出应用,上面我们已经看过了。
connectedVmServiceUri
是设置虚拟机的URL
activeDevToolsServerAddress
设置是否可以连接 DevTools
小结
Binding
的构造方法会调用 initInstances
和initServiceExtensions
两个方法,其中 initInstances
用于初始化实例,initServiceExtensions
用于注册虚拟机可以调用的方法。
所以 Binding 的构造方法
起到了模版方法的功能,定义好了初始化的流程。
根据上面介绍到的规则,大家知道 WidgetsFlutterBinding 初始化执行的顺序吗?
就是从前向后的执行,因为每一个 Binding 都调用了 super
BaseBinding
的构造方法起到了模版方法的功能,定义好了初始化的流程。下面我们看各个 Binding
初始化了啥。
GestureBinding 初始化
initInstances 初始化实例
@override
void initInstances() {
super.initInstances();
_instance = this; //第一处
window.onPointerDataPacket = _handlePointerDataPacket;//第二处
}
static GestureBinding? get instance => _instance;
static GestureBinding? _instance;//第一处
第一处就是对 \_instance
进行了赋值,因为 initInstances
是在构造方法中调用的,并且构造方法值调用一次,所以 \_instance
只会初始化一次,这也是 Flutter
中另外一种单例的实现方式。
第二处就是对 window
的 onPointerDataPacket
进行赋值。onPointerDataPacket
是一个方法回调,就是屏幕的手势会调用到这里。
所以 GestureBinding
的 _handlePointerDataPacket
是 Flutter
手势系统的起点。
如果我们自己对 onPointerDataPacket
进行重新复制,那么就会走到我们自定义的手势流程。
比如:
@override
void initState() {
super.initState();
ui.window.onPointerDataPacket = (PointerDataPacket packet) {
};
}
这样不管怎么点击、滑动屏幕,都是没有任何反应的。
这个有什么用呢?拦截手势增加自定义操作。
比如 屏幕上有一个浮窗,点击浮窗以外的其他区域,关闭浮窗,就可以在这个里面做。定义的点击埋点也可以在这里做。
_handlePointerDataPacket
的具体流程,我们后面在详细介绍。
各个子 Binding 初始化
SchedulerBinding 初始化
initInstances 初始化实例
@override
void initInstances() {
super.initInstances();
_instance = this; //第一处
if (!kReleaseMode) { //第二处
addTimingsCallback((List<FrameTiming> timings) {
timings.forEach(_profileFramePostEvent);
});
}
}
第一处的代码是不是很熟悉,同样实例化单例对象。
第二处的代码就是增加了一个回调,这个回调就是一个帧绘制的监听,类似于我们的性能监控,只不过监控的是帧的信息,包含了以下信息:
postEvent('Flutter.Frame', <String, dynamic>{
'number': frameTiming.frameNumber,
'startTime': frameTiming.timestampInMicroseconds(FramePhase.buildStart),
'elapsed': frameTiming.totalSpan.inMicroseconds,
'build': frameTiming.buildDuration.inMicroseconds,
'raster': frameTiming.rasterDuration.inMicroseconds,
'vsyncOverhead': frameTiming.vsyncOverhead.inMicroseconds,
});
initServiceExtensions 注册服务
@override
void initServiceExtensions() {
super.initServiceExtensions();
if (!kReleaseMode) {
registerNumericServiceExtension(
name: 'timeDilation',
getter: () async => timeDilation,
setter: (double value) async {
timeDilation = value;
},
);
}
}
注册了 timeDilatio
n 服务,timeDilation
就是来设置动画慢放倍数的。Android Studio
和 DevTools
都有这个调试功能。
ServicesBinding 初始化
initInstances 初始化实例
@override
void initInstances() {
super.initInstances();
//第一处
_instance = this;
//第二处
_defaultBinaryMessenger = createBinaryMessenger();
_restorationManager = createRestorationManager();
//第三处
_initKeyboard();
initLicenses();
//第四处
SystemChannels.system.setMessageHandler((dynamic message) => handleSystemMessage(message as Object));
SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage);
SystemChannels.platform.setMethodCallHandler(_handlePlatformMessage);
readInitialLifecycleStateFromNativeWindow();
}
第一处 就是实例化单例对象,和之前的一样
第二处 就是处理 channel 通信和数据恢复,可以在这一层做 channel
调用的拦截
第三处 就是初始化了键盘之类的内容
第四处 就是做了系统自带的 channel
的回调,system
是内存紧张的回调,lifecycle
是生命周期的回调,platform
是剪切板、系统声音等的回调
initServiceExtensions 初始化注册服务
@override
void initServiceExtensions() {
super.initServiceExtensions();
registerStringServiceExtension(
name: 'evict',
getter: () async => '',
setter: (String value) async {
evict(value);
},
);
}
void evict(String asset) {
rootBundle.evict(asset);
}
调试工具调用 ext.flutter.evict
就会从缓存中清除指定路径的资源。
PaintingBinding
initInstances 初始化实例
@override
void initInstances() {
super.initInstances();
_instance = this; //第一处
_imageCache = createImageCache(); //第二处
shaderWarmUp?.execute();//第三处
}
第一处 依然是初始化实例
第二处 声明了一个图片缓存,Flutter
自带了图片缓存,缓存的算法是 LRU ,缓存的大小是 100 MB,图片张数是 1000张。
第三处 是让 Skia 着色器
执行一下,随便画了一个小图片,避免发起绘制任务的时候 Skia
初始化等待的时间。
RendererBinding
initInstances 初始化实例
@override
void initInstances() {
super.initInstances();
_instance = this; //第一处
_pipelineOwner = PipelineOwner( //第二处
onNeedVisualUpdate: ensureVisualUpdate,
onSemanticsOwnerCreated: _handleSemanticsOwnerCreated,
onSemanticsOwnerDisposed: _handleSemanticsOwnerDisposed,
);
window
..onMetricsChanged = handleMetricsChanged
..onTextScaleFactorChanged = handleTextScaleFactorChanged
..onPlatformBrightnessChanged = handlePlatformBrightnessChanged
..onSemanticsEnabledChanged = _handleSemanticsEnabledChanged
..onSemanticsAction = _handleSemanticsAction; //第三处
initRenderView(); //第四处
_handleSemanticsEnabledChanged();
addPersistentFrameCallback(_handlePersistentFrameCallback); //第五处
}
第一处 依然是初始化实例
第二处 初始化了渲染绘制的 PipelineOwner
,PipelineOwner
会管理绘制过程,比如布局、合成涂层、绘制等等
第三处 为 window
中与绘制相关的属性赋值,onMetricsChanged
是窗口尺寸变化的回调,onTextScaleFactorChanged
是系统文字变化的回调,onPlatformBrightnessChanged
是深色模式与否变化的回调
第四处 是根节点 RenderObject
的初始化
第五处 是添加帧阶段的回调,发起布局任务
initServiceExtensions 初始化注册服务
initServiceExtensions
中注册的服务都是和绘制、RenderObject相关的,代码较多,就不一一列举了。
debugPaint
就是 RenderObject
的边框
debugDumpRenderTree
就是打印出 RenderObject
的树信息等等
WidgetsBinding
initInstances 初始化实例
@override
void initInstances() {
super.initInstances();
_instance = this;//第一处
_buildOwner = BuildOwner(); //第二处
buildOwner!.onBuildScheduled = _handleBuildScheduled;
window.onLocaleChanged = handleLocaleChanged;
window.onAccessibilityFeaturesChanged = handleAccessibilityFeaturesChanged; //第三处
SystemChannels.navigation.setMethodCallHandler(_handleNavigationInvocation); //第四处
}
第一处 依然是初始化实例
第二处 是初始化 BuildOwner
,BuildOwner
用于管理 Element
,维护了 '脏' Element 的列表
第三处 是为 window
的属性赋值
第四处 是系统的物理返回键添加 channel
回调
initServiceExtensions 初始化注册服务
initServiceExtensions
中注册的服务都是和 Widget
相关的,代码较多,就不一一列举了。
debugDumpApp
就是打印 Widget
树的信息
showPerformanceOverlay
就是页面中添加帧性能的浮窗等等
小结
不知道到大家注意到一点没有,从 GestureBinding
开始到 WidgetsBinding
结束,它们的 initInstances
和 initServiceExtensions
都调用了 super
。
所以按着我们之前介绍的混入规则,虽然 WidgetsBinding
在最后面,但是调用的顺序也是在最后面,这样保证了初始化的正确性。
总结
这一篇介绍了混入的使用和规则,并借此延伸到了 Flutter 的初始化。WidgetsFlutterBinding 的继承体系看着唬人,其实就是从前向后的依次调用,后面我们就从第一个 GestureBinding
开始看起。
作者:一条上岸小咸鱼
链接:https://juejin.cn/post/7088962808385110053
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter事件之GestureBinding
Flutter在启动时(runApp
)会进行一些浇水类的"粘合",WidgetsFlutterBinding作为主类,需要粘合一系列的Binding,其中GestureBinding
就是事件处理类;
GestureBinding是Flutter中管理手势事件的Binding,是Flutter Framework层处理事件的最起点;
GestureBinding实现了HitTestable, HitTestDispatcher, HitTestTarget,分别具有以下功能
hitTest
命中测试dispatchEvent
事件分发handleEvent
处理事件()
成员变量:
//触点路由,由手势识别器注册,会把手势识别器的pointer和handleEvent存入
//以便在GestureBinding.handleEvent调用
final PointerRouter pointerRouter = PointerRouter();
//手势竞技场管理者,管理竞技场们的相关操作
final GestureArenaManager gestureArena = GestureArenaManager();
//hitTest列表,里面存储了被命中测试成员
final Map<int, HitTestResult> _hitTests = <int, HitTestResult>{};
GestureBinding在_handlePointerDataPacket
方法接收有Engine层传递过来的触点数据,经过数据包装转换为Framework层可处理的数据:PointerAddedEvent、PointerCancelEvent、PointerDownEvent、PointerMoveEvent、PointerUpEvent
等等,随后在_handlePointerEventImmediately
方法中进行命中测试和事件分发;
手指按下
当手指按下时,接收到的事件类型是PointerDownEvent
首先是命中测试
当事件类型是event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent
会进行新的命中测试,命中测试相关请看这,得到命中测试列表后,开始调用dispatchEvent
进行事件分发。
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
assert(!_hitTests.containsKey(event.pointer));
hitTestResult = HitTestResult();
hitTest(hitTestResult, event.position);
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
assert(() {
if (debugPrintHitTestResults)
debugPrint('$event: $hitTestResult');
return true;
}());
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {
//当前事件是按下状态,重用hitTest结果
hitTestResult = _hitTests[event.pointer];
}
if (hitTestResult != null ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
dispatchEvent(event, hitTestResult);
}
}
事件分发
事件分发的目的是调用命中对象的handleEvent
方法以处理相关逻辑,比如我们熟知的Listener组件,它做的事就是回调相关方法,比如按下时Listener会回调onPointerDown
## GestureBinding ##
@override // from HitTestDispatcher
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
assert(!locked);
if (hitTestResult == null) {
assert(event is PointerAddedEvent || event is PointerRemovedEvent);
try {
pointerRouter.route(event);
} catch (exception, stack) {
...
}
return;
}
for (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
} catch (exception, stack) {
...
}
}
}
## Listener ##
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent)
return onPointerDown?.call(event);
if (event is PointerMoveEvent)
return onPointerMove?.call(event);
if (event is PointerUpEvent)
return onPointerUp?.call(event);
if (event is PointerHoverEvent)
return onPointerHover?.call(event);
if (event is PointerCancelEvent)
return onPointerCancel?.call(event);
if (event is PointerSignalEvent)
return onPointerSignal?.call(event);
}
我们知道命中测试最后会把GestrueBinding本身加入到列表中,所以最后也会执行GestrueBinding的handleEvent方法
handleEvent
GestrueBinding.handleEvent
是处理手势识别器相关的逻辑,pointerRouter.route(event)
调用了识别器的handleEvent
方法(需要提前进行触点注册),随后的是竞技场的相关处理;可以看这里了解手势识别器;
## GestrueBinding ##
@override // from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
pointerRouter.route(event);
if (event is PointerDownEvent) {
gestureArena.close(event.pointer);
} else if (event is PointerUpEvent) {
gestureArena.sweep(event.pointer);
} else if (event is PointerSignalEvent) {
pointerSignalResolver.resolve(event);
}
}
手指抬起,
手指抬起会重用之前hitTest结果,并不会重新hitTest,如果是Listener组件,则会回调PointerUpEvent
作者:palpitation97
链接:https://juejin.cn/post/7087873740658180133
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter使用source_gen快速提升开发效率
认识APT
APT(Annotation Process Tool),注解处理器,可以在编译期或运行时获取到注解信息,进行生成代码源文件、其他文件或逻辑处理的功能。
Java中按注解保留的范围可以分为三类,功能也各不相同,分别是:
SOURCE
:编译期间丢弃,编译完成后这些注解没有任何意义,可提供IDE语法检查,静态模版代码
例 :
@Override
,@SuppressWarnings
、Lombok
CLASS
: 保留在class文件中,类加载期间被丢弃,运行时不可见,可以用于字节码操作、可获取到加载类信息的动态代码生成
例:
AspectJ
、ButterKnife
、Room
、EventBus3.0之后
、ARouter
RUNTIME
:注解保留至运行期,结合反射技术使用
例:
Retrofit
、EventBus3.0之前
在应用程序构建的阶段分布如图:
第一阶段为编译期,由外部构建工具将源代码翻译成目标可执行文件,如exe。类似嵌入式c语言开发的构建工具make、cmake,java中为javac。对应SOURCE
第二阶段为执行期,生成的字节码.class文件是JVM可执行文件,由JVM加载.class文件、验证、执行的过程,在JVM内部完成,把.class翻译成平台相关的本地机器码。对应CLASS
第三阶段为运行时,硬件执行机器码过程,程序运行期间。对应RUNTIME
Flutter出于安全性考虑,不支持反射,所以本文讨论范围不包含运行时部分功能
为什么使用代码生成
在特定的场景下,代码自动生成有很多好处,如下几个场景:
- 数据类(Data classes):这些类型的类相当简单,而且通常需要创建很多。因此,最好的方法是生成它们而不是手动编写每一个
- 架构样板(Architecture boilerplate):几乎每个架构解决方案都会带有一定数量的样板代码。每次重复编写就会让人很头疼,所以,通过代码生成可以很大程度上避免这种情况。 MobX就是一个很好的这样的例子
- 公共特性/方法(Common features/functions):几乎所有model类使用确定的方法,比如
fromMap
,toMap
,和copyWith
。通过代码可以一键生成所有这些方法
代码生成不仅节省时间和精力,提高效率,更能提升代码质量,减少手动编写的bug数量。你可以随便打开任何生成的文件,并保证它能正常运行
项目现状
使用领域驱动(DDD)架构设计,核心业务逻辑层在domain层,数据获取在service层,这两层包含了稳定数据获取架构,提供了稳定性的同时,也造成了项目架构的弊病,包含大量的模版代码。
经过多次激烈讨论,如果单纯的将servce层删掉,将势必导致domain层耦合了数据层获取的逻辑或是service层耦合底层数据池获取的逻辑,对domain层只关心核心业务和将来数据池的扩展和迁移都造成不利影响,总之,每一层都有意义。所以,最终决定保留
不删除又会导致,实现一个功能,要编写很多代码、类。为此需要一个开发中提升效率的折中方案
Dart运行时注解处理及代码生成库build刚好可以完成这个功能
确定范围
确定好Flutter支持代码生成的功能后,需要分析代码结构特点,确定使用范围
分析代码结构
主要业务逻辑实现分为两部分:
1、调用接口实现的获取数据流程
2、调用物模型实现的属性服务
两部分都在代码中有较高的书写频率,同时也是架构样板代码的重灾区,需要重点优化
期望效果
定义好repo层,自动生成中间层代码
文件名、类名遵循架构规范
移动文件到指定位置
困难与挑战
source_gen
代码生成配置流程、API熟悉、调试
根据注解类信息,拿到类中方法,包括方法名、返回类型、必选参数、可选参数
物模型设置时,set/get方法调用不同API,返回参数为对象时,要添加convert方法自动转换
接口生成类文件移动到指定目录,物模型生成文件需要拼接
Build相关库
类似java中的Java-APT,dart中也提供一系列注解生成代码的工具,核心库有如下几个:
- build:提供代码生成的底层基础依赖库,定义一些创建Builder的接口
- build_config:提供解析build.yaml文件的支持库,由build_runner使用
- build_runner:提供了一些用于生成文件的通用命令,触发builders执行
- source_gen:提供build库的上层封装,方便开发者使用
生成器package配置
快速开始:
1、创建生成器package
创建注解解析器的package,配置依赖
dependency_overrides:
build: ^2.0.0
build_runner: ^2.0.0
source_gen: ^0.9.1
2、创建注解
创建一个类,添加const 构造函数,可选择有参或无参:
class Multiplier {
final num value;
const Multiplier(this.value);
}
3、创建Generator
负责拦截解析创建的注解,创建类继承GeneratorForAnnotation<T>
,实现generate方法。和Java中的Processor类似
泛型参数是要拦截的注解,例:
class MultiplierGenerator extends GeneratorForAnnotation<Multiplier> {
@override
String generateForAnnotatedElement(
Element element,
ConstantReader annotation,
BuildStep buildStep,
) {
final numValue = annotation.read('value').literalValue as num;
return 'num ${element.name}Multiplied() => ${element.name} * $numValue;';
}
}
返回值是String,内容就是生成的代码,可以直接返回文本,例:
class PropertyProductGenerator extends Generator {
@override
String generate(LibraryReader library, BuildStep buildStep) {
final productNames = topLevelNumVariables(library)
.map((element) => element.name)
.join(' * ');
return '''
num allProduct() => $productNames;
''';
}
}
4、创建Builder
Generator是通过Builder触发的,创建Builder
Builder metadataLibraryBuilder(BuilderOptions options) => LibraryBuilder(
MemberCountLibraryGenerator(),
generatedExtension: '.info.dart',
);
Builder multiplyBuilder(BuilderOptions options) =>
SharedPartBuilder([MultiplierGenerator()], 'multiply');
Builder 是build 库中的抽象类
/// The basic builder class, used to build new files from existing ones.
abstract class Builder {
/// Generates the outputs for a given [BuildStep].
FutureOr<void> build(BuildStep buildStep);
Map<String, List<String>> get buildExtensions;
}
实现类在source_gen中,对Builder进行了封装,提供更友好的API。执行Builder要依赖build_runner ,允许通过dart 代码生成文件,是编译期依赖dev_dependency
;只在开发环境使用
各个Builder作用:
PartBuilder
:生成属于文件的part of
代码。官方不推荐使用,更推荐SharedPartBuilderSharedPartBuilder
:生成共享的可和其他Builder合并的part of
文件。比PartBuilder优势是可合并多个部分文件到最终的一个.g.dart
文件输出LibraryBuilder
:生成单独的Dart 库文件CombiningBuilder
:合并其他SharedPartBuilder生产的文件。收集所有.*.g.part
文件
需要注意的是SharedPartBuilder 会生成.g.dart后缀文件输出,并且,执行命令前,要在源文件引入
part '*.g.dart'
才会生成文件
LibraryBuilder,比较灵活,可以扩展任意后缀
5、配置build.yaml
创建的Builder要在build.yaml文件配置,build期间,会读取该文件配置,拿到自定义的Builder
# Read about `build.yaml` at https://pub.dev/packages/build_config
builders:
# name of the builder
member_count:
# library URI containing the builder - maps to `lib/member_count_library_generator.dart`
import: "package:source_gen_example/builder.dart"
# Name of the function in the above library to call.
builder_factories: ["metadataLibraryBuilder"]
# The mapping from the source extension to the generated file extension
build_extensions: {".dart": [".info.dart"]}
# Will automatically run on any package that depends on it
auto_apply: dependents
# Generate the output directly into the package, not to a hidden cache dir
build_to: source
property_multiply:
import: "package:source_gen_example/builder.dart"
builder_factories: ["multiplyBuilder"]
build_extensions: {".dart": ["multiply.g.part"]}
auto_apply: dependents
build_to: cache
applies_builders: ["source_gen|combining_builder"]
使用package配置
1、添加依赖
pubspec.yaml文件添加生成器package依赖。可添加到dev_dependencies
dev_dependencies:
source_gen_builder:
path: ../source_gen_builder
2、添加注解
在要生成文件类名添加注解,这里用官方例子
part 'library_source.g.dart';
@Multiplier(2)
const answer = 42;
const tau = pi * 2;
3、配置build.yaml
使用的package也需要配置build.yaml,用来定制化build行为。例如,配置注解扫描范围,详情见build_config
# Read about `build.yaml` at https://pub.dev/packages/build_config
targets:
$default:
builders:
# Configure the builder `pkg_name|builder_name`
# In this case, the member_count builder defined in `../example`
source_gen_builder|property_impl:
generate_for:
source_gen_builder|retrofit:
generate_for:
- lib/*/retrofit.dart
# The end-user of a builder which applies "source_gen|combining_builder"
# may configure the builder to ignore specific lints for their project
source_gen|combining_builder:
options:
ignore_for_file:
- lint_a
- lint_b
4、执行命令
在使用的package根目录下执行:
flutter packages pub run build_runner build
结果展示:
生成*.g.dart
文件
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: lint_a, lint_b
part of 'library_source.dart';
// **************************************************************************
// MultiplierGenerator
// **************************************************************************
num answerMultiplied() => answer * 2;
5、debug调试
复制该目录下文件到使用package根目录下
Android Studio下配置
点击debug按钮,打断点调试即可
注意,debug需要生成器package和使用package在统一工程下才可以
配合脚本使用
上述生成文件都是带.g.dart
或其他后缀文件,并且目录和源文件同级。如果想生成架构中的模版源文件,并生成到其他目录,可以配合脚本实现,可以帮你完成:后缀名修改、移动文件目录、文件代码拼接的功能
这部分代码根据个人情况实现,大体框架如下
#!/bin/bash
# cd到执行目录
cd ../packages/domain
# 执行build命令
flutter packages pub run build_runner build --delete-conflicting-outputs
# 循环遍历目录下文件,
function listFiles()
{
#1st param, the dir name
#2nd param, the aligning space
for file in `ls $1`;
do
if [ -d "$1/$file" ]; then
listFiles "$1/$file" "$2"
else
if [[ $2$file =~ "repository.usecase.dart" ]]
then
# 找到生成对应后缀文件,执行具体操作
# dosmothing
fi
if [[ $2$file =~ "repository.impl.dart" ]]
then
# dosmothing
fi
fi
done
}
listFiles $1 "."
总结
以上,就是利用Dart-APT编译期生成代码的步骤和调试过程
最后实现的效果可以做到只声明业务层接口声明,然后脚本一键生成service中间层实现。后面再有需求过来,再也不用费力梳理架构实现逻辑和敲代码敲的手指疼了
截止到目前,项目现在已有接口统计:GET 79、POST 97,并随着业务持续增长。从统计编码字符的维度来看,单个repo,一只接口,一个参数的情况下需手动编写222个,自动生成1725个,效率提升88.6%
底层的数据获取使用的retrofit,同样是自动生成的代码所以不计入统计字符范围,这里的效率提升并不是指一个接口开发完成的整体效率,而是只涵盖从领域到数据获取中间层的代码编写效率
字符和行数优化前后对比:
达到了既保证不破坏项目架构,又提升开发效率的目标
作者:QiShare
链接:https://juejin.cn/post/7081165404113993736
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter实现掘金App点赞效果
前言
点赞这个动作不得不说在社交、短视频等App中实在是太常见了,当用户手指按下去的那一刻,给用户一个好的反馈效果也是非常重要的,这样用户点起赞来才会有一种强烈的我点了赞的效果,那么今天我们就用Flutter实现一个掘金App上的点赞效果。
- 首先我们看下掘金App的点赞组成部分,有一个小手,点赞数字、点赞气泡效果,还有一个震动反馈,接下来我们一步一步实现。
知识点:绘制、动画、震动反馈
1、绘制小手
这里我们使用Flutter的Icon图标中的点赞小手,Icons图标库为我们提供了很多App常见的小图标,如果使用苹果苹果风格的小图标可以使用cupertino_icons: ^1.0.2
插件,图标并不是图片,本质上和emoji
图标一样,可以添加到文本中使用,所以图标才可以设置不同的颜色属性,对比使用png格式图标可以节省不少的内存。
接下来我们就将这两个图标绘制出来,首先我们从上图可以看到真正的图标数据其实是IconData
类,里面有一个codePoint
属性可以获取到Unicode
统一码,通过String.fromCharCode(int charCode)
可以返回一个代码单元,在Text文本中支持显示。
class IconData{
/// The Unicode code point at which this icon is stored in the icon font.
/// 获取此图标的Unicode代码点
final int codePoint;
}
class String{
/// 如果[charCode]可以用一个UTF-16编码单元表示,则新的字符串包含一个代码单元
external factory String.fromCharCode(int charCode);
}
接下来我们就可以把图标以绘制文本的形式绘制出来了
关键代码:
// 赞图标
final icon = Icons.thumb_up_alt_outlined;
// 通过TextPainter可以获取图标的尺寸
TextPainter textPainter = TextPainter(
text: TextSpan(
text: String.fromCharCode(icon.codePoint),
style: TextStyle(
fontSize: 30,
fontFamily: icon.fontFamily,// 字体形象家族,这个字段一定要设置,不然显示不出来
color: Colors.black)),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr);
textPainter.layout(); // 进行布局
Size size2 = textPainter.size; // 尺寸必须在布局后获取
//将图标偏移到画布中央
textPainter.paint(canvas, Offset(-size2.width / 2, -size2.height / 2));
通过上方代码我们就实现了将图标绘制到画板当中
接下来继续绘制点赞数量,
代码:
TextPainter textPainter2 = TextPainter(
text: TextSpan(
text: "点赞",// 点赞数量
style: TextStyle(
fontSize: 9, fontWeight: FontWeight.w500, color: Colors.black)),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr);
textPainter2.layout(); // 进行布局
// 向右上进行偏移在小手上面
textPainter2.paint(canvas, Offset(size.width / 9, -size.height / 2 + 5));
然后图标就变成了这样样子,
我们看到,掘金App点赞的过程中,周围还有一些小气泡的效果,这里提供一个思路,将这些气泡的坐标点放到一个圆的外环上面,通过动画改变圆的半径达到小圆点由内向外发散,发散的同时改变小圆点的大小,从而达到气泡的效果,
关键代码:
var r = size.width / 2 - 15; // 半径
var d = 4; // 偏移量 气泡的移动距离
// 绘制小圆点 一共4个 掘金也是4个 角度可以自由发挥 这里根据掘金App的发散角度定义的
canvas.drawPoints(
ui.PointMode.points,
[
Offset((r + d * animation2.value) * cos(pi - pi / 18 * 2),
(r + d * animation2.value) * sin(pi - pi / 18 * 2)),
Offset((r + d * animation2.value) * cos(pi + pi / 18 * 2),
(r + d * animation2.value) * sin(pi + pi / 18 * 2)),
Offset((r + d * animation2.value) * cos(pi * 1.5 - pi / 18),
(r + d * animation2.value) * sin(pi * 1.5 - pi / 18)),
Offset((r + d * animation2.value) * cos(pi * 1.5 + pi / 18 * 5),
(r + d * animation2.value) * sin(pi * 1.5 + pi / 18 * 5)),
],
_paint
..strokeWidth = 5
..color = Colors.blue
..strokeCap = StrokeCap.round);
得到现在的图形,
发散前
发散后
接下来继续我们来添加交互效果,添加动画,如果有看上一篇吃豆人,相信这里就很so easy了,首先创建两个动画类,控制小手和气泡,再创建两个变量,是否点赞和点赞数量,代码:
late Animation<double> animation; // 赞
late Animation<double> animation2; // 小圆点
ValueNotifier<bool> isZan = ValueNotifier(false); // 记录点赞状态 默认没点赞
ValueNotifier<int> zanNum = ValueNotifier(0); // 记录点赞数量 默认0点赞
这里我们需要使用动画曲线CurvedAnimation
这个类,这个类可以实现不同的0-1的运动曲线,根据掘金的点赞效果,比较符合这个曲线规则,快速放大,然后回归正常大小,这个类帮我们实现了很多好玩的运动曲线,有兴趣的小伙伴可以尝试下其他运动曲线。
小手运动曲线:
气泡运动曲线:
有了运动曲线之后,接下来我们只需将属性赋值给小手手和小圆点就好了,
完整源码: 封装一下,对外暴露大小,就是一个点赞组件了。
class ZanDemo extends StatefulWidget {
const ZanDemo({Key? key}) : super(key: key);
@override
_ZanDemoState createState() => _ZanDemoState();
}
class _ZanDemoState extends State<ZanDemo> with TickerProviderStateMixin {
late Animation<double> animation; // 赞
late Animation<double> animation2; // 小圆点
ValueNotifier<bool> isZan = ValueNotifier(false); // 记录点赞状态 默认没点赞
ValueNotifier<int> zanNum = ValueNotifier(0); // 记录点赞数量 默认0点赞
late AnimationController _controller; // 控制器
late AnimationController _controller2; // 小圆点控制器
late CurvedAnimation cure; // 动画运行的速度轨迹 速度的变化
late CurvedAnimation cure2; // 动画运行的速度轨迹 速度的变化
int time = 0;// 防止快速点两次赞导致取消赞
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 500)); //500ms
_controller2 = AnimationController(
vsync: this, duration: const Duration(milliseconds: 800)); //500ms
cure = CurvedAnimation(parent: _controller, curve: Curves.easeInOutBack);
cure2 = CurvedAnimation(parent: _controller2, curve: Curves.easeOutQuint);
animation = Tween(begin: 0.0, end: 1.0).animate(cure);
animation2 = Tween(begin: 0.0, end: 1.0).animate(_controller2);
}
@override
Widget build(BuildContext context) {
return InkWell(
child: Center(
child: CustomPaint(
size: Size(50, 50),
painter: _ZanPainter(animation, animation2, isZan, zanNum,
Listenable.merge([animation, animation2, isZan, zanNum])),
),
),
onTap: () {
if (!isZan.value && !_isDoubleClick()) {
_controller.forward(from: 0);
// 延迟300ms弹窗气泡
Timer(Duration(milliseconds: 300), () {
isZan.value = true;
_controller2.forward(from: 0);
});
Vibrate.feedback(FeedbackType.success);
zanNum.value++;
} else if (isZan.value) {
Vibrate.feedback(FeedbackType.success);
isZan.value = false;
zanNum.value--;
}
},
);
}
bool _isDoubleClick() {
if (time == 0) {
time = DateTime.now().microsecondsSinceEpoch;
return false;
} else {
if (DateTime.now().microsecondsSinceEpoch - time < 800 * 1000) {
return true;
} else {
time = DateTime.now().microsecondsSinceEpoch;
return false;
}
}
}
}
class _ZanPainter extends CustomPainter {
Animation<double> animation;
Animation<double> animation2;
ValueNotifier<bool> isZan;
ValueNotifier<int> zanNum;
Listenable listenable;
_ZanPainter(
this.animation, this.animation2, this.isZan, this.zanNum, this.listenable)
: super(repaint: listenable);
Paint _paint = Paint()..color = Colors.blue;
List<Offset> points = [];
@override
void paint(Canvas canvas, Size size) {
canvas.clipRect(Offset.zero & size);
canvas.translate(size.width / 2, size.height / 2);
// 赞
final icon =
isZan.value ? Icons.thumb_up_alt_rounded : Icons.thumb_up_alt_outlined;
// 通过TextPainter可以获取图标的尺寸
TextPainter textPainter = TextPainter(
text: TextSpan(
text: String.fromCharCode(icon.codePoint),
style: TextStyle(
fontSize: animation.value < 0 ? 0 : animation.value * 30,
fontFamily: icon.fontFamily,
color: isZan.value ? Colors.blue : Colors.black)),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr);
textPainter.layout(); // 进行布局
Size size2 = textPainter.size; // 尺寸必须在布局后获取
//将图标偏移到画布中央
textPainter.paint(canvas, Offset(-size2.width / 2, -size2.height / 2));
var r = size.width / 2 - 15; // 半径
var d = 4; // 偏移量
canvas.drawPoints(
ui.PointMode.points,
[
Offset((r + d * animation2.value) * cos(pi - pi / 18 * 2),
(r + d * animation2.value) * sin(pi - pi / 18 * 2)),
Offset((r + d * animation2.value) * cos(pi + pi / 18 * 2),
(r + d * animation2.value) * sin(pi + pi / 18 * 2)),
Offset((r + d * animation2.value) * cos(pi * 1.5 - pi / 18 * 1),
(r + d * animation2.value) * sin(pi * 1.5 - pi / 18 * 1)),
Offset((r + d * animation2.value) * cos(pi * 1.5 + pi / 18 * 5),
(r + d * animation2.value) * sin(pi * 1.5 + pi / 18 * 5)),
],
_paint
..strokeWidth = animation2.value <= 0.5 ? (5 * animation2.value) / 0.5
: 5 * (1 - animation2.value) / 0.5
..color = Colors.blue
..strokeCap = StrokeCap.round);
TextPainter textPainter2 = TextPainter(
text: TextSpan(
text: zanNum.value == 0 ? "点赞" : zanNum.value.toString(),
style: TextStyle(
fontSize: 9, fontWeight: FontWeight.w500, color: Colors.black)),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr);
textPainter2.layout(); // 进行布局
// 向右上进行偏移在小手上面
textPainter2.paint(canvas, Offset(size.width / 9, -size.height / 2 + 5));
}
@override
bool shouldRepaint(covariant _ZanPainter oldDelegate) {
return oldDelegate.listenable != listenable;
}
}
到这里发现是不是少了点什么,不错,还少了震动的效果,这里我们引入flutter_vibrate: ^1.3.0
这个插件,这个插件是用来管理设备震动效果的,Andoroid端记得加入震动权限,
<uses-permission android:name="android.permission.VIBRATE"/>
使用方法也很简单,这个插件封装了一些常见的提示震动,比如操作成功、操作警告、操作失败等,其实就是震动时间的长短,这里我们就在点赞时候调用Vibrate.feedback(FeedbackType.success);
有一个点击成功的震动就好了。
链接:https://juejin.cn/post/7088960905429385253
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
kotlin协程简介
技术是永无止境的,需要不断地学习总结。
什么是协程?
协程就像非常轻量级的线程。线程是由系统调度的,线程切换或线程阻塞的开销都比较大。而协程依赖于线程,但是协程挂起时不需要阻塞线程,几乎是无代价的,协程是由开发者控制的。所以协程也像用户态的线程,非常轻量级,一个线程中可以创建任意个协程。
1. GlobalScope 的使用(不推荐),绑定的为应用的整个生命周期,GlobalScope是生命周期是process级别的,即使Activity或Fragment已经被销毁,协程仍然在执行。所以需要绑定生命周期。
添加依赖如下:
implementation"org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9"
implementation"org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"
kotlin 中 GlobalScope 类提供了几个创建协程的构造函数:
launch: 创建协程
async : 创建带返回值的协程,返回的是 Deferred 类
withContext:不创建新的协程,指定协程上运行代码块
runBlocking:不是 GlobalScope 的 API,可以独立使用,区别是 runBlocking 里面的 delay 会阻塞线程,而 launch 创建的不会
2、lifecycleScope (推荐使用) lifecycleScope只能在Activity、Fragment中使用,会绑定Activity和Fragment的生命周期
**lifecycleScope会绑定调用者的生命周期,因此通常情况下不需要手动去停止
**
添加依赖如下:
implementation'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0'//lifecycleScope
implementation'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'//viewModelScope
1.在不使用回调的前提下完成来线程的切换,代码看上亲也是干净整洁很多。
2.因为线程没有上下文,不能控制线程执行完成后应该回到哪里,但是协程完全帮我们实现自动化,执行完毕自动回到上下文线程中,一般情况下是主线程,可以通过设置来决定要回到哪个线程中。
3.协程可以通过suspend关键字来标志耗时操作,通过编译器来帮助我们避免一些性能上的问题。
作者:张嘉美
链接:https://juejin.cn/post/7087175117976895502
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
flutter倒计时控件
使用方式1 默认为倒计时
CountdownWidget(
5, ///倒计时的时间
onClick: () { /// 点击事件的回调
_skip2main();
},
onFinish: () { ///倒计时完成的回调
_skip2main();
},
)
使用方式2修改圆角和文案
CountdownWidget(
total: 10,
content: "已发送",
textColor: Colors.blue,
borderRadius: 2,
onClick: () {
_skip2main();
},
onFinish: () {
_skip2main();
},
)
倒计时实现
import 'dart:async';
import 'package:bilibili_flutter/common/base/base_state.dart';
import 'package:bilibili_flutter/common/base/base_widget.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
///用于splash界面中倒计时按钮
class CountdownWidget extends BiliWidget {
///构造中传入函数
final VoidCallback? onClick;
final VoidCallback? onFinish;
final int total;
final double borderRadius;
final String content;
final double? height;
final Color? focusColor;
final Color? hoverColor;
final Color? highlightColor;
CountdownWidget(
{this.total = 5,
Key? key,
this.height = 40,
this.onClick,
this.onFinish,
this.borderRadius = 20,
this.content = "倒计时",
this.focusColor,
this.hoverColor,
this.highlightColor})
: super(key: key);
@override
State<CountdownWidget> createState() => _CountdownWidgetState();
}
class _CountdownWidgetState extends BiliState<CountdownWidget> {
var _count = 0;
late Timer _timer;
///注册倒计时
@override
void initState() {
super.initState();
var duration = const Duration(seconds: 1);
_timer = Timer.periodic(duration, (timer) {
if (_count < widget.total) {
setState(() {
_count++;
});
} else {
widget.onFinish?.call();
_timer.cancel();
}
});
}
@override
Widget build(BuildContext context) {
return InkWell(
focusColor: widget.focusColor,
hoverColor: widget.hoverColor,
highlightColor: widget.highlightColor,
onTap: () {
widget.onClick?.call();
},
child: SizedBox(
height: widget.height,
child: Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.all(Radius.circular(widget.borderRadius)),
),
child: Center(
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10),
child: Text("${widget.content}${widget.total - _count}s"),
),
),
),
),
);
}
}
依赖的两个基类
import 'package:flutter/material.dart';
@immutable
abstract class BiliWidget extends StatefulWidget {
BiliWidget({
Key? key,
}) : super(key: key);
String param = "";
void setParam(String param) {
this.param = param;
}
}
import 'package:flutter/material.dart';
import 'base_state.dart';
abstract class BiliState<T extends BiliWidget> extends State<T> {}
项目源码
作者:yanghai
链接:https://juejin.cn/post/7022318876968878111
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
剑指 Offer 10- I. 斐波那契数列
题目描述:
写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项(即 F(N))。斐波那契数列的定义如下:
F(0) = 0, F(1) = 1
F(N) = F(N - 1) + F(N - 2), 其中 N > 1.
斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
来源:力扣(LeetCode)
链接:leetcode-cn.com/problems/fe…
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
第一个想法使用递归:但是这个题目用递归写会超时。所以直接使用非递归的解法。
public int fib(int n) {
final int MOD = 1000000007;
if(n == 0){
return 0;
}
if(n<=2){
return 1;
}
//因为已经确定第一个值和第二个值了,所以直接从第三个数开始做循环
int i1 = 1;
int i2 = 1;
int sum = 0;
for (int i=3;i<=n;i++){
sum = (i1+i2) % MOD;
i1 = i2;
i2 = sum;
}
return sum;
}
作者:未来_牛小牛
链接:https://juejin.cn/post/7087419363595714573
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Android-ViewBinding的内存泄露
场景
在MainActivity中分别加载两个Fragment处理业务。
首先触发加载SecondFragment:
//MainActivity触发
supportFragmentManager.commit {
add(R.id.contentLayout, FirstFragment())
addToBackStack(null)//点击返回键可以回到FirstFragment
}
//FirstFragment布局中有一个自定义MyButton且有bitmap属性
class MyButton @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
) : Button(
context, attrs, defStyle
) {
private val bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.a)
}
然后触发加载SecondFragment;
//MainActivity触发
supportFragmentManager.commit {
replace(R.id.contentLayout, SecondFragment())
addToBackStack(null)
}
Android Profile可以发现有内存泄露
- MyButton中的bitmap无法释放。
为什么认为bitmap无法释放就是内存泄漏呢?
内存泄漏:简单点说,就是该释放的内存无法得到释放,而且内存不能被使用。
从Fragment的生命周期说起,从FirstFragment切换到SecondFragment,前者生命周期从onPause->onStop->onDestoryView,注意这里只走到onDestoryView,并没有onDetach以及onDestory。其实也很好理解,FirstFragment是加入了回退栈,后续是要被恢复,所以保留了Fragment对象,但为了不占用过多的内存,View会被销毁释放资源。
当FirstFragment从回退栈回到前台,会再次触发onCreateView重建View。既然View会重建,那么之前的View就是不需要的,留着也没用,就应该销毁掉。
该释放的View、Bitmap没有被释放,所以就出现了泄漏。
例子比较简单,只是为了说明问题,如果FirstFragment View持有大量占内存的对象,而且SecondFragment的加载需要耗费比较多的内存且存在跳转的其他页面的可能性,那么FirstFragment View的释放就显得很有必要。
补充引用链:FirstFragment-MyButton-Bitmap
onDestoryView官方注释
注意到这句“The next time the fragment needs
* to be displayed, a new view will be created”,当Fragment恢复时,会创建新的view添加到Fragment,也就是重走onCreateView,那么我理解旧的view就应该可以被销毁。
/**
* Called when the view previously created by {@link #onCreateView} has
* been detached from the fragment. The next time the fragment needs
* to be displayed, a new view will be created. This is called
* after {@link #onStop()} and before {@link #onDestroy()}. It is called
* <em>regardless</em> of whether {@link #onCreateView} returned a
* non-null view. Internally it is called after the view's state has
* been saved but before it has been removed from its parent.
*/
@MainThread
@CallSuper
public void onDestroyView() {
mCalled = true;
}
LeakCanary日志
建议在onDestroyView要释放掉View
LeakCanary: Watching instance of androidx.constraintlayout.widget.ConstraintLayout (com.yang.myapplication.MyFragment received Fragment#onDestroyView() callback (references to its views should be cleared to prevent leaks)) with key 0f101dfe-5e4e-4448-95cc-f5d08bbdf06e
解决方案
将ViewBinding置空就欧了。其实这也是官方的建议,当你新建项目的时候,就能看到这样的案列。
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
总结
当出现Fragment没有被销毁(onDestory没有回调),而view需要被销毁时(onDestoryView),要注意把ViewBinding置空,以免出现内存泄露。
作者:杨小妞566
链接:https://juejin.cn/post/7088212218205962276
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
在Flutter上优雅的请求网络数据
当你点进来看这篇文章时,应该和我一样在思考如何优雅的请求网络、处理加载状态、处理加载异常。希望这篇文章和案例能给你带来不一样的思考。
解决的问题
- 通用异常处理
- 请求资源状态可见(加载成功,加载中,加载失败)
- 通用重试逻辑
效果展示
正文
搜索一下关于flutter网络封装的多半都是dio相关的封装,简单的封装、复杂的封装百花齐放,思路都是工具类的封装。今天换一个思路来实现,引入repository对数据层进行操作,在repository里使用dio作为一个数据源供repository使用,需要使用数据就对repository进行操作不直接调用数据源(在repositoy里是不允许直接操作数据源的)。用WanAndroid的接口写个示例demo
定义数据源
使用retrofit作为数据源,感兴趣的小伙伴可以看下retrofit这个库
@RestApi(baseUrl: "https://www.wanandroid.com")
abstract class WanApi {
factory WanApi(Dio dio, {String baseUrl}) = _WanApi;
@GET("/banner/json")
Future<BannerModel> getBanner();
@GET("/article/top/json")
Future<TopArticleModel> getTopArticle();
@GET("/friend/json")
Future<PopularSiteModel> getPopularSite();
}
生成的代码
class _WanApi implements WanApi {
_WanApi(this._dio, {this.baseUrl}) {
baseUrl ??= 'https://www.wanandroid.com';
}
final Dio _dio;
String? baseUrl;
@override
Future<BannerModel> getBanner() async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
final _data = <String, dynamic>{};
final _result = await _dio.fetch<Map<String, dynamic>>(
_setStreamType<BannerModel>(
Options(method: 'GET', headers: _headers, extra: _extra)
.compose(_dio.options, '/banner/json',
queryParameters: queryParameters, data: _data)
.copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl)));
final value = BannerModel.fromJson(_result.data!);
return value;
}
@override
Future<TopArticleModel> getTopArticle() async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
final _data = <String, dynamic>{};
final _result = await _dio.fetch<Map<String, dynamic>>(
_setStreamType<TopArticleModel>(
Options(method: 'GET', headers: _headers, extra: _extra)
.compose(_dio.options, '/article/top/json',
queryParameters: queryParameters, data: _data)
.copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl)));
final value = TopArticleModel.fromJson(_result.data!);
return value;
}
@override
Future<PopularSiteModel> getPopularSite() async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
final _data = <String, dynamic>{};
final _result = await _dio.fetch<Map<String, dynamic>>(
_setStreamType<PopularSiteModel>(
Options(method: 'GET', headers: _headers, extra: _extra)
.compose(_dio.options, '/friend/json',
queryParameters: queryParameters, data: _data)
.copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl)));
final value = PopularSiteModel.fromJson(_result.data!);
return value;
}
RequestOptions _setStreamType<T>(RequestOptions requestOptions) {
if (T != dynamic &&
!(requestOptions.responseType == ResponseType.bytes ||
requestOptions.responseType == ResponseType.stream)) {
if (T == String) {
requestOptions.responseType = ResponseType.plain;
} else {
requestOptions.responseType = ResponseType.json;
}
}
return requestOptions;
}
}
repository封装
Resource是封装的资源加载状态类,用于包装资源
enum ResourceState { loading, failed, success }
class Resource<T> {
final T? data;
final ResourceState state;
final dynamic error;
Resource._({required this.state, this.error, this.data});
factory Resource.failed(dynamic error) {
return Resource._(state: ResourceState.failed, error: error);
}
factory Resource.success(T data) {
return Resource._(state: ResourceState.success, data: data);
}
factory Resource.loading() {
return Resource._(state: ResourceState.loading);
}
bool get isLoading => state == ResourceState.loading;
bool get isSuccess => state == ResourceState.success;
bool get isFailed => state == ResourceState.failed;
}
接下来我们在Repository里使用WanApi来封装,我们通过流的方式返回了资源加载的状态可供View层根据状态展示不同的界面,使用try-catch保证网络请求的健壮性
class WanRepository extends BaseRepository {
late WanApi wanApi = GetInstance().find();
///获取首页所需的所有数据
Stream<Resource<HomeDataMapper>> homeData() async* {
//加载中
yield Resource.loading();
try {
var result = await Future.wait<dynamic>([
wanApi.getBanner(),
wanApi.getPopularSite(),
wanApi.getTopArticle()
]);
final BannerModel banner = result[0];
final PopularSiteModel site = result[1];
final TopArticleModel article = result[2];
//加载成功
yield Resource.success(
HomeDataMapper(site.data, banner.data, article.data));
} catch (e) {
//加载失败
yield Resource.failed(e);
}
}
}
咋一看感觉没啥问题细思之下问题很多,每一个请求还多了try-catch以外那么多的模板方法,实际开发中只写try包裹的内容才符合摸鱼佬的习惯。ok,我们把模板方法提取出来到一个公共方法里去,就变成了这样:
class WanRepository extends BaseRepository {
late WanApi wanApi = GetInstance().find();
///获取首页所需的所有数据
Stream<Resource<HomeDataMapper>> homeData() async* {
///定义加载函数
loadHomeData()async*{
var result = await Future.wait<dynamic>([
wanApi.getBanner(),
wanApi.getPopularSite(),
wanApi.getTopArticle()
]);
final BannerModel banner = result[0];
final PopularSiteModel site = result[1];
final TopArticleModel article = result[2];
//加载成功
yield Resource.success(
HomeDataMapper(site.data, banner.data, article.data));
}
///将加载函数放在一个包装器里执行
yield* MyWrapper.customStreamWrapper(loadHomeData);
}
}
得益于Dart中函数可以作为参数传递,所以我们可以定义一个包装方法,入参是具体业务的函数,出参和业务函数一致,在这个方法里可以处理各种异常,甚至可以实现通用的请求重试(只需要在失败的时候弹窗提醒用户重试,获得认可后再次执行function就可以了,更关键的是此时状态管理里对repository的调用依旧是完整的,也就是说这是一个通用的重试功能)
包装器代码:
class MyWrapper {
//流的方式
static Stream<Resource<T>> customStreamWrapper<T>(
Stream<Resource<T>> Function() function,
{bool retry = false}) async* {
yield Resource.loading();
try {
var result = function.call();
await for(var data in result){
yield data;
}
} catch (e) {
//重试代码
if (retry) {
var toRetry = await Get.dialog(const RequestRetryDialog());
if (toRetry == true) {
yield* customStreamWrapper(function,retry: retry);
} else {
yield Resource.failed(e);
}
} else {
yield Resource.failed(e);
}
}
}
}
其实就是把相同的地方封装成一个通用方法,不同的地方单独拎出来编写,然后作为一个参数传到包装器里执行。显然这样的方法却不够优雅,每次在写repository的时候都得创建一个函数在里面编写请求数据的逻辑然后交给包装器执行。我们肯定希望repository里代码长成这个样子:
@Repo()
abstract class WanRepository extends BaseRepository {
late WanApi wanApi = GetInstance().find();
///获取首页所需的所有数据
@ProxyCall()
@Retry()
Stream<Resource<HomeDataMapper>> homeData() async* {
var result = await Future.wait<dynamic>(
[wanApi.getBanner(), wanApi.getPopularSite(), wanApi.getTopArticle()]);
final BannerModel banner = result[0];
final PopularSiteModel site = result[1];
final TopArticleModel article = result[2];
yield Resource.success(
HomeDataMapper(site.data, banner.data, article.data));
}
}
是的没错,最终的repository就长这个样子,你只需要在类上打个注解@Repo在需要代理调用的方法上注解@ProxyCall,运行 flutter pub run build_runner build 就可以生成对应的包装代码:
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'wan_repository.dart';
// **************************************************************************
// RepositoryGenerator
// **************************************************************************
class WanRepositoryImpl = WanRepository with _WanRepository;
mixin _WanRepository on WanRepository {
@override
Stream<Resource<HomeDataMapper>> homeData() {
return MyWrapper.customStreamWrapper(() => super.homeData(), retry: true);
}
}
结语
感谢你的阅读,这只是一个网络请求封装的思路不是最优解,但希望给你带来新思考
附demo地址:gitee.com/cysir/examp…
flutter版本:2.8
作者:贼不走空
链接:https://juejin.cn/post/7088223867017101343
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
阿里四面,居然栽在一道排序算法上
前言
算法是程序的灵魂,一个优秀的程序是可以在海量的数据中,仍保持高效计算。目前各大厂的面试要求也越来越高,算法肯定会要去。如果你不想去大厂,只想去小公司,获取并不需要要求算法。但是你永远只能当一个代码工人,也就是跟搬砖的没区别。可能一两年后你就会被淘汰。
如果不想永远当个代码工人,就在业余时间学学数据结构和算法。
今天就来分享一个朋友阿里四面挂了的排序算法题912. 排序数组,
排序数组这道题本身是没有规定使用什么排序算法的,但面试官指定需要使用归并排序算法来解答,肯定是有他道理的。
我们知道,排序算法有很多,大致有如下几种:
其中归并排序应该是使用的最多的几种之一,Java中Arrays.sort()采用了一种名为TimSort的排序算法,就是归并排序的优化版本。归并排序自身的优点有二,首先是因为它的平均时间复杂度低,为O(N*logN);其次它是稳定的排序,即相等元素的顺序不会改变;除了这两点优点之外,其蕴含的分治思想,是可以用来解决我们许多算法问题的,这也是面试官为什么要指定归并排序的原因。好了,废话不多说,我们接下来具体看看归并排序算法是如何实现的吧。
归并排序(递归版)
归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治策略,即分为两步:分与治。
1. 分:先递归分解数组成子数组
2. 治:将分阶段得到的子数组按顺序合并
我们来具体看看例子,假设我们现在给定一个数组:[6,3,2,7,1,3,5,4],我们需要使用归并算法对其排序,其大致过程如下图所示:
分阶段可以理解为就是递归拆分子序列的过程,递归的深度为log2n。而治的阶段则是将两个子序列进行排序的过程,我们通过图解看看治阶段最后一步中是如何将[2,3,6,7]和[1,3,4,5]这两个数组合并的。
图中左边是复制的临时数组,而右边是原数组,我们将左右指针对应的值进行大小比较,将较小的那个数放入原数组中,然后将相应的指针右移。比如第一步中,我们比较左边指针L指向的2和右指针R指向的1,R指向的1小,则把1放入原数组中的第一个位置中,然后R指针向右移动。后面再继续,直到左边临时数组的元素都按序覆盖了右边的原数组。最后我们通过上图再结合源码来看看吧:
class Solution {
public int[] sortArray(int[] nums) {
sort(0, nums.length - 1, nums);
return nums;
}
// 分:递归二分
private void sort(int l, int r, int[] nums) {
if (l >= r) return;
int mid = (l + r) / 2;
sort(l, mid, nums);
sort(mid + 1, r, nums);
merge(l, mid, r, nums);
}
// 治:将nums[l...mid]和nums[mid+1...r]两部分进行归并
private void merge(int l, int mid, int r, int[] nums) {
int[] aux = Arrays.copyOfRange(nums, l, r + 1);
int lp =l, rp = mid + 1;
for (int i = lp; i <= r; i ++) {
if (lp > mid) { // 如果左半部分元素已经全部处理完毕
nums[i] = aux[rp - l];
rp ++;
} else if (rp > r) { // 如果右半部分元素已经全部处理完毕
nums[i] = aux[lp - l];
lp ++;
} else if (aux[lp-l] > aux[rp - l]) { // 左半部分所指元素 > 右半部分所指元素
nums[i] = aux[rp - l];
rp ++;
} else { // 左半部分所指元素 <= 右半部分所指元素
nums[i] = aux[lp - l];
lp ++;
}
}
}
}
我们可以看到,分阶段的时间复杂度是logN,而合并阶段的时间复杂度是N,所以归并算法的时间复杂度是O(N*logN),因为每次合并都需要对应范围内的数组,所以其空间复杂度是O(N);
归并排序(迭代版)
上面的归并排序是通过递归二分的方法进行数组切分的,其实我们也可以通过迭代的方法来完成分这步,看下图:
其因为数组,所以我们直接通过迭代从1开始合并,其中sz就是合并的长度,这种方法也可以称为自底向上的归并,其具体的代码如下
class Solution {
public int[] sortArray(int[] nums) {
int n = nums.length;
// sz= 1,2,4,8 ... 排序
for (int sz = 1; sz < n; sz *= 2) {
// 对 arr[i...i+sz-1] 和 arr[i+sz...i+2*sz-1] 进行归并
for (int i = 0; i < n - sz; i += 2*sz ) {
merge(i, i + sz - 1, Math.min(i+sz+sz-1, n-1), nums);
}
}
return nums;
}
// 和递归版一样
private void merge(int l, int mid, int r, int[] nums) {
int[] aux = Arrays.copyOfRange(nums, l, r + 1);
int lp =l, rp = mid + 1;
for (int i = lp; i <= r; i ++) {
if (lp > mid) {
nums[i] = aux[rp - l];
rp ++;
} else if (rp > r) {
nums[i] = aux[lp - l];
lp ++;
} else if (aux[lp-l] > aux[rp - l]) {
nums[i] = aux[rp - l];
rp ++;
} else {
nums[i] = aux[lp - l];
lp ++;
}
}
}
}
总结
归并排序是一种十分高效的排序算法,其时间复杂度为O(N*logN)。归并排序的最好,最坏的平均时间复杂度均为O(nlogn),排序后相等的元素的顺序不会改变,所以也是一种稳定的排序算法。归并排序被应用在许多地方,其java中Arrays.sort()采用了一种名为TimSort的排序算法,其就是归并排序的优化版本。
作者:初念初恋
链接:https://juejin.cn/post/6979760710289424421
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Kotlin-apply、also、run、let、区别
apply、also介绍
- 两者都是T的扩展函数,也就是任何类型对象都调用apply、also;
- 两者的返回值都是this,也就是函数调用者;
- apply的闭包使用this来访问函数调用者,also的闭包使用it来访问函数的调用者。
一看看apply、also源码
public inline fun <T> T.apply(block: T.() -> Unit): T {//1
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()//2
return this//返回值为this,也就是apply的调用者
}
public inline fun <T> T.also(block: (T) -> Unit): T {//3
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block(this)//4
return this 返回值为this,也就是also的调用者
}
- 注释1:apply接受的闭包类型为T.() -> Unit,也就是调用者的扩展函数,例子tv.apply{},闭包{}为tv的扩展函数,所以this可以访问到调用者;
- 注释2:直接调用闭包,完成apply的逻辑;
- 注释3:also接受的闭包类型为 (T) -> Unit,也就是任意函数,只要函数入参类型为also调用类型返回为Unit都可以;
- 注释3:把this作为闭包的参数传入,例子tv.also{},闭包的入参为tv,所以it能访问到tv;
- apply this可以访问调用者本身,因为闭包是扩展函数,而also用it访问调用者本身,因为调用者是作为参数传入闭包的。
apply、also适用场景
因为返回值为调用者this,所以它们非常适合对同一个对象连续操作的链式调用。
以下代码以apply为例,链式调用对tv进行一系列操作。注意:例子不一定合理,只是想表达相应的意思而已。
private fun init() {
val tv = TextView(this)
tv.apply {
this.text = "name" //操作1
}.apply {
this.setOnClickListener { //操作2
Log.d("MainActivity", "setOnClickListener")
}
}.apply {
this.gravity = Gravity.CENTER //操作3
}
}
run、let介绍
- 两者都是T的扩展函数,也就是任何类型对象都调用run、let;
- 两者的返回值是:最后一行非赋值代码作为闭包的返回值,否则返回Unit;
- run的闭包使用this来访问函数调用者,let的闭包使用it来访问函数的调用者。
一起看看 run、let源码
public inline fun <T, R> T.run(block: T.() -> R): R {//1
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()//2
}
public inline fun <T, R> T.let(block: (T) -> R): R {//3
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)//4
}
- 注释1:run接受的闭包类型为T.() -> Unit,也就是调用者的扩展函数,this可以访问到调用者,这点跟apply一样;
- 注释2:直接调用闭包,将闭包的返回值返回;
- 注释3:let接受的闭包类型为block: (T) -> Unit,也就是任意函数,只要函数入参类型为also调用者类型返回为Unit都可以;
- 注释4:直接调用闭包,将this作为参数传入闭包;
- run this可以访问调用者本身,因为闭包是扩展函数,而let用it访问调用者本身,因为是作为参数传入闭包。
run、let适用场景
它们都可以有返回值,所以非常适合上一个操作返回值作用于下一个操作的链式调用。以下代码以let为例,操作1返回值作用于操作2,操作2返回值作用于操作3。注意:例子不一定合理,只是想表达相应的意思而已。
private fun init(data: Int): Int {
return data.let {
if (data == 1) it + 1 else it + 2 //操作1
}.let {
if (data == 2) it + 3 else it + 4 //操作2
}.let {
if (data == 3) it + 5 else it + 6 //操作3
}
}
作用函数更重要的作用
确保操作的作用域,以下代码确保tv不为空的情况下执行,保证操作的作用域。
val tv = TextView(this)
tv?.apply {
text = count.toString()
setOnClickListener {
Log.d("MainActivity", "setOnClickListener")
}
gravity = Gravity.CENTER
}
为什么有的用this访问调用者,有的则用it?
前面分析源码的时候可以看到,
- apply、run接收的闭包类型为调用者的扩展函数,既然是扩展函数,那么当然是用this来访问调用者;
- also、let接受的闭包类型为任意类型的函数,只要函数入参类型为调用者类型返回为Unit都可以,既然是参数,那么就能用不能用this来访问,就得用其他字符来访问,定义it来访问也未尝不可;
总结
- apply、also,闭包的返回值都是this,前者apply接受的闭包类型调用者的扩展函数,后者接受的闭包类型为 入参为调用者类型的函数;
- also、apply,非常适合对同一个对象连续操作的链式调用;
- run、let,闭包的返回值为最后一行非赋值代码,前者run接受的闭包类型调用者的扩展函数,后者接受的闭包类型为 入参为调用者类型的函数;
- run、let,非常适合上一个操作返回值作用于下一个操作的调用;
以上分析有不对的地方,请指出,互相学习,谢谢哦!
作者:杨小妞566
链接:https://juejin.cn/post/7088908213399060488
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
仿海报工厂效果的自定义View
之前做了一个自定义View,效果有些类似海报工厂,当做自定义View的入门学习吧~先看下效果图:
就是一个背景图,中间挖了若干个形状不同的“洞”,每个“洞”里放着一张图片,用手可以拖拽、缩放、旋转该图片,并且当前图片备操作时会有红色的高亮边框。点击选中某个图片的时候,底部会弹出菜单栏,菜单栏有三个按钮,分别是对该图片进行旋转90度、对称翻转图片、和保存整个海报到手机内置sd卡根目录。
这就类似海报工厂效果,选择若干张图片还有底部模板(就是背景图片和挖空部分的位置和形状),然后通过触摸改变选择的图片的大小位置角度,来制作一张自己喜爱的海报。
这里主要是一个自定义View,项目中叫做JigsawView完成的。它的基本结构是最底层绘制可操作的图片,第二层绘制背景图片,第三层绘制镂空的部分,镂空部分通过PorterDuffXfermode来实现,镂空部分的形状由对应手机目录的svg文件确定。
在用Android中的Canvas进行绘图时,可以通过使用PorterDuffXfermode将所绘制的图形的像素与Canvas中对应位置的像素按照一定规则进行混合,形成新的像素值,从而更新Canvas中最终的像素颜色值,这样会创建很多有趣的效果。关于PorterDuffXfermode详细可以参考
Android中Canvas绘图之PorterDuffXfermode使用及工作原理详解
首先这里要关掉硬件加速,因为硬件加速可能会使效果丢失。在View的初始化语句中调用
setLayerType(View.LAYER_TYPE_SOFTWARE, null);
即可。
由于JigsawView的代码不少,所以这里只展示比较重要的部分,完整代码请见文章末尾的GitHub链接。
首先需要两支画笔:
//绘制图片的画笔
Paint mMaimPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
//绘制高亮边框的画笔
Paint mSelectPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
这里图片的模型是PictureModel 。PictureModel 主要都是包含了位置和缩放信息以及镂空部分的HollowModel,而图片的具体位置和大小由HollowModel确定,图片时填充镂空部分的,以类似ImageView的CenterCrop方式填充,这个在JigsawView的makePicFillHollow方法中处理。
HollowModel持有解析svg文件得到的path对象集合,该集合可以表示一个svg文件表示的路径。具体的解析工作由自定义的SvgParseUtil类处理,SvgParseUtil从手机的内置sd卡中(当然路径可以灵活配置)读取对应的svg文件,然后解析为可以绘制的Path集合对象。SvgParseUtil本质是解析xml文件(以为svg就是一个xml文件),对于svg路径直接拷贝系统的PathParser处理,其他的圆形矩形多边形就自己处理。这里具体代码这里就不展示了,详细请看GitHub上的源码。
以下是完整的onDraw方法:
@Override
protected void onDraw(Canvas canvas) {
if (mPictureModels != null && mPictureModels.size() > 0 && mBitmapBackGround != null) {
//循环遍历画要处理的图片
for (PictureModel pictureModel : mPictureModels) {
Bitmap bitmapPicture = pictureModel.getBitmapPicture();
int pictureX = pictureModel.getPictureX();
int pictureY = pictureModel.getPictureY();
float scaleX = pictureModel.getScaleX();
float scaleY = pictureModel.getScaleY();
float rotateDelta = pictureModel.getRotate();
HollowModel hollowModel = pictureModel.getHollowModel();
ArrayList<Path> paths = hollowModel.getPathList();
if (paths != null && paths.size() > 0) {
for (Path tempPath : paths) {
mPath.addPath(tempPath);
}
drawPicture(canvas, bitmapPicture, pictureX, pictureY, scaleX, scaleY, rotateDelta, hollowModel, mPath);
} else {
drawPicture(canvas, bitmapPicture, pictureX, pictureY, scaleX, scaleY, rotateDelta, hollowModel, null);
}
}
//新建一个layer,新建的layer放置在canvas默认layer的上部,当我们执行了canvas.saveLayer()之后,我们所有的绘制操作都绘制到了我们新建的layer上,而不是canvas默认的layer。
int layerId = canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), null, Canvas.ALL_SAVE_FLAG);
drawBackGround(canvas);
//循环遍历画镂空部分
for (PictureModel pictureModel : mPictureModels) {
int hollowX = pictureModel.getHollowModel().getHollowX();
int hollowY = pictureModel.getHollowModel().getHollowY();
int hollowWidth = pictureModel.getHollowModel().getWidth();
int hollowHeight = pictureModel.getHollowModel().getHeight();
ArrayList<Path> paths = pictureModel.getHollowModel().getPathList();
if (paths != null && paths.size() > 0) {
for (Path tempPath : paths) {
mPath.addPath(tempPath);
}
drawHollow(canvas, hollowX, hollowY, hollowWidth, hollowHeight, mPath);
mPath.reset();
} else {
drawHollow(canvas, hollowX, hollowY, hollowWidth, hollowHeight, null);
}
}
//把这个layer绘制到canvas默认的layer上去
canvas.restoreToCount(layerId);
//绘制选择图片高亮边框
for (PictureModel pictureModel : mPictureModels) {
if (pictureModel.isSelect() && mIsNeedHighlight) {
canvas.drawRect(getSelectRect(pictureModel), mSelectPaint);
}
}
}
}
思路还是比较清晰的。第3行到第22行为绘制可操作图片。第19行的drawPicture就是绘制所有的可操作图片,而当该图片对应的镂空部分没有相应的svg时,就绘制HollowModel的位置尺寸对应的矩形作为镂空部分,即20行的drawPicture。
看下drawPicture方法:
private void drawPicture(Canvas canvas, Bitmap bitmapPicture, int coordinateX, int coordinateY, float scaleX, float scaleY, float rotateDelta
, HollowModel hollowModel, Path path) {
int picCenterWidth = bitmapPicture.getWidth() / 2;
int picCenterHeight = bitmapPicture.getHeight() / 2;
mMatrix.postTranslate(coordinateX, coordinateY);
mMatrix.postScale(scaleX, scaleY, coordinateX + picCenterWidth, coordinateY + picCenterHeight);
mMatrix.postRotate(rotateDelta, coordinateX + picCenterWidth, coordinateY + picCenterHeight);
canvas.save();
//以下是对应镂空部分相交的处理,需要完善
if (path != null) {
Matrix matrix1 = new Matrix();
RectF rect = new RectF();
path.computeBounds(rect, true);
int width = (int) rect.width();
int height = (int) rect.height();
float hollowScaleX = hollowModel.getWidth() / (float) width;
float hollowScaleY = hollowModel.getHeight() / (float) height;
matrix1.postScale(hollowScaleX, hollowScaleY);
path.transform(matrix1);
//平移path
path.offset(hollowModel.getHollowX(), hollowModel.getHollowY());
//让图片只能绘制在镂空内部,防止滑动到另一个拼图的区域中
canvas.clipPath(path);
path.reset();
} else {
int hollowX = hollowModel.getHollowX();
int hollowY = hollowModel.getHollowY();
int hollowWidth = hollowModel.getWidth();
int hollowHeight = hollowModel.getHeight();
//让图片只能绘制在镂空内部,防止滑动到另一个拼图的区域中
canvas.clipRect(hollowX, hollowY, hollowX + hollowWidth, hollowY + hollowHeight);
}
canvas.drawBitmap(bitmapPicture, mMatrix, null);
canvas.restore();
mMatrix.reset();
}
这里主要是运用了Matrix处理图片的各种变化。在onTouchEvent方法中,会根据触摸的事件不同对正在操作的PictureModel对象的位置、缩放、角度进行对应的赋值,所以在drawPicture中将每次触摸后的赋值参数取出来,交给Matrix对象处理,最后通过
canvas.drawBitmap(bitmapPicture, mMatrix, null);
就能将触摸后的变化图片显示出来。
另外第26行的canvas.clipPath(path);是将图片的可绘制区域限定在镂空部分中,防止图片滑动到其他的镂空区域。
注意onDraw的第25行的
int layerId = canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), null, Canvas.ALL_SAVE_FLAG);
为了正确显示PorterDuffXfermode所带来的的效果,需要新建一个图层,具体可以参见上面链接引用的博文。
onDraw第26行的drawBackGround方法就是绘制背景,这个很简单就不必说了。
第28行到第44行为绘制镂空部分,主要是先把HollowModel中存储的Path集合取出,再通过addPath方法将路径数据交给mPath对象,再由drawHollow方法去真正绘制镂空部分。
private void drawHollow(Canvas canvas, int hollowX, int hollowY, int hollowWidth, int hollowHeight, Path path) {
mMaimPaint.setXfermode(mPorterDuffXfermodeClear);
//画镂空
if (path != null) {
canvas.save();
canvas.translate(hollowX, hollowY);
//缩放镂空部分大小使得镂空部分填充HollowModel对应的矩形区域
scalePathRegion(canvas, hollowWidth, hollowHeight, path);
canvas.drawPath(path, mMaimPaint);
canvas.restore();
mMaimPaint.setXfermode(null);
} else {
Rect rect = new Rect(hollowX, hollowY, hollowX + hollowWidth, hollowY + hollowHeight);
canvas.save();
canvas.drawRect(rect, mMaimPaint);
canvas.restore();
mMaimPaint.setXfermode(null);
}
}
这里首先对设置画笔的PorterDuffXfermode:
mMaimPaint.setXfermode(mPorterDuffXfermodeClear);
这里为了镂空效果,PorterDuffXfermode使用PorterDuff.Mode.CLEAR。
然后对画布进行平移,然后通过scalePathRegion方法让表示镂空路径的Path对象进行缩放,使得镂空的路径填充HollowModel对应的矩形区域。接着使用
canvas.drawRect(rect, mMaimPaint);
将镂空的路径绘制上去。
最后别忘了
canvas.restore();
mMaimPaint.setXfermode(null);
恢复画布和画笔的状态。
然后onDraw的第47行把这个layer绘制到canvas默认的layer上去:
canvas.restoreToCount(layerId);
onDraw最后的
//绘制选择图片高亮边框
for (PictureModel pictureModel : mPictureModels) {
if (pictureModel.isSelect() && mIsNeedHighlight) {
canvas.drawRect(getSelectRect(pictureModel), mSelectPaint);
}
}
在onTouchEvent中,将通过触摸事件判断哪个图片当前被选择,然后在onDraw中让当前被选择的图片绘制对应的HollowModel的边框。
onDraw到此结束。
再看下onTouchEvent方法:
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mPictureModels == null || mPictureModels.size() == 0) {
return true;
}
switch (event.getActionMasked()) {
case MotionEvent.ACTION_POINTER_DOWN:
//双指模式
if (event.getPointerCount() == 2) {
//mPicModelTouch为当前触摸到的操作图片模型
mPicModelTouch = getHandlePicModel(event);
if (mPicModelTouch != null) {
// mPicModelTouch.setSelect(true);
//重置图片的选中状态
resetNoTouchPicsState();
mPicModelTouch.setSelect(true);
//两手指的距离
mLastFingerDistance = distanceBetweenFingers(event);
//两手指间的角度
mLastDegree = rotation(event);
mIsDoubleFinger = true;
invalidate();
}
}
break;
//单指模式
case MotionEvent.ACTION_DOWN:
//记录上一次事件的位置
mLastX = event.getX();
mLastY = event.getY();
//记录Down事件的位置
mDownX = event.getX();
mDownY = event.getY();
//获取被点击的图片模型
mPicModelTouch = getHandlePicModel(event);
if (mPicModelTouch != null) {
//每次down重置其他picture选中状态
resetNoTouchPicsState();
mPicModelTouch.setSelect(true);
invalidate();
}
break;
case MotionEvent.ACTION_MOVE:
switch (event.getPointerCount()) {
//单指模式
case 1:
if (!mIsDoubleFinger) {
if (mPicModelTouch != null) {
//记录每次事件在x,y方向上移动
int dx = (int) (event.getX() - mLastX);
int dy = (int) (event.getY() - mLastY);
int tempX = mPicModelTouch.getPictureX() + dx;
int tempY = mPicModelTouch.getPictureY() + dy;
if (checkPictureLocation(mPicModelTouch, tempX, tempY)) {
//检查到没有越出镂空部分才真正赋值给mPicModelTouch
mPicModelTouch.setPictureX(tempX);
mPicModelTouch.setPictureY(tempY);
//保存上一次的位置,以便下次事件算出相对位移
mLastX = event.getX();
mLastY = event.getY();
//修改了mPicModelTouch的位置后刷新View
invalidate();
}
}
}
break;
//双指模式
case 2:
if (mPicModelTouch != null) {
//算出两根手指的距离
double fingerDistance = distanceBetweenFingers(event);
//当前的旋转角度
double currentDegree = rotation(event);
//当前手指距离和上一次的手指距离的比即为图片缩放比
float scaleRatioDelta = (float) (fingerDistance / mLastFingerDistance);
float rotateDelta = (float) (currentDegree - mLastDegree);
float tempScaleX = scaleRatioDelta * mPicModelTouch.getScaleX();
float tempScaleY = scaleRatioDelta * mPicModelTouch.getScaleY();
//对缩放比做限制
if (Math.abs(tempScaleX) < 3 && Math.abs(tempScaleX) > 0.3 &&
Math.abs(tempScaleY) < 3 && Math.abs(tempScaleY) > 0.3) {
//没有超出缩放比才真正赋值给模型
mPicModelTouch.setScaleX(tempScaleX);
mPicModelTouch.setScaleY(tempScaleY);
mPicModelTouch.setRotate(mPicModelTouch.getRotate() + rotateDelta);
//修改模型之后,刷新View
invalidate();
//记录上一次的两手指距离以便下次计算出相对的位置以算出缩放系数
mLastFingerDistance = fingerDistance;
}
//记录上次的角度以便下一个事件计算出角度变化值
mLastDegree = currentDegree;
}
break;
}
break;
//两手指都离开屏幕
case MotionEvent.ACTION_UP:
// for (PictureModel pictureModel : mPictureModels) {
// pictureModel.setSelect(false);
// }
mIsDoubleFinger = false;
double distance = getDisBetweenPoints(event);
if (mPicModelTouch != null) {
//是否属于滑动,非滑动则改变选中状态
if (distance < ViewConfiguration.getTouchSlop()) {
if (mPicModelTouch.isLastSelect()) {
mPicModelTouch.setSelect(false);
mPicModelTouch.setLastSelect(false);
if (mPictureCancelSelectListner != null) {
mPictureCancelSelectListner.onPictureCancelSelect();
}
} else {
mPicModelTouch.setSelect(true);
mPicModelTouch.setLastSelect(true);
//选中的回调
if (mPictureSelectListener != null) {
mPictureSelectListener.onPictureSelect(mPicModelTouch);
}
}
invalidate();
} else {
//滑动则取消所有选择的状态
mPicModelTouch.setSelect(false);
mPicModelTouch.setLastSelect(false);
//取消状态之后刷新View
invalidate();
}
} else {
//如果没有图片被选中,则取消所有图片的选中状态
for (PictureModel pictureModel : mPictureModels) {
pictureModel.setLastSelect(false);
}
//没有拼图被选中的回调
if (mPictureNoSelectListener != null) {
mPictureNoSelectListener.onPictureNoSelect();
}
//取消所有图片选中状态后刷新View
invalidate();
}
break;
//双指模式中其中一手指离开屏幕,取消当前被选中图片的选中状态
case MotionEvent.ACTION_POINTER_UP:
if (mPicModelTouch != null) {
mPicModelTouch.setSelect(false);
invalidate();
}
}
return true;
}
虽然比较长,但是并不难理解,基本是比较套路化的东西,看注释应该就能懂。
总的流程就是:
首先在Down事件:
不管单手还是双手模式,都将选择当前点击到的图片模型,这也是为了以后的事件中可以修改选中的图片模型以在onDraw中改变图片的显示。
Move事件中:
单手模式的话,针对每个MOVE事件带来的位移给PictureModel的位置赋值,然后就调用invalidate进行刷新界面。
如果是双手模式,则根据每个MOVE事件带来的角度变化和两个手指间的距离变化分别给PictureModel的角度和缩放比赋值,然后调用invalidate进行刷新界面。
Up事件:
单指模式下,先判断是否已经滑动过(滑动距离小于ViewConfiguration.getTouchSlop()就认为不是滑动而是点击),不是滑动的话就以改变当前的图片选中状态处理,切换选中状态。
是滑动过的话则取消所有图片的选中状态。
双指状态下均取消所有图片的选中状态。
这里为了使得缩放旋转体验更好,所以只要手指DOWN事件落在镂空部分中,在没有Up事件的情况下即使滑出镂空部分仍然可以继续对选中的图片进行操作,避免因为镂空部分小带来的操作不便,这也和海报工厂的效果一致。
作者:半岛铁盒里的猫
链接:https://juejin.cn/post/7084555852606242846
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter制作一个吃豆人加载Loading
- 知识点:绘制、动画、多状态监听
国际惯例,先看效果图:
- 具体效果就是吃豆人会根据吃不同颜色的豆子改变身体的颜色。
1、绘制静态吃豆人、豆豆、眼睛
首先,我们需要将这个静态的吃豆人绘制出来,我们可以把吃豆人看做是一个实心圆弧,豆豆和眼睛就是一个圆。
关键代码:
//画头
_paint
..color = color.value
..style = PaintingStyle.fill;
var rect = Rect.fromCenter(
center: Offset(0, 0), width: size.width, height: size.height);
/// 起始角度
var a = 40 / 180 * pi;
// 绘制圆弧
canvas.drawArc(rect, 0, 2 * pi - a * 2, true, _paint);
// 画豆豆
canvas.drawOval(
Rect.fromCenter(
center: Offset(
size.width / 2 +
ddSize -
angle2.value * (size.width / 2 + ddSize),
0),
width: ddSize,
height: ddSize),
_paint..color = color2.value);
//画眼睛
canvas.drawOval(
Rect.fromCenter(
center: Offset(0, -size.height / 3), width: 8, height: 8),
_paint..color = Colors.black87);
动画属性: 嘴巴的张合:通过圆弧的角度不断改变实现,豆豆移动:从头的右侧源源不断的有豆子向左移动,改变豆豆x轴的坐标即可,接下来我们让吃豆人动起来吧。
2、加入动画属性
这里我们需要创建2个动画控制器,一个控制头,一个控制豆豆,我们看到因为头部一开一合属于动画正向执行一次然后再反向执行一次,相当于执行了两次,豆豆的从右边到嘴巴只执行了一次,所以头的执行时间是豆豆执行时间的两倍,嘴巴一张一合才能吃豆子嘛,吃豆完毕,将豆子颜色赋值给头改变颜色,豆子随机获取另一个颜色,不断的吃豆。 这里的绘制状态有多种情况,嘴巴的张合、豆子的平移、颜色的改变都需要进行重新绘制,这里我们可以使用 Listenable.merge
方法来进行监听,接受一个Listenable
数组,可以将我们需要改变的状态放到这个数组里,返回一个 Listenable
赋值给CustomPainter
构造函数repaint
属性即可,然后在监听只需判断这个Listenable
即可。
factory Listenable.merge(List<Listenable?> listenables) = _MergingListenable;
复制代码
关键代码: 动画执行相关。
late Animation<double> animation; // 吃豆人
late Animation<double> animation2; // 豆豆
late AnimationController _controller = AnimationController(
vsync: this, duration: Duration(milliseconds: 500)); //1s
late AnimationController _controller2 = AnimationController(
vsync: this, duration: Duration(milliseconds: 1000)); //2s
//初始化吃豆人、豆豆颜色
ValueNotifier<Color> _color = ValueNotifier<Color>(Colors.yellow.shade800);
ValueNotifier<Color> _color2 =
ValueNotifier<Color>(Colors.redAccent.shade400);
// 动画轨迹
late CurvedAnimation cure = CurvedAnimation(
parent: _controller, curve: Curves.easeIn); // 动画运行的速度轨迹 速度的变化
@override
void initState() {
super.initState();
animation = Tween(begin: 0.2, end: 1.0).animate(_controller)
..addStatusListener((status) {
// dismissed 动画在起始点停止
// forward 动画正在正向执行
// reverse 动画正在反向执行
// completed 动画在终点停止
if (status == AnimationStatus.completed) {
_controller.reverse(); //反向执行 100-0
} else if (status == AnimationStatus.dismissed) {
_color.value = _color2.value;
// 获取一个随机彩虹色
_color2.value = getRandomColor();
_controller.forward(); //正向执行 0-100
// 豆子已经被吃了 从新加载豆子动画
_controller2.forward(from: 0); //正向执行 0-100
}
});
animation2 = Tween(begin: 0.2, end: 1.0).animate(_controller2);
// 启动动画 正向执行
_controller.forward();
// 启动动画 0-1循环执行
_controller2.forward();
// 这里这样重复调用会导致两次动画执行时间不一致 时间长了就不对应了
// _controller2.repeat();
}
@override
void dispose() {
_controller.dispose();
_controller2.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: CustomPaint(
size: Size(50, 50),
painter: Pain2Painter(
_color,
_color2,
animation,
animation2,
Listenable.merge([
animation,
animation2,
_color,
]),
ddSize: 8),
));
}
// 获取一个随机颜色
Color getRandomColor() {
Random random = Random.secure();
int randomInt = random.nextInt(6);
var colors = <Color>[
Colors.red,
Colors.orange,
Colors.yellow,
Colors.green,
Colors.blue,
Colors.indigo,
Colors.purple,
];
Color color = colors[randomInt];
while (color == _color2.value) {
// 重复再选一个
color = colors[random.nextInt(6)];
}
return color;
}
绘制吃豆人源码:
class Pain2Painter extends CustomPainter {
final ValueNotifier<Color> color; // 吃豆人的颜色
final ValueNotifier<Color> color2; // 豆子的的颜色
final Animation<double> angle; // 吃豆人
final Animation<double> angle2; // 豆
final double ddSize; // 豆豆大小
final Listenable listenable;
Pain2Painter(
this.color, this.color2, this.angle, this.angle2, this.listenable,
{this.ddSize = 6})
: super(repaint: listenable);
Paint _paint = Paint();
@override
void paint(Canvas canvas, Size size) {
canvas.clipRect(Offset.zero & size);
canvas.translate(size.width / 2, size.height / 2);
// 画豆豆
canvas.drawOval(
Rect.fromCenter(
center: Offset(
size.width / 2 +
ddSize -
angle2.value * (size.width / 2 + ddSize),
0),
width: ddSize,
height: ddSize),
_paint..color = color2.value);
//画头
_paint
..color = color.value
..style = PaintingStyle.fill;
var rect = Rect.fromCenter(
center: Offset(0, 0), width: size.width, height: size.height);
/// 起始角度
/// angle.value 动画控制器的值 0.2~1 0是完全闭合就是 起始0~360° 1是完全张开 起始 40°~ 280° 顺时针
var a = angle.value * 40 / 180 * pi;
// 绘制圆弧
canvas.drawArc(rect, a, 2 * pi - a * 2, true, _paint);
//画眼睛
canvas.drawOval(
Rect.fromCenter(
center: Offset(0, -size.height / 3), width: 8, height: 8),
_paint..color = Colors.black87);
canvas.drawOval(
Rect.fromCenter(
center: Offset(-1.5, -size.height / 3 - 1.5), width: 3, height: 3),
_paint..color = Colors.white);
}
@override
bool shouldRepaint(covariant Pain2Painter oldDelegate) {
return oldDelegate.listenable != listenable;
}
}
至此,一个简单的吃豆人加载Loading就完成啦。再也不要到处都是菊花转的样式了。。。
总结
通过这个加载Loading动画可以重新复习下Flutter中绘制、动画的使用的联动使用、还有多状态重绘机制,通过动画还可以改变吃豆的速度和吃豆的时间运动轨迹,有兴趣可以试试哦,希望这篇文章对你有所帮助,喜欢的话点个赞再走呗~
作者:老李code
链接:https://juejin.cn/post/7088268036804706318
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
【Flutter 组件集录】Autocomplete 自动填充
简单来说,Autocomplete
意为 自动填充
。其作用就是在输入时,进行 关键字联想
。在输入框下方展示列表,如下所示:注意,这是目前 Flutter
框架内部的组件,非三方组件。目前已收录入 FlutterUnit ,下面效果的源码详见之,大家可以更新查看体验:
FlutterUnit 中 | 输入时联想效果 |
---|---|
下面是动态搜索的效果展示:
1. Autocomplete 组件最简代码
我们先一步步来了解 Autocomplete
组件,先实现如下的最简代码:
使用 Autocomplete
时,必须提供的是 optionsBuilder
参数,另外可以通过 onSelected
回调来监听选中的条目。
Autocomplete<String>(
optionsBuilder: buildOptions,
onSelected: onSelected,
)
optionsBuilder
是一个 AutocompleteOptionsBuilder<T>
类型的函数,从下面的定义中可以发现,该函数会回调 TextEditingValue
对象,且返回 FutureOr<Iterable<T>>
。这说明这个函数是一个异步函数,我们可以在此进行网络请求,数据库查询等工作,来返回一个 Iterable<T>
的可迭代对象。
用脚指头想一下也知道,这个可迭代对象,就决定这输入框下面的联想词是哪些。
final AutocompleteOptionsBuilder<T> optionsBuilder;
typedef AutocompleteOptionsBuilder<T extends Object> =
FutureOr<Iterable<T>> Function(TextEditingValue textEditingValue);
比如下面通过 searchByArgs
模拟网络请求,通过 args
参数搜索数据,
Future<Iterable<String>> searchByArgs(String args) async{
// 模拟网络请求
await Future.delayed(const Duration(milliseconds: 200));
const List<String> data = [
'toly', 'toly49', 'toly42', 'toly56',
'card', 'ls', 'alex', 'fan sha',
];
return data.where((String name) => name.contains(args));
}
这样,buildOptions
的逻辑如下,这就完成了 输入--> 搜索 --> 展示联想词
的流程。这也是 Autocomplete
组件最简单的使用。
Future<Iterable<String>> buildOptions( TextEditingValue textEditingValue ) async {
if (textEditingValue.text == '') {
return const Iterable<String>.empty();
}
return searchByArgs(textEditingValue.text);
}
2. 自定义 Autocomplete 组件内容
其实上面那样的默认样式很丑,而且没有提供 直接
的属性设置样式。所以了解如何自定义是非常关键的,否则只是一个玩具罢了。如下,我们先来实现搜索高亮显示的自定义,其中也包括对输入框的自定义。
Autocomplete
中提供了 fieldViewBuilder
和 optionsViewBuilder
分别用于构造输入框
和 浮层面板
。
如下,代码中通过 _buildOptionsView
和 _buildFieldView
进行相应组件构造:
Autocomplete<String>(
optionsBuilder: buildOptions,
onSelected: onSelected,
optionsViewBuilder: _buildOptionsView,
fieldViewBuilder: _buildFieldView,
);
如下是 _buildOptionsView
方法的实现,其中会回调 onSelected
回调函数,和 options
数据,我们需要做的就是依靠数据,构建组件进行展示即可。另外,默认浮层面板和输入框底部平齐,可以通过 Padding
进行下移。另外,由于是浮层,展示文字时,上面需要嵌套 Material
组件。
至于高亮某个关键字,下面是我封装的一个小方法,拿来即用。
---->[高亮某些文字]----
final TextStyle lightTextStyle = const TextStyle(
color: Colors.blue,
fontWeight: FontWeight.bold,
);
InlineSpan formSpan(String src, String pattern) {
List<TextSpan> span = [];
List<String> parts = src.split(pattern);
if (parts.length > 1) {
for (int i = 0; i < parts.length; i++) {
span.add(TextSpan(text: parts[i]));
if (i != parts.length - 1) {
span.add(TextSpan(text: pattern, style: lightTextStyle));
}
}
} else {
span.add(TextSpan(text: src));
}
return TextSpan(children: span);
}
另外,对于输入框的构建,通过如下的 _buildFieldView
实现,其中有 _controller
记录一下 TextEditingController
,是因为 optionsViewBuilder
回调中并没有回调输入的 arg
字符,所以想要输入的关键字高亮,只能出此下策。这样,在 TextFormField
构建时,你可以指定自己需要的装饰。
到此,我们就实现了上面,输入过程中,浮层面板内容关键字高亮显示的效果。
3.关于 Autocomplete 中的泛型
泛型的作用非常明显,它最主要的是对浮层面板的构建,如果浮层中的条目不止是 String
,我们就需要使用泛型,来提供某个的数据类型。比如下面的效果,其中浮层面板的条目是可以显示更多的信息:
先定义一个数据类 User
,记录信息:
class User {
final String name;
final bool man;
final String image;
const User(this.name, this.man, this.image);
@override
String toString() {
return 'User{name: $name, man: $man, image: $image}';
}
}
然后在 Autocomplete
的泛型中使用 User
即可。
这样在 _buildOptionsView
中,回调的就是 User
的可迭代对象。如下。封装一个 _UserItem
组件,对条目进行显示。
4、Autocomplete 源码简看
Autocomplete
本质上依赖于 RawAutocomplete
组件进行构建,可见它是一层简单的封装,简化使用。为我们提供了默认的 optionsViewBuilder
和 fieldViewBuilder
,显示一个很丑的界面。也就是说,如果你了解如何定制这两部分内容,你也就会了 RawAutocomplete
组件。
我们先看一下 Autocomplete
对 optionsViewBuilder
提供的默认显示,其返回的是 _AutocompleteOptions
组件。如下,其实和我们自己实现的也没有太大的区别,只是个默认存在,方便使用的小玩意而已。
另外,对于输入框的构建,使用 _defaultFieldViewBuilder
静态方法完成。
该方法,返回 _AutocompleteField
组件,本质上也就是构建了一个 TextFormField
组件。
对 Autocomplete
来说,只是 RawAutocomplete
套了个马甲,本质上的功能还是在 RawAutocomplete
的状态类中完成的。如下是 _RawAutocompleteState
的部分代码,可以看出这里的浮层面板,是通过 Overlay
实现的,另外通过 CompositedTransformTarget
和 CompositedTransformFollower
对浮层进行定位。
那本文就这样,如果想要简单地实现搜索联想词,Autocomplete
是一个很不错的选择。
作者:张风捷特烈
链接:https://juejin.cn/post/7087805411377545252
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter 应用程序创建一个扩展面板列表
正文
了解如何在您的 Flutter 应用程序创建一个扩展面板列表
在本文中,我们将探讨 ExpansionPanelList In Flutter. 。我们将实施一个扩展面板列表演示程序,并学习如何自定义其风格与不同的属性在您的 Flutter 应用程序。
Expansion Panel List:
它是一个类似于 listView 的实质性 Flutter 小部件。它可以只有扩展面板作为儿童。在某些情况下,我们可能需要显示一个列表,其中子元素可以展开/折叠以显示/隐藏一些详细的数据。为了显示这样的列表 flutter,提供了一个名为 ExapansionPanelList 的小部件。
在这个列表中,每个子元素都是 expsionpanel 小部件。在这个列表中,我们不能使用不同的窗口小部件作为子窗口。我们可以借助于 expsioncallback 属性来处理事物的状态调整(扩展或崩溃)。
演示模块:
这个演示视频显示了如何在一个 Flutter 扩展面板清单。它显示如何扩展面板列表将工作在您的 Flutter 应用程序。它显示了一个列表,在这个列表中孩子们可以展开/折叠以显示/隐藏一些详细信息。它会显示在你的设备上。
Constructor:
要使用 ExpansionPanelList,需要调用下面的构造函数:
const ExpansionPanelList({
Key? key,
this.children = const <ExpansionPanel>[],
this.expansionCallback,
this.animationDuration = kThemeAnimationDuration,
this.expandedHeaderPadding = _kPanelHeaderExpandedDefaultPadding,
this.dividerColor,
this.elevation = 2,
})
Properties:
ExpansionPanelList 的一些属性如下:
- > children: 此属性用于扩展面板 List 的子元素。它们的布局类似于[ListBody]。
- > expansionCallback: 此属性用于每当按下一个展开/折叠按钮时调用的回调。传递给回调的参数是按下的面板的索引,以及面板当前是否展开。
- > animationDuration: 这个属性用于展开或折叠时,我们可以观察到一些动画在一定时间内发生。我们可以通过使用扩展面板 List 的 animationDuration 属性来更改持续时间。我们只需要提供以微秒、毫秒或分钟为单位的持续时间值。
- > expandedHeaderPadding: 此属性用于展开时围绕面板标头的填充。默认情况下,16px 的空间是在扩展期间垂直添加到标题(上面和下面)。
- > dividerColor: 当[ expsionpanel.isexpanded ]为 false 时,此属性用于定义分隔符的颜色。如果‘ dividerColor’为空,则使用[ DividerThemeData.color ]。如果为 null,则使用[ ThemeData.dividerColor ]。
- > elevation: 此属性用于在扩展时定义[ expsionpanel ]的提升。这使用[ kElevationToShadow ]来模拟阴影,它不使用[ Material ]的任意高度特性。默认情况下,仰角的值为 2。
如何实现 dart 文件中的代码:
你需要分别在你的代码中实现它:
在
lib
文件夹中创建一个名为main.dart
的新 dart 文件。
首先,我们将生成虚拟数据。我们将创建一个列表 <Map<String, dynamic>> 并添加变量 _ items 等于生成一个列表。在这个列表中,我们将添加 number、 id、 title、 description 和 isExpanded。
List<Map<String, dynamic>> _items = List.generate(
10,
(index) => {
'id': index,
'title': 'Item $index',
'description':
'This is the description of the item $index. Lorem Ipsum is simply dummy text of the printing and typesetting industry.',
'isExpanded': false
});
在正文中,我们将添加 ExpansionPanelList() 小部件。在这个小部件中,我们将添加标高为 3,在括号中添加 expsioncallback 索引和 isExpanded。我们将添加 setState ()方法。在方法中,我们将添加 _ items [ index ][‘ isexpanded’] equal not isExpanded。
ExpansionPanelList(
elevation: 3,
expansionCallback: (index, isExpanded) {
setState(() {
_items[index]['isExpanded'] = !isExpanded;
});
},
animationDuration: Duration(milliseconds: 600),
children: _items
.map(
(item) => ExpansionPanel(
canTapOnHeader: true,
backgroundColor:
item['isExpanded'] == true ? Colors._cyan_[100] : Colors._white_,
headerBuilder: (_, isExpanded) => Container(
padding:
EdgeInsets.symmetric(vertical: 15, horizontal: 30),
child: Text(
item['title'],
style: TextStyle(fontSize: 20),
)),
body: Container(
padding: EdgeInsets.symmetric(vertical: 15, horizontal: 30),
child: Text(item['description']),
),
isExpanded: item['isExpanded'],
),
)
.toList(),
),
我们将增加 animationDuration 为 600 毫秒。我们将添加子节点,因为 variable_items 映射到 expsionpanel ()小部件。在这个小部件中,我们将添加 canTapOnHeader was true,backgroundColor,headerBuilder 返回 Container ()小部件。在这个小部件中,我们将添加填充,并在其子属性上添加文本。在正文中,我们将添加 Conatiner 及其子属性,我们将添加文本。当我们运行应用程序时,我们应该获得屏幕输出,就像下面的屏幕截图一样。
全部代码
import 'package:flutter/material.dart';
import 'package:flutter_expansion_panel_list/splash_screen.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors._teal_,
),
home: Splash());
}
}
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
List<Map<String, dynamic>> _items = List.generate(
10,
(index) => {
'id': index,
'title': 'Item $index',
'description':
'This is the description of the item $index. Lorem Ipsum is simply dummy text of the printing and typesetting industry.',
'isExpanded': false
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text('Flutter Expansion Panel List Demo'),
),
body: SingleChildScrollView(
child: ExpansionPanelList(
elevation: 3,
// Controlling the expansion behavior
expansionCallback: (index, isExpanded) {
setState(() {
_items[index]['isExpanded'] = !isExpanded;
});
},
animationDuration: Duration(milliseconds: 600),
children: _items
.map(
(item) => ExpansionPanel(
canTapOnHeader: true,
backgroundColor:
item['isExpanded'] == true ? Colors._cyan_[100] : Colors._white_,
headerBuilder: (_, isExpanded) => Container(
padding:
EdgeInsets.symmetric(vertical: 15, horizontal: 30),
child: Text(
item['title'],
style: TextStyle(fontSize: 20),
)),
body: Container(
padding: EdgeInsets.symmetric(vertical: 15, horizontal: 30),
child: Text(item['description']),
),
isExpanded: item['isExpanded'],
),
)
.toList(),
),
),
);
}
}
结语
在本文中,我已经简单地解释了 ExpansionPanelList 的基本结构; 您可以根据自己的选择修改这段代码。这是一个小的介绍扩展/panellist On User Interaction 从我这边,它的工作使用 Flutter。
作者:会煮咖啡的猫
链接:https://juejin.cn/post/7088342250300571655
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
实现一个悬浮在软键盘上的输入栏
前言
我们要实现一个悬浮在软键盘上的输入栏(即一个悬浮栏),过程中遇到了很多问题,查阅了一些网上的文章,结果发现不少是错误的,走了一些弯路,这里就一一记录一下。
悬浮栏
实现悬浮栏很简单
chatInputPanel.setVisibility(View.VISIBLE);
chatInputEt.setFocusable(true);
chatInputEt.setFocusableInTouchMode(true);
chatInputEt.requestFocus();
InputMethodManager inputManager = (InputMethodManager)chatInputEt.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
inputManager.showSoftInput(chatInputEt, 0);
chatInputPanel
就是悬浮栏整个layout,mChatPanelContent
才是悬浮栏实际部分,chatInputEt
是其中的EditText,对它做一些设置就可以实现将chatInputPanel
悬浮在软件盘上。
这里chatInputPanel
是全屏的(点击mChatPanelContent
以外部分隐藏键盘),mChatPanelContent
是在它的bottom底部,默认隐藏(INVISIBLE)。
横屏时软键盘全屏
横屏时,安卓默认会将软键盘全屏显示,这样无法实现悬浮栏。所以需要取消全屏显示
在EditText中使用android:imeOptinos
可对Android自带的软键盘进行一些界面上的设置
android:imeOptions="flagNoExtractUi" //使软键盘不全屏显示,只占用一部分屏幕
android:imeOptions="actionNone" //输入框右侧不带任何提示
android:imeOptions="actionGo" //右下角按键内容为'开始'
android:imeOptions="actionSearch" //右下角按键为放大镜图片,搜索
android:imeOptions="actionSend" //右下角按键内容为'发送'
android:imeOptions="actionNext" //右下角按键内容为'下一步'
android:imeOptions="actionDone" //右下角按键内容为'完成'
所以我们为EditText设置android:imeOptions="flagNoExtractUi"
即可实现在横屏时不全屏显示。同时,可能EditText添加相应的监听器,捕捉用户点击了软键盘右下角按钮的监听事件,以便进行处理。
editText.setOnEditorActionListener(new OnEditorActionListener() {
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
Toast.makeText(MainActivity.this, "text2", Toast.LENGTH_SHORT).show();
return false;
}
});
监听软键盘(该方法不可靠,废弃,下面有靠谱的)
注意:这是网上的一个错误方法,所以特意拿出来说一下,不感兴趣直接去看(3)即可。
显示没问题了,但是软键盘隐藏的时候要求悬浮栏同步隐藏起来。
系统并没有提供监听软键盘收起的api,所以我们只能自己实现。
chatInputPanel.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
if(chatInputPanel.getBottom() > container.getRootView().getHeight() / 2){
chatInputPanel.setVisibility(View.INVISIBLE);
}
else{
chatInputPanel.setVisibility(View.VISIBLE);
}
}
});
监听chatInputPanel
(悬浮栏整体布局)的布局变化,当底部大于rootview高度一半的时候隐藏,否则显示。
因为我们的功能是横屏的,所以键盘弹起时,chatInputPanel
因为悬浮在键盘上,所以底部一定小于rootview高度(屏幕宽度)一半。
当收起键盘,chatInputPanel
会回到最底部(设置是在父布局底部),所以底部一定大于一半。
这个方法不靠谱,而且重绘会导致onGlobalLayout频繁的执行,虽然可以加上一个时间来控制,但是不推荐使用这个方式来监听软键盘,下面看看另外一种方式。
靠谱的监听软键盘的方法
上面的方法为什么不考虑,是因为全屏显示FLAG_FULLSCREEN(隐藏通知栏)导致问题
当我们需要全屏显示隐藏通知栏时,会使用FLAG_FULLSCREEN属性
getActivity().getWindow().setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN);
但是会影响上面的悬浮栏,因为发现chatInputPanel.getBottom()
始终没变化,但是我们判断显示隐藏就靠这个变化。
没变化是因为android在全屏FLAG_FULLSCREEN的处理方式导致的,全屏时软键盘会出现很多问题,这个网上有很多。
如何解决?
我们换一种方式监听软键盘即可
getActivity().getWindow().getDecorView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
Rect rect = new Rect();
rootView.getWindowVisibleDisplayFrame(rect);
int rootHeight=rootView.getRootView().getHeight();
int displayHeight=rect.height();
int diffHeight=rootHeight-displayHeight;
if(diffHeight==0){
//键盘收起
chatInputPanel.setVisibility(View.INVISIBLE);
}else{
//键盘弹出
chatInputPanel.setVisibility(View.VISIBLE);
}
}
});
通过监听根布局种的content布局的变化来判断,目前这个方法是最靠谱的。
但是还存在一个小问题,就是全屏状态下键盘会覆盖悬浮栏底部的一小部分,这个怎么办?
终极悬浮方式
上面解决了软键盘的监听问题,但是全屏状态下悬浮栏总会被遮住一部分,那怎么办?
其实这里还有一个问题,当显示键盘后,app中的布局整体被向上推起,这样导致部分组件缩小等情况。
我们要首先解决这个问题,让app的布局整体保持不动,键盘覆盖在其上面,这需要在弹起键盘前手动设置一下,如下:
mChatInput.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
DisplayMetrics metric = new DisplayMetrics();
getActivity().getWindowManager().getDefaultDisplay().getMetrics(metric);
chatInputPanel.setY(-metric.heightPixels);//解决首次可能向上推的问题
chatInputEt.setFocusable(true);
chatInputEt.setFocusableInTouchMode(true);
chatInputEt.requestFocus();
InputMethodManager inputManager = (InputMethodManager)chatInputEt.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
inputManager.showSoftInput(chatInputEt, 0);
}
});
这样将悬浮栏移到了最顶部以上,就不会出现上推的情况了(猜测与键盘的机制有关,因为键盘弹出如果遮挡了有焦点的输入组件就好重新调整窗口,我们将悬浮窗放在最上面,键盘怎么也不会遮挡到焦点的EditText,所以不会重新调整窗口)。
但是这样悬浮栏就一直看不见了,而且我们可以看到在这里去掉了chatInputPanel.setVisibility(View.VISIBLE);
代码,那么如何显示?
上面我们提到使用OnGlobalLayoutListener方式监听键盘,我们就在这里显示即可,同时优化一下显示的位置,在这里计算窗口显示区域上移多少,让chatInputPanel也上移相应位置即可,如:
private int mLastHeight = 0;
getActivity().getWindow().getDecorView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
Rect rect = new Rect();
getActivity().getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
int height = rect.height();
int rawHeight = getResources().getDisplayMetrics().heightPixels - rect.top;
if (height == mLastHeight)
return;
if (height < rawHeight) {
UiThreadHandler.postDelayed(new Runnable() {
@Override
public void run() {
chatInputPanel.setVisibility(View.VISIBLE);
chatInputPanel.setTranslationY(-(rawHeight - height));
}
}, 200);
} else {
UiThreadHandler.postDelayed(new Runnable() {
@Override
public void run() {
chatInputPanel.setVisibility(View.GONE);
}
}, 100);
}
mLastHeight = height;
}
});
可以看到先得到当前窗口的显示高度和屏幕的实际高度(窗口部分)
然后先判断窗口显示区域是否变化了,如果没变化则不处理。
如果有变化,则判断变大还是变小了。
如果变小了
说明键盘弹起,这时候显示chatInputPanel,同时设置translationY为-(rawHeight - height)
首先chatInputPanel初始位置底部是与屏幕底部对齐的,虽然设置了setY,但是setY实际上就是setTranslationY,初始位置没变,源码:
public void setY(float y) {
setTranslationY(y - mTop);
}
而弹起键盘后想要显示在键盘以上,那么就需要从最底部向上移动一个键盘的高度,键盘高度就是rawHeight - height,所以向上移动是将translationY设置为-(rawHeight - height)。
如果变大了
说明键盘收起,隐藏chatInputPanel即可。
这样不仅解决了窗口推起的问题,也同时解决了软键盘遮挡部分悬浮栏的问题,因为悬浮栏的位置是通过计算得到的,不是通过软键盘上推导致布局调整而改变位置的。
最终代码
最后想将这个形成一个独立的组件,直接可用,再编写过程中发现出现好多问题,解决所有问题后发现与上面的代码都不一样,不过思路是一致的,只不过细节调整了,比如获取键盘高度等。
作者:BennuCTech
链接:https://juejin.cn/post/7083315630841004068
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
优雅读取Activity的Intent、Fragment的Argument
属性委托实现方式有两种,这里直接通过实现接口的形式实现:
var
修饰的属性实现属性委托需要实现ReadWriteProperty
接口val
修饰的属性实现属性委托需要实现ReadOnlyProperty
接口
这里由于我们只需要读取值,所以直接实现ReadOnlyProperty
接口即可。下面直接上Activity的Intent
委托读取代码 :
class IntentWrapper<T>(private val default: T) : ReadOnlyProperty<AppCompatActivity, T?> {
override fun getValue(thisRef: AppCompatActivity, property: KProperty<*>): T? {
return when(default) {
is Int -> thisRef.intent.getIntExtra(property.name, default)
is String -> thisRef.intent.getStringExtra(property.name)
else -> throw Exception()
} as? T
}
}
需要注意,在这里读取Activity的Intent
使用的key默认为属性名称:property.name
,这也就意味着通过Intent
存储值的时候key也要使用属性名称。
如果需要读写Intent
的话key
不想要使用属性名称,那就得对这个属性委托类IntentWrapper
稍微改造下,构造方法中支持从外部传入key
键值
上面的属性委托类IntentWrapper
中,只是简单处理了String
和Int
类型,其他的类型Boolean
、Float
等类型需要请自行添加。
看下使用:
private val data by IntentWrapper(56)
//读
printlin(data)
上面使用起来还是有一丢丢不优雅,每次都得手动创建IntentWrapper
并传入默认值,我们可以封装几个常用类型的方法,实现起来更加方便:
fun intIntent(default: Int = 0) = IntentWrapper(default)
fun stringIntent(default: String = "") = IntentWrapper(default)
intIntent()
方法给了一个默认值为0,外部可以选择性传入的默认值,其他的类型也是一样处理。
然后就可以这样使用:
private val data by intIntent()
上面主要展示的是读取Activity的Intent
,Fragment的Argument
处理类似:
class ArgumentWrapper<T>(private val default: T) : ReadOnlyProperty<Fragment, T?> {
override fun getValue(thisRef: Fragment, property: KProperty<*>): T? {
return when(default) {
is Int -> thisRef.arguments?.getInt(property.name, default)
is String -> thisRef.arguments?.getString(property.name)
else -> throw Exception()
} as? T
}
}
使用起来也和Activity类似,这里就不做展示了。当然了,这里可以定义几个常用类型的方法创建ArgumentWrapper
,参考上面Activity的处理即可。
后续准备出一篇文章,从类委托
的角度考虑封装下Activity的Intent、Fragment的Argument
的读取。
作者:长安皈故里
链接:https://juejin.cn/post/7084418325878407181
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
解析 InheritedWidget
概览
打开源码可以看到对 InheritedWidget的解释
Base class for widgets that efficiently propagate information down the tree
--在UI树上能够有效传递信息的一个基本组件。
我们可以使用这个类来定制一个组件来传递我们需要在整个界面的状态传递。可以在官方的介绍上看到一个案例:
###### 创建一个继承InheritedWidget的组件
其中我们会使用一个惯例的写法,创建一个 static 的方法 of 去获得他对应的InheritedWidget。此方法会间接的调用 BuildContext的
dependOnInheritedWidgetOfExactType方法,范型传入我们当前的 FrogColor组件。
///创建一个InheritedWidget的继承类,用于管理 color 信息的传递
class FrogColor extends InheritedWidget {
const FrogColor({Key key, @required this.color, @required Widget child})
: assert(color != null),
assert(child != null),
super(key: key, child: child);
final Color color;
///一般的写法,官方推荐
static FrogColor of(BuildContext context) {
///这个方法做了什么事情,待会会进行分析解释!
return context.dependOnInheritedWidgetOfExactType<FrogColor>();
}
@override
bool updateShouldNotify(covariant FrogColor oldWidget) {
return color != oldWidget.color;
}
}
当然我们有时候也可能会直接使用一个Wiget去包裹一个InheritedWidget去传递组件的状态,典型的一个就是Theme组件,参考Theme的源码,我们知道他内部包裹了 _InheritedTheme 和 CupertinoTheme 并且 在 他的字组件中传递了 ThemeData 。所以我们能够很方便的全局的去修改我们的主题。
使用这个InheritedWidgt进行信息的传递
这里我们使用了一个Builder来创建需要使用FrogColor配置信息的子组件。其实我们使用的这个context就是一个Element,这个Element属于FrogColor的Element中的子Element。这一点很重要(关于context的知识需要参考下 Builder的源码)。
class MyPage extends StatelessWidget{
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: FrogColor(
color: Colors.green,
child: Builder(builder: (BuildContext context){
//这里我门打印了一下context 。 Builder(dirty, dependencies: [FrogColor])
//这里打印的是 Widget的一些信息,其实最终调用的是Element的 toString 方法
/// @override
/// String toStringShort() {
/// return widget != null ? widget.toStringShort() : '[${objectRuntimeType(this, 'Element')}]';
/// }
print(context);
print(context as Element) ///并不会报错
return Text("This is InnerText" ,style: TextStyle(
color: FrogColor.of(context).color
));
}),
),
);
}
}
为什么我们需要将需要FrogColor信息的组件外面包裹一层Builder,因为 FrogColor.of(context) 这个context 必须是 FrogColor的子 context
(其实是Element),若是使用 MyPage的context , 这个context 位于FrogColor的父级,那么我们需要的效果肯定会达不到的。
为什么会出现这样的效果,我们应该从 我们 惯例定义的 static 方法 中说起。
dependOnInheritedWidgetOfExactType
这个方法我们调用的是 BuildContext.dependOnInheritedWidgetOfExactType , 其中唯一实现这个方法的类就是 Element,我们看 Element
中的这个方法的实现:
- 去 _inheritedWidgets 中找输入范型对应的 InheritedElement,对应 FrogColor中的Element。在FrogColorElement创建的时候会将其加入到 _inheritedWidgets 中。
- 在当前 Builder对应的Element中的_dependencies加入 找到的 FrogColorElement ,这样两者就建立了相关联系。
- ancestor.updateDependencies(this, aspect) 在 FrogColorElement更新依赖关系。
///from Eelement
@override
T dependOnInheritedWidgetOfExactType<T extends InheritedWidget>({Object aspect}) {
assert(_debugCheckStateIsActiveForAncestorLookup());
//还记得我么给 这个 方法传入了一 FrogColor的范型。这里 _inheritedWidgets 将类型作为Key 查找 对应的 Element;
/// _inheritedWidgets 这个 变量 会在Element 的 mount 方法 和 active 方法中调用,就是:如果当前的Elemnt属于InheritedElement便将此Element放入 _inheritedWidgets 中,不是的话向上追溯加入。
final InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[T];
///查到的情况下
if (ancestor != null) {
assert(ancestor is InheritedElement);
/// 关键点 : 添加依赖关系
return dependOnInheritedElement(ancestor, aspect: aspect) as T;
}
_hadUnsatisfiedDependencies = true;
return null;
}
/// from Element 添加依赖关系
@override
InheritedWidget dependOnInheritedElement(InheritedElement ancestor, { Object aspect }) {
assert(ancestor != null);
_dependencies ??= HashSet<InheritedElement>();
///在本Element的依赖链中添加,一切的操作都在本Element中操作而非 FrogColor对应的Element中,
///这也是为什么我们在前面的打印中看到了 : Builder(dirty, dependencies: [FrogColor])
_dependencies.add(ancestor);
///刷新依赖关系(这个依赖关系刷新的是 FrogColor中的依赖关(这个一会再讲)
ancestor.updateDependencies(this, aspect);
return ancestor.widget;
}
分析到这一步,其实我们便可以使用 InheritedWidget提供的一些属性信息,但是当我们更新数据的时候(比如:FrogColor 动态更新 color属性),若是子组件的Widget属性没有发生变化(参考 setState()原理)的时候,子组件是不会发生变化的。也就是说只能在第一次构建的时候生效,那么我们全局修改主题,全局修改 ForgColor 的属性怎么办。我们需要解析 ancestor.updateDependencies(this, aspect) 内在都干了什么,怎么运行的。
InHeritedWidget 动态更新
经过以上的分析,我们知道,在 添加依赖关系的时候,会分别在自己的 _dependencies 中添加父级,然后在父级中的 _dependencies 添加 调用的Element。为什么要添加这种依赖关系?我们应分析当 Element更新的时候对这种依赖关系操作了什么。
熟悉Element更新原理的情况下(不知道顺着思路走)我们知道:属性更改后会调用update方法。而 InheritedElement 继承在 ProxyElement ,我们来分析 ProxyElement(这个类里面包含了通知客户端更新的定义方法) 中的update 做了什么。
/// from ProxyElement
@override
void update(ProxyWidget newWidget) {
final ProxyWidget oldWidget = widget;
assert(widget != null);
assert(widget != newWidget);
super.update(newWidget);
assert(widget == newWidget);
///调用 updated 方法
updated(oldWidget);
_dirty = true;
rebuild();
}
@protected
void updated(covariant ProxyWidget oldWidget) {
/// 通知Clients ; 这个方法在ProxyElement中是空的
notifyClients(oldWidget);
}
ProxyElement最终会调用 notifyClients方法,这个方法在 InhertedElement中进行了实现。
/// from InhertedElement
/// Notifies all dependent elements that this inherited widget has changed, by
/// calling [Element.didChangeDependencies].
@override
void notifyClients(InheritedWidget oldWidget) {
assert(_debugCheckOwnerBuildTargetExists('notifyClients'));
//查找所有的依赖关系
for (final Element dependent in _dependents.keys) {
///断言==一些判断。
assert(() {
// check that it really is our descendant
Element ancestor = dependent._parent;
while (ancestor != this && ancestor != null)
ancestor = ancestor._parent;
return ancestor == this;
}());
// check that it really depends on us
assert(dependent._dependencies.contains(this));
///通知依赖
notifyDependent(oldWidget, dependent);
}
}
}
/// from InhertedElement
@protected
void notifyDependent(covariant InheritedWidget oldWidget, Element dependent) {
///调用 element的 didChangeDependencies 方法;
dependent.didChangeDependencies();
}
///from Element
@mustCallSuper
void didChangeDependencies() {
assert(_active); // otherwise markNeedsBuild is a no-op
assert(_debugCheckOwnerBuildTargetExists('didChangeDependencies'));
/// 标记需要重新进行绘制;
markNeedsBuild();
}
总结一句话:重新构建的时候,InheritedElement将_dependencies中的ELement全部标记为 重新构建。这样,当 InheritedWidget的属性发生变化的时候,便会很快的有目标的将使用到它属性的Widget进行更新。
这也是为什么很多基于组件基于InheritedWidget,这样的效率很高,而且使用起来很方便。一些状态管理框架也是基于InheritedWIdget进行了一定的封装。
PS:_inheritedWidgets的注册和继承
上面有一点我们是一句话带过的 :在FrogColorElement创建的时候会将其加入到 _inheritedWidgets 中。
这里我们需要分析 Element的mount方法中执行了什么操作;
/// from Element
@mustCallSuper
void mount(Element parent, dynamic newSlot) {
////---省略
///最终调用
_updateInheritance();
assert(() {
_debugLifecycleState = _ElementLifecycle.active;
return true;
}());
}
/// from Element 若不是InheritedElement的情况下,会查找父级的 _inheritedWidgets
void _updateInheritance() {
assert(_active);
_inheritedWidgets = _parent?._inheritedWidgets;
}
/// from InheritedElement 若是InheritedElement的情况下
@override
void _updateInheritance() {
assert(_active);
/// 将父级的拿过来
final Map<Type, InheritedElement> incomingWidgets = _parent?._inheritedWidgets;
if (incomingWidgets != null)
_inheritedWidgets = HashMap<Type, InheritedElement>.from(incomingWidgets);
else
_inheritedWidgets = HashMap<Type, InheritedElement>();
/// 加入自己
_inheritedWidgets[widget.runtimeType] = this;
}
总结:通过 _updateInheritance 查找上级的 _inheritedWidgets 若本Element是InheritedElement那么将自己加入进去。
作者:szhua32175
链接:https://juejin.cn/post/7087404404105543693
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
用compose撸一个雷达图
介绍
项目中需要使用雷达图来展示各个属性的不同比例,文字根据控件大小自动换行。
效果图
如何实现
1、绘制背景的三个圆形
从外圆向内圆绘制,这样内圆的颜色正确覆盖在外圆上,style = Stroke(2f)
用来绘制圆形的border。
val CIRCLE_TURN = 3
val center = Offset(size.width / 2, size.height / 2)
val textNeedRadius = 25.dp.toPx() // 文本绘制范围
val radarRadius = center.x - textNeedRadius
val turnRadius = radarRadius / CIRCLE_TURN
for (turn in 0 until CIRCLE_TURN) {
drawCircle(colors[turn], radius = turnRadius * (CIRCLE_TURN - turn))
drawCircle(colors[3], radius = turnRadius * (CIRCLE_TURN - turn), style = Stroke(2f))
}
2、绘制圆环内的虚线
使用360/data.size
算出每个区块需要的角度。
我们知道,竖直向上为-90
度,当区块数量为奇数时,第一条虚线应当在竖直方向上,即起始绘制角度为-90
度;当区块数量为偶数时,虚线绘制应当左右对称,所以将初始角度设置为-90 - itemAngle / 2
。inCircleOffset()
是用来获取在圆形中的xy位置,点击kotlin.math.cos()/sin()
查看方法的描述Computes the cosine of the angle x given in radians
可知,我们需要传入一个弧度,角度换算弧度的推导如下。
val itemAngle = 360 / data.size
val startAngle = if (data.size % 2 == 0) {
-90 - itemAngle / 2
} else {
-90
}
for (index in data.indices) {
// 绘制虚线
val currentAngle = startAngle + itemAngle * index
val xy = inCircleOffset(center, progress * radarRadius, currentAngle)
drawLine(colors[4], center, xy, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
}
/**
* 根据圆心,半径以及角度获取圆形中的xy坐标
*/
fun DrawScope.inCircleOffset(center: Offset, radius: Float, angle: Int): Offset {
return Offset((center.x + radius * cos(angle * PI / 180)).toFloat(), (center.y + radius * sin(angle * PI / 180)).toFloat())
}
3、绘制雷达范围
在最大值为100的情况下,根据bean的value换算出应当绘制点的radius。并算出对应的xy的位置,将其记录到path中方便连成闭合区间绘制。
data class RadarBean(
val text: String,
val value: Float
)
for (index in data.indices) {
val pointData = data[index]
val pointRadius = radarRadius * pointData.value / 100
val fixPoint = inCircleOffset(center, pointRadius, currentAngle)
if (index == 0) {
path.moveTo(fixPoint.x, fixPoint.y)
} else {
path.lineTo(fixPoint.x, fixPoint.y)
}
}
drawPath(path, colors[5]) // 绘制闭合区间
drawPath(path, colors[6], style = Stroke(5f)) // 绘制区间的深色描边
4、绘制文字位置
接下来就是绘制最重要的文字的位置啦,首先我们先了解什么是StaticLayout,这里面有1.4小节介绍StaticLayout是如何使用的。 观察效果图,我们先分析出位置的绘制规律:
- 垂直方向的文字x轴在文字宽度的正中间,y轴在文字的底部
- 水平方向的文字x轴与y轴皆在文字的正中间
- 左上角的文字x轴在文字的最右边,y轴在最后一行文字的中间
- 右上角的文字x轴在文字的最左边,y轴在最后一行文字的中间
- 左下角的文字x轴在文字的最右边,y轴在第一行文字的中间
- 右下角的文字x轴在文字的最左边,y轴在第一行文字的中间
根据以上规律,需要对文字绘制区域进行区分:
private fun quadrant(angle: Int): Int {
return if (angle == -90 || angle == 90) {
0 // 垂直
} else if (angle == 0) {
-1 // 水平右边
} else if (angle == 180) {
-2 // 水平左边
} else if (angle > -90 && angle < 0) {
1 // 右上角
} else if (angle > 0 && angle < 90) {
2 // 右下角
} else if (angle > 90 && angle < 180) {
3 // 左下角
} else {
4 // 左上角
}
}
设置文本的最大宽度:绿色
虚线为左半边的文字最大宽度,蓝色
虚线为右半边的文字最大宽度。通过quadrant(currentAngle)
获取文字需要绘制的区域,垂直区域的文字最大宽度设置为雷达控件的一半,绿色虚线的文字最大宽度为offset.x
,蓝色虚线的文字最大宽度为size.width - offset.x
。
fun DrawScope.wrapText(
text: String, // 绘制的文本
textPaint: TextPaint, // 文字画笔
width: Float, // 雷达控件的宽度
offset: Offset, // 未调整前的文字绘制的xy位置
currentAngle: Int, // 当前文字绘制所在的角度
chineseWrapWidth: Float? = null // 用来处理UI需求中文每两个字符换行
) {
val quadrant = quadrant(currentAngle)
var textMaxWidth = width
when (quadrant) {
0 -> {
textMaxWidth = width / 2
}
-1, 1, 2 -> {
textMaxWidth = size.width - offset.x
}
-2, 3, 4 -> {
textMaxWidth = offset.x
}
}
}
创建StaticLayout,传入文本绘制的最大宽度textMaxWidth
,该控件会根据设置的最大宽度对文本自动换行。
val staticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
StaticLayout.Builder.obtain(text, 0, text.length, textPaint, textMaxWidth.toInt()).apply {
this.setAlignment(Layout.Alignment.ALIGN_NORMAL)
}.build()
} else {
StaticLayout(text, textPaint, textMaxWidth.toInt(), Layout.Alignment.ALIGN_NORMAL, 1.0f, 0f, false)
}
通过staticLayout获取文本的高度,文本的行数。这里不能使用staticLayout.width
来获取文本的宽度,因为假设设置的textMaxWidth=100,而文本绘制后的宽度只有50,通过staticLayout.width
获取的宽度为100,这不是我们想要的。所以通过lines>1
来判断文本是否换行,如果未换行,直接通过textPaint.measureText
获取文本的真实宽度;如果换行,则staticLayout.getLineWidth(0)
用来获取文本第一行的宽度就是文本的真实宽度。
val textHeight = staticLayout.height
val lines = staticLayout.lineCount
val isWrap = lines > 1
val textTrueWidth = if (isWrap) staticLayout.getLineWidth(0) else textPaint.measureText(text)
使用canvas绘制文本,这里的save() translate() staticLayout.draw(canvas) restore()
是使用StaticLayout绘制的四步曲。
// 绘制文字
val textPointRadius = progress * radarRadius + 10f
val offset = inCircleOffset(center, textPointRadius, currentAngle)
val text = data[index].text
wrapText(
text,
textPaint,
size.width,
offset,
currentAngle,
if (specialHandle) textPaint.textSize * 2 else null
)
drawContext.canvas.nativeCanvas.save()
when (quadrant) {
0 -> { // 规律1
drawContext.canvas.nativeCanvas.translate(offset.x - textTrueWidth / 2, offset.y - textHeight)
}
-1 -> { // 规律2
drawContext.canvas.nativeCanvas.translate(offset.x, offset.y - textHeight / 2)
}
-2 -> { // 规律2
drawContext.canvas.nativeCanvas.translate(offset.x - textTrueWidth, offset.y - textHeight / 2)
}
1 -> { // 规律4
drawContext.canvas.nativeCanvas.translate(
offset.x,
if (!isWrap) offset.y - textHeight / 2 else offset.y - (textHeight - textHeight / lines / 2)
)
}
2 -> { // 规律6
drawContext.canvas.nativeCanvas.translate(offset.x, if (!isWrap) offset.y - textHeight / 2 else offset.y - textHeight / lines / 2)
}
3 -> { // 规律5
drawContext.canvas.nativeCanvas.translate(
offset.x - textTrueWidth,
if (!isWrap) offset.y - textHeight / 2 else offset.y - textHeight / lines / 2
)
}
4 -> { // 规律3
drawContext.canvas.nativeCanvas.translate(
offset.x - textTrueWidth,
if (!isWrap) offset.y - textHeight / 2 else offset.y - (textHeight - textHeight / lines / 2)
)
}
}
staticLayout.draw(drawContext.canvas.nativeCanvas)
drawContext.canvas.nativeCanvas.restore()
这样就画好了,但是产品看完效果图后不喜欢换行的效果,希望每两个字就换行,于是新增如下判断。
// 需要特殊处理换行&&包含中文字符&&文本绘制一行的宽度>文本最大宽度
if (chineseWrapWidth != null && isContainChinese(text) && textPaint.measureText(text) > textMaxWidth) {
textMaxWidth = chineseWrapWidth
}
private fun isContainChinese(str: String): Boolean {
val p = Pattern.compile("[\u4e00-\u9fa5]")
val m = p.matcher(str)
return m.find()
}
5、增加个小动画
当雷达图从屏幕中出现的时候,做一个绘制值从0到实际值的动画
var enable by remember {
mutableStateOf(false)
}
val progress by animateFloatAsState(if (enable) 1f else 0f, animationSpec = tween(2000))
Modifier.onGloballyPositioned {
enable = it.boundsInRoot().top >= 0 && it.boundsInRoot().right > 0
}
如何使用
private val list = listOf(
RadarBean("基本财务", 43f),
RadarBean("基本财务财务", 90f),
RadarBean("基", 90f),
RadarBean("基本财务财务", 90f),
RadarBean("基本财务", 83f),
RadarBean("技术择时择时", 50f),
RadarBean("景气行业行业", 83f)
)
ComposeRadarView(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(180.dp),
list
)
项目地址
最后贴上项目的地址:ComposeRadar
如果觉得对您有帮助就点个👍吧~
作者:Loren
链接:https://juejin.cn/post/7083055968703119373
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Android自定义View- 画一幅实时心电测量图
概述
这次来讲讲心电图的绘制,这也是项目当中用到过的。心电图继承自View,概括一下主要有以下内容要实现:**实时显示动态心电测量数据、心电波形左右滑动、惯性滑动及波形 X轴和 Y轴方向双指滑动缩放。**下面我们来看看效果图,图片上传大小有限制,所以分两张:
Screenrecorder-2021-08-09-18-44-54-1282021891847387.gif
ECG_2.gif
下面我们将功能拆解,分步实现:
- 画背景绿色网格线
- 绘制实时动态心电曲线
- 实现单指曲线左右平移
- 实现曲线惯性滑动
- 实现 X轴及 Y轴方向上曲线的双指滑动缩放(多点触控改变曲线增益)
- 左上角显示当前增益
1、画网格线
这个就比较简单了。首先确定每一小格的边长,然后获取控件宽高。这样就能分别计算出水平方向及竖直方向有多少小格,也就是可以确定横线和竖线一共要画多少条。然后就可以用循环画出所有的线条,其中每隔5条进行线条加粗,而且画实线,这样就形成了实线大格。下面先看实现:
// 画 Bitmap
protected Bitmap gridBitmap;
// 画 Canvas
protected Canvas bitmapCanvas;
// 控件宽高
protected int viewWidth, viewHeight;
@Override
protected void onSizeChange() {
// 获取控件宽高
viewWidth = mBaseChart.getWidth();
viewHeight = mBaseChart.getHeight();
// 初始化网格 Bitmap
gridBitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888);
bitmapCanvas = new Canvas(gridBitmap);
Log.d(TAG, "onSizeChange - " + "-- width = " +
mBaseChart.getWidth() + "-- height = " + mBaseChart.getHeight());
}
/**
* 准备好画网格的 Bitmap
*/
private void initBitmap(){
// 计算横线和竖线条数
hLineCount = (int) (viewHeight / gridSpace) + 2;
vLineCount = (int) (viewWidth / gridSpace) + 2;
// 画横线
for (int h = 0; h < hLineCount; h ++){
float startX = 0f;
float startY = gridSpace * h;
float stopX = viewWidth;
float stopY = gridSpace * h;
// 每个 5根画一条粗实线
if (h % 5 != 0){
linePaint.setPathEffect(pathEffect);
linePaint.setStrokeWidth(1.5f);
}else {
linePaint.setPathEffect(null);
linePaint.setStrokeWidth(3f);
}
// 画线
bitmapCanvas.drawLine(startX, startY, stopX,stopY, linePaint);
}
// 画竖线
for (int v = 0; v < vLineCount; v ++){
float startX = gridSpace * v;
float startY = 0f;
float stopX = gridSpace * v;
float stopY = viewHeight;
// 每隔 5根画一条粗实线
if (v % 5 != 0){
linePaint.setPathEffect(pathEffect);
linePaint.setStrokeWidth(1.5f);
}else {
linePaint.setPathEffect(null);
linePaint.setStrokeWidth(3f);
Log.d(TAG, "v = " + v);
}
// 画线
bitmapCanvas.drawLine(startX, startY, stopX,stopY, linePaint);
}
}
@Override
protected void onDraw(Canvas canvas) {
// 注释 1,Bitmap左边缘位置为getScrollX(),防止网格滑动
canvas.drawBitmap(gridBitmap, mBaseChart.getScrollX(), 0, null);
}
这里想提一下的是,这里网格线并不是直接画在控件 onDraw方法的 Canvas上的。而是在控件初始化时,事先将网格所有线条画在一张 Bitmap上,然后绘制时直接绘制 Bitmap。这样搞就不用每次绘制时都计算一遍线条的位置了。
还有就是上面注释 1处,绘制网格 Bitmap的左边缘的位置是 getScrollX()。因为后面要实现曲线左右滑动,但网格要固定不动。
2、绘制动态实时心电曲线
这就是心电图最主要的实现了。心电在测量的时候会实时传递电压值,我们需要把电压值实时存进数组里。然后把电压值换算成 Y坐标值,再根据事先确定好的 X轴方向两个数据点的距离来确定每个电压值在 X轴方向的坐标。然后从左到右确定曲线的路径Path,再将Path绘制到Canvas上就可以了。
我们观察上面效果图会发现,这里的实现是最后一个到达的数据的显示不会超过控件右边缘。也就是当曲线 X方向的长度不超过控件宽度时,曲线第一个点的横坐标 x = 0。当曲线 X方向长度大于控件宽度时,曲线 Path的第一个点的横坐标就向左移,也就是 x为负的了。这样就实现上面效果中,测量实时心电时,曲线会向左移。这样新来的数据就显示在控件可见范围内,早来的数据逐步向左移出控件可见范围。下面画个草图吧,草图大概就这么个意思:
心电.png
下面看一下实现:
/**
* 创建曲线
*/
private boolean createPath() {
// 曲线长度超过控件宽度,曲线起点往左移
// 根据控件宽度和数组长度以及 X增益算出数组第一个数的 X坐标
float startX = (this.data.size() * dataSpaceX > viewWidth) ?
(viewWidth - (this.data.size() * dataSpaceX)) : 0f;
// 曲线复位
dataPath.reset();
for (int i = 0; i < this.data.size(); i++) {
// 确定 X轴坐标
float x = startX + i * this.dataSpaceX;
// 确定 Y轴坐标
float y = getVisibleY(this.data.get(i));
// 绘制曲线
if (i == 0) {
dataPath.moveTo(x, y);
} else {
dataPath.lineTo(x, y);
}
}
return true;
}
/**
* 电压 mv(毫伏)在 Y轴方向的换算
* 屏幕向上往下是 Y 轴正方向,所以电压值要乘以 -1进行翻转
* 目前默认每一大格代表 1000 mv,而真正一大格的宽度只有 150,所以 data要以两数换算
* Y == 0,是在 View的上边缘,所以要向下偏移将波形显示在中间
*
* @param data
* @return
*/
// 注释 2
private float getVisibleY(int data) {
// 电压值换算成 Y值
float visibleY = -smallGridSpace * 5 / mvPerLargeGrid * data;
// 向下偏移
visibleY = visibleY + smallGridSpace * 5 * offset;
return visibleY;
}
@Override
protected void onDraw(Canvas canvas) {
// 绘制心电曲线
canvas.drawPath(dataPath, linePaint);
}
上面有一点需要注意的,就是我们的 Y值的换算。我们知道Android屏幕自上而下是 Y轴正方向,所以我们如果直接把电压值画在屏幕上它是倒挂的。另外,这里默认的一大格代表1000mv电压值(可设),而真正一大格的边长是150。所以我们需要将电压值换算成屏幕像素。具体看上面注释 2的getVisibleY方法上面注释。
3、实现曲线左右平移
当心电测量完之后,我们需要实现曲线随手指滑动平移。这样才能看到心电图的全部内容。这个实现原理也简单,也就是监听onTouch事件,根据手指位移使用View的scrollBy方法来实现内容平移就可以了:
/**
* @param event 单指事件
*/
private void singlePoint(MotionEvent event) {
mVelocityTracker.addMovement(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
lastX = event.getX();
break;
case MotionEvent.ACTION_MOVE:
float deltaX = event.getX() - lastX;
delWithActionMove(deltaX);
lastX = event.getX();
break;
case MotionEvent.ACTION_UP:
// 计算滑动速度
computeVelocity();
break;
}
}
/**
* @param deltaX 处理 MOVE事件
*/
private void delWithActionMove(float deltaX) {
if (this.data.size() * dataSpaceX <= viewWidth) return;
int leftBorder = getLeftBorder(); // 左边界
int rightBorder = getRightBorder(); // 右边界
int scrollX = mBaseChart.getScrollX(); // X轴滑动偏移量
if ((scrollX <= leftBorder) && (deltaX > 0)) {
mBaseChart.scrollTo((int) (viewWidth - this.data.size() * dataSpaceX), 0);
} else if ((scrollX >= rightBorder) && (deltaX < 0)) {
mBaseChart.scrollTo(0, 0);
} else {
// 内容平移
mBaseChart.scrollBy((int) -deltaX, 0);
}
}
注意上面左右边界的设定,别让曲线划出屏幕了。
4、惯性滑动
惯性滑动的实现,这里使用的套路是 VelocityTracker。先追踪手指滑动速度,然后使用 Scroller并结合 View的 computeScroll()方法和 scrollTo方法,实现手指离开屏幕后的惯性滑动。这部分内容在我上一篇文章画一个FM调频收音机刻度表
有讲,这里不再重复。
5、实现双指滑动,在横纵坐标方向缩放曲线
在实现双指滑动曲线缩放功能之前,我们先讲讲一小部分 MotionEvent的基础知识。为什么说只讲一小部分呢?因为 MotionEvent这个事件体系还蛮大。我们只讲一下这次用到的部分。
onTou.png
onTouch2.png
好吧,还是直接画表格吧。这样也直观一点,不用解释那么多。上面红色圈圈圈出来的几个哥们是我们这次要用到的。
event.getActionMasked() :上面也有解释,这个方法和 getAction()类似。只不过我们这次要处理多点触控,所以一定要用 getActionMasked() 来获取事件类型。
event.getPointerCount() :上面也有解释,获取屏幕上手指个数。因为我们这次要处理双指滑动,所以要用 (getPointerCount() == 2)进行判断。两根手指以外的事件我们不做缩放处理。
ACTION_POINTER_DOWN :上面又有解释,第一根手指之后,按下的其他手指。如果结合 (getPointerCount() == 2)这个前提条件,那么我们可以认为这次ACTION_POINTER_DOWN 就是第二根手指按下所触发的事件。
event.getX(int pointerIndex):上面也有介绍,获取某个手指当前的 X坐标。我们在获取到两个手指当前的 X坐标之后,就可以算出两指当前在 X轴方向的距离。然后再结合 ACTION_POINTER_DOWN 时所记录的坐标值,就可以计算出两个手指在 X方向上是靠近了还是疏远了(收缩了还是放大了)。getY(int pointerIndex) 方法同理,不做解释了。
ACTION_MOVE :两指滑动当然也要用到 MOVE事件,只不过这里 ACTION_MOVE 和单指的使用方法一样,就不做解释了。
好了,我们再看看 X轴方向缩放具体实现吧:
/**
* 处理onTouch事件
*
* @param event 事件
* @return 拦截
*/
@Override
protected boolean onTouchEvent(MotionEvent event) {
Log.d(TAG, "pointerCount = " + event.getPointerCount());
if (event.getPointerCount() == 1) { // 单指平滑
singlePoint(event);
}
if (event.getPointerCount() == 2) { // 双指缩放
doublePoint(event);
}
return true;
}
/**
* @param event 双指事件
*/
private void doublePoint(MotionEvent event) {
if (pointOne == null) pointOne = new PointF();
if (pointTwo == null) pointTwo = new PointF();
switch (event.getActionMasked()) {
case MotionEvent.ACTION_POINTER_DOWN: // 第二根手指按下
Log.d(TAG, "ACTION_POINTER_DOWN");
// 记录第二根手指按下时,两指的坐标点
saveLastPoint(event);
numbersPerLargeGridOnThisTime = getDataNumbersPerGrid();
mvPerLargeGridOnThisTime = getMvPerLargeGrid();
break;
case MotionEvent.ACTION_MOVE: // 双指拉伸
Log.d(TAG, "ACTION_MOVE");
// 计算 X方向缩放量
getScaleX(event);
// 计算 Y轴方向所放量
getScaleY(event);
break;
case MotionEvent.ACTION_POINTER_UP: // 先离开的手指
Log.d(TAG, "ACTION_POINTER_UP");
break;
}
}
/**
* 处理 X方向的缩放
*
* @param event 事件
* @return 拉伸量
*/
private float getScaleX(MotionEvent event) {
float pointOneX = event.getX(0);
float pointTwoX = event.getX(1);
// 算出 X轴方向的拉伸量
float deltaScaleX = Math.abs(pointOneX - pointTwoX) - Math.abs(pointOne.x - pointTwo.x);
// 设置拉伸敏感度
int inDevi = mBaseChart.getWidth() / 54;
// 计算拉伸时增益偏移量
int inDe = (int) deltaScaleX / inDevi;
// 算出最终增益
int perNumber = numbersPerLargeGridOnThisTime - inDe;
// 设置增益
setDataNumbersPerGrid(perNumber);
return deltaScaleX;
好了,该解释的原理上面都做了解释。上面代码要解释的无非就是缩放敏感度调节的问题,代码里做了解释。缩放量计算出来之后,我们就可以改变心电曲线的增益了。比如说 X方向两点数据之间的距离做了调整、Y方向心电数值计算因子做了调整,然后重新算出曲线 Path再重绘,也就可以了。
6、左上角显示当前增益
最后我们要把当前增益显示出来,比如说 X轴方向一大格绘制了多少点数据、Y轴方向一大格代表多少毫伏。这两个参数都是在上一步双指缩放时动态改变的,所以要留一个对外接口让外界获取到这两个参数。
/**
* 获取每大格显示的数据个数,再结合医疗版的采样率,就可以算出一格显示了多长时间的数据
*
* @return
*/
public int getDataNumbersPerGrid() {
return this.dataNumbersPerGrid;
}
/**
* @return 获取每大格代表多少毫伏
*/
public float getMvPerLargeGrid() {
return this.mvPerLargeGrid;
}
因为这次心电图的绘制比以往的文章都涉及到更多的细节,所以之前文章里讲过的一些实现细节这里就没重复讲。另外,这次自定义 View使用了 Base模板设计模式,用好几个类来实现了这幅心电图,所以没把完整代码贴在这里。代码还是直接放Github吧 :心电图
作者:传道士
链接:https://juejin.cn/post/7084158192497721381
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »
Flutter 项目复盘 纯纯的实战经验
一. 项目开始
1. 新建flutter项目时首先明确native端用的语言 java还是kotlin , objectC 还是swift ,否则选错了后期换挺麻烦的
2. 选择自己的路由管理和状态管理包,决定项目架构
以我而言 第一个项目用的 fluro 和 provider 一个路由管理一个状态管理,项目目录新建store和route文件夹,分别存放provider的model文件和fluro的配置文件,到了第二个项目,发现了Getx,一个集合了依赖注入,路由管理,状态管理的包,用起来! 项目目录结构有了很大的变化,整体条理整洁
第一个
第二个
3. 常用包配置,比如 Getx 需要把外层MaterialApp换成GetMaterialApp, flutter_screenutil 需要初始化设计图比例,provider全局导入,Dio 封装,拦截器,网络提示等等
二. 全局配置
1. 复用样式
1. 由于flutter 某些小widget复用性很高,而App 需要统一样式 ,样式颜色之类的预设文件放在command文件夹内
colours.dart ,可以预设静态class,存储常用主题色
styles.dart,可以预设 字体样式 分割线样式 各种固定值间隔
2. 建议全局管理后端接口,整洁还便于维护,舒服
3. models 文件夹
models 文件夹,可能在web端并不常用,但是在dart里我觉得很需要,后端返回的Json 字符串,一定要通过model类 格式化为一个类,可以极大地减少拼写错误或者类型错误, . 语法也比 [''] 用起来舒服的多推荐一个网站 quickType 输入json对象,一键输出model类!
4. 是否强制横竖屏?
需要在main.dart里配置好
// 强制横屏
SystemChrome.setPreferredOrientations(
[DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
5. 是否需要修改顶部 底部状态栏布局以及样式?
用 SystemUiOverlayStyle
和 SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle);
来配置
6. 设置字体不跟随系统
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Container(color: Colors.white),
builder: (context, widget) {
return MediaQuery(
//设置文字大小不随系统设置改变
data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
child: widget,
);
},
);
}
}
7. 国际化配置
使用部分widget会显示英文,比如IOS风格的dialog,显示中文这需要设置一下了,
首先需要一个包支持,
flutter_localizations:
sdk: flutter
引入包,然后在main.dart MetrialApp 的配置项中加入
// 设置本地化,部分原生内容显示中文,例如长按选中文本显示复制、剪切
localizationsDelegates: [
GlobalMaterialLocalizations.delegate, //国际化
GlobalWidgetsLocalizations.delegate,//国际化
const FallbackCupertinoLocalisationsDelegate() // 这里是为了解决一个报错 看第 8 条
],
//国际化
supportedLocales: [
const Locale('zh', 'CH'),
const Locale('en', 'US'),
],
8. 使用CupertinoAlertDialog报错:The getter 'alertDialogLabel' was called on null
解决方法:
在main.dart中 加入如下类,然后在MetrialApp 的 localizationsDelegates 中实例化 见第 7 条
class FallbackCupertinoLocalisationsDelegate
extends LocalizationsDelegate {
const FallbackCupertinoLocalisationsDelegate();
@override
bool isSupported(Locale locale) => true;
@override
Future load(Locale locale) =>
DefaultCupertinoLocalizations.load(locale);
@override
bool shouldReload(FallbackCupertinoLocalisationsDelegate old) => false;
}
9. ImageCache
最近版本的flutter更新,限制了catchedImage的上限, 100张 1000mb ,而业务需求却需要缓存更多,设置一下了这需要
class ChangeCatchImage extends WidgetsFlutterBinding {
@override
createImageCache() *{*
Global.myImageCatche = ImageCache()
..maximumSize = 1000
..maximumSizeBytes = 1000 << 20; // 1000MB
return Global.myImageCatche;
}
}
然后在main.dart runApp那里实例化一下ChangeCatchImage() 就可以了
三. 业务模块
常见的业务模块代码分析,比如登录页,闪屏页,首页,退出登录等
1. 首先安利一下Getx
一个文件夹就是一个业务模块,独自管理数据,通过依赖注入数据共享,
非常舒服
包括 logic 逻辑控制层 state 数据管理层 view 视图组件层 ,当前业务的复用widget写在文件夹下
2. 登录模块
作为app的入口门户,炫酷美观是少不了的,这就需要关注性能优化,而输入的地方,验证的逻辑要有安全设计
- 首先关于动画性能优化,最关键的一点是精准的更新需要变化的组件,我们可以通过devtool的工具查看更新范围
- 其次时安全设计,简单的来看,限制登录次数,禁止简易密码,加密传输,验证token等,进阶版的比如,防止参数注入,过滤敏感字符等
- 登录之前的账户验证,密码验证,必填项等,然后登录请求,需要加loading,按钮禁用,就不需要防抖了
- 登录之后保存到本地用户基本信息(可能存在安全问题,暂未深究),然后下次登陆默认检测是否存在基本信息,并验证过期时间,和token,之后隐式登录到首页
3. splash闪屏模块
app登陆首页的准备页面,可以嵌入广告,或者定制软件宣传动画,提示三秒后跳过
如何优雅的加入app闪屏页?
其实就是在main.dart里把初始化页面设置为splash页面,之后通过跳转逻辑
判断去首页还是登录注册页面
比如这里我用了Getx 就简单配置一下
4. 操作引导模块
第一次使用app,或者重大更新之后往往会有操作引导
我的项目里用到了两种类型的操作引导
成果图
第一种
第二种
二者都是基于overlayEntry()
和Overlay.of(context).insert(overlayEntry)
实现的
第二种用了一个包 操作引导 flutter_intro: ^2.2.1
,绑定Widget的GlobalKey,来获取Element信息,拿到位置大小,确保框选的位置正确,外层遮罩与第一种一样都是用overlayEntry()创建的
创建之后,展示出来
Overlay.of(context).insert(your_overlayEntry)
在某个按钮处切换下一个 比如点击我知道了,下一页之类的
onPressed: () {
// 执行 remove 方法销毁 第一个overlayEntry 实例
overlayEntryTap.remove();
// 第二个
Overlay.of(context).insert(overlayEntryScale);
},
关于第二个实现涉及的flutter_intro包,粘一下我的代码,详细的可以参照pub食用
final intro = Intro(
// 一共有几步,这里就会创建2个GlobalKey,一会用到
stepCount: 2,
// 点击遮罩下一个
maskClosable: true,
// 高亮区域与 widget 的内边距
padding: EdgeInsets.all(0),
// 高亮区域的圆角半径
borderRadius: BorderRadius.all(Radius.circular(4)),
// use defaultTheme
widgetBuilder: StepWidgetBuilder.useDefaultTheme(
texts: ["点击添加收藏", "下拉添加书签"],
buttonTextBuilder: (currPage, totalPage) {
return currPage < totalPage - 1
? '我知道了 ${currPage + 3}/${totalPage + 2}'
: '完成 ${currPage + 3}/${totalPage + 2}';
},
),
);
......
// 这里用到key来绑定任意Widget
Positioned(
key: intro.keys[1],
top: 0,
right: 20,
...
)
......
5. CustomPaint 绘图画板模块
成果图
当初选择flutter就是因为,有大量的绘制需求,看中了自带skia,绘制效率高且流畅而且具备平台一致性
结果坑也不少
首先来讲一下 猪脚 CustomPaint
顾名思义,这是一个个性化绘制组件,他的工作就是给你创建一个画布,你想怎么画怎么画,我们直接看怎么用
首先格式化写法
- 首先 需要写在widget 树里吧
Container(
child: CustomPaint(
painter: myPainter(),
),
看一下参数列表,发现painter 接收一个CustomePainter对象,这里可以注意一下child参数,很奇怪明明绘制界面都放在painter里了,留一个child干啥用??? 其实有大用,这里面放是他的子widget,但是不参与绘制更新的,通俗一点就是我绘制一片流动的云彩,但是有个静止的太阳,云彩的位置是实时repaint的,这时就可以把太阳widget放在child中,优化性能
- 接下来我们创建myPainter()
class myPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// 创建画笔
final Paint paint = Paint();
// 绘制一个圆
canvas.drawCircle(Offset(50, 50), 5, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
- 到这里 我们需要实现两个重要的函数(如上代码)
第一个paint()函数,自带了画布对象 canvas,和画布尺寸 size,这样我们就可以使用Canvas的内置绘制函数了!
而绘制函数,都需要接收一个Paint 画笔对象
这个画笔对象,就是用来设置画笔颜色,粗细,样式,接头样式等等
Paint paint = Paint();
//设置画笔
paint ..style = PaintingStyle.stroke
..color = Colors.red
..strokeWidth = 10;
第二个函数shouldRepaint() 顾名思义判断是否需要重绘,如果返回false就是不需要重绘,只执行一次paine(),返回true就是总是重绘,依据实际需求设置
如果需要绘制类似于 根据数值不断变高的柱状图动画
代码如下(搬走就能用哦)
class BarChartPainter extends CustomPainter {
final List datas;
final List datasrc;
final List xAxis;
final double max;
final Animation animation;
BarChartPainter(
{@required this.xAxis,
@required this.datas,
this.max,
this.datasrc,
this.animation})
: super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
_darwBars(canvas, size);
_drawAxis(canvas, size);
}
@override
bool shouldRepaint(BarChartPainter oldDelegate) => true;
// 绘制坐标轴
void _drawAxis(Canvas canvas, Size size) {
final double sw = size.width;
final double sh = size.height;
// 使用 Paint 定义路径的样式
final Paint paint = Paint()
..color = Colors.grey
..style = PaintingStyle.stroke
..strokeWidth = 1
..strokeCap = StrokeCap.round;
// 使用 Path 定义绘制的路径,从画布的左上角到左下角在到右下角
final Path path = Path()
..moveTo(40, sh)
..lineTo(sw - 20, sh);
// 使用 drawPath 方法绘制路径
canvas.drawPath(path, paint);
}
// 绘制柱形
void _darwBars(Canvas canvas, Size size) {
final sh = size.height;
final paint = Paint()..style = PaintingStyle.fill;
final double _barWidth = size.width / 20;
final double _barGap = size.width / 25 * 2 + 18;
final double textFontSize = 14.0;
for (int i = 0; i < datas.length; i++) {
final double data = datas[i] * ((size.height - 15) / max);
final top = sh - data;
// 矩形的左边缘为当前索引值乘以矩形宽度加上矩形之间的间距
final double left = i * _barWidth + (i * _barGap) + _barGap;
// 使用 Rect.fromLTWH 方法创建要绘制的矩形
final rect = RRect.fromLTRBAndCorners(
left, top, left + _barWidth, top + data,
topLeft: Radius.circular(5), topRight: Radius.circular(3));
// 使用 drawRect 方法绘制矩形
final offset = Offset(
left + _barWidth / 2 - textFontSize / 2 - 8,
top - textFontSize - 5,
);
paint.color = Color(0xFF59C8FD);
//绘制bar
canvas.drawRRect(rect, paint);
// 使用 TextPainter 绘制矩形上放的数值
TextPainter(
text: TextSpan(
text: datas[i] == 0.0 ? '' : datas[i].toStringAsFixed(0) + " %",
style: TextStyle(
fontSize: textFontSize,
color: paint.color,
// color: Colours.gray_33,
),
),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
)
..layout(
minWidth: 0,
maxWidth: textFontSize * data.toString().length,
)
..paint(canvas, offset);
final xData = xAxis[i];
final xOffset = Offset(left, sh + 6);
// 绘制横轴标识
TextPainter(
textAlign: TextAlign.center,
text: TextSpan(
text: '$xData' != ''
? '$xData'.substring(0, 4) + '-' + '$xData'.substring(4, 6)
: '',
style: TextStyle(
fontSize: 12,
color: Colors.black,
),
),
textDirection: TextDirection.ltr,
)
..layout(
minWidth: 0,
maxWidth: size.width,
)
..paint(canvas, xOffset);
}
}
}
好了,customPainter,大体就这么用,下面回归话题,绘制画板
其实整体任务相当复杂,这里刨析一处,其他的融会贯通
拿最经典的铅笔画图来说
其实单纯的实现铅笔画图,甚至带笔锋,类似于签名,都很简单,网上教程一堆
大体思路就是 加一个GestureDetector ,主要用 onPanUpdate事件实时触发绘制动作,用canvas绘制出来
绘制简单,但是性能优化复杂
这里直接给出我测试的最优解
先把新的坐标点与之前的点连成线,可以一次多连接几个,也就是类似于节流的处理手法,
比如等panUpate触发了五次回调,先都把这五个点连接成线,第六次再统一绘制一条线(要是还有啥好办法,希望不吝赐教!)
详细的以后单独整理出来一个项目
6. websocket 即时通讯模块
成果图
只做了最基本的文字 图片 文件功能
简单把各项功能实现说一下,以后会详细整理,并加入音视频
关于websocket
首先肯定是连接websocket,用到一个包
web_socket_channel
然后初始化websocket
// 初始化websocket
initWebsocket(){
Global.channel = IOWebSocketChannel.connect(
WebsocketUrl, // websocket地址
//这个参数注意一下, 这里是每隔10000毫秒发送ping,如果间隔10000ms没收到pong,就默认断开连接
//所以收网速等影响,这个参数如果太小,比如100ms就会,出现过一阵子自己断开连接的问题,参考实际设置
pingInterval: Duration(milliseconds: 10000),
);
// 监听服务端消息
Global.channel.stream.listen(
(mes) => onMessage(mes),// 处理消息
onError: (error) => {onError(error)}, // 连接错误
onDone: () => {onDone()}, // 断开连接
cancelOnError: true //设置错误时取消订阅
);
}
处理消息
进入页面加载聊天消息,长列表还是得用ListView.build(),消息多的时候体验好很多
每次监听到新消息,加入到数组中,并更新视图,这一步不同的状态管理方法不同.
加入消息这里就有难点了
首先分四种情况 a. 自己发的并且在ListView底部,b. 自己发的但是不在ListView底部, c. 别人发的消息并且在底部,d. 别人发的不在底部.
a 和 b,c: 只要是自己发得就滚动到底部,在底部时就滚动的慢点,有种消息上拉的感觉
// 这里要确保在LIstView中已经加入并渲染完成新消息
// 我的处理就是加了一个延迟,再滚动
// 直接滚动到ListView底部
scrollController.jumpTo(scrollController.position.maxScrollExtent);
// 滚动到某个确定的元素
Scrollable.ensureVisible(
// 给每一条消息对象加GlobalKey,获取到当前上下文
state.messageList[index].key.currentContext,
duration: Duration(milliseconds: 100),
curve: Curves.easeInOut,
// 控制对齐方式
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
d : 这种情况,做了个类似于微信的提示
但是点击定位到消息有坑了,因为用的listView.build,当你在翻阅上边的消息,下面的消息并没有加载,因此获取不到currentContext,因为元素并没有渲染,也就定位错乱了,目前最理想的解决办法就是,往上翻的时候,之下的记录全部渲染,往下滑时再依次清.
文件和图片
用到了几个包
file_picker, open_file, path_provider
file_picker ,用来选择文件和图片,可以配置单选多选,需要在安卓的配置文件里加权限
open_file , 类似于微信点击文件,先下载,然后调用本地默认程序打开文件
path_provider,提供系统可用路径,用于创建文件目录
具体使用如下
// 访问不到app私有目录 导致我卡了很久...
// Directory dirloc = await getTemporaryDirectory();
// 访问外置存储目录
final dirPath = await getExternalStorageDirectory();
Directory file = Directory(dirPath.path + "/" + "temFile");
// 不存在就创建目录
try {
bool exists = await file.exists();
if (!exists) {
await file.create(); // 创建了temFile 目录 用于缓存文件
}
} catch (e) {
print(e);
}
// 下边就很关键了 可能不同的后端数据不同实现
// 请求存储权限 需要一个包 permission_handler: ^6.1.1
Permission.storage.request().then((value) async {
//如果许可
if (value.isGranted) {
// 判断文件是否存在 wjmc 就是一个变量存储着文件名
File _tempFile = File(file.path + '/' + wjmc);
if (!await _tempFile.exists()) {
try {
//1、创建文件地址 带扩展 我用了getx cstate
// final ChatState cState = Get.find().state;
// 这是一个通用组件 不管理数据 从chatState里注入
cState.path = file.path + '/' + wjmc;
//2、下载文件到本地
cState.downloading.value = nbbh;
var response = await dio.get(fileUrl);
Stream resp = response.data.stream;
//4. 转为uint8类型
final Uint8List bytes =
await consolidateHttpClientResponseBytes(resp);
//5. 转为List并写入文件
final List_filelist = List.from(bytes);
final filePath = File(cState.path);
await filePath.writeAsBytes(_filelist,
mode: FileMode.append, flush: true);
} catch (e) {
print(e);
}
}
cState.downloading.value = '';
// 6.这里可以记录位置,保存path到一个数组里,退出软件之后清除缓存 我没做
open(cState.path);
}
});
// 读取Stream 文件流 处理为Uint8List
FutureconsolidateHttpClientResponseBytes(Stream response) {
final Completercompleter = Completer .sync();
final List- > chunks =
- >[];
int contentLength = 0;
response.listen((chunk) {
chunks.add(chunk);
contentLength += chunk.length;
}, onDone: () {
final Uint8List bytes = Uint8List(contentLength);
int offset = 0;
for (Listchunk in chunks) {
bytes.setRange(offset, offset + chunk.length, chunk);
offset += chunk.length;
}
completer.complete(bytes);
}, onError: completer.completeError, cancelOnError: true);
return completer.future;
}
void open(path) {
// 下载完成 准备打开文件
showCupertinoDialog(
context: Get.context,// 舒服
builder: (context) {
return Material(
color: Colors.transparent,
child: CupertinoAlertDialog(
title: Padding(
padding: EdgeInsets.only(bottom: 10),
child: Text("提示"),
),
content: Padding(
padding: EdgeInsets.only(left: 5),
child: Text("是否打开文件?"),
),
actions:[
CupertinoButton(
child: Text(
"取消",
style: TextStyle(color: Colours.gray_88),
),
onPressed: () {
Get.back();
},
),
CupertinoButton(
child: Text("确定"),
onPressed: () async {
Get.back();
// 直接调用就能打开,会通过系统默认程序打开 比如.doc 默认用office等.
await OpenFile.open(
cState.path,
);
}),
]),
);
},
);
}
音视频用的小鱼易连,但是木有Flutter SDK ,只能基于安卓的去封装,以后有机会再讲讲.
作者:零念念
链接:https://juejin.cn/post/7036636900379066376
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »