当前位置: 首页 > news >正文

文件系统小册(FusePosixK8s csi)【1 Fuse】

文件系统小册(Fuse&Posix&K8s csi)【1 Fuse:用户空间的文件系统】

Fuse(filesystem in userspace),是一个用户空间的文件系统。通过fuse内核模块的支持,开发者只需要根据fuse提供的接口实现具体的文件操作就可以实现一个文件系统。由于其主要实现代码位于用户空间中,而不需要重新编译内核,这给开发者带来了众多便利。

  • 虽然Fuse简化了文件系统的实现,给开发者带来了便利。但是其额外的内核态/用户态切换带来的性能开销不能被忽视,所以fuse性能问题,一直是业界绕不开的话题。下面说到的splice、多线程、writeback cache都是为了改善其性能问题。

1 架构设计(执行流程)

  1. 用户程序挂载到fuse文件系统,比如此时执行ls命令
  2. VFS(虚拟文件系统)检测到挂载到fuse文件系统上的用户程序发送的请求,会将其转发给fuse driver
  3. fuse driver接受到request请求,会将其保存到queue中,同时暂停用户程序(ls会卡主,等待返回结果),同时唤醒fuse daemon处理请求
  4. fuse daemon(守护进程)通过/dev/fuse读取queue中的request,经过处理后将其转发给内核底层文件系统(EXT4等)。
  5. 内核文件系统处理完成后将结果返回给fuse daemon,fuse daemon将结果写回/dev/fuse
  6. fuse driver将该request标记为completed,并唤醒用户进程,返回对应执行结果。(ls执行结束,终端展示文件列表)
    在这里插入图片描述

2 相关组件

①VFS:转发请求给fuse driver

VFS(虚拟文件系统)检测到挂载到fuse文件系统上的用户程序发送的请求,会将其转发给fuse driver

② FUSE drvier(queue):接受请求保存到queue

fuse driver接受到request请求,会将其保存到queue中,同时暂停用户程序(ls会卡主,等待返回结果)

③/dev/fuse(桥梁):fuse daemon通过/dev/fuse读取queue中的请求

FUSE 驱动程序(fuse driver)处理请求并将其加入队列,然后通过 /dev/fuse 文件(FUSE 守护程序无法读取该文件)中的特定连接实例将请求提交给负责处理该 FUSE 文件系统的 FUSE 守护程序。

  • fuse daemon通过/dev/fuse来读取request queue中的请求

④fuse daemon(中间人):从queue中读取请求转发给底层文件系统

fuse daemon(守护进程)通过/dev/fuse读取queue中的request,经过处理后将其转发给内核底层文件系统(EXT4等)。

⑤fuse lib:提供接口和内核fuse模块通信

fuse的lib库,封装好了对应接口。fuse的lib库,提供接口和内核fus模块通信

⑥内核文件系统(如:EXT4)

内核层面的文件系统,真正操作文件的系统。

3 实现细节

① fuse用户空间流程

1. fuse mount:通过mount函数将path挂载到/dev/fuse设备

Fuse的挂载通过mount函数,将指定的fuse_path挂载到/dev/fuse设备上。之后对于fuse_path下的文件操作,都会通过fuse文件系统,并通过/dev/fuse被fuse daemon读取处理。

在这里插入图片描述

2. fuse thread:fuse daemon创建的服务线程

Fuse daemon还会创建一个服务线程,基于libfuse库来处理文件操作请求。这里主要关注fuse_session_new和fuse_session_loop_mt。通过fuse_session_new在libfuse中注册了fuse daemon实现的fuse_lowlevel_ops,之后通过fuse的所有的文件操作,都会通过libfuse回调到fuse daemon进行处理。fuse_session_loop_mt在libfuse中实现了一个多线程模式来读取请求,相比单线程,在请求处理上效率更高。

  • fuse daemon创建的服务线程
  • 基于libfuse库处理请求
  • 可多线程模式
  • 通过fuse_session_new(new一个session,与内核fuse模块通信)+fuse_session_loop_mt(多线程处理请求)

在这里插入图片描述

3. libfuse:fuse的lib库,提供接口和内核fus模块通信

fuse_session_loop_mt:fuse thread基于多线程方式处理请求

  • splice实现内存零拷贝。在默认情况下,fuse daemon必须通过read()从/dev/fuse读取请求,通过write()将请求回复写入/dev/fuse。每次读写系统调用都需要进行一次内核-用户空间的内存拷贝。这样对读写的性能损耗十分严重,因为一次内存拷贝需要处理大量数据。为了缓解这个问题,fuse支持了Linux内核提供的 splice 功能。splice 允许用户空间在两个内核内存缓冲区之间传输数据,而无需将数据复制给用户空间。如果fuse daemon实现了write_buf()方法,则 FUSE 从/dev/fuse读取数据,并以包含文件描述符的缓冲区的形式将数据直接传递给此方法处理,从而省去了一次内存申请与拷贝。[提供缓冲区传数据,避免用户空间与内核空间来回切换耗时]
  • 多线程模式。在多线程模式下,fuse daemon以一个线程开始,如果内核队列中有两个以上的request,则会自动生成其他线程。默认最大支持10个线程同时处理请求。 [多线程:队列request>2,自动生成新线程,最大支持10并发]在这里插入图片描述

②fuse内核队列(维护了5个队列)

fuse在内核中维护了五个队列,分别为:Backgroud、Pending、Processing、Interrupts、Forgets。一个请求在任何时候只会存在于一个队列中。

  • Backgroud:存异步请求
  • Pending:存同步请求
  • Processing:存处理中的请求
  • Interrupts:存中断请求(如:用户ctrl+C,取消请求),优先级最高
  • Forgets:存forget请求(清理cache中的inode)

在这里插入图片描述

1. Backgroud:暂存异步请求

Backgroud:background 队列用于暂存异步请求。在默认情况下,只有读请求进入 background 队列;当writeback cache启用时,写请求也会进入 background 队列。当开启writeback cache时,来自用户进程的写请求会先在页缓存中累积,然后当bdflush 线程被唤醒时会下刷脏页。在下刷脏页时,FUSE会构造异步请求,并将它们放入 background 队列中。

2. Pending:存储同步请求

同步请求(例如,元数据)放在 pending 队列中,并且pending队列会周期性接收来自background 的请求。但是pending队列中异步请求的个数最大为max_background(最大为12),当pending队列的异步请求未达到12时,background队列的请求将被移动到pending队列中。这样做的目的是为了控制pending队列中异步请求的个数,防止在突发大量异步请求的情况下,阻塞了同步请求。

3. Processing:存储正在处理的请求

Processing:当pending队列中的请求被转发到fuse daemon的同时,也被移动到processing队列。所以processing队列中的请求,表示正在被处理fuse daemon处理的请求。当fuse daemon真正处理完请求,通过/dev/fuse下发reply时,该请求将从processing队列中删除。

4. Interrupts:存放中断请求(用户取消请求:如:ctrl+C)

Interrupts:用于存放中断请求,比如当发送的请求被用户取消时,内核会发送一个Interrupts请求,来取消已被发送的请求。中断请求的优先级最高,Interrupts中的请求会最先得到处理。

5. Forgets:记录清理cache中inode的请求

Forgets:存储forgets请求,forget请求用于删除cache中缓存的inode。

③/dev/fuse 读写调用流程

Fuse driver加载过程中注册了对/dev/fuse的操作接口fuse_dev_operations。fuse_dev_do_read/fuse_dev_do_write分别对应fuse daemon从内核读取请求,以及处理完请求后写回reply的函数调用。

  1. pending 、interrups、forgets队列为空时,读进程休眠。
  2. 一旦有request到达,对应等待队列上的进程被唤醒(Interrups 和 forgets优先级高于pending队列请求)
  3. 当请求数据内容被拷贝到用户空间后(fuse daemon在进行处理了)
  4. 该请求被移动到processing队列,标识该请求已被处理。
  5. req->flags会保存当前请求的状态
  6. fuse daemon处理完请求后(fuse daemon与内核底层FS打交道)
  7. fuse daemon将结果写回到/dev/fuse。
  • 其中写数据保存在struct fuse_copy_state中,并且会根据unique id在fc(fuse_conn)中找到对应的req,并将写回的参数从fuse_copy_state拷贝至req->out。

源码逻辑:

当pending 、interrups、forgets队列都没有请求时,读进程进入休眠。一旦有请求到达,这个等待队列上的进程将被唤醒。Interrups 和 forgets的请求优先级高于pending队列。当请求的数据内容被拷贝至用户空间后,该请求会被移至processing队列,并且req->flags会保存当前请求的状态。

在这里插入图片描述

当fuse daemon处理完请求后,会将结果写回到/dev/fuse。写数据保存在struct fuse_copy_state中,并且会根据unique id在fc(fuse_conn)中找到对应的req,并将写回的参数从fuse_copy_state拷贝至req->out。

在这里插入图片描述

4案例:以unlink为例

  1. fuse daemon会阻塞在读/dev/fuse,当app进程在fuse挂载点下面有新的文件操作(unlink)
  2. 这时系统调用会调用fuse内核接口,并生成request,同时唤醒阻塞的fuse daemon
  3. fuse daemon读到request后,在libfuse中进行解析,根据request的opcode来执行对应的ops
  4. 完成后会把处理结果返回给/dev/fuse。此时vfs调用阻塞的行为将被唤醒,最后返回vfs调用。

在这里插入图片描述

5 实战(go-fuse)

相关仓库地址:

  • https://github.com/hanwen/go-fuse
  • https://github.com/bazil/fuse
  • https://github.com/libfuse/libfuse/

Golang操作fuse的库主要有go-fuse、libfuse。这里主要讲解go-fuse

①概述

Go-Fuse 是一个开源的库,由 Han-Wen Nienhuys 创建并维护。该库提供了对 Linux FUSE(Filesystem in Userspace)接口的支持,使得开发人员可以使用 Go 语言构建自己的文件系统。
功能:

  • 构建自定义文件系统:使用 Go-Fuse,您可以根据需要构建自己的文件系统。这可能包括加密、压缩、优化性能等功能。
  • 支持各种平台:由于 Go-Fuse 基于 FUSE,因此它可以跨多个操作系统(如 Linux、macOS 和 Windows)运行。
  • 高度自定义:通过实现特定的接口方法,您可以控制文件系统的每个细节。这为实现复杂的文件系统行为提供了极大的灵活性。

②环境准备

我准备在我本地macos上构建,因此需要fuse命令。

  • macos:https://github.com/osxfuse/osxfuse/releases(下载dmg安装配置)
  • ubuntu: sudo apt-get -y update && sudo apt-get install -y fuse
  • centos:sudo yum -y update && sudo yum install -y fuse

安装好之后,需要确保当前用户需要有执行fuse命令的权限

# 如果当前用户没有权限,可以进行提权或者切换用户,或者修改fuse配置
vim /etc/fuse.conf打开user_allow_other

③全部代码&解析

//安装依赖
go get "github.com/hanwen/go-fuse/v2/fs"
go get "github.com/hanwen/go-fuse/v2/fuse"
package mainimport ("context""flag""log""syscall""github.com/hanwen/go-fuse/v2/fs""github.com/hanwen/go-fuse/v2/fuse"
)type HelloRoot struct {fs.Inode
}func (r *HelloRoot) OnAdd(ctx context.Context) {ch := r.NewPersistentInode(ctx, &fs.MemRegularFile{Data: []byte("file.txt data"),Attr: fuse.Attr{Mode: 0644,},}, fs.StableAttr{Ino: 2})r.AddChild("file.txt", ch, false)
}func (r *HelloRoot) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {out.Mode = 0755return 0
}var _ = (fs.NodeGetattrer)((*HelloRoot)(nil))
var _ = (fs.NodeOnAdder)((*HelloRoot)(nil))//./yi-fuse test
func main() {debug := flag.Bool("debug", false, "print debug data")flag.Parse()if len(flag.Args()) < 1 {log.Fatal("Usage:\n  ./yi-fuse MOUNTPOINT")}opts := &fs.Options{}opts.Debug = *debugserver, err := fs.Mount(flag.Arg(0), &HelloRoot{}, opts)if err != nil {log.Fatalf("Mount fail: %v\n", err)}server.Wait()
}
  • 我们通过go-fuse库创建了一个用户空间文件系统,该文件系统只包含一个名为file.txt的文件。
  • context:用于处理上下文,可以在异步操作中取消请求。
  • flag:处理命令行参数。
  • log:日志记录。
  • syscall:系统调用接口。
  • fs 和 fuse:来自github.com/hanwen/go-fuse/v2的库,用于实现用户空间文件系统。
  • HelloRoot 结构体:
    • 表示文件系统的根节点,实现了NodeGetattrer和NodeOnAdder接口。
    • OnAdd 方法:当文件系统被加载时调用,创建一个包含file.txt的持久化节点。
    • Getattr 方法:获取文件属性,将file.txt的权限设置为0755。
    • main 函数:
      处理命令行参数,设置调试标志。
      检查至少有一个挂载点参数。
      创建fs.Options,启用调试模式。
      调用fs.Mount挂载文件系统。
      如果挂载失败,打印错误信息并退出。
  • server.Wait()阻塞直到文件系统卸载。

④测试

//编译可执行文件到linux
GOOS=linux GOARCH=amd64 go build -o yi-fuse main.go 
//创建挂载目录
mkdir -p /root/test
//执行挂载(如果不加nohup,默认前台运行)
nohup ./yi-fuse /root/test &//预期返回我们代码里写的file.txt文件
ls -l /root/test//读取file.txt文件内容
cat /root/test/file.txt//卸载挂载
umount /root/test

在这里插入图片描述

参考文章:
https://www.cnblogs.com/Linux-tech/p/14110335.html
https://blog.csdn.net/gitblog_00007/article/details/136569849

相关文章:

文件系统小册(FusePosixK8s csi)【1 Fuse】

文件系统小册&#xff08;Fuse&Posix&K8s csi&#xff09;【1 Fuse&#xff1a;用户空间的文件系统】 Fuse(filesystem in userspace),是一个用户空间的文件系统。通过fuse内核模块的支持&#xff0c;开发者只需要根据fuse提供的接口实现具体的文件操作就可以实现一个文…...

Bootstrap 环境安装

Bootstrap 环境安装 Bootstrap 是一个流行的前端框架,用于快速开发响应式和移动设备优先的网站。在开始使用 Bootstrap 之前,您需要安装相应的环境。本文将指导您如何安装 Bootstrap 环境。 1. 环境要求 在开始之前,请确保您的计算机上已安装以下软件: Node.js:Bootstr…...

GWT 与 Python App Engine 集成

将 Google Web Toolkit (GWT) 与 Python App Engine 集成可以实现强大的 Web 应用程序开发。这种集成允许你使用 GWT 的 Java 客户端技术构建丰富的用户界面&#xff0c;并将其与 Python 后端结合在一起&#xff0c;后端可以运行在 Google App Engine 上。 1、问题背景 在 Pyt…...

golang的函数为什么能有多个返回值?

在golang1.17之前&#xff0c;函数的参数和返回值都是放在函数栈里面的&#xff0c;比如函数A调用函数B&#xff0c;那么B的实参和返回值都是存放在函数A的栈里面&#xff0c;所以可以轻松的返回多个值。 其他的编程语言大都使用某个寄存器来存储函数的返回值。 但是从golang…...

一次 K8s 故障诊断:从 CPU 高负载到存储挂载泄露根源揭示

一、背景 现代软件部署中&#xff0c;容器技术已成为不可或缺的一环&#xff0c;在云计算和微服务架构中发挥着核心作用。随着容器化应用的普及&#xff0c;确保容器环境的可靠性成为了一个至关重要的任务。这就是容器SRE&#xff08;Site Reliability Engineering&#xff0c…...

python大作业:实现的简易股票简易系统(含源码、说明和运行截图)

实现一个简单的股票交易模拟系统。该系统将包括以下几个部分: 数据处理:从CSV文件中读取股票数据。 股票交易算法:实现一个简单的交易策略。 命令行界面(CLI):允许用户查看股票数据和进行交易。 数据持久化:将用户的交易记录和当前资金存储在数据库中。 为了简化这个示例…...

python-NLP常用数据集0.1.012

XNLI数据集 用户语言翻译和跨语言分类的语料库 官网地址&#xff1a;https://github.com/facebookresearch/XNLI下载地址&#xff1a;https://dl.fbaipublicfiles.com/XNLI/XNLI-1.0.zip注意事项&#xff1a;数据集有json格式的&#xff0c;和txt格式的数据格式 txt格式 la…...

【大事件】docker可能无法使用了

今天本想继续学习docker的命令&#xff0c;突然发现官方网站的文档页面打不开了。 难道是被墙了&#xff1f; 我用同事的翻了一下&#xff0c;能进&#xff0c;果然&#xff01; 正好手头的工作告一段落&#xff0c;将代码上传&#xff0c;然后通过jenkins将服务器自动部署到…...

探索Linux中的gzip命令:压缩与解压缩的艺术

探索Linux中的gzip命令&#xff1a;压缩与解压缩的艺术 在Linux世界中&#xff0c;文件压缩和解压缩是日常任务中不可或缺的一部分。gzip命令是这些任务中的佼佼者&#xff0c;它提供了高效的压缩和解压缩功能&#xff0c;广泛应用于各种场景。本文将带您深入了解gzip命令的工…...

Shell 输入/输出重定向

&#x1f49d;&#x1f49d;&#x1f49d;首先&#xff0c;欢迎各位来到我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里不仅可以有所收获&#xff0c;同时也能感受到一份轻松欢乐的氛围&#xff0c;祝你生活愉快&#xff01; &#x1f49d;&#x1f49…...

为什么RPC要比Http高效?

RPC和HTTP RPC&#xff08;Remote Procedure Call&#xff09;基于TCP连接通常比HTTP在性能上要高很多&#xff0c;原因如下&#xff1a; 1. 协议开销 HTTP开销&#xff1a; HTTP协议报文头部相对较大&#xff0c;包含大量的元数据&#xff08;如方法、URI、头字段等&#x…...

局域网电脑监控软件是如何监控到内网电脑的?

在信息化快速发展的今天&#xff0c;局域网电脑监控软件成为许多企业、学校和机构重要的实用工具。这些软件的主要功能在于对局域网内的电脑进行实时监控&#xff0c;以确保网络的安全、员工的工作效率以及合规性。那么&#xff0c;局域网电脑监控软件是如何做到对内网电脑进行…...

精妙无比的App UI 风格

精妙无比的App UI 风格...

SQL优化系列-快速学会分析SQL执行效率(下)

1 show profile 分析慢查询 有时需要确定 SQL 到底慢在哪个环节&#xff0c;此时 explain 可能不好确定。在 MySQL 数据库中&#xff0c;通过 profile&#xff0c;能够更清楚地了解 SQL 执行过程的资源使用情况&#xff0c;能让我们知道到底慢在哪个环节。 知识扩展&#xff1…...

交流非线性RCD负载的核心功能

非线性RCD负载是一种广泛应用于电力系统中的电子元件&#xff0c;主要用于保护电路免受过电压和欠电压的影响。它的核心功能主要包括以下几个方面&#xff1a; 1. 过电压保护&#xff1a;当电路中的电压超过设定值时&#xff0c;非线性RCD负载会自动断开电路&#xff0c;防止电…...

英语学习笔记31——Where‘s Sally?

Where’s Sally? Sally在哪&#xff1f; 词汇 Vocabulary garden /ˈɡɑːrdn/ n. 花园&#xff0c;院子&#xff08;属于私人&#xff09; 区别&#xff1a;park n. 公园&#xff08;公共的&#xff09; 例句&#xff1a;我的花园非常大。    My garden is very big. 搭…...

【Unity脚本】使用脚本操作游戏对象的组件

【知识链】Unity -> Unity脚本 -> 游戏对象 -> 组件 【知识链】Unity -> Unity界面 -> Inspector【摘要】本文介绍如何使用脚本添加、删除组件&#xff0c;以及如何访问组件 文章目录 引言第一章 游戏对象与组件1.1 什么是组件&#xff1f;1.2 场景、游戏对象与…...

学习VUE3——组件(一)

组件注册 分为全局注册和局部注册两种。 全局注册&#xff1a; 在main.js或main.ts中&#xff0c;使用 Vue 应用实例的 .component() 方法&#xff0c;让组件在当前 Vue 应用中全局可用。 import { createApp } from vue import MyComponent from ./App.vueconst app crea…...

2024-6-6 石群电路-25

2024-6-6&#xff0c;星期四&#xff0c;15:56&#xff0c;天气&#xff1a;晴&#xff0c;心情&#xff1a;晴。今天又是阳光明媚的一天打印了毕业论文&#xff0c;准备了一些毕业&答辩的材料&#xff0c;感觉离毕业越来越近了&#xff0c;加油学习喽~ 今日观看了石群老师…...

vue 文件预览mp4、txt、pptx、xls、xlsx、docx、pdf、html、xml

vue 文件预览 图片、mp4、txt、pptx、xls、xlsx、docx、pdf、html、xml 最近公司要做一个类似电脑文件夹的功能&#xff0c;支持文件夹操作&#xff0c;文件操作,这里就不说文件夹操作了&#xff0c;说说文件预览操作&#xff0c;本人是后端java开发&#xff0c;前端vue&#…...

硬件解放:开源工具突破设备限制的深度探索指南

硬件解放&#xff1a;开源工具突破设备限制的深度探索指南 【免费下载链接】OpenCore-Legacy-Patcher Experience macOS just like before 项目地址: https://gitcode.com/GitHub_Trending/op/OpenCore-Legacy-Patcher 当你的设备被厂商贴上"过时"标签&#x…...

FreeRTOS实战:如何用TIM2定时器精准统计任务运行时间(附完整代码)

FreeRTOS任务性能调优实战&#xff1a;基于硬件定时器的精准统计与优化 在嵌入式系统开发中&#xff0c;任务执行时间的精确测量是性能调优的基础。想象一下&#xff0c;当你发现系统响应变慢时&#xff0c;如何快速定位哪个任务消耗了过多CPU资源&#xff1f;或者当系统出现偶…...

微服务架构的陷阱:我们是如何从拆分成“微”麻烦的

对于软件测试从业者而言&#xff0c;微服务架构的兴起既带来了前所未有的挑战&#xff0c;也揭示了隐藏在水面之下的诸多陷阱。从单体应用向微服务转型&#xff0c;初衷是为了提升系统的灵活性、可维护性和团队的交付效率。然而&#xff0c;在实践中&#xff0c;许多团队却发现…...

Phi-3-mini-4k-instruct-gguf实战案例:用轻量模型替代Llama3-8B做高频短任务降本

Phi-3-mini-4k-instruct-gguf实战案例&#xff1a;用轻量模型替代Llama3-8B做高频短任务降本 1. 为什么选择轻量模型 在AI应用落地的过程中&#xff0c;我们常常面临一个困境&#xff1a;大模型效果虽好&#xff0c;但部署成本高、响应速度慢。特别是在处理大量高频短任务时&…...

从芯片包到破解:Keil MDK5完整安装与配置实战(附最新支持包离线导入方法)

从芯片包到破解&#xff1a;Keil MDK5完整安装与配置实战&#xff08;附最新支持包离线导入方法&#xff09; 在嵌入式开发领域&#xff0c;Keil MDK5作为ARM架构微控制器的主流开发环境&#xff0c;其安装配置的完整性与稳定性直接影响后续开发效率。本文将系统性地拆解从软件…...

Livox Mid360激光雷达动态避障实战:DWA算法在移动机器人中的应用

1. Livox Mid360激光雷达与DWA算法初探 第一次接触Livox Mid360这款固态激光雷达时&#xff0c;我就被它的性能惊艳到了。相比传统机械式雷达&#xff0c;Mid360不仅体积小巧&#xff0c;而且扫描频率高达100Hz&#xff0c;特别适合用在移动机器人上做实时避障。记得去年给一个…...

视频修复终极指南:如何用UNTRUNC拯救你的损坏视频文件

视频修复终极指南&#xff1a;如何用UNTRUNC拯救你的损坏视频文件 【免费下载链接】untrunc Restore a damaged (truncated) mp4, m4v, mov, 3gp video. Provided you have a similar not broken video. 项目地址: https://gitcode.com/gh_mirrors/unt/untrunc 还记得那…...

别再只盯着数据了!用Arduino+GP2Y1014AU传感器,手把手教你做个能“看见”空气的PM2.5监测仪

用Arduino打造智能PM2.5监测仪&#xff1a;从硬件连接到可视化交互 在空气质量日益受到关注的今天&#xff0c;拥有一个实时监测PM2.5浓度的设备不仅能提升生活品质&#xff0c;还能为健康保驾护航。不同于市面上千篇一律的商用监测仪&#xff0c;自己动手打造一个兼具实用性和…...

OpenProject:构建高效团队协作的终极开源项目管理平台

OpenProject&#xff1a;构建高效团队协作的终极开源项目管理平台 【免费下载链接】openproject OpenProject is the leading open source project management software. 项目地址: https://gitcode.com/GitHub_Trending/op/openproject OpenProject 是一款领先的开源项…...

告别枯燥Loading!聊聊Android骨架屏的‘心理战术’与设计取舍

告别枯燥Loading&#xff01;Android骨架屏的UX心理学与架构设计博弈 当用户盯着那个旋转的小圆圈超过3秒时&#xff0c;他们的耐心就像沙漏里的沙子一样快速流失。但有趣的是&#xff0c;如果换成骨架屏——那些跳动的灰色块——同样的3秒等待却变得可以接受。这不是魔法&…...