一个仿iOS和Android内置时钟应用的app。分两部分:
- 个没有交互的数字时钟,简述Kivy的事件驱动(event-driven)方法,引入计时器的功能,持续更新。
- 交互的秒表功能,设计流畅的自适应布局。
学习大纲:
- Kivy语言基础,DSL(domain-specific language)处理部件(widgets)
- Kivy布局方式
- 自定义字体和文字样式
- 事件管理
app最终效果如下,只要60行代码,Python代码和kv代码各一半。
起点
将kivy的helloworld
稍作修改。
1 2 3 4 5 6 7 8 9 10 |
# %load ../0_Hello/main.py from kivy.app import App class ClockApp(App): pass if __name__ == "__main__": ClockApp().run() |
增加一个布局容器(layout container),BoxLayout,后面可增加更多部件。
1 2 3 4 5 6 |
# clock.kv BoxLayout: orientation: 'vertical' Label: text: '00:00:00' |
BoxLayout
容器可以包含多个子部件,水平或垂直堆放。由于kv
只有一个子部件,BoxLayout
就会让它充满所有空间。
当运行
main.py
文件时,Kivy自动调用clock.kv
。类名是ClockApp
,.kv
文件名就是clock
,类名小写并去掉App
。
新UI
扁平化设计模式(flat design paradigm)如日中天,覆盖Web,移动,桌面应用领域,兴起于iOS7和Win8。互联网公司也追随,于Google I/O 2014出Material design,其他HTML5框架,如Bootstrap亦如是。
扁平化设计强调内容胜于外观,忽略逼真图片的阴影和细致的质地,支持纯色和简单几何图形。强调比学院派的仿真设计(skeuomorphic design)更简单的程序化创造,前者倾向于丰富视觉效果和艺术感。
仿真主义是用户界面设计的主流方法。认为应用程序属于真实世界的一部分,比如一个带按钮的计算器app应该被做成廉价的、物质的计算器的感觉,有助于提升用户体验(得看是谁用)。
如今,放弃视觉细节而转向简单、流线型界面仿佛是共识。另一方面,仅靠一堆彩色框框就想做成惊世骇俗的作品很有难度。扁平化设计成了文字排版好的代名词原因就是文字成了UI设计中重要的部分,所有我们要让文字好看。
设计灵感
模仿Android 4.1 Jelly Bean的时钟设计。字体是Google的Roboto字体,取代了Android 4.0 Ice Cream Sandwich的Droid字体。
加载自定义字体
Kivy默认是Droid Sans字体,通过font_name
属性可设置自定义字体。这里只有一种字体,可以直接将.ttf
文件名放上。
1 2 3 |
# clock.kv Label: font_name: 'Loster.ttf' |
但是我们要好几种字体,一个属性就不够了。因为不同字体都是单个文件,而属性只能跟一个文件名。涉及多种字体可以用LabelBase.register
方法可以接受多种字体,如下所示:
1 2 3 4 5 6 7 |
LabelBase.register( name="Roboto", fn_regular="Roboto-Regular.ttf", fn_bold="Roboto-Bold.ttf", fn_italic="Roboto-Italic.ttf", fn_bolditalic="Roboto-BoldItalic.ttf", ) |
改进之后,一个部件的font_name
属性可设置多种自定义字体了。但这种方法有两个限制:
- kivy只接受TrueType的
.ttf
字体。如果是OpenType的.otf
或者网页字体如.woff
,得先转换。 - 字体normal,italic,bold,bold italic四种样式有最大值。旧字体没问题,如Droid Sans。但是新字体都有4到20多种样式,其高度和其他特征也不同。Roboto至少有12种样式。
第二点迫使我们选择app字体时要把12种样式全放进去,这么做会增大app的体积,Roboto字体有1.7M。
本例中我们只要两种样式:浅色(Roboto-Thin.ttf
)和加粗(Roboto-Medium.ttf
)
1 2 3 4 5 |
from kivy.core.text import LabelBase LabelBase.register( name="Roboto", fn_regular="Roboto-Thin.ttf", fn_bold="Roboto-Medium.ttf" ) |
下面我们来使用字体,放到Label
后面即可。
1 2 3 4 5 |
# clock.kv Label: text: '00:00:00' font_name: 'Roboto' font_size: 60 |
字体格式
markup语言毋庸置疑HTML。Kivy实现了另外一种BBCode的markup语言,用[]作标签。
BBCode tag | Effect on text |
---|---|
[b]…[/b] | Effect on text |
[i]…[/i] | Italic |
[font=Lobster]…[/font] | Change font |
[color=#FF0000]…[/color] | Set color with CSS-like syntax |
[sub]…[/sub] | Subscript (text below the line) |
[sup]…[/sup] | Superscript (text above the line) |
[ref=name]…[/ref] | Clickable zone, <a href="..."> in HTML |
[anchor=name] | Named location, <a name="..."> in HTML |
由于Kivy发展很快,以上内容绝非最终版本,详情查阅kivy文档。
再看看图2,我们要实现小时数字加粗的效果就easy了。
1 2 3 4 |
# clock.kv Label: text: '[b]00[/b]:00:00' markup: True |
Kivy的BBCode需要将markup属性设置为True。
如果要整行加粗,可以直接设bold属性为True。其他斜体、颜色、字体、大小同理。
改变背景色
下面我们来调整窗口背景色,是Window
对象的一个属性。可以在__name__ == '__main__'
后面增加代码:
1 2 3 4 |
from kivy.core.window import Window from kivy.utils import get_color_from_hex Window.clearcolor = get_color_from_hex("#101216") |
函数get_color_from_hex
允许使用CSS的RGB颜色值(#RRGGBB
),也可以用其他函数。
显示时间
大多数UI框架都是事件驱动,Kivy也不例外。这种方式相比通常的程序更简单——事件驱动的代码需要不断返回到主循环(main loop
);但是,这么做不能处理用户行为(点击鼠标,改变窗口),而且界面会冻结(freeze
),Windows经常这样程序停止响应
。
总之,不能在程序里面加无限循环实现。
1 2 3 4 |
# Don't do this while True: update_time() # some function that displays time sleep(1) |
理论上可行,但UI实际会失去相应,直到系统或用户关闭进程才结束。记住Kivy内部一直运行主循环,我们可以通过事件与计算器来利用它。
事件驱动还意味着我们需要对不同事件作出响应,可能是用户输入,网络行为,或超时等等。
很多程序监听共同事件之一就是App.on_start
,定义在类里面,在app初始化的时候调用。另一个常见的是on_press
,当用户点击,tap,或其他按钮操作时启用。
通过时间和计时器,我们就可以用Kivy自带的Clock类实现想要的功能。两个方法:
Clock.schedule_once
:在一段时间后运行一次Clock.schedule_interval
:周期性的运行
和JavaScript中的
window.setTimeout
和window.setInterval
类似。其实Kivy和JS很像,即使API完全不同。
Clock
所有的计时事件都是Kivy主循环的一部分。这种方法与线程不同,这样调用一个阻塞函数可能会阻止其他事件被及时唤醒。
更新屏幕上的时间
要接入显示时间的Label
部件,需要给它一个id
,通过id
属性来获取部件,这和Web开发类似。
1 2 3 |
# clock.kv Label: id: time |
之后就可以通过root.ids.time
来接入Label
部件了。这里root
就是BoxLayout
。
给ClockApp
类增加一个update_time
方法来更新时间:
1 2 |
def update_time(self, nap): self.root.ids.time.text = strftime("[b]%H[/b]:%M:%S") |
再增加一个调度功能,让程序更新后每秒更新一次:
1 2 |
def on_start(self): Clock.schedule_interval(self.update_time, 1) |
运行程序看看是不是开始更新了。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# %load main.py from kivy.app import App from kivy.clock import Clock from kivy.core.window import Window from kivy.utils import get_color_from_hex from time import strftime class ClockApp(App): def update_time(self, nap): self.root.ids.time.text = strftime("[b]%H[/b]:%M:%S") def on_start(self): Clock.schedule_interval(self.update_time, 1) if __name__ == "__main__": Window.clearcolor = get_color_from_hex("#301216") ClockApp().run() |
看看python的time
标准库strftime
函数是如何与Kivy的BBCode组合成C语言字符串的。
1 2 3 4 5 6 7 8 |
# %load clock.kv BoxLayout: orientation: 'vertical' Label: text: '[b]00[/b]:00:00' markup: True id: time |
用属性绑定部件
除了ID绑定部件,还可以新建一个属性,在kv文件中进行绑定。这么做更符合DRY原则,只是多几行代码。如下所示:
1 2 3 4 5 6 7 |
# In main.py from kivy.properties import ObjectProperty from kivy.uix.boxlayout import BoxLayout class ClockLayout(BoxLayout): time_prop = ObjectProperty(None) |
我们在这段代码用BoxLayout
写了个新root部件类,它有一个自定义属性time_prop
,将连接Label
部件。
在clock.kv
文件里,我们把属性绑定id
,自定义属性和默认属性语法一致:
1 2 3 4 5 6 |
# %load clock.kv ClockLayout: time_prop: time Label: id: time |
这样,Python代码不需要知道id
就可以连接Label
部件,用新属性root.time_prop.text = "demo"
。
这样做使代码的可移植性更好,消除了反射(refactor)时Python代码同步的问题。靠属性还是root.ids
去连接Python代码这事儿,只是代码风格问题,不重要。后面还会介绍其他Kivy属性的用法,让数据绑定更容易。
布局基础
Kivy提供了一堆Layout
类来布局。Layout
又是Widget
类的子类,是个容器类。每个布局都是影响其子类位置和尺寸。
在这个app中,我们的UI很直接,不需要什么神奇,如下所示:
做这种界面就要BoxLayout
,一种一维网格。在clock.kv
里面已经有BoxLayout
了,只有一个子部件。Kivy的布局默认充满屏幕,所以自动适应屏幕。
如果增加一个Layout
,就会分一半屏幕,vertical
和horizontal
决定分割的方向。
我们这里就用vertical
分三块,然后中间那块用horizontal
分两块,Esay吧。
完成布局
由于中间这块是按钮,不应该比时间还大,可以增加一个height
属性,然后设置size_hint
属性为None
。size_hint
属性是一个元组(宽, 高)
,影响部件的宽和高。如果你想用绝对高度和宽度,就要设置size_hint
属性为None
,否则高度和宽度设置无效,部件会自动计算尺寸。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
# %load clock.kv BoxLayout: orientation: 'vertical' Label: id: time text: '[b]00[/b]:00:00' font_name: 'Roboto' font_size: 60 markup: True BoxLayout: height: 90 orientation: 'horizontal' padding: 20 spacing: 20 size_hint: (1, None) Button: text: 'Start' font_name: 'Roboto' font_size: 25 bold: True Button: text: 'Reset' font_name: 'Roboto' font_size: 25 bold: True Label: id: stopwatch text: '00:00.[size=40]00[/size]' font_name: 'Roboto' font_size: 60 markup: True |
运行代码,会发现按钮没有完全填充BoxLayout
,因为用了padding
和spacing
属性,与CSS类似。main.py
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
# %load main.py from kivy.app import App from kivy.clock import Clock from kivy.core.window import Window from kivy.utils import get_color_from_hex from kivy.core.text import LabelBase from time import strftime LabelBase.register( name="Roboto", fn_regular="Roboto-Thin.ttf", fn_bold="Roboto-Medium.ttf" ) class ClockApp(App): def update_time(self, nap): self.root.ids.time.text = strftime("[b]%H[/b]:%M:%S") def on_start(self): Clock.schedule_interval(self.update_time, 1) if __name__ == "__main__": Window.clearcolor = get_color_from_hex("#123456") ClockApp().run() |
减少重复
之前的kv代码一堆重复,其实可以借助CSS的方法是代码更精炼,更DRY。在BoxLayout
外面增加一个新定义:
1 2 3 4 5 |
# %load clock.kv <Label>: font_name: 'Roboto' font_size: 60 markup: True |
这是一个类,与CSS的selector类似。每个Label
都会带<Label>
类特性。
这样就可以把clock.kv
里面每个Label
的font_name
,font_size
和markup
属性都删掉了。如果想改变一个属性的值,就直接写上,会覆盖原来的值,与CSS完全一样。
定义类并没有创造一个新部件,只是一个属性集合。增加一个定义类,如果不使用就不会改变app的布局。
命名类
前面kv代码里类的处理有点问题,类只能有一个名字叫Label
。当我们要为同一种部件加不同的属性定义类时,可以自定义类。如果直接改写Label
和Button
这些标准类,之后再用到通过类部件时改前改后一堆麻烦。所幸,命名类可以解决这一问题,RobotoButton
是一种Button
:
1 2 3 4 |
<RobotoButton@Button>: font_name: 'Roboto' font_size: 25 bold: True |
@
前面是新类的名称,后面是部件类型,本质是面向对象的子类class RobotoButton(Button)
,在kv代码里使用时,可以直接用命名类代替原来的Button
类:
1 2 |
RobotoButton: text: 'Start' |
命名类可以精简代码,而且可以改良部件。
按钮样式
UI设计的死角是可点击元素,像按钮之类,没有一个统一样式。Win8的Metro风格十分激进,点击部分完全是纯色矩形,很小甚至基本没图案。Apple使用弧度;还有一种使用圆角的趋势,尤其在CSS3风格里。轻微的阴影也开始使用。
Kivy在这方面很灵活,不强制任何一种风格,而且提供一堆特性帮你实现任意风格。其中之一就是9-patch缩放功能。
9-patch scaling
传统UI开发中,如果背景的大小不一样,一般需要为每种大小都制作一张图片,这在button中尤为明显。当然我们也可以一小块一小块水平重复的画,也可以垂直的话。在android中专门有一种叫9-patch图片(以9.png结尾)来解决背景大小不一样时,只用一张背景图片。无论横屏还是竖屏,高分辨率还是低分辨率,都能自动填充满,而且不失真。
缩放算法的目的就是尽可能的适应不同场合的像素要求,尤其是包含一堆文字的按钮。等比放缩图片容易实现,但是由于变形比例问题,质量不太好。
非等比的9-patch放大可以产生不失真的效果。其理念就是把图片分成若干静止的、可缩放的块。假设下图是个可缩放按钮。黄色部分是操作区,其他颜色都是边:
当红色区域被压缩时,蓝色区域大小不变。如下图所示:
蓝色的角是不变的,红色的边可以垂直、水平缩放。图片中唯一等比变化的部分就是黄色的操作区,通常都是用纯色,也可以加上文字。
使用9-patch图
本例中,我们用一个简单的1px边的纯色按钮,改下颜色就可以重用。如下所示:
按下去的状态就用相反的颜色,如下所示:
现在在clock.kv
中添加9-patch图,我们需要告诉Kivy图像边的像素,因为默认是等比变化的。
1 2 3 4 |
<RobotoButton@Button>: background_normal: 'button_normal.png' background_down: 'button_down.png' border: (2, 2, 2, 2) |
border
属性与CSS一致是顺时针:上,右,下,左。不过,不能像CSS里面直接写统一值border: 2
,暂时还不行。
当然用Python语法
border:[2] * 4
是最短的。
前面说过,与CSS类似,后面的属性会覆盖前面同名的属性,比如新建Reset
按钮,就可以在RobotoButton
下修改:
1 2 3 4 |
RobotoButton: text: 'Reset' background_normal: 'red_button_normal.png' background_down: 'red_button_down.png' |
这样按钮就搞定了,但是还不能运行,下面我们来实现秒表功能。
计时功能
秒表不只是显示时间,还需要暂停、复位,比普通的钟表要复杂一点。反映到程序上,就是Python的datatime
模块和strftime()
函数的区别。后者可以直接将现在的时间格式化,正是秒表显示所要的。
首先,我们要建立一个计时器。由于Kivy的Clock.schedule_interval
事件handler支持时间参数,所以不通过Python的时间函数也容易实现。
1 2 3 4 5 6 |
def on_start(self): Clock.schedule_interval(self.update, 0.016) def update(self, nap): pass |
时间单位是秒,就是说app每秒运行60次(60fps)为1帧,平均间隔时间为
然后就是时间持续更新:
1 2 3 4 5 |
class ClockApp(App): sw_seconds = 0 def update(self, nap): self.sw_seconds += nap |
我们先做时间显示功能,然后再实现停止功能。
秒表时间格式
对于主时间显示,格式很简单,因为标准模块strftime
提供了datetime
时间转换字符串的功能。但是这个函数有一些不足:
- 只接受Python的
datetime
时间格式(但是秒表需要秒用小数显示sw_seconds
) - 没有十进制秒的转换功能
datetime
的不足容易克服:可以将sw_seconds
转换为datetime
时间格式。但是有点多余,因为我们最后还是需要小数显示,所以strftime
格式不行。那么我们就自己做个轮子。
计算时间
首先计算分、秒和分秒,divmode
函数输出(商,余数)。
1 |
minutes, seconds = divmod(self.sw_seconds, 60) |
divmode
函数只计算一次,普通/
和%
运算需要两次。如果我们每一帧画面都有大量这样的浮点数除法,就像游戏或仿真,CPU就费劲了。
不太同意所谓“过早优化是魔鬼”,许多差的实践导致程序性能低下,其实一开始很容易避免,而且不影响代码质量,不去做才是魔鬼。
要注意divmode
函数结果都还是浮点数,所以要去争:int(minutes)
和 int(seconds)
。
现在就剩下分秒了,可以这样获得:
1 |
int(seconds * 100 % 100) |
实现秒表
现在所有的数值都有了,让我们组合一下。Python的字符串处理有很多格式,与The Zen of Python(打开Python输入import this)的 “There should be one—and preferably only one—obvious way to do it”并不一致,呵呵。最简单的就是%为代表的C语言风格。
1 2 3 4 5 6 7 8 |
def update_time(self, nap): self.sw_seconds += nap minutes, seconds = divmod(self.sw_seconds, 60) self.root.ids.stopwatch.text = "%02d:%02d.[size=40]%02d[/size]" % ( int(minutes), int(seconds), int(seconds * 100 % 100), ) |
现在有分秒了,之前用的更新频率1fps就不适用了。让我们把update_time
时间间隔改为0,即每一帧都更新:
1 |
Clock.schedule_interval(self.update_time, 0) |
运行程序会看到时间更新,但是还缺少控件,下面就是。
秒表控件
用按钮来控制应用是最简单的。下面就是所有代码:
1 2 3 4 5 6 7 8 9 10 |
def start_stop(self): self.root.ids.start_stop.text = "Start" if self.sw_started else "Stop" self.sw_started = not self.sw_started def reset(self): if self.sw_started: self.root.ids.start_stop.text = "Start" self.sw_started = False self.sw_seconds = 0 |
第一个事件handler是Start和Stop按钮,由sw_started
改变状态实现。第二个handler是Reset按钮。
还需要增加状态属性跟踪秒表是否在运行:
1 2 3 4 5 6 7 |
class ClockApp(App): sw_started = False sw_seconds = 0 def update_clock(self, nap): if self.sw_started: self.sw_seconds += nap |
我们改变update_clock
函数只有秒表开始sw_started
为True
才更新,秒表开始默认为停止状态。
在clock.kv
文件里,我们把方法绑定到on_press
事件上:
1 2 3 4 5 6 7 8 9 |
RobotoButton: id: start_stop text: 'Start' on_press: app.start_stop() RobotoButton: id: reset text: 'Reset' on_press: app.reset() |
在Kivy语言里面,有几个上下文相关的参考:
- self:引用当前部件;
- root:整个程序中最外层的部件;
- app:应用类的一个实例。
你会发觉,按钮事件处理一点也不难。就这样,我们的app实现了秒表的交互功能,允许用户开始,停止,复位。
总结
这一章我们做了一个app,如果要打包并发布到Google Play或其他商店供大家用,还需要一点工作,因为涉及到具体的平台,但是最难的部分——编程——已经结束。
通过个app,我们学习了ivy应用开发的很多方面,并不需要太多复杂代码就搞定了。Kivy的主要特点就是短小精悍的代码,允许快速迭代。一点点旧代码就可以获得很多新特性。Kivy生命力旺盛,将长盛不衰。
这本书所以内容的共同基础是,无论我们的程序还是Kivy,都不是凭空产生的。一切都源自Python的cheese shop
——Python Package Index (PyPI)——以及其他工具包,包括操作系统底层服务。
我们还更新了许多网页应用开发的资源,如CSS框架Bootstrap中的字体、颜色和阴影。当然也希望你看看Google的Material design principles
——不仅只是设计资源集合,也是一个完整教程,教我们实现风格统一、界面友好的UI,同时保留app的”个性”和特点。
当然,这才刚刚开始。欲知后事如何,请听下回分解。