一个仿iOS和Android内置时钟应用的app。分两部分:

  1. 个没有交互的数字时钟,简述Kivy的事件驱动(event-driven)方法,引入计时器的功能,持续更新。
  2. 交互的秒表功能,设计流畅的自适应布局。

学习大纲:

  • Kivy语言基础,DSL(domain-specific language)处理部件(widgets)
  • Kivy布局方式
  • 自定义字体和文字样式
  • 事件管理

app最终效果如下,只要60行代码,Python代码和kv代码各一半。

clockapp

起点

将kivy的helloworld稍作修改。增加一个布局容器(layout container),BoxLayout,后面可增加更多部件。

# %load ../0_Hello/main.py
from kivy.app import App


class ClockApp(App):
    pass


if __name__ == "__main__":
    ClockApp().run()
# 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字体。

clockui

加载自定义字体

Kivy默认是Droid Sans字体,通过font_name属性可设置自定义字体。这里只有一种字体,可以直接将.ttf文件名放上。

# clock.kv
Label:
    font_name: 'Loster.ttf'

但是我们要好几种字体,一个属性就不够了。因为不同字体都是单个文件,而属性只能跟一个文件名。涉及多种字体可以用LabelBase.register方法可以接受多种字体,如下所示:

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属性可设置多种自定义字体了。但这种方法有两个限制:

  1. kivy只接受TrueType的.ttf字体。如果是OpenType的.otf或者网页字体如.woff,得先转换
  2. 字体normal,italic,bold,bold italic四种样式有最大值。旧字体没问题,如Droid Sans。但是新字体都有4到20多种样式,其高度和其他特征也不同。Roboto至少有12种样式。

第二点迫使我们选择app字体时要把12种样式全放进去,这么做会增大app的体积,Roboto字体有1.7M。

本例中我们只要两种样式:浅色(Roboto-Thin.ttf)和加粗(Roboto-Medium.ttf)

from kivy.core.text import LabelBase

LabelBase.register(
    name="Roboto", fn_regular="Roboto-Thin.ttf", fn_bold="Roboto-Medium.ttf"
)

下面我们来使用字体,放到Label后面即可。

# 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了。

# clock.kv
Label:
    text: '[b]00[/b]:00:00'
    markup: True

Kivy的BBCode需要将markup属性设置为True。

如果要整行加粗,可以直接设bold属性为True。其他斜体、颜色、字体、大小同理。

改变背景色

下面我们来调整窗口背景色,是Window对象的一个属性。可以在__name__ == '__main__'后面增加代码:

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经常这样程序停止响应

总之,不能在程序里面加无限循环实现。

# 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.setTimeoutwindow.setInterval类似。其实Kivy和JS很像,即使API完全不同。

Clock所有的计时事件都是Kivy主循环的一部分。这种方法与线程不同,这样调用一个阻塞函数可能会阻止其他事件被及时唤醒。

更新屏幕上的时间

要接入显示时间的Label部件,需要给它一个id,通过id属性来获取部件,这和Web开发类似。

# clock.kv
Label:
    id: time

之后就可以通过root.ids.time来接入Label部件了。这里root就是BoxLayout

ClockApp类增加一个update_time方法来更新时间:

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)

运行程序看看是不是开始更新了。代码如下:

# %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语言字符串的。

# %load clock.kv
BoxLayout:
    orientation: 'vertical'

    Label:
        text: '[b]00[/b]:00:00'
        markup: True
        id: time

用属性绑定部件

除了ID绑定部件,还可以新建一个属性,在kv文件中进行绑定。这么做更符合DRY原则,只是多几行代码。如下所示:

# 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,自定义属性和默认属性语法一致:

# %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很直接,不需要什么神奇,如下所示:

layout

做这种界面就要BoxLayout,一种一维网格。在clock.kv里面已经有BoxLayout了,只有一个子部件。Kivy的布局默认充满屏幕,所以自动适应屏幕。

如果增加一个Layout,就会分一半屏幕,verticalhorizontal决定分割的方向。

我们这里就用vertical分三块,然后中间那块用horizontal分两块,Esay吧。

完成布局

由于中间这块是按钮,不应该比时间还大,可以增加一个height属性,然后设置size_hint属性为Nonesize_hint属性是一个元组(宽, 高),影响部件的宽和高。如果你想用绝对高度和宽度,就要设置size_hint属性为None,否则高度和宽度设置无效,部件会自动计算尺寸。代码如下:

# %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,因为用了paddingspacing属性,与CSS类似。main.py代码如下:

# %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外面增加一个新定义:

# %load clock.kv
<Label>:
    font_name: 'Roboto'
    font_size: 60
    markup: True

这是一个类,与CSS的selector类似。每个Label都会带<Label>类特性。

这样就可以把clock.kv里面每个Labelfont_namefont_sizemarkup属性都删掉了。如果想改变一个属性的值,就直接写上,会覆盖原来的值,与CSS完全一样。

定义类并没有创造一个新部件,只是一个属性集合。增加一个定义类,如果不使用就不会改变app的布局。

命名类

前面kv代码里类的处理有点问题,类只能有一个名字叫Label。当我们要为同一种部件加不同的属性定义类时,可以自定义类。如果直接改写LabelButton这些标准类,之后再用到通过类部件时改前改后一堆麻烦。所幸,命名类可以解决这一问题,RobotoButton是一种Button

<RobotoButton@Button>:
    font_name: 'Roboto'
    font_size: 25
    bold: True

@前面是新类的名称,后面是部件类型,本质是面向对象的子类class RobotoButton(Button),在kv代码里使用时,可以直接用命名类代替原来的Button类:

RobotoButton:
    text: 'Start'

命名类可以精简代码,而且可以改良部件。

按钮样式

UI设计的死角是可点击元素,像按钮之类,没有一个统一样式。Win8的Metro风格十分激进,点击部分完全是纯色矩形,很小甚至基本没图案。Apple使用弧度;还有一种使用圆角的趋势,尤其在CSS3风格里。轻微的阴影也开始使用。

Kivy在这方面很灵活,不强制任何一种风格,而且提供一堆特性帮你实现任意风格。其中之一就是9-patch缩放功能。

9-patch scaling

传统UI开发中,如果背景的大小不一样,一般需要为每种大小都制作一张图片,这在button中尤为明显。当然我们也可以一小块一小块水平重复的画,也可以垂直的话。在android中专门有一种叫9-patch图片(以9.png结尾)来解决背景大小不一样时,只用一张背景图片。无论横屏还是竖屏,高分辨率还是低分辨率,都能自动填充满,而且不失真。

缩放算法的目的就是尽可能的适应不同场合的像素要求,尤其是包含一堆文字的按钮。等比放缩图片容易实现,但是由于变形比例问题,质量不太好。

非等比的9-patch放大可以产生不失真的效果。其理念就是把图片分成若干静止的、可缩放的块。假设下图是个可缩放按钮。黄色部分是操作区,其他颜色都是边:

scalebutton

当红色区域被压缩时,蓝色区域大小不变。如下图所示:

stretchedbutton

蓝色的角是不变的,红色的边可以垂直、水平缩放。图片中唯一等比变化的部分就是黄色的操作区,通常都是用纯色,也可以加上文字。

使用9-patch图

本例中,我们用一个简单的1px边的纯色按钮,改下颜色就可以重用。如下所示:

1pxbutton

按下去的状态就用相反的颜色,如下所示:

inversionbutton

现在在clock.kv中添加9-patch图,我们需要告诉Kivy图像边的像素,因为默认是等比变化的。

<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下修改:

RobotoButton:
    text: 'Reset'
    background_normal: 'red_button_normal.png'
    background_down: 'red_button_down.png'

这样按钮就搞定了,但是还不能运行,下面我们来实现秒表功能。

计时功能

秒表不只是显示时间,还需要暂停、复位,比普通的钟表要复杂一点。反映到程序上,就是Python的datatime模块和strftime()函数的区别。后者可以直接将现在的时间格式化,正是秒表显示所要的。

首先,我们要建立一个计时器。由于Kivy的Clock.schedule_interval事件handler支持时间参数,所以不通过Python的时间函数也容易实现。

def on_start(self):
    Clock.schedule_interval(self.update, 0.016)


def update(self, nap):
    pass

时间单位是秒,就是说app每秒运行60次(60fps)为1帧,平均间隔时间为 $$\frac{1}{60} = 0.016(6)$$

然后就是时间持续更新:

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函数输出(商,余数)。

minutes, seconds = divmod(self.sw_seconds, 60)

divmode函数只计算一次,普通/%运算需要两次。如果我们每一帧画面都有大量这样的浮点数除法,就像游戏或仿真,CPU就费劲了。

不太同意所谓“过早优化是魔鬼”,许多差的实践导致程序性能低下,其实一开始很容易避免,而且不影响代码质量,不去做才是魔鬼。

要注意divmode函数结果都还是浮点数,所以要去争:int(minutes)int(seconds)

现在就剩下分秒了,可以这样获得:

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语言风格。

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,即每一帧都更新:

Clock.schedule_interval(self.update_time, 0)

Warning: 目前,大多数显示都是60fps,我们的值精确到1/100s,1秒钟100次更新。但是这么做没啥意义,因为在普通硬件上,人不会识别出100fps和60fps的区别。因此,大多数情况下,代码都应该与帧率分离,由于它的效果依赖于用户的硬件,而硬件种类千差万别,没法儿预测你的app会在什么机子上运行。
运行程序会看到时间更新,但是还缺少控件,下面就是。

秒表控件

用按钮来控制应用是最简单的。下面就是所有代码:

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是StartStop按钮,由sw_started改变状态实现。第二个handler是Reset按钮。

还需要增加状态属性跟踪秒表是否在运行:

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_startedTrue才更新,秒表开始默认为停止状态。

clock.kv文件里,我们把方法绑定到on_press事件上:

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的"个性"和特点。

当然,这才刚刚开始。欲知后事如何,请听下回分解。