2024年3月

GitLab 介绍

GitLab 的历史

  • GitLab 最初是一个完全免费的开源软件,根据 MIT 许可证分发。2013 年 7 月,它被分为两个不同的版本 - GitLab CE(社区版)和 GitLab EE(企业版)。GitLab EE 处于受限许可证下,但源代码仍然公开可见,而 GitLab CE 许可模型保持不变。2017 年,GitLab 宣布他们的代码将在 MIT 许可证下完全开源。

为什么使用 GitLab?

  • GitLab是在集中式服务器上管理git仓库的好方法。GitLab让你完全控制你的仓库或项目,并允许你免费决定它们是公开还是私有的。

  • 使用 GitLab 的主要好处是它允许所有团队成员在项目的每个阶段进行协作。GitLab 提供从规划到创建的跟踪,帮助开发人员自动化整个 DevOps 生命周期并实现最佳结果。越来越多的开发人员开始使用 GitLab,因为它具有广泛的功能和可用的代码块。

Git,GitHub 与 GitLab 的区别

  • Git是一种版本控制系统,是一种工具,用于跟踪修改和从远程资源推送或拉取代码的版本控制。

  • GitHub是一个目前全球最大的基于Git实现的在线代码仓库托管平台,是一个公开的、免费的服务,它要求所有的代码(除非你有一个付费账户)都是公开的。任何人都可以看到你推送到GitHub的代码,并提供改进建议。

  • GitLab是一个基于Git实现的在线代码仓库软件,你可以用GitLab自己搭建一个类似于GitHub一样的仓库,但是GitLab有完善的管理界面和权限控制,一般用于在企业、学校等内部网络搭建Git私服。

  • GitHub和GiLlab两个都是基于Web的Git远程仓库,它们都提供了分享开源项目的平台,为开发团队提供了存储、分享、发布和合作开发项目的中心化云存储的场所。从代码的私有性上来看,GitLab 是一个更好的选择。但是对于开源项目而言,GitHub 依然是代码托管的首选。

特点

  • GitLab免费托管您的(私有)软件项目。

  • GitLab是一个管理 Git 仓库的平台。

  • GitLab提供免费的公共和私有仓库、问题跟踪和维基。

  • GitLab是一个建立在Git之上的用户友好的网络界面层,它提高了使用Git的速度。

  • GitLab提供了自己的 持续集成 (CI)系统来管理项目,并提供用户界面和GitLab的其他功能。

优势

  • GitLab提供了 GitLab社区版 ,用户可以定位他们的代码在哪个服务器上。

  • GitLab免费提供无限数量的私有和公共仓库。

  • Snippet 部分可以分享一个项目中的少量代码,而不是分享整个项目。

缺点

  • 在推送和提取仓库时,它没有GitHub快。

  • GitLab的界面在从一个页面切换到另一个页面时需要时间。

先决条件

建议使用至少具有以下各项的服务器:

  • CPU 4 核是建议的最小核心数,最多支持500个用户。
  • 内存 4 GB RAM 是所需的最小内存大小,最多支持500个用户。

GitLab 还要求文件系统准备好用于存储 Git 仓库和各种其它文件。
请注意,如果您使用 NFS(网络文件系统),文件将通过网络传输,根据实现,该网络需要打开端口 666666 和 2049

GitLab 架构图

Gitlab组件:

  • repository
    :代码库,可以是硬盘或 分布式文件系统。

  • Nginx
    :Web 入口。

  • gitlab-workhorse
    :轻量级反向代理服务器,可以处理一些大的HTTP请求(磁盘上的 CSS、JS 文件、文件上传下载等),处理 Git Push/Pull 请求,处理到Rails 的连接会反向代理给后端的unicorn(修改由 Rails 发送的响应或发送给 Rails 的请求,管理 Rails 的长期 WebSocket 连接等)。

  • gitlab-shell
    :用于 SSH 交互,而不是 HTTP。gitlab-shell 通过 Redis 与 Sidekiq 进行通信,并直接或通过 TCP 间接访问 Unicorn。用于处理Git命令和修改authorized keys列表。

  • Unicorn
    :Gitlab 自身的 Web 服务器(Ruby Web Server),包含了 Gitlab 主进程,负责处理快速/一般任务,与 Redis 一起工作,配置参考:CPU核心数 + 1 = unicorn workers数量。工作内容包括:


    • 通过检查存储在 Redis 中的用户会话来检查权限。

    • 为 Sidekiq 制作任务。

    • 从仓库(warehouse)取东西或在那里移动东西。

  • Redis
    :缓存每个客户端的sessions和后台队列,负责分发任务。Redis需求的存储空间很小,大约每个用户25KB。

  • Gitaly
    :后台服务,专门负责访问磁盘以高效处理 gitlab-shell 和 gitlab-workhorse 的git 操作,并缓存耗时操作。所有的 git 操作都通过 Gitaly 处理,并向 GitLab web 应用程序提供一个 API,以从 git(例如 title, branches, tags, other meta data)获取属性,并获取 blob(例如 diffs,commits,files)。

  • Sidekiq
    :后台核心服务,可以从redis队列中提取作业并对其进行处理。后台作业允许GitLab通过将工作移至后台来提供更快的请求/响应周期。Sidekiq任务需要来自Redis。

  • 数据库(PostgreSQL/MySQL)
    :包含以下信息:


    • repository 中的数据(元数据,issue,合并请求 merge request 等)。

    • 可以登录 Web 的用户(权限)。

  • mail_room
    :处理邮件请求。回复 GitLab 发出的邮件时,GitLab 会调用此服务处理Sidekiq、Unicorn 和 GitLab-shell 的任务 。

  • logrotate
    :日志文件管理,切割。

组件操作命令

# nginx
gitlab-ctl start|stop|restart|status nginx

# unicorn
gitlab-ctl start|stop|restart|status unicorn

# sidekiq
gitlab-ctl start|stop|restart|status sidekiq

# postgresql
gitlab-ctl start|stop|restart|status postgresql

# redis
gitlab-ctl start|stop|restart|status redis

# gitlab
gitlab-ctl start|stop|restart|status|reconfigure

# supervisor
systemctl start|stop|restart|status gitlab-runsvdir.service

GitLab部署过程

准备环境

[root@GitServer ~]# cat /etc/redhat-release
CentOS Linux release 7.9.2009 (Core)
[root@GitServer ~]# uname -r
3.10.0-1160.el7.x86_64
 
[root@GitServer ~]# setenforce 0
[root@GitServer ~]# sed -i.bak '7s/enforcing/disabled/' /etc/selinux/config
 
[root@GitServer ~]# systemctl stop firewalld
[root@GitServer ~]# systemctl status firewalld
● firewalld.service - firewalld - dynamic firewall daemon
   Loaded: loaded (/usr/lib/systemd/system/firewalld.service; disabled; vendor preset: enabled)
   Active: inactive (dead)
     Docs: man:firewalld(1)
[root@GitServer ~]# firewall-cmd --state
not running

安装

[root@GitLabServer ~]# yum install -y curl policycoreutils-python openssh-server perl postfix wget
[root@GitLabServer ~]# wget https://mirrors.tuna.tsinghua.edu.cn/gitlab-ce/yum/el7/gitlab-ce-16.4.2-ce.0.el7.x86_64.rpm
[root@GitLabServer ~]# yum localinstall gitlab-ce-16.4.2-ce.0.el7.x86_64.rpm
It looks like GitLab has not been configured yet; skipping the upgrade script.

       *.                  *.
      ***                 ***
     *****               *****
    .******             *******
    ********            ********
   ,,,,,,,,,***********,,,,,,,,,
  ,,,,,,,,,,,*********,,,,,,,,,,,
  .,,,,,,,,,,,*******,,,,,,,,,,,,
      ,,,,,,,,,*****,,,,,,,,,.
         ,,,,,,,****,,,,,,
            .,,,***,,,,
                ,*,.



     _______ __  __          __
    / ____(_) /_/ /   ____ _/ /_
   / / __/ / __/ /   / __ `/ __ \
  / /_/ / / /_/ /___/ /_/ / /_/ /
  \____/_/\__/_____/\__,_/_.___/


Thank you for installing GitLab!
GitLab was unable to detect a valid hostname for your instance.
Please configure a URL for your GitLab instance by setting `external_url`
configuration in /etc/gitlab/gitlab.rb file.    # 注意:设置external_url
Then, you can start your GitLab instance by running the following command:
  sudo gitlab-ctl reconfigure    # 注意:执行重载配置

For a comprehensive list of configuration options please see the Omnibus GitLab readme
https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/README.md

Help us improve the installation experience, let us know how we did with a 1 minute survey:
https://gitlab.fra1.qualtrics.com/jfe/form/SV_6kVqZANThUQ1bZb?installation=omnibus&release=16-0

  Verifying  : gitlab-ce-16.4.2-ce.0.el7.x86_64                                                                                                                                1/1

Installed:
  gitlab-ce.x86_64 0:16.4.2-ce.0.el7

Complete!

修改GitLab配置

[root@GitLabServer ~]# hostname -I
172.16.70.191

[root@GitLabServer ~]# cp /etc/gitlab/gitlab.rb /etc/gitlab/gitlab.rb_bak
[root@GitLabServer ~]# diff /etc/gitlab/gitlab.rb /etc/gitlab/gitlab.rb_bak
32c32
# 例如1:external_url 'http://IP'            # 默认端口80
# 例如2:external_url 'http://IP:PORT'
< external_url 'http://172.16.70.191:91'    # 修改此行内容, 设置为本机IP:PORT
---
> external_url 'http://gitlab.example.com'


# 重载配置
[root@GitLabServer ~]# gitlab-ctl reconfigure

可选设置

# 启动ssh服务&设置为开机启动
systemctl enable sshd && sudo systemctl start sshd

# 设置postfix开机自启,并启动,postfix支持gitlab发信功能
systemctl enable postfix && systemctl start postfix

# 如系统防火墙是开启状态,则需设置允许策略
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --zone=public --add-port=PORT/tcp --permanent

systemctl reload firewalld

查看GitLab版本信息

[root@GitLabServer ~]# gitlab-rake gitlab:env:info
System information
System:
Current User:	git
Using RVM:	no
Ruby Version:	3.0.6p216
Gem Version:	3.4.19
Bundler Version:2.4.19
Rake Version:	13.0.6
Redis Version:	7.0.13
Sidekiq Version:6.5.7
Go Version:	unknown

GitLab information
Version:	16.4.2
Revision:	67c88353b76
Directory:	/opt/gitlab/embedded/service/gitlab-rails
DB Adapter:	PostgreSQL
DB Version:	13.11
URL:		http://172.16.70.191:91
HTTP Clone URL:	http://172.16.70.191:91/some-group/some-project.git
SSH Clone URL:	git@172.16.70.191:some-group/some-project.git
Using LDAP:	no
Using Omniauth:	yes
Omniauth Providers:

GitLab Shell
Version:	14.28.0
Repository storages:
- default: 	unix:/var/opt/gitlab/gitaly/gitaly.socket
GitLab Shell path:		/opt/gitlab/embedded/service/gitlab-shell

Gitaly
- default Address: 	unix:/var/opt/gitlab/gitaly/gitaly.socket
- default Version: 	16.4.2
- default Git Version: 	2.42.0

Gitlab目录结构

/opt/gitlab/                     # 主目录
/etc/gitlab/                     # 放置配置文件
/var/opt/gitlab/                 # 各个组件
/var/log/gitlab/                 # 放置日志文件
/var/opt/gitlab/git-data/repositories    # 数据库的地址  
/var/opt/gitlab/postgresql/data          # gitlab组和项目的地址
/etc/gitlab/gitlab.rb                      # gitlab配置文件

端口信息

[root@GitLabServer ~]# netstat -ntpl
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 127.0.0.1:9229          0.0.0.0:*               LISTEN      2637/gitlab-workhor
tcp        0      0 127.0.0.1:9168          0.0.0.0:*               LISTEN      2653/ruby
tcp        0      0 127.0.0.1:8080          0.0.0.0:*               LISTEN      2222/puma 6.3.1 (un
tcp        0      0 0.0.0.0:91              0.0.0.0:*               LISTEN      2330/nginx: master    # 对应前面设置external_url PORT
tcp        0      0 127.0.0.1:8082          0.0.0.0:*               LISTEN      2252/sidekiq_export
tcp        0      0 127.0.0.1:9236          0.0.0.0:*               LISTEN      2599/gitaly
tcp        0      0 127.0.0.1:8150          0.0.0.0:*               LISTEN      2626/gitlab-kas
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      1270/sshd
tcp        0      0 127.0.0.1:8151          0.0.0.0:*               LISTEN      2626/gitlab-kas
tcp        0      0 127.0.0.1:8153          0.0.0.0:*               LISTEN      2626/gitlab-kas
tcp        0      0 127.0.0.1:25            0.0.0.0:*               LISTEN      875/master
tcp        0      0 127.0.0.1:8154          0.0.0.0:*               LISTEN      2626/gitlab-kas
tcp        0      0 127.0.0.1:8155          0.0.0.0:*               LISTEN      2626/gitlab-kas
tcp        0      0 127.0.0.1:8092          0.0.0.0:*               LISTEN      2250/sidekiq 6.5.7
tcp        0      0 0.0.0.0:8060            0.0.0.0:*               LISTEN      2330/nginx: master
tcp        0      0 127.0.0.1:9121          0.0.0.0:*               LISTEN      2655/redis_exporter
tcp        0      0 127.0.0.1:9090          0.0.0.0:*               LISTEN      2662/prometheus
tcp        0      0 127.0.0.1:9187          0.0.0.0:*               LISTEN      2688/postgres_expor
tcp        0      0 127.0.0.1:9093          0.0.0.0:*               LISTEN      2678/alertmanager
tcp        0      0 127.0.0.1:9100          0.0.0.0:*               LISTEN      2647/node_exporter
tcp6       0      0 ::1:9168                :::*                    LISTEN      2653/ruby
tcp6       0      0 :::22                   :::*                    LISTEN      1270/sshd
tcp6       0      0 ::1:25                  :::*                    LISTEN      875/master
tcp6       0      0 :::9094                 :::*                    LISTEN      2678/alertmanager

GitLab 密码

获取初始登录密码,默认登录账号:root

设置初始密码:GitLab 14.0 开始引入。
默认情况下,Linux 软件包安装会自动为初始管理员用户账号 (root) 生成密码,并将其存储到 /etc/gitlab/initial_root_password 至少 24 小时。出于安全原因,24 小时后,此文件会被第一次 gitlab-ctl reconfigure 自动删除。

[root@GitLabServer ~]# grep Password: /etc/gitlab/initial_root_password
Password: JPbE/qElViuH3O1REgsnA+9hB669393vA0f19NAe8RY=

设置简体中文

其他设置

禁用注册

默认情况下,任何访问您的极狐GitLab 域名的用户都可以注册一个账户。对于运行面向公众的极狐GitLab 实例的客户,如果您不希望公众用户注册账户,我们强烈建议您考虑禁用新注册。

要禁用注册:
在左侧边栏中,选择 搜索或转到。
选择 管理中心。
选择 设置 > 通用。
展开 注册限制。
清除 已启用注册功能 复选框,然后选择 保存修改。

重置/修改root密码

使用Rake任务:GitLab 13.9 开始引入。
密码长度设置为最少8个字符,且必须符合复杂性要求。

[root@GitLabServer ~]# gitlab-rake "gitlab:password:reset[root]"
Enter password:    # 输入新密码
Confirm password:  # 再次输入
Password successfully updated for user with username root.

防止新用户创建顶级群组

# 编辑 /etc/gitlab/gitlab.rb 并添加以下行:
gitlab_rails['gitlab_default_can_create_group'] = false

gitlab-ctl reconfigure
gitlab-ctl restart

防止用户更改其用户名

默认情况下,新用户可以更改他们的用户名。要禁用您的用户更改其用户名的能力:

编辑 /etc/gitlab/gitlab.rb 并添加以下行:
gitlab_rails['gitlab_username_changing_enabled'] = false

gitlab-ctl reconfigure
gitlab-ctl restart

前言:

前几天,点开自己的博客,看了一下
CYQ.Data V5系列
都有哪些文章,

发现了一篇2019年写的:
CYQ.Data 对于分布式缓存Redis、MemCache高可用的改进及性能测试
,于是点进去看了看。

感觉文章中有些表述存有问题,不过不是重点。

重点,看了里面的测试结论,如果四五年过去了,CYQ.Data 低调的更新,有没有进步呢?

为了和之前的版本进行对比,好在当年有留下代码,就直接拿当年的代码来测试一下。

下面就来重新看一下新的测试结果:

测试环境:

本次测试为.Net Windown 版本,下次再测试.Net Core Linux 版本。

Win11 系统:6核16G内存

Redis 版本:redis_version:3.2.100

由于单机测试,受运行程序和Redis自身程序等多综合环境影响,测试结果仅供参考,用娱乐的心态看看即可。

测试代码:

见上一篇文章的测试代码:
点击下载

CYQ.Data Redis 测试结果:

使用  CYQ.Data 当前最新版本:V5.9.2.7

VS2022 中直接运行 Release 版本:

直接运行编绎的exe程序结果:

StackExchange.Redis 测试结果:

使用 StackExchange.Redis 当前最新版本:V2.7.27

考虑到 StackExchange.Redis 默认是单线程,因此提前设置最低线程池,不然会抛异常。

 ThreadPool.SetMinThreads(128, 128);

VS2022 中直接运行 Release 版本:

运行编绎后程序 exe 结果:

redis-benchmark.exe 测试结果:

运行结果1:

运行结果2:

运行结果3:

运行结果4:

总结:

具体的性能指标,应该结合自身业务和生产环境,测试出适合自身要求的指标。

下面就来看看总结词吧:

1、Redis 自带测试工具

单线程时,能跑到2w/s,为啥6核下,最高也是6w/s出头,而不是2W*6=12W呢?

Redis 是一个单线程的内存数据库,它的性能受限于单个 CPU 核心的处理能力。即使在多核处理器上运行 Redis,它也只能利用其中一个核心进行处理,所以无法直接通过简单地将单核性能乘以核心数来计算多核性能。

在一个六核处理器上运行 Redis,虽然有多个核心可供利用,但 Redis 本身仍然是单线程的,因此最高的吞吐量受限于单个核心的性能。虽然可以同时处理多个请求,但每个请求仍然只能在一个核心上依次执行,不能完全实现性能的线性增长。

此外,Redis 在处理请求时还会涉及到锁竞争、线程切换等开销,这些因素也会限制其在多核处理器上的性能表现。因此,尽管在多核处理器上运行 Redis 会比单核处理器有一定的性能提升,但不会达到简单地将单核性能乘以核心数的增长幅度。

2、StackExchange.Redis 测试结果总结:

在VS2002中启动运行时,单线程时,性能特别低,只有4000/s上下,这不禁让我想起上一份测试的结果,是不是也是受这种运行模式影响。

在直接运行exe程序,单线程时,在1W/s左右,8线程下到5.2w/s出头。

16线程之后,突飞猛进了,突破 Redis自身极限,达到10-20w/s ?

听说StackExchange.Redis 从2.X 版本后,重构了代码,在资源管够的情况下,将代码从单条提交串行转批量提交,因此就有了这种测试结果。

3、CYQ.Data Redis 测试结果总结:

在VS中启动运行,和直接运行启动,两者性能相差不大。

单线程时,在1.5w/s左右,多线程下到5.2w/s,效果基本稳定在5w/s上下。

整体 CYQ.Data Redis 表现是不错的,常规调用性能比 StackExchange.Redis 高一些(去掉它鸡贼的批量方式)。

目前 CYQ.Data 未提供批量方法调用,后续会考量,是否引入这种自动批量处理方式,或提供批量调用入口。

目前机制下,可以通过增加 Redis 服务数量来提升并发性能。

写在开头

经过上几篇博文的学习,我们知道在Java中可以通过new Thread().start()创建一个线程,那今天我们就来思考另外一个问题:
线程的终止
自然终止有两种情况:

1. 线程的任务执行完成;
2. 线程在执行任务过程中发生异常。

start之后,如果线程没有走到终止状态,我们该如何停止这个线程呢?

为什么stop终止不可用

翻看Thread源码后,发现其提供过一个stop()方法,可以用来终止线程,我们看一下它的源码。

【源码解析1】

@Deprecated
    public final void stop() {
        SecurityManager security = System.getSecurityManager();
        if (security != null) {
            checkAccess();
            if (this != Thread.currentThread()) {
                security.checkPermission(SecurityConstants.STOP_THREAD_PERMISSION);
            }
        }
        // A zero status value corresponds to "NEW", it can't change to
        // not-NEW because we hold the lock.
        if (threadStatus != 0) {
            resume(); // Wake up thread if it was suspended; no-op otherwise
        }

        // The VM can handle all thread states
        stop0(new ThreadDeath());
    }

这个方法使用了@Deprecated修饰,代表着它是废弃的方法,在Java的编码规约中,过时的方法不建议继续使用,并且在这个方法的注释中官方也提示说这是一个不安全的强制恶意中断方法,会破坏线程的原子性。
image

因此,在这里强烈建议大家不要再用stop方法去停止线程了!

如何优雅的停止一个线程

我们知道线程只有从 runnable 状态(可运行/运行状态) 才能进入terminated 状态(终止状态),如果线程处于 blocked、waiting、timed_waiting 状态(休眠状态),就需要通过 Thread 类的 interrupt() 方法,让线程从休眠状态进入 runnable 状态,从而结束线程。

这里就涉及到了一个概念“
线程中断
”,这是一种协作机制,当其他线程通知需要被中断的线程后,线程中断的状态被设置为 true,但是具体被要求中断的线程要怎么处理,完全由被中断线程自己决定,可以在合适的时机中断请求,也可以完全不处理继续执行下去,这样一来,安全性就得到了保障。

Thread类中提供线程中断的方法如下:

  • Thread.interrupt()
    :中断线程。这里的中断线程并不会立即停止线程,而是设置线程的中断状态为 true(默认是 flase);
  • Thread.currentThread().isInterrupted()
    :测试当前线程是否被中断。线程的中断状态会受这个方法的影响,调用一次可以使线程中断状态变为 true,调用两次会使这个线程的中断状态重新转为 false;
  • Thread.isInterrupted()
    :测试当前线程是否被中断。与上面方法不同的是调用这个方法并不会影响线程的中断状态。

Ok,写了那么多,我们来写一个小的demo测试一下线程中断的方法。

【代码示例】

public class Test {
    public static void main(String[] args) {
        //测试系统监控器
        testSystemMonitor();
    }

    /**
     * 测试系统监控器
     */
    public static void testSystemMonitor() {
        SystemMonitor sm = new SystemMonitor();
        sm.start();
        try {
            //运行 10 秒后停止监控
            Thread.sleep(10 * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("监控任务启动 10 秒后,停止...");
        sm.stop();
    }
}
/*系统监控器*/
class SystemMonitor {

    private Thread t;
    //线程中断标识
    private volatile boolean stop = false;

    /**
     * 启动一个线程监控系统
     */
    void start() {
        t = new Thread(() -> {
            while (!stop) {//判断当前线程是否被打断
                System.out.println("正在监控系统...");
                try {
                    Thread.sleep(3 * 1000L);//执行 3 秒
                    System.out.println("任务执行 3 秒");
                    System.out.println("监控的系统正常!");
                } catch (InterruptedException e) {
                    System.out.println("任务执行被中断...");
                    //重新设置线程为中断状态,保证JVM抛异常情况下,中断状态仍为true。
                    Thread.currentThread().interrupt();
                }
            }
        });
        t.start();
    }
    //线程中断
    void stop() {
        stop = true;
        t.interrupt();
    }
}

在这里我们先创建了一个SystemMonitor类作为系统检测器,每3秒一循环的进行检测,考虑到在Thread.currentThread().isInterrupted()可能在某些情况下中断失效,所以我们这里自定义一个stop变量,作为线程中断的标识,检测线程启动先对标识位进行判断。

然后,我们在Test类中写一个测试方法,调用这个系统监控器,进行检测,并设置10秒后,调用stop方法中断检测线程,将中断标识stop设置为true。启动代码后,我们在控制台可以看到这样的输出:

正在监控系统...
任务执行 3 秒
监控的系统正常!
正在监控系统...
任务执行 3 秒
监控的系统正常!
正在监控系统...
任务执行 3 秒
监控的系统正常!
正在监控系统...
监控任务启动 10 秒后,停止...
任务执行被中断...

与我们的预期一样,监控线程在执行了3个循环的检测任务后,被成功中断调。到这里,我们就成功的、安全的、优雅的停止了一个线程啦!

结尾彩蛋

如果本篇博客对您有一定的帮助,大家记得
留言+点赞+收藏
呀。原创不易,转载请联系Build哥!

image

如果您想与Build哥的关系更近一步,还可以关注“JavaBuild888”,在这里除了看到《Java成长计划》系列博文,还有提升工作效率的小笔记、读书心得、大厂面经、人生感悟等等,欢迎您的加入!

image

本文首发于公众号:Hunter后端

原文链接:
Python笔记六之多进程

在 Python 里,我们使用 multiprocessing 这个模块来进行多进程的操作。

multiprocessing 模块通过创建子进程的方式来运行多进程,因此绕过了 Python 里 GIL 的限制,可以充分利用机器上的多个处理器。

1、多进程使用示例

多进程的使用方式和多线程的方式类似,这里使用到的是
multiprocessing.Process
类,下面是一个简单的示例:

from multiprocessing import Process
import time

def f(x):
    if x % 2 == 1:
        time.sleep(x+1)
    print(x * x)
    return x * x


def test_multi_process():
    processes = []

    for i in range(5):
        processes.append(Process(target=f, args=(i,)))

    for p in processes:
        p.start()

    for p in processes:
        p.join(0.5)

    for p in processes:
        print(p, p.is_alive(), p.pid, p._parent_pid)


if __name__ == "__main__":
    test_multi_process()

在上面的示例中,test_multi_process() 函数里使用多进程的方式调用 f 函数,和多线程的调用方式一致,通过 start() 方法启动进程活动,使用 join() 方法阻塞调用其的进程。

接下来介绍一下 multiprocessing.Process 的一些方法和属性。

1. run()

表示进程活动的方法,可以在子类中重载此方法,比如多线程笔记的操作里重写 run() 对函数执行报错进行了处理,并返回了执行结果

2. start()

启动进程活动,将对象的 run() 方法在一个单独的进程中调用

3. join()

阻塞调用 join() 方法的进程,在上面的示例中也就是父进程,默认值为 None,也就表示阻塞操作。

如果设置为其他正数值,那么则最多会阻塞多少秒,比如上面的示例为 0.5 秒,如果超时,那么父进程则会继续往后执行。

比如上面的示例输出结果如下:

0
4
16
<Process name='Process-1' pid=6600 parent=24248 stopped exitcode=0> False 6600 24248
<Process name='Process-2' pid=4368 parent=24248 started> True 4368 24248
<Process name='Process-3' pid=13024 parent=24248 stopped exitcode=0> False 13024 24248
<Process name='Process-4' pid=3288 parent=24248 started> True 3288 24248
<Process name='Process-5' pid=16880 parent=24248 stopped exitcode=0> False 16880 24248
1
9

在打印每个进程的信息时,f() 函数内部进行 sleep 的进程还没有执行结束,但是进程已经超时了,所以不再阻塞父进程向下执行。

4. is_alive()

上面有打印出信息,返回布尔值,表示该进程是否还活着。

5. pid 和 parent_pid

上面使用 .pid 和 ._parent_pid 属性打印出了每个进程的 id 和其父进程的 id。

2、进程池

进程使用的对象是 multiprocessing.pool.Pool()。

接受 processes 参数为进程数,表示要使用的工作进程数目,如果不传入,则默认使用 cpu 的核数,根据 os.cpu_count() 获取。

接下来分别使用示例介绍 multiprocessing.pool 下的几个调用方法,进程池的使用可以使用 map() 和 starmap() 两个函数。

1. map()

map() 接受两个参数,func 表示多进程要执行的函数,iterable 表示要执行的 func 函数输入的参数的迭代对象。

这里需要注意一下,map() 函数使用的 func 函数只能接受一个参数,比如我们前面定义的 f 函数,下面是其使用示例:

def f(x):
    return x * x


def test_pool_map():
    with Pool(processes=4) as pool:
        results = pool.map(func=f, iterable=range(10))

    print(results)

2. starmap()

starmap() 函数与 map() 使用方法类似,但是 iterable 迭代参数的元素是 func 函数的多个参数,比如我们想要对下面的 add() 函数使用多进程:

def f_add(x, y):
    return x + y

它的调用方式如下:

def test_pool_starmap():
    with Pool(processes=4) as pool:
        results = pool.starmap(func=f_add, iterable=zip(range(6), range(6, 12)))
    
    print(results)

这里返回的 results 是一个列表,元素是每个进程执行的函数的返回结果。

3、进程间交换对象

前面介绍了,多进程的运行方式是通过建立子进程的形式来操作,而不同进程间数据是不共享的,这一点不同于多线程。

因为多线程的操作是在同一个进程内实现的,所以线程间数据是共享数据资源的。

接下来介绍一下如何在进程间进行对象的交换,其实进程间进行对象的交换是一个子命题,更高层级的概括是在进程间进行通信,在官方的文档中对其进行了细分,所以这里也对其进行分类别的介绍。

在进程间进行对象交换的方式有两种,一种是队列,一种是管道。

1. 队列

1) 队列的代码示例

这里的模块的引入是
multiprocess.Queue
,这个类近似于是 queue.Queue 的克隆,以下是官方文档的一个示例,内容是在父进程中创建一个队列,然后在子进程中写入数据,然后再在父进程中读取:


from multiprocessing import Process, Queue

def f(q):
    q.put([42, None, 'hello'])

if __name__ == '__main__':
    q = Queue()
    p = Process(target=f, args=(q,))
    p.start()
    print(q.get())    # prints "[42, None, 'hello']"
    p.join()

队列的写入使用 put(),读取使用 get()。

get() 还可以加上两个参数,block 和 timeout,block 表示是否阻塞,timeout 表示获取的超时时间。

接下来我们再实现一个功能,两个子进程写入数据,一个子进程读取数据,代码示例如下:

from multiprocessing import Queue, Process


def f_write(q, n, name):
    for i in range(n):
        q.put(f"{name}_{i}")
        time.sleep(0.1)


def f_read(q):
    while q.qsize() > 0:
        print(q.get(block=False, timeout=1))
        time.sleep(0.5)


def test_queue():
    # 三个进程,一个写进程,两个读进程
    q = Queue()
    q.put("origin_value")
    q.put("b")

    # p1 = Process(target=f_queue, args=(q, "c"))
    # p2 = Process(target=f_queue, args=(q, ))
    p1 = Process(target=f_write, args=(q, 5, "a"))
    p2 = Process(target=f_write, args=(q, 8, "b"))
    p3 = Process(target=f_read, args=(q,))

    p1.start()
    p2.start()
    p3.start()

    p1.join()
    p2.join()
    p3.join()

    print("total: ", q.qsize())

if __name__ == "__main__":
    test_queue()

2) 队列的相关方法

关于队列的相关函数,除了前面介绍的几种,还有比如判断队列的长度,是否为空等。

a) Queue()

在定义一个队列的时候,我们前面是直接定义
q=Queue()
,不为其设置元素长度,而如果我们想要为其设置一个最大的长度,可以加上 maxsize 参数:

q = Queue(maxsize=3)

那么队列里最多只能有三个元素,而如果队列满了还往其中 put() 加入操作,则会阻塞,直到其他进程对其读取其中的数据。

b) put()

put() 函数表示的是往队列里添加元素,元素的类型不限,添加数字,字符串,字典,列表都可以:

q = Queue()
q.put(1)
q.put({"a": 4})
q.put([1,3,4])

前面介绍了,如果队列满了,还往队列里进行 put() 操作,则会进入阻塞操作,可以通过添加 block 或者 timeout 来进行避免。

block 表示是否阻塞,为 True 的话则会进入阻塞等待状态。False 的话则会引发异常。

timeout 表示超时,尝试往队列里添加数据,超出等待时间同样已发队列已满的异常。

c) get()

get() 函数表示从队列中读取元素,队列的写入和读取的原则是先入先出,最先进去的最先出来。

而为了避免队列为空的情况下进行 get() 进入阻塞状态,get() 可以使用两个参数,一个是 block,表示是否阻塞,一个是 timeout,表示超时时间。

如果队列为空还进行 get() 操作,使用上面这两个操作则会 raise 一个 Empty 的 error。

d) qsize()

返回队列的长度,但由于多进程或多线程的上下文,这个数字是不可靠的。

e) empty()

如果队列是空的,则返回 True,否则返回 False,由于多进程或多线程的环境,该状态是不可靠的。

f) full()

如果队列设置了 maxsize 参数,那么如果队列满了,则返回 True,否则返回 False,由于多进程或多线程的环境,该状态是不可靠的。

g) close()

关闭队列,如果执行了
q.close()
,再往里面添加元素执行
q.put()
操作,则会引发报错。

2. 管道

1) 管道的相关函数

管道的引入方式如下:

from multiprocessing import Pipe

管道的定义可以直接实例化 Pipe,返回管道的两端:

conn1, conn2 = Pipe()

默认情况下,Pipe() 的参数 duplex 值为 True,表示管道是双工的,也就是可以双向通信的,比如 conn1 可以写入,也可以读出,conn2 可以写入也可以读出数据。

而如果手动设置 duplex 为 False,那么管道则是单向的,conn1 只能用于接收消息,conn2 只能发送消息。

管道用于发送和接收的函数分别如下:

发送信息

conn.send(obj)

发送的对象可以是字符串,也可以是其他对象,比如列表,字典等。

接收信息

conn.recv()

关闭连接对象

我们可以使用 close() 来关闭连接对象,当连接对象被垃圾回收时会自动调用:

conn.close()

判断连接对象中是否有可以读取的数据

如果我们直接使用 conn.recv() 的时候,如果管道内没有可接收的对象,会进入阻塞状态,直到管道内传入数据。

我们可以使用 poll() 函数判断管道内是否有可以读取的数据,返回的是一个布尔型数据,表示是否有数据:

has_data = conn.poll()

但是如果不设置超时时间,同样会进入等待状态,所以可以设置一个最大阻塞秒数:

has_data = conn.pool(timeout=3)  # 等待 3 秒

2) 管道的代码示例

接下来我们用下面的代码来进行管道的双工测试,即从管道的两端分别写入和读取数据。

from multiprocessing import Process, Pipe


def send_info(conn, info):
    conn.send(info)
    conn.close()


def read_info(conn):
    while conn.poll(timeout=2):
        info = conn.recv()
        print(info)


def test_pipe():
    # 两个 conn 分别都往里面读和写
    parent_conn, child_conn = Pipe()

    # p1 向 child 管道写入
    print("id out of func: ", id(child_conn))
    p1 = Process(target=send_info, args=(child_conn, "send_info_from_child"))
    p1.start()
    p1.join()

    # p2 从 parent 管道读取
    p2 = Process(target=read_info, args=(parent_conn,))
    p2.start()
    p2.join()

    # p3 向 parent 管道写入
    p3 = Process(target=send_info, args=(parent_conn, "send_info_from_parent"))
    p3.start()
    p3.join()

    # p4 从 child 管道读取
    p4 = Process(target=read_info, args=(child_conn,))
    p4.start()
    p4.join()


if __name__ == "__main__":
    test_pipe()

注意
:如果两个进程(或线程)同时尝试读取或写入管道的 同一 端,则管道中的数据可能会损坏。当然,在不同进程中同时使用管道的不同端的情况下不存在损坏的风险。

4、进程间同步

与多线程一样,多进程也可以使用锁来确保一次只有一个进程来执行一个操作,比如有一个打印到标准输出的操作,我们需要确保其打印的日志不紊乱,就可以使用下面的操作:

from multiprocessing import Process, Lock

def f(l, i):
    l.acquire()
    try:
        print("hello ", i)
    finally:
        l.release()

if __name__ == "__main__":
    lock = Lock()
    for num in range(10):
        Process(target=f, args=(lock, num)).start()

而如果不使用锁,我们重写 f 函数如下:

def f(l, i):
    print("hello ", i)

多执行几次,我们可以看到控制台的输出会出现错乱的情况,这样就可能对输出信息不能直观查看,比如:

hello  2
hello  0
hello  4
hello hello  3
 1
hello  5
hello  6
hello  8
hello  9
hello  7

5、进程间共享状态

在并发编程的时候,应当尽量避免使用共享状态,尤其是多进程操作时,但如果真的有这个需求,需要共享一些数据,multiprocessing 提供了两种方法,一种是共享内存,一种是服务进程。

1. 共享内存

我们可以使用 Value 或者 Array 将数据存储在共享内存映射中。

Value 是存储的单个变量,Array 存储的是数组,注意下,这里的 Value 和 Array 在定义的时候都需要指定元素类型。

其引入及代码示例如下:

from multiprocessing import Process, Value, Array


def f(n, a):
    n.value = 5
    a[0] = 100


if __name__ == "__main__":
    num = Value('d', 1)
    arr = Array('i', range(5))
    print(num.value)
    print(arr[:])

    p = Process(target=f, args=(num, arr))
    p.start()
    p.join()

    print(num.value)
    print(arr[:])

其中,引入的方式可以直接从 multiprocessing 中引入,在定义 Value 和 Array 的时候,第一个参数是 'd' 和 'i',分别表示类型是双精度浮点数和有符号整数。

这些共享对象将是进程和线程安全的。

更多的关于共享内存的信息,可以使用
multiprocessing.sharedctypes
模块。

2. 服务进程

我们可以使用 Manager() 返回的管理对象控制一个服务进程,这个进程还可以保存 Python 对象并允许其他进程使用代理操作它们。

这个操作的意思就是使用 Manager() 会跟多进程的操作方式一样,创建一个子进程,然后将一些需要共享的数据都放到这个子进程里,其他子进程可以操作这个子进程的数据来达到数据共享的目的。

Manager() 支持的数据类型有:list,dict,Namespace,Lock,Value,Array 等,下面介绍一下代码示例:

from multiprocessing import Process, Manager


def f(d, l):
    d["a"] = 1
    d["b"] = 2
    l[0] = 100


if __name__ == "__main__":
    with Manager() as manager:
        d = manager.dict()
        l = manager.list(range(5))

        p = Process(target=f, args=(d, l))
        p.start()
        p.join()

        print(d)
        print(l)

使用服务进程的管理器比使用共享内存对象更灵活,因为它们可以支持任意对象类型。

此外,单个管理器可以通过网络由不同计算机上的进程共享。但是,它们比使用共享内存慢。

如果想获取更多后端相关文章,可扫码关注阅读:

上一篇文章拿 TextField 组件举例时,提到了
State
,即状态。本篇文章,即讲解 State 的相关改概念。

一、什么是状态

与其它声明式 UI 框架一样,Compose 的职责非常单纯,仅作为对数据状态的反应。如果数据状态没有改变,则 UI 永远不会自行改变。在 Compose 中,每一个组件都是一个被
@Composable
修饰的函数,其状态就是函数的参数,当参数不变,则函数的输出就不会变,唯一的参数决定唯一输出。反言之,如果要让界面发生变化,则需要改变界面的状态,然后 Composable 响应这种变化。
下面还是拿个例子来说,做一个简单的计数器,有一个显示计数的控件,一个增加的按钮,每点击一次,则技术计数器加 1 ,一个减少的按钮,每点击一次,计时器减 1。
假如我们用此前的 View 视图体系,来写这个方法。代码大概像下面这样:

class MainActivity : AppCompatActivity() {
    // ...
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...
        binding.incrementBtn.setOnClickListener {
            binding.tvCounter.text = "${Integer.valueOf(binding.tvCounter.text.toString()) + 1 }"
        }

        binding.decrementBtn.setOnClickListener {
            binding.tvCounter.text = "${Integer.valueOf(binding.tvCounter.text.toString()) - 1 }"
        }
    }
}

显然上面这个代码,计数逻辑和 UI 的耦合度就很高。稍微优化一下:

class MainActivity : AppCompatActivity() {
    // ...
    private var counter: Int = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...
        binding.incrementBtn.setOnClickListener {
            counter++
            updateCounter()
        }

        binding.decrementBtn.setOnClickListener {
            counter--
            updateCounter()
        }
    }

    private fun updateCounter() {
        binding.tvCounter.text = "$counter"
    }
}

这个代码的改动主要在于,新增了 counter 用于计数,本质上属于一种 “状态上提”, 原本 TextView 内部的状态 “mText”, 上提到了 Activity 中,这样,即使更换了计数器的 UI, 计数逻辑依然可以复用。

但是当前的代码,仍然有一些问题,比如计数逻辑在 Activity 中,无法到其它页面进行复用,进一步使用 MVVM 结构进行改造。引入 ViewModel, 将状态从 Activity 中上提到 ViewModel 中。

class CounterViewModel: ViewModel() {
    private var _counter: MutableStateFlow<Int> = MutableStateFlow(0)
    val counter: StateFlow<Int> get() = _counter

    fun incrementCounter() {
        _counter.value++
    }

    fun decrementCounter() {
        _counter.value--
    }
}

class MainActivity : AppCompatActivity() {
    // ...
    private val viewModel: CounterViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...
        binding.incrementBtn.setOnClickListener {
            viewModel.incrementCounter()
        }

        binding.decrementBtn.setOnClickListener {
            viewModel.decrementCounter()
        }

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.counter.collect {
                    binding.tvCounter.text = $it
                }
            }
        }
    }
}

有 Jetpack 库使用经验的应该非常熟悉上面的代码,将状态上提到 ViewModel 中,使用 StateFlow 或者 LiveData 包装起来,在 Ativity 中监听状态的变化,从而自动刷新 UI。

下面,我们在 Compose 中实现上述计数器:

@Composable
fun CounterPage() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        var counter = 0
        Text(text = "$counter")
        Button(onClick = { counter++ }) {
            Text(text = "increment")
        }
        Button(onClick = { counter-- }) {
            Text(text = "decrement")
        }
    }
}

我们写出上面的代码,运行。

结果发现,无论怎么点击,Text 显示的值总是 0 ,我们的计数逻辑没有生效。为了说明这个问题,现在增加一点日志:

@Composable
fun CounterPage() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        var counter = 0
        Log.d("sharpcj", "counter text --> $counter")
        Text(text = "$counter")
        Button(onClick = {
            Log.d("sharpcj", "increment button click ")
            counter++
        }) {
            Text(text = "increment")
        }
        Button(onClick = {
            Log.d("sharpcj", "decrement button click ")
            counter--
        }) {
            Text(text = "decrement")
        }
    }
}

再次运行,点击按钮,看到日志如下:

2024-03-12 21:39:27.530 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:39:30.859 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:39:31.309 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 21:39:31.468 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 21:39:31.762 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:39:31.927 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:39:32.661 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 

我们重新捋一捋,Compose 的组件实际上就是一个个函数,Compose 刷新 UI 的逻辑是,状态发生变化,触发了重组,函数被重新调用,然后由于参数发生了变化,函数输出改变了,最终渲染出的的画面才会发生变化。
再看上面的代码,我们期望是定义
counter
作为了 Text 组件的状态,点击 Button,改变
counter
, 到这里都没有问题,那么问题处在了哪里呢?问题主要是 counter 发生了变化,没有触发重组,即函数没有被重新调用,日志也证明了这一点。
回看我们上面传统 View 视图的写法,此前,我们改变了状态,需要主动调用
updateCounter
方法去刷新 UI, 后面经过改造,我们把状态提升到 ViewModel 中,不论是使用 StateFlow 还是使用 LiveData 包装后,我们都需要在 Activity 中监听状态的变化,才能对状态的变化做出响应。针对上面的例子,我们现在清楚了,计数器不生效原因在于 counter 改变后,Compose 没有感知到,没有触发重组。下面需要开始学习 Compose 中的状态了。

二、Compsoe 中的状态 State

2.1 State

如同传统试图中,需要使用 StateFlow 或者 LiveData 将状态变量包装成一个可观察类型的对象。Compose 中也提供了可观察的状态类型,可变状态类型 MutableState
和 不可变状态类型 State 。我们需要使用 State/MutableState 将状态变量包装起来,这样即可触发重组。更为方便的是,声明式 UI 框架中,不需要我们显示注册监听状态变化,框架自动实现了这一订阅关系。我们来改写上面的代码:

@Composable
fun CounterPage() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        val counter: MutableState<Int> = mutableStateOf(0)
        Log.d("sharpcj", "counter text --> ${counter.value}")
        Text(text = "${counter.value}")
        Button(onClick = {
            Log.d("sharpcj", "increment button click ")
            counter.value++
        }) {
            Text(text = "increment")
        }
        Button(onClick = {
            Log.d("sharpcj", "decrement button click ")
            counter.value--
        }) {
            Text(text = "decrement")
        }
    }
}

我们使用了
mutableStateOf()
方法初始化了一个 MutableState 类型的状态变量,并传入默认值 0 ,使用的时候,需要调用
counter.value

再次运行,结果发现,点击按钮,计数器值还是没有变化,日志如下:

2024-03-12 21:57:24.773  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:31.428  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:57:31.437  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:31.825  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 21:57:31.834  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:33.047  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:57:33.055  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:33.216  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:57:33.224  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:33.634  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 21:57:33.643  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:33.792  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 21:57:33.801  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0

和上一次不一样了,这次发现,点击按钮之后,
Text(text = "${counter.value}")
有重新执行,即发生了重组,但是执行的时候,参数没有改变,依然是 0,其实这里涉及到一个重组作用域的概念,就是重组是有一个范围的,关于重组作用范围,稍后再讲。这里需要知道,发生了重组,
Text(text = "${counter.value}")
有重新执行,那么
val counter: MutableState<Int> = mutableStateOf(0)
也有重新执行,相当于重组时,counter 被重新初始化了,并赋予了默认值 0 。所以点击按钮发生了重组,但是计数器的值没有发生改变。要解决这个问题,则需要使用到 Compose 中的一个重要函数
remember

2.2 remember

我们先看看 remember 函数的源码:

/**
 * Remember the value produced by [calculation]. [calculation] will only be evaluated during the composition.
 * Recomposition will always return the value produced by composition.
 */
@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)

remember 方法的作用是,对其包裹起来的变量值进行缓存,后续发生重组过程中,不会重新初始化,而是直接从缓存中取。具体使用如下:

@Composable
fun CounterPage() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        val counter: MutableState<Int> = remember { mutableStateOf(0) }
        Log.d("sharpcj", "counter text --> ${counter.value}")
        Text(text = "${counter.value}")
        Button(onClick = {
            Log.d("sharpcj", "increment button click ")
            counter.value++
        }) {
            Text(text = "increment")
        }
        Button(onClick = {
            Log.d("sharpcj", "decrement button click ")
            counter.value--
        }) {
            Text(text = "decrement")
        }
    }
}

再次运行,这次终于正常了。

看日志也正确了。每次点击都出发了重组,并且 counter 的值也没有重新初始化。

2024-03-12 22:18:53.744 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 22:19:10.397 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:10.421 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 1
2024-03-12 22:19:10.967 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:10.981 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 2
2024-03-12 22:19:11.181 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:11.195 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 3
2024-03-12 22:19:11.649 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:11.663 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 4
2024-03-12 22:19:11.806 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:11.821 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 5
2024-03-12 22:19:12.364 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 22:19:12.377 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 4
2024-03-12 22:19:12.640 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 22:19:12.657 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 3
2024-03-12 22:19:13.204 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:13.220 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 4
2024-03-12 22:19:13.747 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 22:19:13.761 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 3

上面的代码中,我们创建 State 的方法如下:

val counter: MutableState<Int> = remember { mutableStateOf(0) }

使用时,通过
counter.value
来使用,这样的代码看起来就很繁琐,我们可以进一步精简写法。
首先, Kotlin 支持类型推导,所以可以写成下面这样:

val counter = remember { mutableStateOf(0) }

另外,借助于 Kotlin 委托语法,Compose 实现了委托方式赋值,使用
by
关键字即可,用法如下:

var counter by remember { mutableStateOf(0) }

并导入如下方法:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

在使用时,直接使用
counter++

counter--

需要注意的一点是,没有使用委托方式创建的对象,类型是
MutableState
类型,我们用
val
声明,使用委托方式创建对象,对象类型是 MutableState 包装的对象类型,这里由于赋初始值为 0 ,根据类型推导,counter 就是
Int
型,由于要修改 counter 的值,所以须使用
var
将其声明为一个可变类型对象。

2.3 rememberSaveable

使用 remember 虽然解决了重组过程中,状态被重新初始化的问题,但是当 Activity 销毁重建时,状态值依然会重新初始化,比如横竖屏旋转,UiMode 切换等场景。在传统试图体系中,也存在这样的问题,对此的解决方案有很多,比如重写 Activity 的回调方法,在合适的时机,对数据进行保存和恢复,又或者使用 ViewModel 存放数据,这些方法对于 Compose 当然也有效,但是考虑到在使用 Compose 时,应该弱化 Activity 生命周期的概念,所以前者不适合在 Compose 中使用,而使用 ViewModel 依然是一种优秀的选择,后文再介绍。但是把所有的数据都放到 ViewModel 中,是否是最好的呢,这个要根据具体场景,进行甄别。举个例子,
针对这种场景,Compose 提供了
rememberSaveable
这个方法来解决这种场景的问题。

var counter by rememberSaveable { mutableStateOf(0) }

用法与 remember 方法用法类似,区别在于,rememberSaveable 在横竖屏旋转,UiMode 切换等场景中,能够对其包裹的数据进行缓存。那是否说明 rememberSaveable 可以在所有的场景替换 remember , remember 方法就没用了? rememberSaveable 方法比 remember 方法功能更强劲,代价就是性能要差一些,具体使用根据实际场景来选择。

到这里,状态相关的知识点,应该就很清楚了,再回头看上一篇文章中的 TextField 组件,应该能明白为什么那样写了。

三、 Stateless 和 Stateful

声明式 UI 的组件一般都可以分为 Stateless 组件和 Stateful 组件。
所谓 stateless 是指这个组件除了依赖参数以外,不依赖其它任何状态。比如
Text
组件,

Text("Hello, Compose")

相对的,某个组件除了参数以外,还持有或者访问了外部的状态,称为 stateful 组件。比如上一篇文章中提到的
TextField
组件,

var text by remember { mutableStateOf("文本框初始值") }
TextField(value = text, onValueChange = {
    text = it
})

Stateless 是不依赖于外部状态,仅依赖传入进来的参数,它是一个“纯函数”,即唯一输入,对应唯一输出。也就是参数不变,UI 就不会变化,它的重组只能是来自上层的调用,因此 Compose 编译器对其进行了优化,当 Stateless 的参数没有变化时,它就不会参与重组,重组的范围局限于 Stateless 外部。另外 Stateless 不耦合任何业务,功能更纯粹,所以复用性更好,也更容易测试。
基于此,我们应该尽可能地将 stateful 组件改造成 stateless 组件,这个过程称之为状态上提。

3.1 状态上提

状态上提,通常的做法就是将内部状态移除,以参数的形式传入。以及需要回调给调用方的事件,也以参数形式传入。
还是以上面计数器的代码为例,为了简洁,去掉前面添加的 log, 代码如下:

@Composable
fun CounterPage() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        var counter by remember{ mutableStateOf(0) }
        Text(text = "$counter")
        Button(onClick = {
            counter++
        }) {
            Text(text = "increment")
        }
        Button(onClick = {
            counter--
        }) {
            Text(text = "decrement")
        }
    }
}

这里计数器主要是依赖了内部状态 counter, 同时两个按钮的点击事件,会改变 counter。状态上提之后,该方法如下:

@Composable
fun CounterPage(counter: Int, onIncrement: () -> Unit, onDecrement: () -> Unit) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = "$counter")
        Button(onClick = {
            onIncrement()
        }) {
            Text(text = "increment")
        }
        Button(onClick = {
            onDecrement()
        }) {
            Text(text = "decrement")
        }
    }
}

这样,Counter 组件,就变成了 stateless 组件,不再与业务耦合,职责更加单一,可复用性和可测试性都更强了。此外,状态上提,有助于单一数据源模型的打造。

四、状态管理

我们再来看一下在 Compose 中应该如何管理状态。

4.1 使用 stateful 管理状态

简单的的 UI 状态,且与业务无关的状态,适合在 Compose 中直接管理。
比如我有一个菜单列表,点一开关,展开一个菜单,再点一下,收起菜单,列表的状态,仅由点击开关这一单一事件决定。并且,列表的状态与任何外部业务无关。那么这种就适合在 Compose 内部进行管理。

4.2 使用 StateHolder 管理状态

当业务有一定的复杂度之后,我们可以将业务逻辑相关的状态统一封装到一个 StateHoler 进行管理。剥离 Ui 逻辑,让 Composable 专注 UI 布局。

4.3 使用 ViewModel 管理状态

从某种意义上讲,ViewModel 也是一种特殊的 StateHolde。单因为它是保存在 ViewModelStore 中,所以有一下特点:

  • 存活范围大,可以脱离 Composition 存在,被所有 Composable 共享。
  • 存活时间长,不会因为横竖屏切换或者 UiMode 切换导致数据丢失。

因此,ViewModel 适合管理应用程序全局状态,而且 ViewModel更倾向于管理哪些非 UI 的业务状态。

以上管理方式可以同时使用,结合具体的业务灵活搭配。

4.4 LiveData、Rxjava、Flow 转 State

在 MVVM 架构中,使用 ViewModel 来管理状态,如果是新项目,把状态直接定义 State 类型就可以了。

对于传统试图项目,一般使用 LiveData、Rxjava 或者 Flow 这类响应式数据框架。而在 Compose 中需要 State 触发重组,刷新 UI,也有相应的方法,将上述响应式数据流转换为 Compose 中的 State。当上有数据变化时,可以驱动 Composable 完成重组。具体方法如下:

拓展方法 依赖库
LiveData.observeAsState() androidx.compose:runtime-livedata
Flow.collectAsState() 不依赖三方库,Compose 自带
Observable.subscribeAsState() androidx.compose:runtime-rxjava2 或者 androidx.compose:runtime-rxjava3

五、小结

本文主要讲解了 Compose 中状态的概念。最后做个小结,

  • Compose UI 依赖状态变化,触发重组,驱动界面更新。
  • 使用 remember 和 rememberSaveable 进行状态持久化。remember 保证在 recompose 过程中状态稳定,rememberSaveable 保证 Activity 自动销毁重建过程中状态稳定。
  • 状态上提,尽可能将 Stateful 组件转换为 Stateless 组件。
  • 视情况使用 Stateful、StateHoler、ViewModel 管理状态。
  • 将 LiveData、RxJava、Flow 数据流转换为 State。