2-画图app

仿照Windows自带的画图程序做一个画图app,除了支持Andorid和iOS,也支持Windows

在第一章做时钟app时,我们用了Kivy的标准部件:布局,文本框和按钮。通过这些高层次的抽象,我们能够灵活的修改部件的外观—,可以使用一整套成熟的组件,而不仅仅是单个原始图形。这种方式并非放之四海而皆准,马上你就会看到,Kivy还提供了低层的抽象工具:画点和线。

我认为做画图app是自由绘画最好的方式。我们的应用会看着有点像Windows自带的画图程序。

不同的是,我们的画图app支持多平台,包括Andorid和iOS。我们也忽略了图像处理的功能,像矩形选框,图层,保存文件等。这些功能可以自己练习。

关于移动设备:Kivy完全支持iOS开发,即使你没有类似开发经验也不难。因此,建议你先在熟悉的平台上快速实现app,这样就可以省略编译的时间和一堆细节。Android开发更简单,由于Kivy Launcher可以让Kivy代码直接在Android上运行。 Kivy可以不用编译直接在Andorid上运行测试,相当给力,绝对RAD(rapid application development)。 窗口改变大小的问题,并没有广泛用于移动设备,Kivy应用在不同的移动设备和桌面系统平台使用类似的处理方式。因此,开始编写和调试都非常容易,直到版本确定的最后阶段才需要集中精力弥补这些问题。

我们还会学习Kivy中两个相反的功能:触摸屏的多点触控和桌面系统的鼠标点击。

作为移动设备的第一大法,Kivy为多点触控输入提供了一个模拟层,可以使用鼠标。可以通过右键激活功能。但是,这个多点触控模拟器并不适合真实的场景,仅适合调试用。

画图app最会这这样:

paintapp

设置画板

我们的app通过root部件自动覆盖全局,整个屏幕都可以画画。到后面增加工具按钮的时候再调整。

root部件是处于最外层,每个Kivy的app都有一个,可以根据app的需求制定任何部件作为root部件。比如上一章的时钟app,BoxLayout就是root部件;如果没其他要求,布局部件就是用来包裹其他控件的。

现在这个画图app,我们需要root部件具有更多的功能;用户应该可以画线条,支持多点触控。不过Kivy没有自带这些功能,所以我们自己建。

建立新部件很简单,只要继承Kivy的Widget类就行。如下所示:

这就是画图app的main.pyPaintApp类就是应用的起点。以后我们不会重复这些代码,只把重要的部分显示出来。

Widget类通常作为基类,就行Python的object和Java的Object。当它按照as is方式使用时,Widget功能极少。它没有可以直接拿来用的可视化的外观和属性。Widget的子类都是很简单易用的。

制作好看的外观

首先,让我们做个好看的外观,虽然不是核心功能,但长相影响第一印象。下面我们改改外观,包括窗口大小,鼠标形状。

可视化外观

我认为任何画图软件的背景色都应该是白的。和第一章类似,我们在__name = '__main__'后面加上就行:

你可能想把import语句放到前面,其实Kivy的一些模块导入有顺序要求,且会产生副作用,尤其是Window对象。这在好的Python程序中很少见,导入模块产生的副作用有点小问题。

窗口大小

另一个要改的就是窗口大小,下面的改变不影响移动设备。在桌面系统上,Kivy的窗口时可以调整的,后面我们会设置禁止调整。

如果目标设备明确,设置窗口大小是很有用的,这样就可以决定屏幕分辨率的参数,实现最好的适配效果。

要改变窗口大小,就把下面的代码放到from kivy.core.window import Window上面。

如果要禁止窗口调整:

如果没有充分理由,千万别这么做,因为把窗口调整这点小自由从用户手中拿走实在太伤感情了。如果把应用像素精确到1px,移动设备用户可能就不爽了,而Kivy布局可以建立自适应的界面。

鼠标样式

之后就是改变鼠标光标的样式。Kivy没有支持,不过可以过Pygame实现,基于SDL窗口和OpenGL内容管理模块,在Kivy的桌面平台应用开发中用途广泛。如果你这么用,移动应用大都不支持Pygame。

之后就是改变鼠标光标的样式。Kivy没有支持,不过可以过Pygame实现,基于SDL窗口和OpenGL内容管理模块,在Kivy的桌面平台应用开发中用途广泛。如果你这么用,移动应用大都不支持Pygame。 mousepointer

图中@是黑的,-是白的,其他字符是透明的。所以的线都是等宽的,且是8的倍数(SDL的限制)。鼠标的光标运行后是这样: crosshair

当前的Pygame版本有个bug,pygame.cursors.compile()黑白显示颠倒。以后应该会修复。不过pygame_compile_cursor()是正确的方法,Pygame的Simple DirectMedia Layer (SDL)兼容库。 现在,我们把光标应用到app中,替换PaintApp.build方法:

代码很简单,注意下面四点:

  • EventLoop.ensure_window(): 这个函数到app窗口 ( EventLoop.window ) 准备好才执行。
  • EventLoop.window.__class__.__name__.endswith('Pygame'): 这个条件检查窗口名称Pygame,只是Pygame条件下才执行自定义光标。
  • try ... except模块里面是Pygame的mouse.set_cursor
  • 变量ab通过SDL构建了光标,表示异或(XOR)和与(AND),都是SDL独有的实现方式。

    Pygame文档提供了全部的api说明。 现在做的这些比Kivy的模块更底层,并不常用,不过也不用害怕触及更多的细节。有很多功能只能通过底层的模块实现,因为Kivy还没达到面面俱到的程度。尤其是那些不能跨平台的功能,会涉及很多系统层的实现。

Kivy/Pygame/SDL/OS的关系如下图所示: multiapi

SDL已经把系统底层的API都封装好了,兼容多个系统,Pygame再将SDL转换成Python,Kivy可以导入Pygame模块调用这些功能。

为什么不直接用SDL呢?可以看SDL文档

多点触控模拟器

让运行桌面应用时,Kivy提供了一个模拟器实现多点触控操作。实际上是一个右击行为,获取半透明的点;按住右键时可以拖拽。

如果你没有真实的多点触控设备,这个功能可能适合调试。但是,也会占用右键的功能。不调试的时候还是建议你禁用这个功能,避免对用户造成困扰。设置方法如下:

触摸绘画

要实现用户通过触摸绘画的效果,可以在用户输入后屏幕会出现一个圆圈。

部件如果带on_touch_down事件,就可以实现上述功能。正在需要的是点击位置的坐标,为CanvasWidget添加一个方法获取即可:

要在屏幕上画画,我们就要实现Widget.canvas属性。Kivy的canvas属性是一个底层为OpenGL的可绘制层,不过没有底层图形API那么复杂,canvas可以持续保留我们画过的图。

基本图形如圆(Color),线(Line), 矩形(Rectangle),贝塞尔曲线(Bezier),可以通过kivy.graphics导入。

canvas简介

Canvas的API可以直接调用,也可以通过上下文关联with关键字调用。如下所示:

这里的Line元素的参数是图形命令队列。

如果你想立刻试验代码,请先看下一节屏幕显示触摸轨迹中更完整的例子。

通过上下文关联with关键字调用可以让代码更简练,尤其是在同时操作多个指令时。下面的代码与之前一致:

需要注意的是,如前面所说,canvas上后面调用的指令不会覆盖前面调用的指令;因此,canvas是一个不断增长的数组,里面都是不断显示元素的指令,更新频率60fps,但是也不能让canvas无限增长下去。

例如,所见即所得的程序(如HTML5的<canvas>)里有一条设计规则就是通过背景色填充擦除之前的图像。在浏览器里面可以很直观的写出:

在Kivy设计中,这种模型也是增加指令;首先获取前面所有的图形元素,然后把它们画成矩形。这个看着挺好其实不对:

和内存泄露差不多,这个bug很久没被发现,使代码冗余,性能降低。由于显卡加速的功能,包括智能手机运行速度都很快。所以很难意识到这是一个bug。为了清除Kivy的canvas,应该用canvas.clear()来清除所有指令,后面会介绍。

屏幕显示触摸轨迹

我们马上做一个按钮来清屏;现在让我们把触摸的轨迹显示出来。让我们把print()删掉,然后增加一个方法在CanvasWidget下面:

这样就每次都会画一个空心圆在画布上。Color指令为Line取色。

注意hex('#0080FF80')并不是CSS颜色格式,因为它有四个组成部分,表示alpha值,即透明度。类似于rgb()rgba()的区别。

可能你会觉得奇怪,我们用Line画的是圈,而不是直线。Kivy的图形元素具体很强的自定义功能,比如我们可以用RectangleTriangle画自定义的图片,用source参数设置即可。

前面的程序效果如下图所示: Displayingtouches 画图app完整的代码如下:

这里没有加入鼠标光标显示的部分。paint.kv文件也没有了,用build()方法返回根部件。

注意from kivy.core.window import Window行,是由于有些模块有副作用,所有放在后面导入。Config.set()应该放在任何有副作用模块的前面。

下面,我们增加一些特性,让画图app实现我们想要的功能。

清屏

到目前为止,我们清屏的做法就是重启程序。下面我们增加一个按钮来清屏。我们用上一章时钟app的按钮即可,没什么新鲜,有意思的是位置。

上一章时钟app里面,我们没有讨论过位置,所有部件都放在BoxLayouts里面。现在我们的app没有任何布局,因为根部件就是CanvasWidget,我们没有实现任何子部件的位置。

在Kivy里面,布局部件缺失表示每一个部件都可以随意设置位置和大小(类似的UI设计工具,如Delphi,Visual Basic等等都如此)。

要让清屏按钮放在右上角,我们这么做:

按钮的righttop属性与根部件的属性一致。我们还可以进行数学运行,如root.top – 20。结果很直接,righttop属性都是绝对值。

注意我们定义了一个<CanvasWidget>类却没有指定父类。这么做可以是因为我们在Python代码理论已经定义了一个同样的类。Kivy允许我们扩展所有的类,包括内部类,如<Button><Label>,以及自定义类。

这里体现了Kivy语言描述对象的可视化属性的一个好思路,类似于MVC设计方法,让内容与逻辑分离。同时,也更好的保持了所有Python程序的结构不变。这种Python代码与Kivy语言分离的思想让程序更容易维护。

传递事件

如果你跟着教程看到现在,准备去按清屏键。你会发现没反应,因为还没有增加事件,所有没有反馈。所有单击按钮不会有动作,相反会在画布上留下空心圈。

因为所有的触摸都是发生在CanvasWidget.on_touch_down上,并没有传递给其他子部件,所以清屏按钮没反应。不像HTML的DOMDOM,Kivy事件不会从嵌套的元素升级为父元素显示出来。它们走另一条路,如果事件传递到父元素没有反应,才从父元素下降到子元素。

最直接的方式就是这样:

实际上,Widget.on_touch_down的默认行为有很多,所有我们可以直接调用,让代码更简练。

如果事件被正常处理了,on_touch_down这个handler返回True。触摸按钮会返回True是因为按钮响应了,然后很快的改变其外观。这就是为了取消我们的事件处理需要做的事情,当我们画圈的时候,方法的第二个行就return

清屏

现在我们回到清屏按钮上。其实很简单,就是下面两行:

别忘了把事件绑定到paint.kv文件:

这样就可以清屏了,同时还把按钮也清除了。因为CanvasWidget是根部件,按钮是子部件。按钮部件本身没有被删除,它的画布Button.canvasCanvasWidget.canvas.children层级中移除了,因此不存在了。

要保留按钮,可以这样:

但是这么做不够好,因为不同的部件初始化和运行方式不同。更好的做法是:

  1. CanvasWidget部件中删除所有子部件;
  2. 然后清除画布;
  3. 最后再重新增加子部件,这样它们就可以正确的初始化了。

这个版本有点长,但是更合理:

解释一下saved = self.children[:]语句。[:]操作符是复制数组(就是“创建一个元素相同的数组”)。如果我们写saved = self.children,那就会从self.childrensaved同时删除所有子部件。因为Python赋值是引用,与Kivy无关。

如果想进一步了解Python的特性,可以看看StackOverflow

现在,我们已经可以用蓝色的圈钱画图了,如下所示。这当然并非最终版,请看下面的内容。 Deletebutton

连点成线

我们的app已经可以清屏了,不过只能画圈。下面在改进一下。

要保持连续触控画线(按住然后拖拽),我们需增加一个监听器,on_touch_move。每次使用都会收到最新点的位置。

如果我们一次只有一条线,我们可以把这条线保存为self.current_line。但是,由于这是多点触控,我们就要用其他方法来保存touch变量了。

之所以能实现这些,是因为每个触控自始至终都访问相同的touch对象。还有一个touch.ud属性,是一个字典类型,ud就是用户数据(user data),可以灵活的跟踪所有的触控。初始值为空字典{}

下面我们要做的是:

  • on_touch_down的handler创建一个新线,然后储存到touch.ud。现在我们要用直线来代替空心圈。
  • on_touch_move里面增加一个新点到线的末尾。我们增加的是直线元素,但是事件处理过程是每秒调用很多次实现这条线,每次都很短,最终看起来就很平滑。

更先进的图形程序可以用复杂的算法让线条呈现的更真实。包括贝塞尔曲线实现线条的高分辨率的无缝连接,并且从点的速度和压力推断线的厚度。这些具体的技术我们不打算实现了,不过读者可以作为一个练习。

上述过程的代码如下:

这样就可以画线了。之后让我们来实现颜色选择功能,不断的完善我们的画图app。

调色板

画图app当然不能没有调色板。调色板其实就是可选颜色列表,可以让颜色选取很简单。通过图像编辑器都有调色板,带有全真彩24位色16,777,216种。如下图所示: colorpalettewindow 但是,就是你不打算完成一个主流的图像编辑器,我们也打算限制颜色的种类。因为对那些没有色彩常识的人来说,放一堆颜色只会让人头大。而且,互联网上的UI设计用色也会逐渐统一。

在我们的app中,我们打算使用扁平化的UI设计风格,基于一列精心挑选的颜色。当然,你可以选自己喜欢的颜色,因人而异。

颜色是一门学问,尤其是具体任务的兼容性与稳定性。低对比度的组合可能用来装饰元素或者标题,但是它们不符合正文的风格;另外,高对比度的颜色,如白与黑,不容易吸引注意力。

因此,颜色使用的首要原则是除非你很专业,否则用别人调好的颜色。最好的起点就是操作系统的用色。一些精彩案例如下:

还有很多调色板可以学习,自行Google之。

按钮的子类

因为我使用的颜色很少,所以用单选按钮就可以了。Kivy的ToggleButton可以实现功能,不过有个限制:在一个单选组内,所有的按钮可以同时不选。也就是说,画图的时候可能没颜色。当然我们也可以设定默认颜色,但是用户可能会觉得很奇怪,所有我们不打算这么用。

Python的OOP模式可以很好的解决这个问题,我们可以继承ToggleButton类,然后改造它的功能。之后,每次都会有一个颜色被选中了。

子类还会实现另外一个功能:在调色板上,我们想让每个颜色按钮有唯一颜色。我们可以用之前的技术为每个按钮分配背景色,那就要一堆代码来分配。但是,我们如果写一个背景色属性,就可以在paint.kv文件里面分配了。

这样就可以在paint.kv文件中使用按钮时保持调色板定义的可读性,同时在子类中实现的具体的细节——会展示OOP程序应该怎样实现。

去掉全不选功能

首先,让我们把全不选的功能去掉。

首先,让我们实现一个标准的ToggleButton部件。我们之间在paint.kv文件里面增加如下代码:

我们用了与BoxLayout类似的方式,每个颜色按钮单独分配一个工具栏。布局部件本文的位置是绝对的,其xy的值都是0,也就是左下角,宽度与CanvasWidget一致。

每个ToggleButton都属于同一color组。因此同一时间只有一个颜色可以被选中。

改写标准行为

要实现改写,让我们定义ToggleButton子类:

这样当按下按钮,状态'normal'就会变成'down'

现在我们把paint.kv文件里面ToggleButton改成RadioButton,立刻就会看到不同。

这也是Kivy框架最吸引人的地方:小代码实现大功能。

要在Kivy语言中使用RadioButton,其定义需要在导入main.py文件。由于现在只有一个Python文件,这并不重要,但是一定记住:自定义的Kivy部件,和其他的Python类和函数一样,需要在使用之前被导入。

彩色按钮

现在按钮的功能正常了,我们把彩色按钮都做出来。如下图所示: Paintappcolorpalette 要实现这些,我们得用background_color属性。Kivy的背景色不仅可以使用单一颜色,可以用彩色;我们首先需要一个纯白色背景,然后画上想要的颜色。这样我们就只要为任意数量的彩色按钮准备两种模式(正常和按下的)即可。

这和第一章时钟app是一样的。除了按钮的中心区域允许着色,选中的状态有个黑边。 colorbuttontexture

新按钮

加油!我们就快完工了,在paint.kv里面加入新类ColorButton

你会发现,我们把group: 'color'移到这里避免重复代码。

我们还要配置on_release事件handler,作用于已经被选中的按钮。现在,每个按钮已经把自己的background_color属性传递给事件handler,剩下的事情就是把颜色分配给画布。这个事件将由CanvasWidget处理,需要通过PaintApp类显示出来。

这么配置的原因是我们不能在paint.kv文件的类定义中使用root;因为那样会指向ColorButton自身(类规则里面的根定义在paint.kv文件的顶层)。我们还可以设置默认颜色,就像代码里显示的。

main.py文件里面,让我们来实现CanvasWidgetset_color()方法,可以当作是ColorButton的事件handler。代码很简单,就是把颜色作为参数:

定义调色板

下面我们来定义调色板。首先让我们把RadioButtonpaint.kv文件中删掉。

为了使用CSS颜色定义方式,我们需要将适当的函数导入paint.kv文件。把下面这行代码放在paint.kv文件开头。

这行代码实际上和Python的代码一样:

我们使用扁平化设计的配色方式,代码如下:

很简单吧,这样就为每个ColorButton按钮定义了background_color属性。其他的属性都是继承于Python中ColorButton类的定义。

这样,增加任意数量的按钮都可以很好的排列了。

设置线的宽度

最后一个,也是最简单的功能就是设置线条的宽度。如下图所示,我们可以重用前面调色板的资源和样式。

这个UI也是一种RadioButton子类,命名为LineWidthButton。在paint.kv文件中就是这样:

ColorButton不同之处在于第2、3行代码。这些按钮属于另外一组,由其他的事件handler触发。当然,这两组按钮依然很相似。

布局很简单,和调色板的样式一致,只是垂直摆放:

注意CanvasWidget.set_line_width事件监听器会接受宽度调节按钮的text属性。这样实现是为了简化,允许我们为每一个按钮定义一个唯一的宽度值。 实际开发中,这种方法固然无可厚非。但是,当我们要把文字翻译成日语或法语的时候,这种对应关系就丢失了。

改变线条宽度

让我们把前面做好的模块都组合起来,这样就可以控制线条的粗细了。我们把线条宽度存储在CanvasWidget.line_width变量中,与按钮的文字一一对应,然后用on_touch_down触发事件改变线条宽度。代码如下:

这样就完成Kivy的画图app了,开始画图吧。

总结

这一章,我们重点学习了Kivy应用开发中的一些方法,包括自定义窗口,改变鼠标光标,窗口大小,背景色,通过画布指令绘制自定义的图形,正确的处理支持多平台的触摸事件,并且考虑多点触控的情况。

在完成画图app之后,关于Kivy的一件显而易见的事情就是这个框架具有高度的开放性和通用性。不需要一大堆死板的组件,Kivy让开发者可以通过图形基本元素和行为的运用,让自定义模块变得简单灵活。也就是说,Kivy没有自带很多开箱即用的部件,但是通过几行Python代码就可以做出需要的东西。

模块化的API设计方法缺乏美感,因为它限制了设计的柔性。最终的结果完全的满足你对项目的需求。客户总想要一些爆点,比如三角形按钮——当然,你还可以为它增加质地,这些都可以两三行代码搞定。(假如你想用WinAPI做一个三角形按钮。那就真掉坑里了。)

Kivy的自定义部件还可以重用。实际上,你可以把main.pyCanvasWidget模块导入其他应用。

自然用户界面

我们的第二个应用比第一个应用更具交互性。不仅是在按钮上,还有多点触控手势。

所有的窗口都支持触摸屏,对用户来说这是普遍共识,尤其在触摸屏设备上。只要用手指就可以绘画,好像在真实的画布上,即使手指很脏也可以上面画画。

这种界面被称为NUI(自然界面,natural user interface)。有一些有趣的特性:NUI应用可以被小朋友或者宠物使用——可以在屏幕上看到和触摸图形元素。这是一种自然、直观的界面,一种“不需要思考”的事情,与Norton Commander的反直觉截然不同。直觉不应该接受蓝屏、ASCII码的表现形式。

下一章,我们将建立另外一个Kivy程序,只能Android用。将Python与Android API的Java类很好的结合在一起。