前言

wordpress是应用很广泛的CMS系统。无论是用作博客或者是安装自定义的主题来建站,用起来都是很简单,而且自定义主题的开发也容易上手。近期因工作需要开发一个wordpress的主题来建站,发现wordpress代码比较老,需要兼容的情况很多。API一致性很差,经常做同一个功能有多个相似的api,而且名字看起来差不多,但参数却差别很大。这些都还算好的,遇到一个比较大的问题是,wordpress里必须配置主站的域名(也就是在wp-admin需要配置的url),而且mysql的数据里充斥着hard code的url。这给数据迁移和反向代理带来了很大的不便。这次是由于安全的原因,我只能把wordpress假设在反代后面,就这么一个小小的改变,我发现wordpress死活水土不服。

下面记录下配置的要点,方便以后查看。我使用的服务器是nginx,反向代理的服务器也是nginx。

一般情况

一般情况我们架设wordpress 的结构是这样的:

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
                    HTTP请求
+
|
+--------------------------------------------+
| | |
| +-----------------v----------------+ |
| | | |
| | Nginx | |
| | | |
| +-----------------+----------------+ |
| | |
| +-----------------v----------------+ |
| | | |
| | wordpress | |
| | | |
| | | |
| | | |
| | | |
| +-----------------+----------------+ |
| | |
| +-----------------v----------------+ |
| | mysql | |
| | | |
| +----------------------------------+ |
| |
| |
+--------------------------------------------+

而配置域名方面也比较简单,主要是两处

  1. nginx 的配置 nginx.conf
1
server_name www.example.com
  1. wordpress wp-admin中的设置

wordpress域名设置

其实这第二步就稍显多余,为什么wordpress不能都使用相对路径?如果大家多次修改后wp-admin进不去,可参见附录直接修改数据库。

反向代理的情况

这个时候wordpress的架构是这样:

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
32
33
34
35
36
37
38
39
40
41
                    HTTP请求
+
|
|
|
+--------------------------------------------+
| | |
| +-----------------+---------------+ |
| | Nginx Reverse Proxy | |
| | | |
| +-----------------+---------------+ |
| | |
+--------------------------------------------+
|
|
|
|
+--------------------------------------------+
| | |
| +-----------------v----------------+ |
| | | |
| | Nginx | |
| | | |
| +-----------------+----------------+ |
| | |
| +-----------------v----------------+ |
| | | |
| | wordpress | |
| | | |
| | | |
| | | |
| | | |
| +-----------------+----------------+ |
| | |
| +-----------------v----------------+ |
| | mysql | |
| | | |
| +----------------------------------+ |
| |
| |
+--------------------------------------------+

理论上我不就是在外面加了个反向代理,我就把两个nginx的配置改下应该就可以了吧?

  1. Nginx Reverse Proxy
1
2
3
4
5
6
7
8
9
10
server_name www.example.com

location / {
proxy_pass http://<Wordpress-Server-IP>:<Port>

#Proxy Settings
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
  1. wordpress 服务器的Nginx 配置
1
2
listen <Port>
server_name localhost

可惜只是理论上应该这样。只配置了这两个,wordpress 是仍然不能工作的。经过反复调试,不是反向代理服务器返回500错误,就是wordpress的redirect不正常。

经过多次搜索研究,还要配置下面两步

  1. 清理mysql的数据

    把hard code的数据url 给改成过来,确保不存在域名不正确的url。比如: localhost:<Port>/xxx 这样的url在数据库, 都需要改成 www.example.com/xxx。大家可以使用Search-Replace-DB这个工具,来替换数据库。

  2. 改PHP代码手动设置反向代理的HOST,因为wordpress的核心开发者说:让wordpress支持反向代理不是他们的责任

    核心思想就是要让 PHP代码知道反向代理转发的域名是什么,然后强制地在每个页面里动态设置。

第一步 Nginx Reverse Proxy 配置改为

1
2
3
4
5
6
7
8
9
10
11
server_name www.example.com

location / {
proxy_pass http://<Wordpress-Server-IP>:<Port>

#Proxy Settings
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

加了X-Forwarded-For

第二步 修改wp-config.php的代码,添加如下部分

1
2
3
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_HOST'] ) ) {
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_X_FORWARDED_HOST'];
}

修改了以上的内容,wordpress 的反向代理才能真正工作起来。

总结

综上所述:让wordpress支持反向代理一共有五步

  1. 反向代理服务器需要配置域名,并且需要配置X-Forwarded-For
1
2
3
4
5
6
7
8
9
10
11
server_name www.example.com

location / {
proxy_pass http://<Wordpress-Server-IP>:<Port>

#Proxy Settings
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
  1. wordpress的服务器,配置server_name 为localhost 和端口号。
1
2
listen <Port>
server_name localhost
  1. wordpress 的wp-admin里要设置成域名,而不是localhost或者wordpress server的IP。

wordpress域名设置

  1. 要在wp-config.php修改里 HTTP_HOST
1
2
3
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_HOST'] ) ) {
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_X_FORWARDED_HOST'];
}
  1. 【可选的】用Search-Replace-DB来检查替换数据库,确保数据库里不存在域名不正确的url。

附录

在wp-admin进不去的情况下,操作 wp_options 数据库表, 来更新wordpress的域名

1
2
3
4
5
msyql -u xxx -p # 连接数据库。
> use wordpress; # 使用数据库
> update wp_options SET option_value='http://www.example.com' WHERE option_name='home';
> update wp_options SET option_value='http://www.example.com' WHERE option_name='siteurl';
> quit

近期接手到公司一些项目是使用SVN管理代码,对于我这样一个git命令行的重度使用者,实在是颇感不便。比较了svn的命令行和和客户端,发现svn的客户端是比较友好的。但是我是个命令使用者,怎么能用客户端呢?(捂脸,主要是没钱买个好用的mac客户端)。所以就乖乖总结下svn命令行的使用。

下面对标git常用命令,看下在svn里是怎么实现相应的功能。

下载代码

git

1
git clone git://gcc.gnu.org/git/gcc.git

svn

1
svn checkout svn://gcc.gnu.org/svn/gcc/trunk gcc

比较总结

git clone的是下载的是一个仓库的所有branch和tag的元数据,同时下载了master branch的所有文件,然后checkout到master branch。svn checkout则是checkout trunk目录下所有的文件。所以同一个项目里.svn 文件夹是要比.git文件夹小。

查看日志

git

1
git log

svn

1
svn log

比较总结

git log默认是输出到pager里的,但是svn log是不通过pager的,一股脑输出所有信息。因此只要日志超过一页,git log里用户最先看到的较新的提交记录,svn log里用户最先看到的是较旧的提交记录,会有些不自然。

可以在shell里给svn log 也默认加个pager,比如在zsh里给svn log加pager

1
2
3
4
5
6
7
8
svn() {
if [ "$1" = "log" ]
then
command svn "$@" | less -FX
else
command svn "$@"
fi
}

查看当前状态

git

1
git status

svn

1
svn status

比较总结

对于 svn status的结果解释可以查看书籍Subversion 版本控制中的示例:

如果在工作副本的根目录不加任何参数地执行 svn status, Subversion 就会检查并报告所有 文件和目录的修改.

1
2
3
4
5
6
7
$ svn status
? scratch.c
A stuff/loot
A stuff/loot/new.c
D stuff/old.c
M bar.c
$

在默认的输出模式下,其中最常的几种字符或状态是:

? item

文件, 目录或符号链接 item 不在版本 控制的名单中.

A item

文件, 目录或符号链接 item 是新增的, 在下一次提交时就会加入到仓库中.

C item

文件 item 有未解决的冲突, 意思是说从 服务器收到的更新和该文件的本地修改有所重叠,    
Subversion 在处理 这些重叠的修改时发生了冲突. 用户必须解决掉冲突后才能向仓库 提交修改.

D item

文件, 目录或符号链接 item 已被删除, 在下一次提交时就会从仓库中删除 item.

M item

文件 item 的内容被修改.

git status 则更直观一些,没有使用字母的缩写。比如书籍Pro git中的示例:

1
2
3
4
5
6
7
8
9
10
11
12
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

new file: README
modified: CONTRIBUTING.md

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)

modified: CONTRIBUTING.md

获取更新

git

1
git pull

svn

1
svn update

添加文件

git

1
git add xxx

svn

1
svn add xxx

提交修改

git

1
2
git commit -m "xxx"
git push origin

svn

1
svn commit -m "xxx"

比较总结

因为git是分布式,本地有全量的仓库信息,所以设计上git commit是commit到本地的repo,git还需要通过push推送其他服务器,比如公司的中心服务器。svn commit是直接提交到中心服务器的。

查看diff

git

1
git diff HEAD

svn

1
svn diff

比较总结

我现在使用的是最常用的一种情况,比较当前未提交的修改跟上一个commit的区别。git要指定当前修改与哪个commit比较(比如HEAD),svn则不用。

revert 文件

git

1
2
git checkout xxxfile # revert 一个文件
git reset --hard # revert 当前所有修改

svn

1
2
svn revert xxxfile # revert 一个文件
svn revert xxxDirectory -R # revert 文件夹下所有修改

比较总结

对于revert文件,svn命令看起来要自然些,git用checkout表示使用该文件最新commit中的版本,自然的本地为提交的修改就被revert了。对于revert当前所有的修改,git 可以通过reset来实现。svn 则可以通过revert当前文件夹下所有为提交的修改来实现。另外git可以本地提交或者stash来暂存修改,而svn中没有。

创建分支

git

1
git branch xxx

svn

1
svn copy http://example.com/repos/myproject/trunk http://example.com/repos/myproject/branches/releaseForAug -m 'create branch for release on August'

比较总结

git中创建branch是元数据的修改,不需要拷贝项目文件。svn中创建branch是把项目文件和再拷贝一份,同时把新文件对应到旧的元数据上去。

创建tag

git

1
git tag xxx

svn

1
svn copy http://example.com/repos/myproject/trunk http://example.com/repos/myproject/tags/releaseForAug -m 'create branch for release on August'

比较总结

git中创建tag是元数据的修改,只生成一个commit的引用。svn中创建tag和branch是一样的,都是复制。

结语

svn和git从设计上就有根本的不同,svn是集中式的,git是分布式。比如svn的copy有点像git的fork,但是由于svn是集中式,所以svn的copy只能在同一个repo下进行,这就限制了svn项目的分享,这也解释了为什么git出现后,github出现了。不过虽然两个项目设计上有如此大的不同,但是从开发流程上,我们仍然可以使用svn做我们想做的各种事情,比如提交,查看log,branch,tag等,所以svn还能用。两个不同的项目,殊途同归吧。

当我们开始学习一门新技术的时候,我们往往是从配置开发环境开始的,而也就是这一步,总有些难迈过。我们配置环境时,往往不熟悉这门技术,所以才要去配置环境来开发学习。所以这个配置的过程,总是会带有稀里糊涂“折腾”的成分。

折腾有时是乐趣,有时却是沮丧。所以折腾不一定必要,为了避免不必要的折腾,我总结了一些配置开发环境的经验,以供参考。

第一步 明确配置什么

配置wordpress的主题开发环境。

第一步很简单,但是要明确,避免你配置环境时,配着配着,因为某些问题越走越远。

第二步 找清依赖

配置环境,其实就是配置依赖。 你在用别人开发好的工具或者库时你要明确,你要用的技术到底依赖哪些工具和库。这一步最好看官方文档。wordpress 是基于php开发的,所以肯定需要php,那还需要什么呢?wordpress 是一个web程序,所以我们需要一个server。还有呢?

根据文档我们需要以下几个组件:

  • PHP解释器

  • Web Server

  • Database

知道要什么组件后,但是方案有很多种类。 由于每个人对某个领域的经验不一样,配好后要用在的环境不一样,所以技术上选择的粒度控制会不一样
比如PHP, 安装时需不需要关注PHP版本,用PHP5.6 还是PHP7.0,有没有什么PHP的plugin需要安装? 所以这个没有统一的答案。

但是技术选择的基线要搞清楚,防止自己犯低级错误。比如wordpress 4.9 对PHP的最低的版本要求是多少?database选择Mysql的最低版本多少?选择MariaDB作为wordpress数据库,有特别要注意的地方吗?

而且不同组件之间的关系要搞清楚,他们是怎么协同工作的,怎么通信。比如PHP 和 Web Server 是怎么协同工作的?都装上去就行吗?

我准备安装wordpress的当前最新版本4.9.4,查看文档发现wordpress对环境的兼容性很好, 所以对各个组件的版本要求很低,并且他有推荐的配置。

所以安装组件最低要求是:

  • PHP 5.2.4以上

  • MySQL 5.6 以上或者 MariaDB 10.0 以上

  • 推荐使用Apache 和 Nginx

由于我有配置Apache 和Nginx的经验,我感到这两个软件,配置时还不够方便,所以我选择了使用足够简单的且稳定的Caddy。而且我要配置的是本地开发环境,对稳定性要求没那么高,所以我会倾向于选择较新的配置,这样可以使用较新的软件特性。

我的配置是:

  • PHP 7.0

  • MariaDB 10.0

  • Caddy 最新版

我是在macOS下开发,有一个好用的程序管理器是很有必要的比如homebrew这个类似于Ubuntu下apt的管理程序软件。

没有安装homebrew的运行下面的命令来安装

1
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

第三步 逐个安装,逐步验证

好,开始正题

安装 PHP

1
brew install php70 	--with-fpm

–with-fpm 是什么稍后解释。

测试PHP

1
php -v

MariaDB

1
brew install mariadb

测试 MariaDB

1
2
mysql.server start
mysql -u root -p

也可以用brew services,像使用linux中的systemctl一样方便开启服务

1
brew services start mariadb

测试PHP和MariaDB协同工作

创建数据库

1
2
3
4
mysql -u root -p
mysql
>CREATE DATABASE wordpress;
>quit

使用PHP测试脚本连接数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
# Fill our vars and run on cli
# $ php -f db-connect-test.php
$dbname = 'wordpress';
$dbuser = 'root';
$dbpass = '';
$dbhost = 'localhost';
$link = mysqli_connect($dbhost, $dbuser, $dbpass) or die("Unable to Connect to '$dbhost'");
mysqli_select_db($link, $dbname) or die("Could not open the db '$dbname'");
$test_query = "SHOW TABLES FROM $dbname";
$result = mysqli_query($link, $test_query);
$tblCnt = 0;
while($tbl = mysqli_fetch_array($result)) {
$tblCnt++;
#echo $tbl[0]."<br />\n";
}
if (!$tblCnt) {
echo "There are no tables<br />\n";
} else {
echo "There are $tblCnt tables<br />\n";
}

安装Caddy

Caddy的安装很简单,甚至不需要安装,只需要下载即可。下载时选择操作系统,插件和License,下载Caddy的zip文件,解压之后,里面有个caddy的binary文件,这个binary文件就是Caddy的全部,这也是使用它的简单之处。

你有两种方式使用这个caddy binary

  1. 把caddy放到系统的PATH目录下,这样可以全局访问到这个binary
  2. 放到项目的文件夹下直接使用。

我选择2。

第四步 遇到问题,理清组件职责

进行到这一步大部分软件都安装完了,但是wordpress还是没办法工作。有很多问题缠绕着我们?而且主要是出在Caddy的配置上。我们先把问题理清楚列出来。理清组件的职责,是要调整哪些组件,哪些组件时无关的。

  1. wordprss代码放在哪里?
  2. 用Caddy怎么配置wordpress?

一个一个解决:

  1. wordpress代码放在哪儿?

    这个很简单,直接建folder就行

    1
    2
    3
    4
    5
    mkdir wordpress-caddy-template # 假设是wordpress-caddy-template你的工作目录s
    cd wordpress-caddy-template
    wget https://cn.wordpress.org/wordpress-4.9.4-zh_CN.zip
    unzip wordpress-4.9.4-zh_CN.zip
    rm wordpress-4.9.4-zh_CN.zip

    这时wordpress就在 wordpress-caddy-template/wordpress 下面。

  2. 用Caddy怎么配置wordpress?

    要回答这个问题,还得往下分析,得想清楚Caddy是干什么的。

    1. 接收url请求(我们的网站url是什么?)
    2. 根据url请求找到PHP代码的。(PHP代码在哪里?)
    3. 运行PHP代码(Caddy怎么运行PHP?)
    4. 返回PHP代码的运行结果

Caddy的配置只需要一个文件,如下的Caddyfile可以解决上述1 和 2:

1
2
localhost:9998
root wordpress/

对于3,Caddy怎么运行PHP代码?Caddy并不能直接运行PHP代码,Caddy通过PHP-FPM和PHP解释器进行进程间通信,Caddy把PHP代码通过PHP-FPM发给PHP解释器,PHP解释器解释好PHP代码后,把解释的结果通过PHP—FPM再发给Caddy。所以要安装PHP-FPM

默认PHP-FPM是和PHP一起安装,如果你像我一样安装到一半才意识到没有安装PHP-FPM,可以先卸载php70,再重新安装带PHP-FPM的php70

1
2
3
4
brew uninstall php70
rm -rf /usr/local/etc/php/7.0
brew doctor # 查看是否有问题
brew install php70 --with-fpm

安装好后,开启php-fpm后台运行

1
brew services start php@7.0

进程间通信可以通过socket通信,也可以通过端口通信. 那macOS系统下PHP-FPM的默认方式时是什么呢? PHP-FPM的配置文件是/usr/local/etc/php/7.0/php-fpm.d/www.conf,它的相关配置如下

1
listen = 127.0.0.1:9000

所以加上了PHP-FPM的Caddyfile配置如下

1
2
3
localhost:9998
root wordpress/
fastcgi / 127.0.0.1:9000 php

这就是Caddy的配置了,最基本的只需要三行。

第五部 集成测试,优化配置

目前整个项目的目录如下

1
2
3
-rw-r--r--  1 fosteryin staff       59 Apr  5 16:18 Caddyfile
-rwxr-xr-x 1 fosteryin staff 16737696 Apr 5 15:34 caddy
drwxr-xr-x 21 fosteryin staff 672 Feb 8 12:53 wordpress

运行起来也很简单

1
./caddy

由于我想开发wordpress主题,所以我关心的只是主题的代码,所以我想把自己的代码和wordpress框架隔离,但是默认情况下主题代码是放在wordpress/wp-content/themes下的,这样每次找都不方便。而且我想用git来对代码进行版本管理,这个时候把自己的代码放在 wordpress文件夹下的子文件夹就更不方便了。

所以我在最外层目录新建了我的主题文件夹,并且把它软链接到wordpress的的主题文件夹中。

1
2
3
mkdir example-theme
cd wordpress/wp-content/themes
ln -s ../../../example-theme

使用git管理文件,并设置.gitignore 来隔离自己的代码和wordpress的代码。

1
2
3
4
cd ../../..
git init
touch .gitignore
open .gitignore

我ignore掉wordpress和caddy这个binary,caddy这种大一点binary也不适合放在git中管理。.gitignore 文件内容如下:

1
2
wordpress/
caddy

现在工作目录下所有文件为:

1
2
3
4
5
6
7
8
9
10
drwxr-xr-x 10 fosteryin staff      320 Apr  5 16:33 .
drwxr-xr-x 5 fosteryin staff 160 Apr 5 15:34 ..
-rw-r--r-- 1 fosteryin staff 6148 Apr 5 16:22 .DS_Store
drwxr-xr-x 9 fosteryin staff 288 Apr 5 16:35 .git
-rw-r--r-- 1 fosteryin staff 16 Apr 5 16:33 .gitignore
drwxr-xr-x 3 fosteryin staff 96 Apr 5 16:25 .idea
-rw-r--r-- 1 fosteryin staff 59 Apr 5 16:18 Caddyfile
-rwxr-xr-x 1 fosteryin staff 16737696 Apr 5 15:34 caddy
drwxr-xr-x 2 fosteryin staff 64 Apr 5 16:30 example-theme
drwxr-xr-x 21 fosteryin staff 672 Feb 8 12:53 wordpress

这样一个基本的wordpress开发环境就搭建成功了。

下面给Caddyfile添加一些log设置和wordpress常用的permalinks。

1
2
3
4
5
6
7
8
9
10
localhost:9998
root wordpress/
fastcgi / 127.0.0.1:9000 php

rewrite {
if {path} not_match ^\/wp-admin
to {path} {path}/ /index.php?{query}
}

errors errors.log

.gitignore改为

1
2
3
wordpress/
caddy
*.log

使用Caddy是不是很简单? 所有代码在github上, 有兴趣可以下载wordpress-caddy-template。真正配置环境时,不一定需要这么一板一眼的分析配置,大家搜索一下教程,然后不停的运行命令就可以了。然而当大家真的遇到问题,遇到教程里没讲到的问题时,这上面的分析思路就很有用了,只有定位到问题本身才能解决问题,而我们往往看到的是问题的表象。

缘由

现在NoSQL流行,有一个原因也是因为不需要去刻意处理table的schema,直接存储数据,这样简单!所以也不会有数据库表的迁移问题。数据库表迁移这一块儿一直是一个麻烦点,但我最近用了sqlite3做了个小项目,所以总结下数据库迁移的方案。

原理

  1. 每一次数据表改动,都对应一个数据库版本号
  2. 数据迁移是渐进式的,比如把数据库版本从1 升级到n,那么就升级n-1次,版本1到2,2到3,直到n-1到n。

实施

  1. 使用sqlite3的user_version 存贮自定义的数据库版本

    1
    2
    3
    4
    /*设置版本号*/
    PRAGMA user_version=1;
    /*读取版本号*/
    PRAGMA user_version;
  2. 所有的数据库升级文件,放在一个文件中,都直接使用sql文件,方便直接查看管理。文件结构如下

    文件结构设计

    1. v1.sql v2.sql, v3.sql等 是每个数据库版本,完整的数据库定义文件
    2. v1tov2.sql, v2tov3.sql等 是间隔版本数据库升级文件。一个数据m到n升级的过程就是,运行 v[m]tov[m+1].sql, v[m+1]tov[m+2].sql, 直到 v[n-1]tov[n].sql
    3. run.sh 就是每次要跑的数据迁移脚本,包括了当前的版本号和迁移逻辑
    4. 其中的v2.sql 到v[n].sql 不是必须的,只是为了方便查看当前最新的数据表设计,如果存在v[n].sql 那么创建新数据库也可以直接从这个文件来创建
  3. 迁移脚本如下, 具体逻辑注释中已经写明

  4. v[n].sql 和v[n-1]tov[n].sql 文件的最后都去需要通过user_version来设置数据版本为n,一个v2tov3.sql 的demo如下:

总结

使用场景

目前这套方案适合数据量小,对停机维护可以接受的业务情况,因为需要停机升级,但是这个方案,足够简单清晰且能满足所有不同版本间的数据升级。

不足与展望

  1. 这个方案没有考虑到数据升级失败的回滚。由于是小业务,所以考虑更多的是简单易维护。所以针对这种情况的,首先要保证升级脚本经过了足够的线上数据测试,经的起考验。其次,一旦发生问题,线上可以直接操作维护,写脚本。这样说,因为你都没有测试到这种需要rollback的case,你也不能在写升级脚本的时候,知道这种case是怎样,所以提前写好rollback的逻辑不一定可行或者合适。
  2. 部署时,数据迁移之前要备份,数据量较大些,用增量备份,节省时间。备份有成熟的工具,而且备份方便升级失败时rollback。部署的步骤应该是: 拉代码build(或者拉docker镜像)-> 备份数据库 -> 升级数据库 -> 跑新的代码
  3. 对于Android,iOS的设备中使用sqlite3的情况,数据迁移的逻辑是一样。sql文件结构设计可以重用,也可以写到代码里去管理。迁移脚本需要转换成native的Java或者Objective-C,Swift的代码。
  4. 对于更大的业务,多实例的的数据库迁移可以使用Flyway

应用场景

对于一个前端的开发人员,当我们做业务时,可能会涉及到生成单据这样的一些功能。这个时候我们就会考虑用什么方案来生成文件去打印呢?用怎样一个方案更好呢?

根据我们的考虑,我们首先提出几个标准

  1. 修改更新样式方便。
  2. 前端的学习成本更小。

对于第一个标准。显然直接把内容生成图片格式(jpeg,png等),再打印图片的这种方案就不是很灵活。因为单据的内容是动态,画图,划线的的位置需要根据文字的不同而更改,自己来控制就很麻烦。

对于第二个标准。把内容直接生成word,pdf,然后打印文档这种方案,要求前端重新学习word格式或者pdf格式。而且要很熟据悉,这个也是很花学习时间的。

那么怎样,既修改方便,有学习成本小呢?其实直接使用 html + css就可以。我们可以使用html来做,然后利用浏览器的打印功能,直接打印网页,或者由网页来生成pdf, 而且CSS是针对打印机是有特殊设置的。所以通过打印功能生成pdf,把pdf作为最终单据的文件格式是可以的。

那我们就来介绍下这种方案的使用和注意事项:

Continuous media vs Paged media

默认情况下网页一般通过浏览器,显示在显示屏上的,是一个在连续的页面上,是可以滚动的,显示器这种媒介也就连续的媒介( Continuous media)。 但是对于打印机,网页是打印在一页一页纸张的,是分页的。所以打印机是分页的媒介(Paged media)。

大家都知道 html在浏览器这种连续媒介里面的布局模型是盒子模型(Box Model)。对于分页媒介来说,这个模型html的布局模型有什么不同吗?

  1. 分页媒体的每一页中可打印与不可打印的区别

Page Sheet

由于打印机的机械机制原因,纸张中有一部分是不可打印的,通常是纸张的边沿部分。

  1. 分页媒体的盒子模型也是分页,每一页都是一个页盒子(Page Box)

Page Box

Paged media 主要的一些的CSS

  1. 仅仅针对Paged media @page
  2. 纸张的左右页面是使用伪类来是实现 :left :right
  3. 纸张的第一页 :first
  4. 匹配空白页面 :blank
  5. 匹配页面上的位置 top-left-corner top-left top-right
  6. 设置页面页码 counter(page) counter-increment
  7. 设置纸张大小 比如 size: A4 landscape
  8. Media query @media print

注意事项

  1. 单据通常不需要scale的,只需要固定大小就可以。为了更准确的size,减少打印网页的选项,你通常可以指定页面的size, 纸张的打印方向

    1
    2
    3
    @page {
    size: A4 landscape;
    }
  2. 对于可编辑的字段,可以使用 input textarea 这些元素,对于textarea需要有些特殊设置

    1
    2
    3
    input, textarea {
    resize:none; //去掉 textarea的右下角的拉伸的三角
    }

texarea编辑支持自动增高, 可以用autosize

1
autosize($('textarea'))

textarea有多行内容在打印的效果下,textarea估算高度和使用p元素放同样的内容的估算高度是不一样的,在textarea 下会导致显示不全。所以这个时候可以tricky 的使用两个元素存放同一个内容

1. 默认显示p元素,
2. 点击p,隐藏p,显示textarea,把p的内容给textarea
3. textarea时区焦点后,把textarea的内容给p元素,隐藏textarea,显示p元素
4. 打印的时候用的就是p
  1. page的 header 和footer 的高度是通过margin 来设置

参考

CSS Paged Media Module Level 3

客户端的前端化

App的开发,生长于移动互联网时代。手机的无处不在,给了人们访问网络的便捷性,同时也给了开发者并发的挑战。手机虽然计算能力和存储能力相对于PC受到限制,但人们从未降低对互联网访问可用性的期待。所以这就促使开发者更进一步的利用好客户端,剥离客户端不一定需要做的,尽力让客户端更好地做好它必须要做的事情。把数据处理和页面渲染进行分离。所以现在的客户端有一种前端化的趋势。因为数据存储和处理被挪向了后端。客户端要做的事情只剩下了,渲染页面,异步数据管理,这时的客户端架构,在接近web的架构。所以近年来,App上火热的技术,有些是跟随Web技术的发展趋势的。比如Futures and Promise是JavaScript 先火起来,然后传到App。还比如React,响应式编程也是从Web走向了客户端。

未来的App 会更加凸现它User Interface这一本质作用。它扮演输出,数据通过它呈现给用户,它是扮演输入,用户的动作,声音,图像,位置等,都会被它接收传递给后端。

在手机上,人们不会降低对互联网可用性的期待,只会是提高。人们在CS(Client-Server)时代和Web时代(Browser-Server)享受过的红利,在Mobile时代,人们依然要求去享受,而且要的更多。所以人们需要App像CS时代一样有native应用该有的易用性,也需要像Web时代一样,拥有网页的及时更新的能力。

因此这给当下客户端架构,提出以下几点要求

  1. 复杂异步数据管理的能力

    虽然App中可以没有后端处理,但是App中是不能没有数据的,所以必须要有异步通信,而且要善于异步通信。

  2. 及时自我更新的能力

    Web上刷新一下,网页就更新了,App上不行吗?对于特别重业务的App,这在某种程度上是个刚需,比如天猫,每天都可以有商业推广活动,不更新到App上,这不是阻碍业务推广吗?所以天猫的应用是可以通过数据进行模块化配置。

  3. 在线Hotfix的能力

    Fix bug,而且是hot的。是程序都会有bug,但是如果有bug,要一两个星期后(比如App Store 的审核)用户才能收到fix,这对于很多App来说是不能容忍的。大产品出了bug,影响太大了,小产品出了bug,不及时fix会影响到生存。 而且一个公司同样的业务,出现bug,Web上改一下上线就好了,App改一下,确还要等审核这不是摧残产品经理和程序员的心灵吗?所以不管怎样,想做好产品都是需要考虑hotfix的。

而这三点之中,异步数据管理是基础性的。所以这次我重点讲下异步数据管理。 讨论哪些技术会让App更善于管理异步数据。

善于管理异步数据,其实是要解决以下两个问题

  1. 怎么管理更多,更复杂的异步任务

  2. 怎么让异步程序更易懂,而不是随着异步任务的增加,程序的可读性快速衰减。

管理更多的异步

对于数据量的快速增加,简单的去写重复代码,一开始还行,但是越往后就越不行,往后会要求进行组织架构的改变,甚至是设计指导思想的改变。

让我们回顾下,异步任务管理技术的几次升级。

单点式

最开始的异步,是单点式,一个来源,一个监听方,这对于异步任务很少时,仍然是很简洁的。

这个时候的代表是Delegate模式,或者换一种说法也就是回调的模式。这个时候的代码,是下面这样子, 异步的开始代码和结束代码是分开的。回调少时还是OK的,多了的话,多个回调函数各自的顺序,相互共享变量,都会让代码难以维护。

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

self.connection = [[NSURLConnection alloc] initWithRequest:self.request delegate:self startImmediately:NO];
[self.connection scheduleInRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
[self.connection start];
self.executing = YES;

- (void)connection:(NSURLConnection *)connection didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge {
@synchronized (self) {
self.failed = YES;
self.finished = YES;
self.executing = NO;
self.failedAuthentication = YES;
if (self.delegate && [self.delegate respondsToSelector:@selector(connectionFailed:)]){
[self.delegate performSelectorOnMainThread:@selector(connectionFailed:) withObject:self waitUntilDone:YES];
} else {
ATLogError(@"Orphaned connection. No delegate or nonresponsive delegate.");
}
}
}

- (void)connection:(NSURLConnection *)connection didSendBodyData:(NSInteger)bytesWritten totalBytesWritten:(NSInteger)totalBytesWritten totalBytesExpectedToWrite:(NSInteger)totalBytesExpectedToWrite {
if (self.delegate && [self.delegate respondsToSelector:@selector(connectionDidProgress:)]) {
self.percentComplete = ((float)totalBytesWritten)/((float) totalBytesExpectedToWrite);
[self.delegate performSelectorOnMainThread:@selector(connectionDidProgress:) withObject:self waitUntilDone:YES];
} else {
ATLogError(@"Orphaned connection. No delegate or nonresponsive delegate.");
}
}

多点式

随着异步任务的增多,要同时开启多个request是很常见的需求,怎样做到,让代码写起来简洁,没有重复的request代码,同时又能权衡CPU性能和内存占用呢?

答案是queue。把所有的异步任务都放在一个queue中,由queue去管理任务的启动和结束,调用的代码依然只关心传入什么,回调函数中改写什么就可以。
在iOS这方面来说GCD就是一个很优秀的实现, 调用时候就很直接,比如:

1
2
3
4
5
6
7
double delayInSeconds = 2.0;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
if (self.activityViewController) {
[self showProgressHUDWithMessage:nil];
}
});

只需要指定queue和相关参数,写好callback代码就可以了。

链式,流式

无论前面的单点式,还是多点式,都是处理一层的异步,如果遇到异步任务的嵌套呢?即一个异步任务的开始依赖于另一个异步任务的结束。 随着数据任务的复杂化,异步之间的嵌套是很正常的。写嵌套的层次多了,就会形成讨厌的回调金字塔(Pyramid of Doom)),如果不使用block,直接用deleaget来实现嵌套,那么就更不好,极易出现bug。像这种情况下,去拼命的优化代码长度,代码位置,函数名称等,效果是有限的。即使代码被你整的很清晰了,那也免不了花去很多精力。所以这时候,是改变思路的时候了。

怎样去嵌套

嵌套,怎么更清晰呢?那就不嵌套呗,不嵌套怎么办?要把代码铺平。怎么铺平?统一回调函数的接口才能铺平,有了统一的接口,才能方便任务间的结合。

Bolts举个例子, 它最初的思想来源自.NET中的Task Parallel Library

这个库的核心是Task得概念, 一个Task会把一个异步任务的所有相关的部分都包装在一起。任务执行的代码,任务执行后的结果,任务执行过程中遇到的错误,任务的取消逻辑,都在Task中。

链式形式如下

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
- (BFTask *)test {
return [[[self method:@"GET" URLString:@"http://www.baidu.com" parameters:nil resultClass:nil resultKeyPath:nil cancellationToken:nil] continueWithBlock:^id(BFTask *task) {
if (task.error) {
}
else if (task.exception) {
}
else if (task.isCancelled) {
}
else {
//handle the result
}
return [self method:@"GET" URLString:@"http://www.hao123.com" parameters:nil resultClass:nil resultKeyPath:nil cancellationToken:nil];
}] continueWithBlock:^id(BFTask *task) {
if (task.error) {
}
else if (task.exception) {
}
else if (task.isCancelled) {
}
else {
//handle the result
}

return task;
}];
}

所有回调中需要考虑到的,成功,失败,取消处理,而这些都被统一到了一个BFTask对象中,本来需要把第二个request的处理代码嵌套写在第一个request的回调block中的,通过统一的接口 continueWithBlock 返回一个BFTask 对象直接传到下一个处理block了,每个block只需要关心自己面对的BFTask对象既可以,处理好自己的成功,失败,取消就可以。这样就避免了嵌套。而嵌套的移除,就自然形成了链式结构。

异步任务,除了需要嵌套,还需要合并和转换等,当然这些对于已经形成统一接口的链式结构来说都不在话下。

比如合并的例子如下,外部调用者看到的也只是一个Task

1
2
3
4
5
- (BFTask *)test2 {
BFTask *task1 = [self method:@"GET" URLString:@"http://www.baidu.com" parameters:nil resultClass:nil resultKeyPath:nil cancellationToken:nil];
BFTask *task2 = [self method:@"GET" URLString:@"http://www.hao123.com" parameters:nil resultClass:nil resultKeyPath:nil cancellationToken:nil];
return [BFTask taskForCompletionOfAllTasks:@[task1, task2]];
}

让异步程序看起来易懂

易懂的关键点在直观,最直观的方法是顺着人类的思维,让异步代码看起来像是同步的代码,让代码的文本顺序就是它的执行顺序,这样人就能一下子看懂。

这其中有两个技术功劳很大

Block 闭包技术

让一个异步任务自包含,回调函数和开启任务的代码在一起。 回调函数和开启任务在一起,不仅直观了,而且也方便了任务内变量的共享,这样可以把需要共享的变量,从全局变量,转化成方法里面的局部变量。

链式结构

除了Block。还有一个就是链式结构。 如果说Block是让每个任务单元自包含了,那么链式结构就是让任务之间相连,方便形成串联,并联,等复杂的组合。也因为能连在一起,所以链式结构间也方便进行任务间的变量的共享,任务间的变量也可以从全局的变量,转化到任务间的参数。

小结

总的来说,异步数据管理能力,是当下App必备的重要能力。某种程度上说,App的开发能否跟上未来数据科学的发展,扮演好自己作为用户端口的角色,决定性因素之一的就是是否能做好异步数据管理。

扩展阅读

  1. 天猫App的动态化配置中心实践
  2. 理解 Promise 的工作原理

作为一名iOS 开发者,大家都知道UIKit默认是MVC 架构的,Model,View,和Controller 。随着这几年App开发的普及,这三部分所使用的技术都越来越成熟。比如 Model 现在有很多 JSON-binding 像 Mantle,JSONModel;Controller所代表的控制层也出现很多思潮像MVVM,MV;,对于View,现在出来的UI控件更是数不胜数,让人眼花缭乱。在那么多变化中,有没有一些东西是始终不变的呢?是有的,有些核心的思想是一直没有变的,比如今天我们要谈的Layout技术。

Layout 是大家经常接触,但却很少去关注的话题。因为用起来太简单了,都是改大小,改位置。没觉得需要再做些什么了。

不就是 setFrame 吗?

确实只是setFrame,但你也别小看它。细看你也会发现一整片天地。Layout 技术看上去简单,它却是整个GUI框架的基础,上层技术的设计,是必须依赖它才能实现。

Code vs xib family

很早之前(有多早?没有 iOS 之前就有 nib 了) 关于view的Layout就有两种写法。

  1. 用纯code写,继承UIView,UIButton等,实现自定义的View
  2. 用nib,xib 以及后来的Storyboard,来拖控件来实现可视化的编辑

哪一种更好呢?

这个是没有固定答案的,哪一种都好,哪一种都不好,好坏是依据情境而定的,因为产品业务不同,技术栈不同,而且人还是有技术偏好的。所以不能从一而论。但是你用没有用好?是看的出来的。技术与熟悉它的人一起配合才会产生好的效果。所以与其说好坏,不如说一种风格,用code 写的人,觉得纯code,看起代码来更容易,方便继承,组合和代码管理。用xib的觉得xib 和 storyboard更直观,调样式更简单。不管选择怎样,对一名开发人员,你是需要明白两种的利弊来做恰当的选择,对一个iOS团队,是需要统一思想的,去发挥一种方式的优点,要建立规则规避缺点的影响,比如用xib之类的文件,很不利于merge,特别是在多人协作的情况下,这个时候有的团队,就是把xib分配到人,一个人维护一部分。哪怕是两者都用,你也需要去明白,既然你两者优点都想要,那你也会带来维护上复杂度的提升。

我是使用纯 code 来写 view 比较多的。但是代码写多了,总会不满足。总觉得不能老是 override layoutSubviews了,于是就会想:

  1. 能更简单点吗?能把布局逻辑抽象出来吗?
  2. 像Android 一样写在一个简单的可读的xml 里,还可以继承?
  3. 能像 CSS 之于 HTML 一样,完全分开吗?甚至异步加载布局?

有了Auto Layout

我相信不只有我一个人这么想?还有很多人也是的。但是怎么办呢?

随着iOS平台的发展和成熟,事情出现了转机。iOS 刚开始只有 320 480尺寸,后来有了iPad,然后又有了320 596尺寸,还有@2x @3x 等等。而且现在很多App还要适配多语言,不同语言下文字的长度差别可能很大。写死 size 的做法已经越来越不好用了。如何采用通用的方法来布局呢?苹果出了一个通用方案Auto Layout。

以前我们写死固定的 size,origin,现在不能再用固定的数字,否则就没办法统一了。那用什么呢?用关系,对于多种size的屏幕,存贮固定的大小,不如存储关系。因为对于同一个界面,很多情况下,子view 在不同的尺寸下的相对关系是不变的。我们存储关系,让程序在运行时去解析这些关系,根据当前的设备算出,该设定的尺寸,这样才能统一。

那我们怎么找到这些关系呢?怎么抽象出来呢?

来让我们看看我们在 layoutSubviews 里写的代码。一般写 layoutSubviews 里面的代码是这样的

1
2
3
4
5
6
 - (void)layoutSubviews {
[super layoutSubviews];

CGSize textSize = [_centerLabel.text sizeWithAttributes:@{NSFontAttributeName: _centerLabel.font}];
_centerLabel.frame = CGRectMake((CGRectGetWidth(self.bounds) - textSize.width) / 2, (CGRectGetHeight(self.bounds) - textSize.height) / 2, textSize.width, textSize.height);
}

我们进一步分析,如果把一个view的frame中的origin和size都拆开成left, top, width, height

1
2
3
4
5
CGFloat left = (1 / 2) * self.bounds.size.width - textSize.width / 2;
CGFloat top = (1 / 2) * self.bounds.size.height - textSize.height / 2;
CGFloat width = textSize.width;
CGFloat height = textSize.height;
_centerLabel.frame = CGRectMake(left, top, width, height);

对 left top ,width, height进一步进行数学变换,分离变量和常量,

1
2
CGFloat left = (1 / 2) * self.bounds.size.width - textSize.width / 2;
CGFloat top = (1 / 2) * self.bounds.size.height - textSize.height / 2;

对于没有变量的, 假设一个变量,并乘以0

1
2
CGFloat width = 0 * self.bounds.size.width + textSize.width;
CGFloat height = 0 * self.bounds.size.height + textSize.height;

所以呢,代码成了这个样子

1
2
3
4
5
CGFloat left = (1 / 2) * self.bounds.size.width - textSize.width / 2;
CGFloat top = (1 / 2) * self.bounds.size.height - textSize.height / 2;
CGFloat width = 0 * self.bounds.size.width + textSize.width;
CGFloat height = 0 * self.bounds.size.height + textSize.height;
_centerLabel.frame = CGRectMake(left, top, width, height);

最后 left,top, width, height 就可以归一化到 如下形式

1
aView.属性 = 乘数 * bView.属性 + 常量

接着,如果一个view 跟多个view有关呢?

假如像下面这样跟两个view有关

1
aView.属性 = 乘数 * bView.属性 + 乘数 * cView.属性 + 常量

那怎么统一呢?

不急,我们先看下 CGRect 的结构

1
2
3
4
struct CGRect {
CGPoint origin;
CGSize size;
};

rect 有 origin 和 size,size 是 view 的大小,origin 呢?origin 是 view 左上角的位置,size在同一套单位下是绝对的,origin是相对的,但是它是相对于谁的?相对于自己的父view的,又因为所有的view 都在同一个view tree 上的,所以bView cView总是能找到相同的一个父节点的,假设是F节点 那么总会得到如下的形式

1
2
FView.属性 = 乘数 * bView.属性 + 常量
FView.属性 = 乘数 * cView.属性 + 常量

所以就也能转化成

1
aView.属性 = 乘数 * FView.属性 + 常量

即使,再加上 dView, eView 也没有关系,还是上面这个公式。

看到了吧,所有的布局计算都可以对应到这样一个 一次函数

然而推出这个公式有什么用呢?

我们看一下 Auto Layout 的 API

1
2
3
4
/* Create constraints explicitly.  Constraints are of the form "view1.attr1 = view2.attr2 * multiplier + constant" 
If your equation does not have a second view and attribute, use nil and NSLayoutAttributeNotAnAttribute.
*/
+(instancetype)constraintWithItem:(id)view1 attribute:(NSLayoutAttribute)attr1 relatedBy:(NSLayoutRelation)relation toItem:(nullable id)view2 attribute:(NSLayoutAttribute)attr2 multiplier:(CGFloat)multiplier constant:(CGFloat)c;

知道了吧,Auto Layout的API就是如此。就是基于这个最基本的函数运算来设计的。有了这个函数, 在 layoutSubviews 里的动态布局代码,才可以抽象出来写成声明式的关系了!

Auto Layout 介绍

Auto Layout是iOS 6之后,苹果推出的布局技术,主要是为了适配多屏幕而产生的。Auto Layout 本身的原理是基于constraint的,Auto Layout内部的实现是隐藏的,接口设计的非常简单和干净,而且直击要害。

Auto Layout 主要是一个类 NSLayoutConstraint

而NSLayoutConstraint主要是两个API。

  1. 一个是就上面推导出来的那个。
  2. 还有一个就 Visual Format Language,解析方法,而VFL也是为了表示这种函数关系而产生的。
1
+ (NSArray<__kindof NSLayoutConstraint *> *)constraintsWithVisualFormat:(NSString *)format options:(NSLayoutFormatOptions)opts metrics:(nullable NSDictionary<NSString *,id> *)metrics views:(NSDictionary<NSString *, id> *)views;

除了方法之外,再就是常用的属性,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef NS_ENUM(NSInteger, NSLayoutAttribute) {
NSLayoutAttributeLeft = 1,
NSLayoutAttributeRight,
NSLayoutAttributeTop,
NSLayoutAttributeBottom,
NSLayoutAttributeLeading,
NSLayoutAttributeTrailing,
NSLayoutAttributeWidth,
NSLayoutAttributeHeight,
NSLayoutAttributeCenterX,
NSLayoutAttributeCenterY,
NSLayoutAttributeBaseline,
...
NSLayoutAttributeNotAnAttribute = 0
};

关于这个基本函数的运算和属性说完了,算式是说完了。但是还没有完。

你细看,会发现NSLayoutRelation有三种,不只是可以用等号来判定等式两边,还可以大于等于,小于等于

1
2
3
4
5
typedef NS_ENUM(NSInteger, NSLayoutRelation) {
NSLayoutRelationLessThanOrEqual = -1,
NSLayoutRelationEqual = 0,
NSLayoutRelationGreaterThanOrEqual = 1,
};

怎么多了两种呢?以前计算都是相等关系,现在不等关系也是可以处理了。神奇吧?欲知详情,可以看完文章后,查看结尾的论文链接。

好,让我们来用用试试!但是你当你写完代码,你发现它长成了这个样子。

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
CGSize textSize = [_centerLabel.text sizeWithAttributes:@{NSFontAttributeName: _centerLabel.font}];

[self addConstraints:@[[NSLayoutConstraint constraintWithItem:_centerLabel
attribute:NSLayoutAttributeCenterX
relatedBy:NSLayoutRelationEqual
toItem:self attribute:NSLayoutAttributeCenterX
multiplier:1
constant:0],
[NSLayoutConstraint constraintWithItem:_centerLabel
attribute:NSLayoutAttributeCenterY
relatedBy:NSLayoutRelationEqual
toItem:self attribute:NSLayoutAttributeCenterY
multiplier:1
constant:0]]];
[_centerLabel addConstraints:@[[NSLayoutConstraint constraintWithItem:_centerLabel
attribute:NSLayoutAttributeWidth
relatedBy:NSLayoutRelationEqual
toItem:nil
attribute:NSLayoutAttributeNotAnAttribute
multiplier:0
constant:textSize.width],
[NSLayoutConstraint constraintWithItem:_centerLabel
attribute:NSLayoutAttributeHeight
relatedBy:NSLayoutRelationEqual
toItem:nil
attribute:NSLayoutAttributeNotAnAttribute
multiplier:0
constant:textSize.height]]];

恩,设计的很好,但这也太复杂了吧。

出什么问题了?

  1. API 和参数名字太长了
  2. 参数重复出现,只对两个 view 之间设置关系,但是 _centerLabel 出现数次
  3. 再一次阅读代码时,思维是跳跃,你试试,当你把目光放到代码上的时候,你先要定位到第一 item,我看到设置了_centerLabel, 又往后看我设置的是 centerX,那我相对的是什么? self,self的什么?self的centerX,就这样我的思维一直在跳动,看完之后的,我还要把我看到的参数套用到公式中去。

还有个VFL,再试试

1
2
3
4
UIView *superview = self;
NSDictionary *views = NSDictionaryOfVariableBindings(_centerLabel, superview);
[self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"[superview]-(<=1)-[_centerLabel]" options:NSLayoutFormatAlignAllCenterY metrics:nil views:views]];
self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:[superview]-(<=1)-[_centerLabel]" options:NSLayoutFormatAlignAllCenterX metrics:nil views:views]];

好吧,看来出现了神奇的符号。V:[superview]-(<=1)-[_centerLabel],太不直观了。你要记住每个符号的意义。而且当你通过XCode,在引号内部输入一串这样的神奇符号时是没有自动补全的。反应慢的同学,囧,比如我,写之前得停顿一会儿,一次想好。

为什么还是不好用?

VFL 的设计初衷是好的,想通过符号的引入,让你看到,界面元素之间的位置关系,比如 横线 - 表示间隔,方括号包住的内容[] 表示view,。[superview]-[_centerLabel] 表示superview 和 _centerLabel挨着。(<=1)表示距离大于1,[superview]-(<=1)-[_centerLabel] 表示 两个superview和_centerLabel之间的距离大于1,V:表示Vertical,所以完整的意思是 superview 和_centerLabel在竖直方向上某个属性挨着,且距离大于1。解释一遍还是能懂的,但是这个设计还是没有逃脱,去解方程的思维,VFL的表达式用符号表示操作符,把变量通过views和 metrics两个参数传进来。API使用者始终在考虑怎么去处理这个方程。

Coder Interface(API)

作为一个 Coder,我们经常写 User Interface 让 User 更爽,那么我们自己面对的 Coder Interface呢,要怎么设计,用起来更爽呢?

设计接口也是和产品设计一样的,需要考虑使用者的思维习惯,之所以NSLayoutContraint 那么不好用就是因为不符合使用者的思维习惯。借用产品设计的一句话“Don’t make me think”,来做说明的话,NSLayoutConstraint API 一直 Make me think!

所以,该DSL上场了

DSL出场前,让我们先介绍下DSL,DSL是Domain Specific Language的缩写,基本的目的是处理某一领域的特定问题的语言。他不像通用语言,要去覆盖全部的问题域,而是处理某一特定的问题。设计它的目的就是为了转化通用语言,让他更适合使用者的知识模型,用起来顺畅。平常我们也会经常遇到的DSL,比如CSS就是针对编写网页布局的DSL,Podfile语法就是编写Cocoapods依赖规则的DSL。

那我们要设计怎样的DSL呢?

设计DSL,先要了解你面向的使用者。设计语法本身不是目的,语言是为了传播思想服务的,是为了思维的转换,把其他领域的知识内容转换成使用者熟悉的思维方式,适应使用者的思维才行。

历史证明程序员有多少中常用思维,就会有产生多少种DSL。其实机制有了,好的方案就会浮现的。Auto Layout发布后,AutoLayout的DSL如雨后春笋般的涌现.

我找出一些有代表性的,按照程序员偏好的语法风格分下类

  1. 陈述命令形式,像说一句话一样
    1. Masonry
    2. KeepLayout
  2. 还原数学公式,仍然是代入公式计算,但更直观
    1. Cartography
    2. CompactConstraint
  3. Shortcut形式,创造了很多short cut的方法,覆盖了常用的布局需求
    1. PureLayout
    2. FLKAutoLayout
  4. 仿照 CSS 形式,用CSS的语法来做iOS的布局
    1. SwiftBox

大家都可以按照自己的口味选择。但我最终选择了Masonry。

理由是这样的,其实写代码时,我们要去设置什么,我们已经想好了,我们要的是把它写成代码,越直接,越快越好。

来看看 Masonry 是怎么做的,如果我们想 让_centeLabel 在它的 superView 里面居中

如果思考地机械一点就是

1
_centerLabel的中心等于_centerLabel的superview 的中心

翻译成代码就是

1
make.center.equalTo(_centerLabel.superview);

这样直接陈述就可以了。每一句话,都是Masonry能听懂的命令,所以我们直接发送几条命令就能完成目的,接着把设置的清单塞到block里

1
2
3
[_centerLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(_centerLabel.superview);
}];

然后,这个_centerLabel就居中了,是不是很简单!

Masonry

Masonry 这种DSL 设计的优点在于把数学的函数,翻译成可读性极佳的代码,像读句子一样,人好读了,写起来思维流畅,就不容易出错,而且它并没有实现很重的语法,完全利用Objective-C的语法,”.” 操作符作为连接符,block作为设置声明的清单。这样做的好处是实现简单,而且不需要单独的 parser,因此对效率也不会有太大影响,这是很巧妙的地方,这样的设计,平衡各方面的需求,使用者,语言本身,实现复杂性,性能等等。

Masonry本身只是语法的转换,并没有在 Auto Layout 的基础之上添加新的功能。

所以主要的 API 也是一个

1
- (NSArray *)mas_makeConstraints:(void(^)(MASConstraintMaker *make))block;

紧接着就是可以操作的属性,和 Auto Layout一一对应。

MASViewAttribute NSLayoutAttribute
view.mas_left NSLayoutAttributeLeft
view.mas_right NSLayoutAttributeRight
view.mas_top NSLayoutAttributeTop
view.mas_bottom NSLayoutAttributeBottom
view.mas_leading NSLayoutAttributeLeading
view.mas_trailing NSLayoutAttributeTrailing
view.mas_width NSLayoutAttributeWidth
view.mas_height NSLayoutAttributeHeight
view.mas_centerX NSLayoutAttributeCenterX
view.mas_centerY NSLayoutAttributeCenterY
view.mas_baseline NSLayoutAttributeBaseline

然后是 关系符 也对应者 Auto Layout 定义的三种关系符

.equalTo equivalent to NSLayoutRelationEqual

.lessThanOrEqualTo equivalent to NSLayoutRelationLessThanOrEqual

.greaterThanOrEqualTo equivalent to NSLayoutRelationGreaterThanOrEqual

除此之外,他还有些 shortcut的属性,来方便设置相对布局

  1. edges
  2. size
  3. center

与Auto Layout不同之处

Masonry 多了两个API,一个是update

1
- (NSArray *)mas_updateConstraints:(void(^)(MASConstraintMaker *make))block;

原因是出于苹果关于对性能的建议,如果,你只更新 constraint 里面的 constant的值,那么你不需要再 make, 你可以update

1
2
3
[_centerLabel mas_updateConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(_centerLabel.superview);
}];

这个句子,只是更新了center,并没有改变该view与其他view之间的依赖关系。所以 Masonry 会去从已经在_centerLabel 里面找到相似的 contraints 去更新他,而不是再添加一个 新的constraint

还有一个是 remake

1
- (NSArray *)mas_remakeConstraints:(void(^)(MASConstraintMaker *make))block;

不同点就在 remake 会移除以前设定的contraints,这样调用Masonry的外部外部代码就不用为了以后能够移除contraints 而去keep contraints的reference了。

Masonry使用

讲Masonry使用之前,我们先来比较总结一下iOS的布局系统。布局系统是GUI框架的基本组成部分,我这里从三个基本维度来简单分析下

  1. 基本数据结构
  2. 元素之间的相对关系
  3. 常用的布局模型

还是回到CGFrame的frame,frame分为size和origin的,如果拿Android系统做对比的话size这个结构都有,但是 origin就不一样了,Android中有的是margin,margin和origin不一样,margin 是相对的是其他view,可以平级view,也可以不平级,origin却只是相对于父view,跟其他view无关的。所以如果拿Android几种常用的Layout做个解释的话,iOS的只原生支持Andorid的AbsoluteLayout

也正是因为这个基本设计的不同,iOS里面的布局计算都可以归一到刚才推出来的一对一函数。

1
aView.属性 = 乘数 * bView.属性 + 常量

但是 Android的就不是这样的,天生的就是一对多的,margin有四个边,计算要考虑4个邻居元素的位置,所以 Android 的布局代码总是和LinearLayout等Layout一起用的,否则就总是要程序员去处理复杂的计算了。iOS里面却不需要去刻意突出这样的Layout模型,但也是需要这样的模型结构的。那 iOS 怎么实现LinearLayout之类的布局模型呢?通过控件比如UITtableView,UICollectionView,这些UI控件做了布局系统中复杂的计算。

总结如下表

布局 iOS Android Web
数据结构 origin, size margin, size margin, size
相对关系 相对于父view 现对于父view 或者同级 view 都可以 相对于父亲 view 或同级 view 都可以
布局模型 UITableView, UICollectionView… LinearLayout, RelativeLayout… posistion attribute,float attribute,flexbox…

Masonry Demo

既然 iOS 里面没有像Android这样原生的常用的布局模型,下面让我们实现 Android 的几种常见 Layout,来熟悉Masonry的用法

Android主要五个Layout,我们主要实现下面三个Layout,LinearLayout,FrameLayout,GridLayout。AbsoluteLayout本来就是iOS的默认方式,就不用实现了,RelativeLayout本身要解决是布局嵌套过深的问题,而不是位置关系。

LinearLayout 主要是列表的形式,

竖直列表

实现竖直列表可以mas_left , mas_right, 和 mas_height 都是固定的,不断的调整 mas_top的值。这里虽然也有 top left right 和 bottom,但是和 Android 里的的 margin 是不同的。

  1. 你使用的 top left right bottom,要指定相对于那个元素,margin的 top left right bottom 是不需要的。
  2. 这里的数字是有方向的,向下,向右为正,所以设置 right 的 offset 是从右往左的-20。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
UIView *lastCell = nil;
for (UIView *cell in _linearCells) {
[cell mas_updateConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.mas_left).offset(20);
make.right.equalTo(self.mas_right).offset(-20);
make.height.equalTo(@(40));
if (lastCell) {
make.top.equalTo(lastCell.mas_bottom).offset(20);
}
else {
make.top.equalTo(@(20));
}
}];

lastCell = cell;
}

水平列表

实现水平列表可以mas_top mas_bottom 和 mas_width 固定,不断调整 mas_left的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
UIView *lastCell = nil;

for (UIView *cell in _cells) {
[cell mas_updateConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(@(20));
make.bottom.equalTo(@(-20));
make.width.equalTo(@(20));
if (lastCell) {
make.left.equalTo(lastCell.mas_right).offset(20);
}
else {
make.left.equalTo(@(20));
}
}];

lastCell = cell;
}
FrameLayout

FrameLayout 主要是实现层级堆叠的效果,通过layout_gravity, 设置堆叠的位置。

下面我们介绍其中的几种堆叠

左上角

当要设置的view的属性和相对的view属性相同时,相对的view的属性可以直接省略。

1
2
make.left.equalTo(cell.superview.mas_left);
make.top.equalTo(cell.superview.mas_top);

可以写成

1
2
make.left.equalTo(cell.superview);
make.top.equalTo(cell.superview);

居中
很简单直接用 center 属性即可

1
make.center.equalTo(cell.superview);

上边对齐 左右居中

center可以分为centerX和centerY这里使用centerX, 加上top属性即可

1
2
make.centerX.equalTo(cell.superview);
make.top.equalTo(cell.superview);

GridLayout


Grid layout 实现起来就复杂一些了,需要我们去算处于哪一行和列, 不断的更新left 和top。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CGFloat cellWidth = 70;
NSInteger countPerRow = 3;
CGFloat gap = (self.bounds.size.width - cellWidth * countPerRow) / (countPerRow + 1);

NSUInteger count = _cells.count;
for (NSUInteger i = 0; i < count; i++)
{
UIView *cell = [_cells objectAtIndex:i];
NSInteger row = i / countPerRow;
NSInteger column = i % countPerRow;

[cell mas_updateConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(@(row * (gap + cellWidth) + gap));
make.left.equalTo(@(column * (gap + cellWidth) + gap));
make.width.equalTo(@(cellWidth));
make.height.equalTo(@(cellWidth));
}];
}

另外如果你嫌总是要敲mas_ 这个prefix太烦的话,使用时定义一个宏就可以了。

1
2
#define MAS_SHORTHAND
#import "Masonry"

好了,是不是意犹未尽?但是Masonry 的基本使用就介绍到这里了。至于更多内容,可以直接查看Masonry 项目。

SnapKit

如果是新开的的项目准备用Swift的话,可以用SnapKit, SnapKit 是 Masonry开发者开发的Swift版本。
另外在 Swift中使用Masonry 也会有些不便,比如下面的多出来的括号

1
2
3
4
5
6
7
view3.mas_makeConstraints { make in
self.animatableConstraints.extend([
make.edges.equalTo()(superview).insets()(edgeInsets).priorityLow()(),
])
make.height.equalTo()(view1.mas_height)
make.height.equalTo()(view2.mas_height)
}

结尾

本文回顾了iOS平台的布局技术的发展,讲述了Auto Layout的技术的由来,Auto Layout 技术的核心,以及相关DSL技术的产生,最后介绍了Masonry这个DSL的使用。至于文章开头提出的三个问题,部分已经有了答案,剩下要看以后的发展了。另外像我还没有介绍到的技术,比如 Size Class很适合不同大小的设备上使用不同设计的情况,比如同时有iPad和iPhone版本时,使用Size Class可以带来更好的交互体验,限于篇幅就没有介绍。

最后,本文中所有代码都在github上。

如果,大家对Layout相关话题还饶有兴趣,可以继续看下面的链接

  1. Cartography 另一种优秀 Auto Layout DSL
  2. Flexbox 优秀 CSS 3的布局模型
  3. Classy - Expressive, flexible, and powerful stylesheets for native iOS apps
  4. Auto Layout 内部实现的解释
  5. The Cassowary Linear Arithmetic Constraint Solving Algorithm, Auto Layout内部算法

以下是我的博客,我会定期更新 iOS客户端 和 Hybird App开发相关文章。欢迎订阅!

一个热爱太极的程序员

另外,如果发现本文中有任何谬误,请联系我yinjiaji110@gmail.com,我会及时更正。 谢谢!

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment