内容简介:大家好久不见,又有一个多月没有发文章了,所以今天发一篇来刷刷存在感。最近 Flutter 非常火,我这一个月也不断的找资料来学习 Flutter。经过一段时间的摸索,我发现现在很多资料都非常”水“。各种 Dart 入门、Flutter 入门、Flutter 资料收集,完全没有任何有趣的东西。我不想去写重复而无聊的文章,所以本篇文章会抛转引玉的探讨一些在学习和开发 Flutter 的过程中遇见的问题和解决方案。天下事有难易乎?为之,则难者亦易已!Q:Flutter 怎么学?
本文首发于微信公众号——世界上有意思的事,搬运转载请注明出处,否则将追究版权责任。交流qq群:859640274
大家好久不见,又有一个多月没有发文章了,所以今天发一篇来刷刷存在感。最近 Flutter 非常火,我这一个月也不断的找资料来学习 Flutter。经过一段时间的摸索,我发现现在很多资料都非常”水“。各种 Dart 入门、Flutter 入门、Flutter 资料收集,完全没有任何有趣的东西。我不想去写重复而无聊的文章,所以本篇文章会抛转引玉的探讨一些在学习和开发 Flutter 的过程中遇见的问题和解决方案。
阅读须知:
- 1.WE——>WsElement、ECWS——>ElementContainerWidgetState、EAL——>ElementActionListener
本文分为以下章节,读者可按需阅读:
- 1.Flutter之问——以 QA 的形式来阐述我对 Flutter 的看法和学习经验。
- 2.移植一个Flutter控件——将仿写抖音的贴纸控件移植到 Flutter 中。
- 3.Flutter探究——聊一聊 Flutter 的原理。
- 4.尾巴
一、Flutter之问
天下事有难易乎?为之,则难者亦易已!
Q:Flutter 怎么学?
A:这是老生常谈的问题了。随便打开一个 Flutter 系列文章,都会为你铺平接下来几周的路。但是几周之后呢?似乎很少文章会接着写下去,**毕竟大脑最喜欢简单的东西(我也不例外),一件事情的难度与受欢迎程度成反比。**所以 Flutter 怎么学?所谓:取乎其上,得乎其中。我只有一句话: 以让 Flutter 成为你最拿手技能为目标去学。
Q:能给一些 Flutter 的学习资料吗?
A:我列举一下我学习 Flutter 过程中用到的资料:
-
1.Dart官网,啃完官方文档,Dart 你就入门了。
-
2.Flutter实战,这本开源书的例子很多,全部敲一遍 flutter 你就入门了。特别是最后的 Flutter 原理分析可以仔细看看。
-
3. Flutter github 仓库 ,现在网络上 Flutter 原理分析的文章真的非常少,所以真想要成为 Flutter 专家,你必须作为开拓者去阅读 Flutter 在各种层级下的源码。
Q:Flutter 会干掉 Native?
A:Flutter 是 Native 的子集。在手机被”革命“之前,但凡业务比较复杂的公司,只会要求 Native 工程师掌握 Flutter。而不会出现抛弃 Native 只做 Flutter 的工程师,因为 Flutter 说一千道一万只是一个 ui 框架。毕竟它自身的复杂度很难支撑起比它还复杂的业务。 以上只是个人观点,有分歧可以在评论区探讨 。
Q:Flutter 哪些地方做的比 Native 好?
A:下面是我总结出来的 Flutter 比 Native 好的地方:
- 1.ios、android 一把抓,还可能带上 web、mac、pc。
- 2.Dart 语言非常现代,比 java 、oc 好上太多。
- 3.新兴框架没有历史包袱。
- 4.热更技术非常诱人。
- 5.入门很简单。
二、移植一个FluTter控件
经常读我的文章的读者应该看过我上一篇文章: 抖音、ins、微信功能大比拼——Story的贴纸文字 ,这篇文章中详细比较了各家 Story 的贴纸文字的功能,然后在 Android 端实现了一个贴纸框架。而这一章我就打算将这个贴纸框架移植到 Flutter,相信最后的还原度会超过你的想象。接下来建议配合源码阅读文章。 注意这一章的大部分内容和上一篇文章中讲解 Android 端实现控件的章节是差不多的。
使用方式:sticker_framework: ^0.0.1
1.架构方式
我们第一节先讲讲文字贴纸控件的架构实现,我会基于下面的 图1 和 github 上的代码进行讲解。建议大家把代码 clone 下来, 当然别忘了给个 star。
我们先来根据图1来讲讲整个控件的架构
- 1.我们先从整体来看:
- 1.我们需要选择一个 StatefulWidget 作为基本的容器。所以图中的 ElementContainerWidgetState 就是一个构造这样的容器的 State,简单概括一下它有这些功能:
- 1.处理各种手势事件,这里的手势包括单指和双指。
- 2.添加和删除一些子 Widget。这里的子 Widget 用于绘制各种元素。
- 3.提供一些 api 让外部能操控元素。
- 4.提供一个 listener,让外部能够监听内部的各种流程。
- 2.有了绘制容器,我们需要向绘制容器里面添加 Widget。而 Widget 在用户操作的过程中需要有各种数据,所以这里我用了 WE 来封装需要展示的 Widget,其内部有下面这些东西:
- 1.各种用户操作过程中需要的数据例如:scale、rotate、x、y等等。
- 2.有一些方法能够通过数据来更新 Widget。
- 3.提供一些 api 让 ECWS 能更新 WE 里面的数据 。
- 3.由 ECWS 和 WE 就能继续继承出各种各样的扩展控件。
- 1.我们需要选择一个 StatefulWidget 作为基本的容器。所以图中的 ElementContainerWidgetState 就是一个构造这样的容器的 State,简单概括一下它有这些功能:
- 2.整体讲完了,我们就可以来仔细的讲讲图中的流程
- 1.先讲横着的箭头: 外部/内部调用 ,外部需要调用 ECWS 来进行对 WE 的增删改查等操作时会进入这个路径,这个路径里可以有下面这些操作:
- 1.addElement:向 ECWS 中添加一个元素。
- 2.deleteElement:从 ECWS 中删除一个元素。
- 3.update:让 WE 根据当前数构建出一个 Widget。
- 4.findElementByPosition:找到传入的坐标下的最顶层的 WE。
- 5.selectElement:选中一个 WE 且将其调到最顶层。
- 6.unSelectElement:取消选中一个 WE。
- 2.再来讲竖着的箭头: 手势事件流 ,这里中间会经历一些内部逻辑我们后面来讲,最终事件流会触发下面的一系列行为:
- 1.单指移动的整个流程:当我们选中了一个 WE 的时候就可以对它进行移动。这里移动可以分为开始、进行中、结束。每个事件都会调用 WE 的对应方法以更新其内部数据。
- 2.双指旋转缩放的整个流程:当我们选中了一个 WE 的时候可以用双指对它进行缩放和旋转。这里可以分为开始、进行中、结束。这里也会调用 WE 的对应方法更新数据。
- 3.选中元素再次点击:当我们选中了一个 WE 的时候,可以对其再次点击。
- 4.点击空白区域:当我们没有点击任意 WE 的时候可以进行一些操作,例如清除当前 WE 的选中状态。这个行为是可以继承的,可以交由子类来覆写。
- 5.子类事件:我们看上面其实感觉触发的事件比较少。所以在 down、move、up 的时候会优先调用三个方法 downSelectTapOtherAction、scrollSelectTapOtherAction、upSelectTapOtherAction。这三个方法可以被子类覆写,如果返回 true 的话表示事件已经消耗了,ECWS 就不会再触发其他事件。 这样一来子类也可以对手势进行扩展,例如按住某个地方单指缩放等等。
- 7.我图中 ECWS 也实现了一个子类 DECWS,这个类简单的加两个手势:
- 1.单指移动缩放:类似抖音的随拍,按住元素的右下角的时候可以用拖动来对元素进行缩放和旋转。
- 2.删除:类似抖音的随拍,点击元素左上角的时候可以直接删除元素。
- 3.图1中有一个特性其实没有画出来因为画不下了, 那就是:ECWS 在1和2中的几乎所有行为都能被外部监听,ElementActionListener 就是负责监听的接口。ECWS 中存有一个 EAL 的 set 集合所以监听器可以添加多个。
- 1.先讲横着的箭头: 外部/内部调用 ,外部需要调用 ECWS 来进行对 WE 的增删改查等操作时会进入这个路径,这个路径里可以有下面这些操作:
2.技术点实现
我在开发整个控件的时候遇到过比较多的技术实现上的难点,所以这一节就选一些来讲讲,让读者在看源码的时候不会特别困惑。
(1).定义数据结构与绘制坐标系
-----代码块1----- ws_element.dart int mZIndex = -1; // 图像的层级 double mMoveX = 0.0; // 初始化后相对 ElementContainerWidget 中心的移动距离 double mMoveY = 0.0; // 初始化后相对 ElementContainerWidget 中心的移动距离 double mOriginWidth; // 初始化时内容的宽度 double mOriginHeight; // 初始化时内容的高度 Rect mEditRect; // 可绘制的区域 double mRotate = 0.0; // 图像顺时针旋转的角度,以 π 为基准 double mScale = 1.0; // 图像缩放的大小 double mAlpha = 1.0; // 图像的透明度 bool mIsSelected = false; // 是否处于选中状态 bool mIsSingeFingerMove = false; // 是否处于单指移动的状态 bool mIsDoubleFingerScaleAndRotate = false; // 是否处于双指旋转缩放的状态 Widget mElementShowingWidget; // 展示内容的 widget Offset mOffset; // ElementContainerWidget 相对屏幕的位移 复制代码
函数未动数据先行,数据结构是一个框架非常核心的东西,定义了一个好的数据结构可以省去很多不必要的代码。所以这一小节我们来根据代码块1定义一下数据结构和 Widget 绘制坐标系
-
1.我们将 WE 所在的 ECWS 作为 WE 中 view 的可绘制区域,代码块1中的 mEditRect 就是这个区域代表的矩形。所以 mEditRect 一般为**[0, 0, ECWS.getWidth, ECWS.getHeight] ,mEditRect 的单位为 px**。
-
2.我们定义的坐标系原点在 mEditRect 的中心点,也就是 ECWS 的中心点。mMoveX、mMoveY 分别表示 view 距离坐标系原点的距离。因为它们俩默认为 0,所以一般 view 被添加到 ECWS 中的时候默认位置就在 ECWS 的中心。这两个参数的单位为 px 。
-
3.我们的坐标系具有 z 轴,mZIndex 就是 z 轴的坐标,z 轴表示 view 的层叠关系,mZIndex 为 0 时表示 view 在 ECWS 的顶层。mZindex 默认为 -1,表示 view 没有被添加到 ECWS 中。mZIndex 是 整数 。
-
4.我们定义 mRotate 为正时 view 顺时针转动,mRotate 的区间为[-360,360]。
5.我们定义 view 没有缩放的时候 mScale 为 1,mScale 为 2 的时候表示 view 放大 2 倍,以此类推。
-
6.mOriginWidth 和 mOriginHeight 为 view 的初始大小,单位是 px 。
-
7.mAlpha 为 view 的透明度,默认为 1 且小于等于1。
-
8.剩下的参数就不用解释了,代码里面都有注释。
(2).WE是如何刷新元素的
-----代码块2----- ws_element.dart
add() {
mElementShowingWidget = initWidget();
}
Widget initWidget();
Widget buildTransform() {
Matrix4 matrix4 = Matrix4.translationValues(mMoveX, mMoveY, 0);
matrix4.rotateZ(mRotate);
matrix4.scale(mScale, mScale, 1);
return Transform(
alignment: Alignment.center,
transform: matrix4,
child: Opacity(
opacity: mAlpha,
child: mElementShowingWidget,
),
);
}
复制代码
- 1.刷新元素的核心代码就是代码块2:
- 1.首先在 ECWS 添加一个 WE 的时候,WE 的子类中可以通过实现 initWidget() 来初始化自己需要的元素内容
- 2.然后每次数据更新时,我们会通过 buildTransform() 构建一个 Widget 给外部使用。
- 3.而 buildTransfrom 内部则是通过 Matrix4 和 Transform 来实现移动旋转缩放,通过 Opacity 来进行 Alpha 变换。
(3).ECWS如何构建整个容器
-----代码块2----- element_container_widget.dart
@override
Widget build(BuildContext context) {
RawGestureDetector gestureDetectorTwo = GestureDetector(
child: GestureDetector(
child: Stack(
alignment: AlignmentDirectional.center,
key: globalKey,
children: mElementList.map((e) {
return e.buildTransform();
})
.toList()
.reversed
.toList()
),
onPanUpdate: onMove,
behavior: HitTestBehavior.opaque,
),
).build(context);
gestureDetectorTwo.gestures[RotateScaleGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<RotateScaleGestureRecognizer>(
() => RotateScaleGestureRecognizer(debugOwner: this),
(RotateScaleGestureRecognizer instance) {
instance
..onStart = onDoubleFingerScaleAndRotateStart
..onUpdate = onDoubleFingerScaleAndRotateProcess
..onEnd = onDoubleFingerScaleAndRotateEnd;
},
);
return Listener(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: double.infinity,
minWidth: double.infinity,
),
child: gestureDetectorTwo,
),
behavior: HitTestBehavior.opaque,
onPointerDown: onDown,
onPointerUp: onUp,
);
}
复制代码
- 1.我们都知道 State 中需要在 build() 中返回一个 Widget 给 StatefulWidget。
- 2.为了装下多个有层叠关系的元素,我们使用 Stack 作为元素的容器。
- 3.Stack 外面包装了 GestureDetector 来处理 move 事件。
- 4.GestureDetector 外部包装了我自定义的 RotateScaleGestureRecognizer 来处理双指旋转缩放事件。
- 5.最外层则是用 Listener 来监听手指 down 和 up 事件。
- 6.上面这样的设计的原因我会在后面深入 Flutter 的时候讲解。
3.源码流程解析
这一节我主要会对项目中的测试 demo 进行源码流程分析,让读者对控件整体的运行方式有个简单的了解。这一节主要是讲解源码,所以读者一定要去 clone 源码,跟随文章的脚步前进。
(1).添加元素
- 1.简单的初始化动作我就不赘述了,我们从 main.dart 的 add 按钮开始。点击后先会创建一个 StickerElement 这个是我测试用的元素,里面代码很简单也不说了。
- 2. addSelectAndUpdateElement 是一个组合方法,里面调用了 addElement 、 selectElement 、 update ,也就是添加元素,选中元素,更新元素。我们一个个来分析::
- 1. addElement :这个方法里主要做了下面这些事情:
- 1.进行数据检查,如果被添加的 WE 为空或者该 WE 已经在 ECWS 中,那么添加失败。
- 2.在 ECWS 中我维持了一个 WE 的 List,所有的 WE 都存于其中,每次 add 的时候 WE 都会被添加到 list 的最前面 ,其他 WE 的 mZIndex 也会顺势更新。
- 3.调用 WE.add 方法,里面使用 initWidget 初始化了 mElementShowingView,前面我们说过了 initWidget 的逻辑由子类定义。
- 4.调用监听器的对应方法,且调用自动取消选中的方法( ECWS 可以被外部决定是否自动取消选中 )。
- 2. selectElement :WE 被 add 了之后,我们这里直接将其选中,代码里面主要做了下面这些事情:
- 1.进行数据检查,如果需要选中的 WE 没有被添加到 ECWS 中则选中失败。
- 2.将需要选中的 WE 从 list 中移除然后添加到 list 的顶部,然后顺便更新其他 WE 的 mZIndex。
- 3.调用 WE 的 select 方法,里面主要就是更新要选中的 WE 的数据。
- 4.调用监听器对应的方法。
- 3. update :前面都做好了,就需要将 WE 调整到其应该的状态,这里我想大家都猜到了就是调用 setState 然后其会触发我们在第二节中说的 build 方法,然后调用每个 WE 的 buildTransform 返回数据被更新后的 Widget。
- 1. addElement :这个方法里主要做了下面这些事情:
(2).元素单指手势
元素手势不像添加元素那样需要外部调用,元素手势是通过事件分发触发的,我们这里不讲 Flutter 的事件分发机制,只讲我们基于其上的逻辑。
- 1.对于元素单指手势的处理,主要看三个触摸事件:down、move、up。所以我们直接看 ECWS.build 中设置的三个回调方法。
- 1. onDown 里面的逻辑如下:
- 1.通过 findElementByPosition 根据 down 的位置找到当前位置下最顶层的 WE。
- 2.如果当前有选中的 WE 且与当前触摸 WE 是同一个的话,那么先调用 downSelectTapOtherAction ,这个函数可以被子类覆写,默认返回 false。也就是说子类可以优先处理当前事件,如果子类处理了这个事件,那么 return。如果子类不处理,那么将 mMode 标记为 SELECTED_CLICK_OR_MOVE ,表示最终的手势可能是点击元素,也可能是移动元素。具体的行为需要 move 或者 up 的时候才能判定。
- 3.如果当前有选中的 WE 但与当前触摸的 WE 不是同一个的时候也分两种情况:一种情况是触摸的 WE 不存在,此时表示将 mMode 标记为 SINGLE_TAP_BLANK_SCREEN 表示 点击了 ECWS 的空白区域 。另一种情况是触摸的 WE 存在,此时表示重新选中了一个 WE。
- 4.如果当前没有选中的 WE,也会有两种情况:一个是触摸的 WE 也不存在,那么和前面一样表示点击空白区域。否则的话就是选中一个 WE。
- 2. onMove 中会优先将 move 事件交给 scrollSelectTapOtherAction ,该方法也可以被子类覆写,同样默认返回 false,如果子类处理了这个事件,那么就直接 return 了。否则当 mMode 为 SELECTED_CLICK_OR_MOVE(已经选中了 WE 开始移动)、SELECT(没有选中 WE 开始移动)、MOVE(WE 移动过程中) 三种情况中的一种的时候,都可以触发移动手势。具体的逻辑在 singleFingerMove 中:
- 1.先根据 mMode 的状态,调用 singleFingerMoveStart 或 singleFingerMoveProcess 。singleFingerMoveStart 中调用了监听器和 WE 的对应方法,里面基本没什么逻辑。 singleFingerMoveProcess 中也调用了监听和 WE 的对应方法,但是 WE 的对应方法中更新了 mMoveX 和 mMoveY 的数据。
- 2.调用 update 更新 WE 中的 view。将 mMode 设置为 MOVE ,表示处于移动中。
- 3. onUp 方法:
- 1. mMode 为 SELECTED_CLICK_OR_MOVE ,到这里的时候才能确认,用户的行为是 选中了元素之后的点击 ,我们在前面分析过了这里面的事件分发的机制,这里也不赘述了。
- 2. mMode 为 SINGLE_TAP_BLANK_SCREEN ,表示点击 ECWS 的空白处,这里调用的 onClickBlank 也是可以被子类覆写的,可以实现一些自己的逻辑。
- 3. mMode 为 MOVE ,结束调用单指移动结束。
- 1. onDown 里面的逻辑如下:
三、Flutter探究
这一章我会从一个 Android 工程师的角度来研究一下 Flutter,讲一讲我在移植控件时遇见的问题们。
1.Flutter与Android对比
先看看 Flutter 与 Android 写的 App 实际的比较吧
- 1.我在将代码从 Android 移植到 Flutter 上花费了大概 10 个小时。整个控件在 Android 上开始设计到开发完成则是花费了 100 多个小时。所以整个库的移植成本并不算太高。
- 2.看上面 gif 的比较,可以发现流畅度上面并没有区别。我找了几个朋友实际体验了一下,大家都同样没有发现使用起来有差异。
- 3.图3、图4分别是 Flutter 和 Android 的性能图。我们发现的确像很多测评文章里面说到的。Flutter 的内存消耗要比 Native 多。在实验比较的时候我添加了几十个元素。最后两端都稳定在了一个内存数值上面。Flutter 是 256MB 左右,Android 是 128MB 左右。
- 4.在移植代码的过程中,我总结了下面这些写 Java 和 Dart 之间的区别:
- 1.Dart 有非常多的语法糖,代码比起 java 来说有比较多的精简。
- 2.Dart 的传参方式使得写 Flutter 控件的时候更像是在写属性配置表。
2.Flutter原理
以一个 Android 工程师的眼光来看 Flutter
(1).Flutter的事件简单总结
-
1.LIstener 是手势的基础:GestureDetector 是基于 Listener 开发的。
-
2.事件自底向上,事件不可截断
- 1.先定义一下:自底向上表示从子 view 到父 view。自顶向下表示从父 view 到子 view。
- 2.做过 Android 的同学知道 Android 中的事件**是一个自顶向下再自底向上的过程。**在中间的任意一环我们都可以进行拦截,从而让事件不再继续传递。
- 3.Flutter 的事件模型则是: 自底向上,而且目前来看没有任何操作能阻断这个流程。
- 4.也就是说,如果我们使用 Listener 对任意一个 Widget 进行监听,那么我们在事件传递的过程中阻止 Listener 获取事件。
- 5.事件不可截断的特性在开发中最有用的地方就是:如果我们使用 tapUp,tapDown,这类手势想要监听手指的抬起和放下,那么这些手势可能会被其他手势给冲掉。此时我们就能使用 Listener 来通过监听具体的 down 和 up 事件,因为这个是不可截断的。
-
3.开发中我们使用 GestureDetector 封装 Widget,我们定义的一个个手势回调会让 GestureDetector 生成多个 GestureRecognizer 附着在当前的 Widget 上以处理 Widget 接收到的事件。
-
4.每根手指的 down、move、up 都是一个事件流,当 down 事件自底向上确立了一个 Widget 链的时候,附着在链中各个 Widget 上的 GestureRecognizer 们就会去竞争这个事件流的归属。
-
5.一个事件流的胜出 GestureRecognizer 只有一个,胜出后整个事件流都属于这个 GestureRecognizer 。
-
6.GestureRecognizer 的胜出机制,就是 Flutter 在事件不可截断这个 feature 上的补充的灵活性,可以使得某个 Widget 上的手势被截断, 推荐优先使用 Gesture 。
-
7.Gesture 的胜出机制是怎么样的?
- 1.如果一次竞争中只有一个 GestureRecognizer,那么他就直接胜出。
- 2.如果一次竞争中有多个相同的 GestureRecognizer,那么越底层的越胜出。
- 3.如果一次竞争中有不同的 GestureRecognizer:
- 1.GestureRecognizer 中定义了一个超时机制,有些 GestureRecognizer 定义了某个事件进行了一个时间阈值后如果没有其他 GestureRecognizer 申请延长阈值那么本 GestureRecognizer 就直接胜出。例如:TapGestureRecognizer 定义了 down 事件进行了 100 ms 之后,如果没有其他 GestureRecognizer 延长阈值,那么自己就获得事件流。
- 2.而 LongPressGestureRecognizer 定义的时间阈值是 500ms,如果 500ms 后没有其他 GestureRecognizer 申请延长阈值则自己获得事件流。
- 3.那么 TapGestureRecognizer 和 LongPressGestureRecognizer 都在的时候,通过 down 事件的长短来判断谁胜出。
(2).Flutter的绘制逻辑
四、尾巴
啊!感觉这篇文章有点虎头蛇尾的感觉,文章从开始到结束跨了好几周。中间又是加班又是搬家,把我的热血都消磨了。本来多加一些 Flutter 的深入探究的,但是感觉会越写越久,所以先就这样。接下来我会写一系列文章来分析 Flutter 的原理和 Flutter Sdk。所以更多内容敬请期待!ps: 一鼓作气,再而竭,三而衰。真是完美的表现了我写这篇文章的过程,希望读者们不要学我。
连载文章
- 1.从零开始仿写一个抖音app——开始
- 4.从零开始仿写一个抖音App——日志和埋点以及后端初步架构
- 5.从零开始仿写一个抖音App——app架构更新与网络层定制
- 6.从零开始仿写一个抖音App——音视频开篇
- 7.从零开始仿写一个抖音App——基于FFmpeg的极简视频播放器
- 8.从零开始仿写一个抖音App——跨平台视频编辑SDK项目搭建
- 9.从零开始仿写一个抖音App——Android绘制机制以及Surface家族源码全解析
不贩卖焦虑,也不标题党。分享一些这个世界上有意思的事情。题材包括且不限于:科幻、科学、科技、互联网、 程序员 、计算机编程。下面是我的微信公众号: 世界上有意思的事 ,干货多多等你来看。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
The Haskell School of Music
Paul Hudak、Donya Quick / Cambridge University Press / 2018-10-4 / GBP 42.99
This book teaches functional programming through creative applications in music and sound synthesis. Readers will learn the Haskell programming language and explore numerous ways to create music and d......一起来看看 《The Haskell School of Music》 这本书的介绍吧!