马嘉伦

马嘉伦 查看完整档案

深圳编辑黑龙江建筑职业技术学院  |  建筑装饰工程技术 编辑深圳市优量空间科技有限公司  |  App开发 编辑 segmentfault.com/u/majialun 编辑
编辑

Flutter资深开发者,有多个flutter开源库获得高分评价,码云最有价值开源项目 flutter-p2p-engine代码主要提供人,长期维护者。博客作者,最高阅读量50k+,文章曾被谷歌开发者官方公众号刊登并赠送礼品。

个人动态

马嘉伦 发布了文章 · 2020-12-09

【开发经验】Apple M1开发Flutter报IPA processing failed

问题

Apple M1确实性能很强,开发flutter体验也很好,但是在打adhoc包时会报错:
image.png

IPA processing failed这个问题如果去搜索的话,基本就是让你移除framework里的x86架构,但是点进去log就会看到:
image.png

所以其实还是M1的兼容性的问题。

解决

有两个方案

方案一:改设置

把这个设置改到iOS 13+

Xcode –>Build Settings –>iOS Deployment Target–>iOS 13.0 or later

方案二:移除问题库

  1. 打开Xcode的 Window > Organizer
  2. 右键你的App > 在Finder显示
  3. 右键第一个 .xcarchive 文件 > 显示包内容
  4. 右键点 /Products/Applications > 显示包内容
  5. 移除所有的: lib*.dylib
  6. 再点distribute app 即可

结束

如果有帮到你,欢迎给我点个赞,谢谢。

查看原文

赞 1 收藏 0 评论 2

马嘉伦 发布了文章 · 2020-10-30

【开发经验】Flutter中使用dart计算每个月多少天

问题

使用dart计算每个月多少天。

代码

居然这么简单的问题还经常有人问……直接贴代码

for (var month = 1; month <= 12; month++) {
  // 年份,随便哪一年
  var year = DateTime.now().year;
  // 计算下个月1号的前一天是几号,得出结果
  var dayCount = DateTime(year, month + 1, 0).day;
  print('$month' '月:$dayCount' '天');
}

其实就是计算下个月1号的前一天是几号。

总之很简单,听懂掌声 看懂点赞。

查看原文

赞 2 收藏 0 评论 0

马嘉伦 发布了文章 · 2020-10-16

【开发经验】Flutter Blue监听收到重复数据解决办法

前言

FlutterBlueFlutter上最早的蓝牙插件库,为Flutter App提供蓝牙交互的能力,主要资料地址:

GitHub: https://github.com/pauldemarc...
Pub: https://pub.dev/packages/flut...

问题

FlutterBlue的其他部分用起来都很好,代码显得非常易读。但是有一个问题一直伴随着FlutterBlue的使用,在有的项目中,可以忽略这个问题,在有的项目中,这个问题会非常明显的造成逻辑问题,甚至造成整个项目出现问题,那就是重复收到回调问题。

详情

以下是FlutterBlue文档提供的部分使用方法。

Connect to a device

// Connect to the device
await device.connect();

// Disconnect from device
device.disconnect();

Set notifications and listen to changes

await characteristic.setNotifyValue(true);
characteristic.value.listen((value) {
    // do something with new value
});

如果你手动断开你的设备(断电或关闭蓝牙),再按上述代码连接,你会发现// do something with new value处的代码会重复执行,这个问题非常的棘手,似乎限流函数都不对此处起作用。

解决

显然问题就发生在setNotifyValue。在此处,FlutterBlue会注册流,而在自动断开时,并不会取消这个监听,所以,我们需要手动监听device的状态,在断开连接时取消监听:

_connectListenControl = device.state.listen(
  (BluetoothDeviceState newState) {
    if (newState == BluetoothDeviceState.disconnected) {
       // 取消之前的监听函数
       _notifyListenControl?.cancel();
       // 取消监听
      _notify.setNotifyValue(false)
    }
  },
);

看上去似乎有一些反直觉:在设备断开后,还需要去操作设备,去取消设备的监听方法。

只能说这是FlutterBlue本身的设计,这个设计的目的可能在于:在恢复连接时,恢复之前的监听。很显然通常的业务场景并不会设计恢复的操作,而是重新去建立新的连接,所以就造成了重复监听(因为之前的监听还在)。此时,应当手动释放监听。

结语

这也是一个比较顽固的问题,搞了一年才解决,之前都是放着不管。不过我认为这个问题也可能是多方面的,如果按此方法不能解决这个问题,请在评论区告诉我。

作者:马嘉伦
日期:2020/10/16
平台:Segmentfault,勿转载

我的其他文章:

【开发经验】Flutter组件的事件传递与数据控制
【开发经验】Flutter避免代码嵌套,写好build方法

【Flutter工具】fmaker:自动生成倍率切图/自动更换App图标
【Flutter工具】可能是Flutter上最简单的本地数据保存方案
【Flutter应用】Flutter精仿抖音开源

查看原文

赞 1 收藏 1 评论 0

马嘉伦 赞了文章 · 2020-10-01

React 架构的演变 - 从递归到循环

这篇文章是 React 架构演变的第二篇,上一篇主要介绍了更新机制从同步修改为异步,这一篇重点介绍 Fiber 架构下通过循环遍历更新的过程,之所以要使用循环遍历的方式,是因为递归更新过程一旦开始就不能暂停,只能不断向下,直到递归结束或者出现异常。

递归更新的实现

React 15 的递归更新逻辑是先将需要更新的组件放入脏组件队列(这里在上篇文章已经介绍过,没看过的可以先看看《React 架构的演变 - 从同步到异步》),然后取出组件进行一次递归,不停向下寻找子节点来查找是否需要更新。

下面使用一段代码来简单描述一下这个过程:

updateComponent (prevElement, nextElement) {
  if (
    // 如果组件的 type 和 key 都没有发生变化,进行更新
    prevElement.type === nextElement.type &&
    prevElement.key === nextElement.key
  ) {
    // 文本节点更新
    if (prevElement.type === 'text') {
        if (prevElement.value !== nextElement.value) {
            this.replaceText(nextElement.value)
        }
    }
    // DOM 节点的更新
    else {
      // 先更新 DOM 属性
      this.updateProps(prevElement, nextElement)
      // 再更新 children
      this.updateChildren(prevElement, nextElement)
    }
  }
  // 如果组件的 type 和 key 发生变化,直接重新渲染组件
  else {
    // 触发 unmount 生命周期
    ReactReconciler.unmountComponent(prevElement)
    // 渲染新的组件
    this._instantiateReactComponent(nextElement)
  }
},
updateChildren (prevElement, nextElement) {
  var prevChildren = prevElement.children
  var nextChildren = nextElement.children
  // 省略通过 key 重新排序的 diff 过程
  if (prevChildren === null) { } // 渲染新的子节点
  if (nextChildren === null) { } // 清空所有子节点
  // 子节点对比
  prevChildren.forEach((prevChild, index) => {
    const nextChild = nextChildren[index]
    // 递归过程
    this.updateComponent(prevChild, nextChild)
  })
}

为了更清晰的看到这个过程,我们还是写一个简单的Demo,构造一个 3 * 3 的 Table 组件。

Table

// https://codesandbox.io/embed/react-sync-demo-nlijf
class Col extends React.Component {
  render() {
    // 渲染之前暂停 8ms,给 render 制造一点点压力
    const start = performance.now()
    while (performance.now() - start < 8)
    return <td>{this.props.children}</td>
  }
}

export default class Demo extends React.Component {
  state = {
    val: 0
  }
  render() {
    const { val } = this.state
    const array = Array(3).fill()
    // 构造一个 3 * 3 表格
    const rows = array.map(
      (_, row) => <tr key={row}>
        {array.map(
          (_, col) => <Col key={col}>{val}</Col>
        )}
      </tr>
    )
    return (
      <table className="table">
        <tbody>{rows}</tbody>
      </table>
    )
  }
}

然后每秒对 Table 里面的值更新一次,让 val 每次 + 1,从 0 ~ 9 不停循环。

Table Loop

// https://codesandbox.io/embed/react-sync-demo-nlijf
export default class Demo extends React.Component {
    tick = () => {
    setTimeout(() => {
      this.setState({ val: next < 10 ? next : 0 })
      this.tick()
    }, 1000)
  }
  componentDidMount() {
    this.tick()
  }
}

完整代码的线上地址: https://codesandbox.io/embed/react-sync-demo-nlijf。Demo 组件每次调用 setState,React 会先判断该组件的类型有没有发生修改,如果有就整个组件进行重新渲染,如果没有会更新 state,然后向下判断 table 组件,table 组件继续向下判断 tr 组件,tr 组件再向下判断 td 组件,最后发现 td 组件下的文本节点发生了修改,通过 DOM API 更新。

Update

通过 Performance 的函数调用堆栈也能清晰的看到这个过程,updateComponent 之后 的 updateChildren 会继续调用子组件的 updateComponent,直到递归完所有组件,表示更新完成。

调用堆栈

递归的缺点很明显,不能暂停更新,一旦开始必须从头到尾,这与 React 16 拆分时间片,给浏览器喘口气的理念明显不符,所以 React 必须要切换架构,将虚拟 DOM 从树形结构修改为链表结构。

可循环的 Fiber

这里说的链表结构就是 Fiber 了,链表结构最大的优势就是可以通过循环的方式来遍历,只要记住当前遍历的位置,即使中断后也能快速还原,重新开始遍历。

我们先看看一个 Fiber 节点的数据结构:

function FiberNode (tag, key) {
  // 节点 key,主要用于了优化列表 diff
  this.key = key
  // 节点类型;FunctionComponent: 0, ClassComponent: 1, HostRoot: 3 ...
  this.tag = tag

    // 子节点
  this.child = null
  // 父节点
  this.return = null 
  // 兄弟节点
  this.sibling = null
  
  // 更新队列,用于暂存 setState 的值
  this.updateQueue = null
  
  // 节点更新过期时间,用于时间分片
  // react 17 改为:lanes、childLanes
  this.expirationTime = NoLanes
  this.childExpirationTime = NoLanes

  // 对应到页面的真实 DOM 节点
  this.stateNode = null
  // Fiber 节点的副本,可以理解为备胎,主要用于提升更新的性能
  this.alternate = null
}

下面举个例子,我们这里有一段普通的 HTML 文本:

<table class="table">
  <tr>
    <td>1</td>
    <td>1</td>
  </tr>
  <tr>
    <td>1</td>
  </tr>
</table>

在之前的 React 版本中,jsx 会转化为 createElement 方法,创建树形结构的虚拟 DOM。

const VDOMRoot = {
  type: 'table',
  props: { className: 'table' },
  children: [
    {
      type: 'tr',
      props: { },
      children: [
        {
          type: 'td',
          props: { },
          children: [{type: 'text', value: '1'}]
        },
        {
          type: 'td',
          props: { },
          children: [{type: 'text', value: '1'}]
        }
      ]
    },
    {
      type: 'tr',
      props: { },
      children: [
        {
          type: 'td',
          props: { },
          children: [{type: 'text', value: '1'}]
        }
      ]
    }
  ]
}

Fiber 架构下,结构如下:

// 有所简化,并非与 React 真实的 Fiber 结构一致
const FiberRoot = {
  type: 'table',
  return: null,
  sibling: null,
  child: {
    type: 'tr',
    return: FiberNode, // table 的 FiberNode
    sibling: {
      type: 'tr',
      return: FiberNode, // table 的 FiberNode
      sibling: null,
      child: {
        type: 'td',
        return: FiberNode, // tr 的 FiberNode
        sibling: {
          type: 'td',
          return: FiberNode, // tr 的 FiberNode
          sibling: null,
          child: null,
          text: '1' // 子节点仅有文本节点
        },
        child: null,
        text: '1' // 子节点仅有文本节点
      }
    },
    child: {
      type: 'td',
      return: FiberNode, // tr 的 FiberNode
      sibling: null,
      child: null,
      text: '1' // 子节点仅有文本节点
    }
  }
}

Fiber

循环更新的实现

那么,在 setState 的时候,React 是如何进行一次 Fiber 的遍历的呢?

let workInProgress = FiberRoot

// 遍历 Fiber 节点,如果时间片时间用完就停止遍历
function workLoopConcurrent() {
  while (
    workInProgress !== null &&
    !shouldYield() // 用于判断当前时间片是否到期
  ) {
    performUnitOfWork(workInProgress)
  }
}

function performUnitOfWork() {
  const next = beginWork(workInProgress) // 返回当前 Fiber 的 child
  if (next) { // child 存在
    // 重置 workInProgress 为 child
    workInProgress = next
  } else { // child 不存在
    // 向上回溯节点
    let completedWork = workInProgress
    while (completedWork !== null) {
      // 收集副作用,主要是用于标记节点是否需要操作 DOM
      completeWork(completedWork)

      // 获取 Fiber.sibling
      let siblingFiber = workInProgress.sibling
      if (siblingFiber) {
        // sibling 存在,则跳出 complete 流程,继续 beginWork
        workInProgress = siblingFiber
        return;
      }

      completedWork = completedWork.return
      workInProgress = completedWork
    }
  }
}

function beginWork(workInProgress) {
  // 调用 render 方法,创建子 Fiber,进行 diff
  // 操作完毕后,返回当前 Fiber 的 child
  return workInProgress.child
}
function completeWork(workInProgress) {
  // 收集节点副作用
}

Fiber 的遍历本质上就是一个循环,全局有一个 workInProgress 变量,用来存储当前正在 diff 的节点,先通过 beginWork 方法对当前节点然后进行 diff 操作(diff 之前会调用 render,重新计算 state、prop),并返回当前节点的第一个子节点( fiber.child)作为新的工作节点,直到不存在子节点。然后,对当前节点调用 completedWork 方法,存储 beginWork 过程中产生的副作用,如果当前节点存在兄弟节点( fiber.sibling),则将工作节点修改为兄弟节点,重新进入 beginWork 流程。直到 completedWork 重新返回到根节点,执行 commitRoot 将所有的副作用反应到真实 DOM 中。

Fiber work loop

在一次遍历过程中,每个节点都会经历 beginWorkcompleteWork ,直到返回到根节点,最后通过 commitRoot 将所有的更新提交,关于这部分的内容可以看:《React 技术揭秘》

时间分片的秘密

前面说过,Fiber 结构的遍历是支持中断恢复,为了观察这个过程,我们将之前的 3 * 3 的 Table 组件改成 Concurrent 模式,线上地址:https://codesandbox.io/embed/react-async-demo-h1lbz。由于每次调用 Col 组件的 render 部分需要耗时 8ms,会超出了一个时间片,所以每个 td 部分都会暂停一次。

class Col extends React.Component {
  render() {
    // 渲染之前暂停 8ms,给 render 制造一点点压力
    const start = performance.now();
    while (performance.now() - start < 8);
    return <td>{this.props.children}</td>
  }
}

在这个 3 * 3 组件里,一共有 9 个 Col 组件,所以会有 9 次耗时任务,分散在 9 个时间片进行,通过 Performance 的调用栈可以看到具体情况:

异步模式的调用栈

在非 Concurrent 模式下,Fiber 节点的遍历是一次性进行的,并不会切分多个时间片,差别就是在遍历的时候调用了 workLoopSync 方法,该方法并不会判断时间片是否用完。

// 遍历 Fiber 节点
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress)
  }
}

同步模式的调用栈

通过上面的分析可以看出, shouldYield 方法决定了当前时间片是否已经用完,这也是决定 React 是同步渲染还是异步渲染的关键。如果去除任务优先级的概念,shouldYield 方法可以说很简单,就是判断了当前的时间,是否已经超过了预设的 deadline

function getCurrentTime() {
  return performance.now()
}
function shouldYield() {
  // 获取当前时间
  var currentTime = getCurrentTime()
  return currentTime >= deadline
}

deadline 又是如何得的呢?可以回顾上一篇文章(《React 架构的演变 - 从同步到异步》)提到的 ChannelMessage,更新开始的时候会通过 requestHostCallback(即:port2.send)发送异步消息,在 performWorkUntilDeadline (即:port1.onmessage)中接收消息。performWorkUntilDeadline 每次接收到消息时,表示已经进入了下一个任务队列,这个时候就会更新 deadline

异步调用栈

var channel = new MessageChannel()
var port = channel.port2
channel.port1.onmessage = function performWorkUntilDeadline() {
  if (scheduledHostCallback !== null) {
    var currentTime = getCurrentTime()
    // 重置超时时间 
    deadline = currentTime + yieldInterval
    
    var hasTimeRemaining = true
    var hasMoreWork = scheduledHostCallback()

    if (!hasMoreWork) {
      // 已经没有任务了,修改状态 
      isMessageLoopRunning = false;
      scheduledHostCallback = null;
    } else {
      // 还有任务,放到下个任务队列执行,给浏览器喘息的机会 
      port.postMessage (null);
    }
  } else {
    isMessageLoopRunning = false;
  }
}

requestHostCallback = function (callback) {
  //callback 挂载到 scheduledHostCallback
  scheduledHostCallback = callback
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true
    // 推送消息,下个队列队列调用 callback
    port.postMessage (null)
  }
}

超时时间的设置就是在当前时间的基础上加上了一个 yieldInterval, 这个 yieldInterval 的值,默认是 5ms。

deadline = currentTime + yieldInterval

同时 React 也提供了修改 yieldInterval 的手段,通过手动指定 fps,来确定一帧的具体时间(单位:ms),fps 越高,一个时间分片的时间就越短,对设备的性能要求就越高。

forceFrameRate = function (fps) {
  if (fps < 0 || fps > 125) {
    // 帧率仅支持 0~125
    return
  }

  if (fps > 0) {
    // 一般 60 fps 的设备
    // 一个时间分片的时间为 Math.floor(1000/60) = 16
    yieldInterval = Math.floor(1000 / fps)
  } else {
    // reset the framerate
    yieldInterval = 5
  }
}

总结

下面我们将异步逻辑、循环更新、时间分片串联起来。先回顾一下之前的文章讲过,Concurrent 模式下,setState 后的调用顺序:

Component.setState()
  => enqueueSetState()
  => scheduleUpdate()
  => scheduleCallback(performConcurrentWorkOnRoot)
  => requestHostCallback()
  => postMessage()
  => performWorkUntilDeadline()

scheduleCallback 方法会将传入的回调(performConcurrentWorkOnRoot)组装成一个任务放入 taskQueue 中,然后调用 requestHostCallback 发送一个消息,进入异步任务。performWorkUntilDeadline 接收到异步消息,从 taskQueue 取出任务开始执行,这里的任务就是之前传入的 performConcurrentWorkOnRoot 方法,这个方法最后会调用workLoopConcurrentworkLoopConcurrent 前面已经介绍过了,这个不再重复)。如果 workLoopConcurrent 是由于超时中断的,hasMoreWork 返回为 true,通过 postMessage 发送消息,将操作延迟到下一个任务队列。

流程图

到这里整个流程已经结束,希望大家看完文章能有所收获,下一篇文章会介绍 Fiber 架构下 Hooks 的实现。

image

查看原文

赞 8 收藏 5 评论 2

马嘉伦 赞了文章 · 2020-08-13

一行代码使用CSS的黑暗模式

这是一个绝对不费吹灰之力的方法,将已经开发好的网站转换为支持黑暗模式。

话不多说,我们开始吧! 👾

以这个新闻应用为例
image

现在添加魔术CSS

html[theme='dark-mode'] {
  filter: invert(1) hue-rotate(180deg);
}

瞧!你完成了 ✌

实现黑暗模式

image

说明

现在,让我们试着理解下面发生了什么。

CSS filter 属性将模糊或颜色转移等图形效果应用到元素上。滤镜通常用于调整图像、背景和边框的渲染。

对于这种黑暗模式,我们将使用两个滤镜,即 inverthue-rotate

invert滤镜可以帮助反转应用程序的颜色方案,因此,黑色变成了白色,白色变成了黑色,所有颜色也是如此。因此,黑变白,白变黑,所有颜色也是如此。

hue-rotate滤镜可以帮助我们处理所有其他非黑白的颜色。将色调旋转180度,我们确保应用程序的颜色主题不会改变,而只是减弱它的颜色。

image

这个方法唯一的问题是,它也会反转你应用程序中的所有图像。因此,我们将对所有图像添加相同的规则来反转效果。

html[theme='dark-mode'] img{
  filter: invert(1) hue-rotate(180deg);
}

而且我们还会给HTML元素添加一个过渡,确保过渡不会变得华而不实!

html {
  transition: color 300ms, background-color 300ms;
}

结果

image


有同学不知道怎么添加,这就是CSS啊,为了验证我把CSS添加到SF网站:
image
你可以勾选样式和取消勾选样式查看变化


来源:https://dev.to/akhilarjun/one...
翻译:公众号《前端全栈开发者》

查看原文

赞 49 收藏 31 评论 7

马嘉伦 关注了用户 · 2020-07-23

浪里行舟 @langlixingzhou

微信:frontJS,记得备注sf
公众号:前端工匠
文章首发地址:https://github.com/ljianshu/B...

关注 3585

马嘉伦 发布了文章 · 2020-07-22

【开发经验】Flutter组件的事件传递与数据控制

本文使用原生Flutter形式设计代码,只讲最基础的东西,不使用任何其他第三方库(Provider等)

写了接近两年的Flutter,发现数据与事件的传递是新手在学习时经常问的问题:有很多初学者错误的在非常早期就引入providerBLOC等模式去管理数据,过量使用外部框架,造成项目混乱难以组织代码。其主要的原因就是因为忽视了基础的,最简单的数据传递方式

很难想象有人把全部数据放在一个顶层provider里,然后绝对不写StatefulWidget。这种项目反正我是不维护,谁爱看谁看。

本文会列举基本的事件与方法传递方式,并且举例子讲明如何使用基础的方式实现这些功能。本文的例子都基于flutter默认的加法demo修改,在dartpad或者新建flutter项目中即可运行本项目的代码例子。

在局部传递数据与事件

先来看下基本的几个应用情况,只要实现了这些情况,在局部就可以非常流畅的传递数据与事件:

注意思考:下文的Widget,哪些是StatefulWidget

描述:一个Widget收到事件后,改变child显示的值
实现功能:点击加号让数字+1
难度:⭐

描述:一个Widget在child收到事件时,改变自己的值
实现功能:点击改变页面颜色
难度:⭐

描述:一个Widget在child收到事件时,触发自己的state的方法
实现功能:点击发起网络请求,刷新当前页面
难度:⭐

描述:一个Widget自己改变自己的值
实现功能:倒计时,从网络加载数据
难度:⭐⭐⭐

描述:一个Widget自己的数据变化时,触发state的方法
实现功能:一个在数据改变时播放过渡动画的组件
难度:⭐⭐⭐⭐

描述:一个Widget收到事件后,触发childstate的方法
实现功能:点击按钮让一个child开始倒计时或者发送请求
难度:⭐⭐⭐⭐⭐

我们平时写项目基本也就是上面这些需求了,只要学会实现这些事件与数据传递,就可以轻松写出任何项目了。

使用回调传递事件

使用简单的回调就可以实现这几个需求,这也是整个flutter的基础:如何改变一个state内的数据,以及如何改变一个widget的数据。

描述:一个widget收到事件后,改变child显示的值
实现功能:点击加号让数字+1

描述:一个widgetchild收到事件时,改变自己的值
实现功能:点击改变页面颜色

描述:一个widgetchild收到事件时,触发自己的state的方法
实现功能:点击发起网络请求,刷新当前页面

这几个都是毫无难度的,我们直接看同一段代码就行了

代码:

/// 这段代码是使用官方的代码修改的,通常情况下,只需要使用回调就能获取点击事件
class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    // 在按钮的回调中,你可以设置数据与调用方法
    // 在这里,让计数器+1后刷新页面
    setState(() {
      _counter++;
    });
  }

  // setState后就会使用新的数据重新进行build
  // flutter的build性能非常强,甚至支持每秒60次rebuild
  // 所以不必过于担心触发build,但是要偶尔注意超大范围的build
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Text(
          '$_counter',
          style: Theme.of(context).textTheme.headline4,
        ),
      ),
      floatingActionButton: _AddButton(
        onAdd: _incrementCounter,
      ),
    );
  }
}

/// 一般会使用GestureDetector来获取点击事件
/// 因为官方的FloatingActionButton会自带样式,一般我们会自己写按钮样式
class _AddButton extends StatelessWidget {
  final Function onAdd;

  const _AddButton({Key key, this.onAdd}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: onAdd,
      child: Icon(Icons.add),
    );
  }
}

这种方式十分的简单,只需要在回调中改变数据,再setState就会触发build方法,根据当前的数据重新build当前widget,这也是flutter最基本的刷新方法。

在State中改变数据

flutter中,只有StatefulWidget才具有statestate才具有传统意义上的生命周期(而不是页面),通过这些周期,可以做到一进入页面,就开始从服务器加载数据,也可以让一个Widget自动播放动画

我们先看这个需求:

描述:一个Widget自己改变自己的值
实现功能:倒计时,从网络加载数据

这也是一个常见的需求,但是很多新手写到这里就不会写了,可能会错误的去使用FutureBuilder进行网络请求,会造成每次都反复请求,实际上这里是必须使用StatefulWidgetstate来储存请求返回信息的。

一般项目中,动画,倒计时,异步请求此类功能需要使用state,其他大多数的功能并不需要存在state

例如这个widget,会显示一个数字:

class _CounterText extends StatelessWidget {
  final int count;

  const _CounterText({Key key, this.count}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text('$count'),
    );
  }
}

可以试着让widget从服务器加载这个数字:

class _CounterText extends StatefulWidget {
  const _CounterText({Key key}) : super(key: key);

  @override
  __CounterTextState createState() => __CounterTextState();
}

class __CounterTextState extends State<_CounterText> {
  @override
  void initState() {
    // 在initState中发出请求
    _fetchData();
    super.initState();
  }

  // 在数据加载之前,显示0
  int count = 0;

  // 加载数据,模拟一个异步,请求后刷新
  Future<void> _fetchData() async {
    await Future.delayed(Duration(seconds: 1));
    setState(() {
      count = 10;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text('$count'),
    );
  }
}

又或者,我们想让这个数字每秒都减1,最小到0。那么只需要把他变成stateful后,在initState中初始化一个timer,让数字减小:

class _CounterText extends StatefulWidget {
  final int initCount;

  const _CounterText({Key key, this.initCount:10}) : super(key: key);

  @override
  __CounterTextState createState() => __CounterTextState();
}

class __CounterTextState extends State<_CounterText> {
  Timer _timer;

  int count = 0;

  @override
  void initState() {
    count = widget.initCount;
    _timer = Timer.periodic(
      Duration(seconds: 1),
      (timer) {
        if (count > 0) {
          setState(() {
            count--;
          });
        }
      },
    );
    super.initState();
  }

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text('${widget.initCount}'),
    );
  }
}

这样我们就能看到这个widget从输入的数字每秒减少1。

由此可见,widget可以在state中改变数据,这样我们在使用StatefulWidget时,只需要给其初始数据,widget会根据生命周期加载或改变数据。

在这里,我建议的用法是在Scaffold中加载数据,每个页面都由一个StatefulScaffold和若干StatelessWidget组成,由ScaffoldState管理所有数据,再刷新即可。

注意,即使这个页面的body是ListView,也不推荐ListView管理自己的state,在当前state维护数据的list即可。使用ListView.builder构建列表即可避免更新数组时,在页面上刷新列表的全部元素,保持高性能刷新。

在State中监听widget变化

描述:一个Widget自己的数据变化时,触发state的方法
实现功能:一个在数据改变时播放过渡动画的组件

做这个之前,我们先看一个简单的需求:一行widget,接受一个数字,数字是偶数时,距离左边24px,奇数时距离左边60px

这个肯定很简单,我们直接StatelessWidget就写出来了;

class _Row extends StatelessWidget {
  final int number;

  const _Row({
    Key key,
    this.number,
  }) : super(key: key);

  double get leftPadding => number % 2 == 1 ? 60.0 : 24.0;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 60,
      width: double.infinity,
      alignment: Alignment.centerLeft,
      padding: EdgeInsets.only(
        left: leftPadding,
      ),
      child: Text('$number'),
    );
  }
}

这样就简单的实现了这个效果,但是实际运行的时候发现,数字左右横跳,很不美观。看来就有必要优化这个widget,让他左右移动的时候播放动画,移动过去,而不是跳来跳去。

一个比较简单的方案是,传入一个AnimationController来精确控制,但是这样太复杂了。这种场景下,我们在使用的时候通常只想更新数字,再setState,就希望他在内部播放动画(通常是过渡动画),就可以不用去操作复杂的AnimationController了。

实际上,这个时候我们使用didUpdateWidget这个生命周期就可以了,在state所依附的widget更新时,就会触发这个回调,你可以在这里响应上层传递的数据的更新,在内部播放动画。

代码:

class _Row extends StatefulWidget {
  final int number;

  const _Row({
    Key key,
    this.number,
  }) : super(key: key);

  @override
  __RowState createState() => __RowState();
}

class __RowState extends State<_Row> with TickerProviderStateMixin {
  AnimationController animationController;

  double get leftPadding => widget.number % 2 == 1 ? 60.0 : 24.0;

  @override
  void initState() {
    animationController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 500),
      lowerBound: 24,
      upperBound: 60,
    );
    animationController.addListener(() {
      setState(() {});
    });
    super.initState();
  }
  
  // widget更新,就会触发这个方法
  @override
  void didUpdateWidget(_Row oldWidget) {
    // 播放动画去当前位置
    animationController.animateTo(leftPadding);
    super.didUpdateWidget(oldWidget);
  }

  @override
  void dispose() {
    animationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 60,
      width: double.infinity,
      alignment: Alignment.centerLeft,
      padding: EdgeInsets.only(
        left: animationController.value,
      ),
      child: Text('${widget.number}'),
    );
  }
}

这样在状态之间就完成了一个非常平滑的动画切换,再也不会左右横跳了。

方法3: 传递ValueNotifier/自定义Controller

这里我们还是先看需求

描述:一个Widget收到事件后,触发childstate的方法
实现功能:点击按钮让一个child开始倒计时或者发送请求(调用state的方法)
难度:⭐⭐⭐⭐⭐

首先必须明确的是,如果出现在业务逻辑里,这里是显然不合理,是需要避免的。StatefulWidget嵌套时应当避免互相调用方法,在这种时候,最好是将childstate中的方法与数据,向上提取放到当前层state中。

这里可以简单分析一下:

  1. 有数据变化
    有数据变化时,使用StatedidUpdateWidget生命周期更加合理。这里我们也可以勉强实现一下,在flutter框架中,我推荐使用ValueNotifier进行传递,child监听ValueNotifier即可。
  2. 没有数据变化
    没有数据变化就比较麻烦了,我们需要一个controller进去,然后child注册一个回调进controller,这样就可以通过controller控制。
这里也可以使用providereventbus等库,或者用keyglobalKey相关方法实现。但是,必须再强调一次:不管用什么方式实现,这种嵌套是不合理的,项目中需要互相调用state的方法时,应当合并写在一个state里。原则上,需要避免此种嵌套,无论如何实现,都不应当是项目中的通用做法。

虽然不推荐在业务代码中这样写,但是在框架的代码中是可以写这种结构的(因为必须暴露接口)。这种情况可以参考ScrollController,你可以通过这个Controller控制滑动状态。

值得一提的是:ScrollController继承自ValueNotifier。所以使用ValueNotifier仍然是推荐做法。

其实controller模式也是flutter源码中常见的模式,一般用于对外暴露封装的方法。controller相比于其他的方法,比较复杂,好在我们不会经常用到。

作为例子,让我们实现一个CountController类,来帮我们调用组件内部的方法。

代码:

class CountController extends ValueNotifier<int> {
  CountController(int value) : super(value);

  // 逐个增加到目标数字
  Future<void> countTo(int target) async {
    int delta = target - value;
    for (var i = 0; i < delta.abs(); i++) {
      await Future.delayed(Duration(milliseconds: 1000 ~/ delta.abs()));
      this.value += delta ~/ delta.abs();
    }
  }

  // 实在想不出什么例子了,总之是可以这样调用方法
  void customFunction() {
    _onCustomFunctionCall?.call();
  }

  // 目标state注册这个方法
  Function _onCustomFunctionCall;
}

class _Row extends StatefulWidget {
  final CountController controller;
  const _Row({
    Key key,
    @required this.controller,
  }) : super(key: key);

  @override
  __RowState createState() => __RowState();
}

class __RowState extends State<_Row> with TickerProviderStateMixin {
  @override
  void initState() {
    widget.controller.addListener(() {
      setState(() {});
    });
    widget.controller._onCustomFunctionCall = () {
      print('响应方法调用');
    };
    super.initState();
  }

  // 这里controller应该是在外面dispose
  // @override
  // void dispose() {
  //   widget.controller.dispose();
  //   super.dispose();
  // }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 60,
      width: double.infinity,
      alignment: Alignment.centerLeft,
      padding: EdgeInsets.only(
        left: 24,
      ),
      child: Text('${widget.controller.value}'),
    );
  }
}

使用controller可以完全控制下一层state的数据和方法调用,比较灵活。但是代码量大,业务中应当避免写这种模式,只在复杂的地方构建controller来控制数据。如果你写了很多自定义controller,那应该反思你的项目结构是不是出了问题。无论如何实现,这种传递方式都不应当是项目中的通用做法。

单例管理全局数据与事件

全局的数据,可以使用顶层provider或者单例管理,我的习惯是使用单例,这样获取数据可以不依赖context

简单的单例写法,扩展任何属性到单例即可。

class Manager {
  // 工厂模式
  factory Manager() =>_getInstance();
  static Manager get instance => _getInstance();
  static Manager _instance;
  Manager._internal() {
    // 初始化
  }
  static Manager _getInstance() {
    if (_instance == null) {
      _instance = new Manager._internal();
    }
    return _instance;
  }
}

总结

作者:马嘉伦
日期:2020/07/22
平台:Segmentfault,勿转载

我的其他文章:
【开发经验】Flutter避免代码嵌套,写好build方法
【Flutter工具】fmaker:自动生成倍率切图/自动更换App图标
【Flutter应用】Flutter精仿抖音开源
【Flutter工具】可能是Flutter上最简单的本地数据保存方案

写这篇文章的原因,是因为看到不少人在学习flutter时,对于数据与事件的传递非常的不熟悉,又很早的去学习provider等第三方框架,对于基础的东西又一知半解,导致代码混乱项目混乱,不知如何传递数据,如何去刷新界面。所以写这篇文章总结了最基础的各种事件与数据的传递方法。

简单总结,flutter改变数据最基础的就是这么几种模式:

  • 改变自己state的数据,setStatechild传递新数据
  • 接受child的事件回调
  • child更新目标数据,child监听数据的变化,更加细节的改变自己的state
  • child传递controller,全面控制childstate

项目中只需要这几种模式就能很简单的全部写完了,使用provider等其他的库,代码上并不会有特别大的改善和进步。还是希望大家学习flutter的时候,能先摸清基本的写法,再进行更深层次的学习。

查看原文

赞 32 收藏 11 评论 8

马嘉伦 收藏了文章 · 2020-07-06

说说Flutter中的RepaintBoundary

起因

一个懒洋洋的下午,偶然间看到了这篇Flutter 踩坑记录,作者的问题引起了我的好奇。作者的问题描述如下:

一个聊天对话页面,由于对话框形状需要自定义,因此采用了CustomPainter来自定义绘制对话框。测试过程中发现在ipad mini上不停地上下滚动对话框列表竟然出现了crash,进一步测试发现聊天过程中也会频繁出现crash。

在对作者的遭遇表示同情时,也让我联想到了自己使用CustomPainter的地方。

寻找问题

flutter_deer中有这么一个页面:

效果图

页面最外层是个SingleChildScrollView,上方的环形图是一个自定义CustomPainter,下方是个ListView列表。

实现这个环形图并不复杂。继承CustomPainter,重写paintshouldRepaint方法即可。paint方法负责绘制具体的图形,shouldRepaint方法负责告诉Flutter刷新布局时是否重绘。一般的策略是在shouldRepaint方法中,我们通过对比前后数据是否相同来判定是否需要重绘。

当我滑动页面时,发现自定义环形图中的paint方法不断在执行。???shouldRepaint方法失效了?其实注释文档写的很清楚了,只怪自己没有仔细阅读。(本篇源码基于Flutter SDK版本 v1.12.13+hotfix.3)


  /// If the method returns false, then the [paint] call might be optimized
  /// away.
  ///
  /// It's possible that the [paint] method will get called even if
  /// [shouldRepaint] returns false (e.g. if an ancestor or descendant needed to
  /// be repainted). It's also possible that the [paint] method will get called
  /// without [shouldRepaint] being called at all (e.g. if the box changes
  /// size).
  ///
  /// If a custom delegate has a particularly expensive paint function such that
  /// repaints should be avoided as much as possible, a [RepaintBoundary] or
  /// [RenderRepaintBoundary] (or other render object with
  /// [RenderObject.isRepaintBoundary] set to true) might be helpful.
  ///
  /// The `oldDelegate` argument will never be null.
  bool shouldRepaint(covariant CustomPainter oldDelegate);

注释中提到两点:

  1. 即使shouldRepaint返回false,也有可能调用paint方法(例如:如果组件的大小改变了)。
  2. 如果你的自定义View比较复杂,应该尽可能的避免重绘。使用RepaintBoundary或者RenderObject.isRepaintBoundary为true可能会有对你有所帮助。

显然我碰到的问题就是第一点。翻看SingleChildScrollView源码我们发现了问题:


  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      final Offset paintOffset = _paintOffset;

      void paintContents(PaintingContext context, Offset offset) {
        context.paintChild(child, offset + paintOffset); <----
      }

      if (_shouldClipAtPaintOffset(paintOffset)) {
        context.pushClipRect(needsCompositing, offset, Offset.zero & size, paintContents);
      } else {
        paintContents(context, offset);
      }
    }
  }

SingleChildScrollView的滑动中必然需要绘制它的child,也就是最终执行到paintChild方法。


  void paintChild(RenderObject child, Offset offset) {
    
    if (child.isRepaintBoundary) {
      stopRecordingIfNeeded();
      _compositeChild(child, offset);
    } else {
      child._paintWithContext(this, offset);
    }

  }

  void _paintWithContext(PaintingContext context, Offset offset) {
      ...
    _needsPaint = false;
    try {
      paint(context, offset); //<-----
    } catch (e, stack) {
      _debugReportException('paint', e, stack);
    }
   
  }

paintChild方法中,只要child.isRepaintBoundary为false,那么就会执行paint方法,这里就直接跳过了shouldRepaint

解决问题

isRepaintBoundary在上面的注释中提到过,也就是说isRepaintBoundary为true时,我们可以直接合成视图,避免重绘。Flutter为我们提供了RepaintBoundary,它是对这一操作的封装,便于我们的使用。


class RepaintBoundary extends SingleChildRenderObjectWidget {
  
  const RepaintBoundary({ Key key, Widget child }) : super(key: key, child: child);

  @override
  RenderRepaintBoundary createRenderObject(BuildContext context) => RenderRepaintBoundary();
}


class RenderRepaintBoundary extends RenderProxyBox {
  
  RenderRepaintBoundary({ RenderBox child }) : super(child);

  @override
  bool get isRepaintBoundary => true; /// <-----

}

那么解决问题的方法很简单:在CustomPaint外层套一个RepaintBoundary。详细的源码点击这里

性能对比

其实之前没有到发现这个问题,因为整个页面滑动流畅。

为了对比清楚的对比前后的性能,我在这一页面上重复添加十个这样的环形图来滑动测试。下图是timeline的结果:

优化前

优化后

优化前的滑动会有明显的不流畅感,实际每帧绘制需要近16ms,优化后只有1ms。在这个场景例子中,并没有达到大量的绘制,GPU完全没有压力。如果只是之前的一个环形图,这步优化其实可有可无,只是做到了更优,避免不必要的绘制。

在查找相关资料时,我在stackoverflow上发现了一个有趣的例子

作者在屏幕上绘制了5000个彩色的圆来组成一个类似“万花筒”效果的背景图。


class ExpensivePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    print("Doing expensive paint job");
    Random rand = new Random(12345);
    List<Color> colors = [
      Colors.red,
      Colors.blue,
      Colors.yellow,
      Colors.green,
      Colors.white,
    ];
    for (int i = 0; i < 5000; i++) {
      canvas.drawCircle(
          new Offset(
              rand.nextDouble() * size.width, rand.nextDouble() * size.height),
          10 + rand.nextDouble() * 20,
          new Paint()
            ..color = colors[rand.nextInt(colors.length)].withOpacity(0.2));
    }
  }

  @override
  bool shouldRepaint(ExpensivePainter other) => false;
}

同时屏幕上有个小黑点会跟随着手指滑动。但是每次的滑动都会导致背景图的重绘。优化的方法和上面的一样,我测试了一下这个Demo,得到了下面的结果。
在这里插入图片描述
这个场景例子中,绘制5000个圆给GPU带来了不小的压力,随着RepaintBoundary的使用,优化的效果很明显。

一探究竟

那么RepaintBoundary到底是什么?RepaintBoundary就是重绘边界,用于重绘时独立于父布局的。

在Flutter SDK中有部分Widget做了这个处理,比如TextFieldSingleChildScrollViewAndroidViewUiKitView等。最常用的ListView在item上默认也使用了RepaintBoundary
在这里插入图片描述
大家可以思考一下为什么这些组件使用了RepaintBoundary

接着上面的源码中child.isRepaintBoundary为true的地方,我们看到会调用_compositeChild方法;


  void _compositeChild(RenderObject child, Offset offset) {
    ...
    // Create a layer for our child, and paint the child into it.
    if (child._needsPaint) {
      repaintCompositedChild(child, debugAlsoPaintedParent: true); // <---- 1
    } 

    final OffsetLayer childOffsetLayer = child._layer;
    childOffsetLayer.offset = offset;
    appendLayer(child._layer);
  }

  static void repaintCompositedChild(RenderObject child, { bool debugAlsoPaintedParent = false }) {
    _repaintCompositedChild( // <---- 2
      child,
      debugAlsoPaintedParent: debugAlsoPaintedParent,
    );
  }

  static void _repaintCompositedChild(
    RenderObject child, {
    bool debugAlsoPaintedParent = false,
    PaintingContext childContext,
  }) {
    ...
    OffsetLayer childLayer = child._layer;
    if (childLayer == null) {
      child._layer = childLayer = OffsetLayer(); // <---- 3
    } else {
      childLayer.removeAllChildren();
    }
   
    childContext ??= PaintingContext(child._layer, child.paintBounds);
    /// 创建完成,进行绘制
    child._paintWithContext(childContext, Offset.zero);
    childContext.stopRecordingIfNeeded();
  }

child._needsPaint为true时会最终通过_repaintCompositedChild方法在当前child创建一个图层(layer)。

这里说到的图层还是很抽象的,如何直观的观察到它呢?我们可以在程序的main方法中将debugRepaintRainbowEnabled变量置为true。它可以帮助我们可视化应用程序中渲染树的重绘。原理其实就是在执行上面的stopRecordingIfNeeded方法时,额外绘制了一个彩色矩形:

  @protected
  @mustCallSuper
  void stopRecordingIfNeeded() {
    if (!_isRecording)
      return;
    assert(() {
      if (debugRepaintRainbowEnabled) { // <-----
        final Paint paint = Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 6.0
          ..color = debugCurrentRepaintColor.toColor();
        canvas.drawRect(estimatedBounds.deflate(3.0), paint);
      }
      return true;
    }());
  }

效果如下:

在这里插入图片描述
不同的颜色代表不同的图层。当发生重绘时,对应的矩形框也会发生颜色变化。

在重绘前,需要markNeedsPaint方法标记重绘的节点。


  void markNeedsPaint() {
    if (_needsPaint)
      return;
    _needsPaint = true;
    if (isRepaintBoundary) {
      // If we always have our own layer, then we can just repaint
      // ourselves without involving any other nodes.
      assert(_layer is OffsetLayer);
      if (owner != null) {
        owner._nodesNeedingPaint.add(this);
        owner.requestVisualUpdate(); // 更新绘制
      }
    } else if (parent is RenderObject) {
      final RenderObject parent = this.parent;
      parent.markNeedsPaint();
      assert(parent == this.parent);
    } else {
      if (owner != null)
        owner.requestVisualUpdate();
    }
  }

markNeedsPaint方法中如果isRepaintBoundary为false,就会调用父节点的markNeedsPaint方法,直到isRepaintBoundary为 true时,才将当前RenderObject添加至_nodesNeedingPaint中。

在绘制每帧时,调用flushPaint方法更新视图。


  void flushPaint() {

    try {
      final List<RenderObject> dirtyNodes = _nodesNeedingPaint; <-- 获取需要绘制的脏节点
      _nodesNeedingPaint = <RenderObject>[];
      // Sort the dirty nodes in reverse order (deepest first). 
      for (RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => b.depth - a.depth)) {
        assert(node._layer != null);
        if (node._needsPaint && node.owner == this) {
          if (node._layer.attached) {
            PaintingContext.repaintCompositedChild(node); <--- 这里重绘,深度优先
          } else {
            node._skippedPaintingOnLayer();
          }
        }
      }
      
    } finally {
     
      if (!kReleaseMode) {
        Timeline.finishSync();
      }
    }
  }

这样就实现了局部的重绘,将子节点与父节点的重绘分隔开。

tips:这里需要注意一点,通常我们点击按钮的水波纹效果会导致距离它上级最近的图层发生重绘。我们需要根据页面的具体情况去做处理。这一点在官方的项目flutter_gallery中就有做类似处理。

总结

其实总结起来就是一句话,根据场景合理使用RepaintBoundary,它可以帮你带来性能的提升。 其实优化方向不止RepaintBoundary,还有RelayoutBoundary。那这里就不介绍了,感兴趣的可以查看文末的链接。

如果本篇对你有所启发和帮助,多多点赞支持!最后也希望大家支持我的Flutter开源项目flutter_deer,我会将我关于Flutter的实践都放在其中。


本篇应该是今年的最后一篇博客了,因为没有专门写年度总结的习惯,就顺便在这来个年度总结。总的来说,今年定的目标不仅完成了,甚至还有点超额完成。明年的目标也已经明确了,那么就努力去完成吧!(这总结就是留给自己看的,不必在意。。。)

参考

查看原文

马嘉伦 发布了文章 · 2020-05-20

【Flutter应用】Flutter精仿抖音开源

flutter_tiktok

Flutter精仿抖音开源了,左滑搜索,右滑个人中心,上下滑动刷视频,双击点赞冒爱心,无敌精仿,感谢点Star。

应用截图

screenshot.png

实现功能

  • 上下刷视频,视频会自动加载封面
  • 左右滑动去搜索与个人中心
  • 双击冒爱心点赞
  • 看评论
  • 切换底部Tabbar

项目结构

依赖:

  # 加载动画库(好像改版之后就没用到了)
  flutter_spinkit: ^4.1.2
  # Bilibili开源的视频播放组件
  fijkplayer: ^0.8.3
  # 基础的透明动画点击效果
  tapped: any
  # map安全取值
  safemap: any

主要文件:

./lib
├── main.dart
├── mock
│   └── video.dart # 假数据
├── other
│   └── bottomSheet.dart # 修改了系统BottomSheet的高度
├── pages
│   ├── cameraPage.dart # 拍摄页(没有实际功能)
│   ├── followPage.dart  # 略
│   ├── homePage.dart # 主页面,包含tikTokScaffold的实际应用功能
│   ├── msgDetailListPage.dart # 略
│   ├── msgPage.dart # 略
│   ├── searchPage.dart # 略
│   ├── todoPage.dart # 略
│   ├── userDetailPage.dart # 略
│   ├── userPage.dart # 略
│   └── walletPage.d # 略
├── style
│   ├── style.dart # 全局文字大小与颜色
│   └── text.dart # 主要的几个文字样式
└── views
    ├── backButton.dart # iOS形状的返回按钮组件
    ├── loadingButton.dart # 可以设置为载入样式的按钮组件
    ├── selectText.dart # 可设置为“选中”或者“未选中”样式的文字
    ├── tikTokCommentBottomSheet.dart # 仿Tiktok评论样式
    ├── tikTokHeader.dart # 仿Tiktok顶部切换组件
    ├── tikTokScaffold.dart # 仿Tiktok核心脚手架,封装了手势与切换等功能,本身不包含UI内容
    ├── tikTokVideo.dart # 仿Tiktok的视频UI样式封装,不包含视频播放
    ├── tikTokVideoButtonColumn.dart # 仿Tiktok视频右侧的头像与点赞等按钮列的组件
    ├── tikTokVideoGesture.dart # 仿Tiktok的双击点赞效果
    ├── tikTokVideoPlayer.dart # 视频播放页面,带有控制滑动的VideoListController类
    ├── tiktokTabBar.dart # 仿Tiktok的底部Tabbar组件
    ├── tilTokAppBar.dart # 仿Tiktok的Appbar组件
    ├── topToolRow.dart # 用户页面的顶部状态,在tab切换到user页面时隐藏返回按钮
    └── userMsgRow.dart # 一条用户信息的样式组件

地址

GitHub仓库地址

App Demo下载地址

查看原文

赞 19 收藏 15 评论 3

马嘉伦 关注了用户 · 2020-05-18

高阳Sunny @sunny

SegmentFault 思否 CEO
C14Z.Group Founder
Forbes China 30U30

独立思考 敢于否定

曾经是个话痨... 要做一个有趣的人!

任何问题可以给我发私信或者发邮件 sunny@sifou.com

关注 2176

认证与成就

  • 获得 234 次点赞
  • 获得 9 枚徽章 获得 0 枚金徽章, 获得 3 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • flutter_tiktok

    Flutter精仿抖音,在GitHub获取到400+的Star和100+的Fork。

  • flutter-assets-maker

    fmaker是一个flutter辅助图片处理工具,解决了flutter倍图资源管理混乱的问题,也可以用来给iOS或Android项目生成图标。

  • flutter-p2p-engine

    第一个支持Flutter的WebRTC P2P引擎,荣获码云推荐项目。基于HLS流媒体协议(m3u8)的直播和点播场景加速与节省流量,支持加密,支持任何播放器,是Cdnbye的flutter插件。

  • table-cli

    公司内部项目,高度可配置的表格页面生成器,可极大提升管理系统开发效率,不再辛苦搬砖。适用于vue-element-admin框架,自动根据json对象生成element-ui表格与表单的工具,支持在页面上进行增删查改。支持直接从后端数据库生成表格,支持自动翻译字段名称。

注册于 2018-01-05
个人主页被 5.1k 人浏览