2026年2月

https://ladybird.org/posts/adopting-rust/

Ladybird 浏览器宣布将采用 Rust 语言逐步替代原有的 C++ 代码,以提升内存安全性和代码质量。尽管此前因 Rust 不适合传统面向对象编程风格而被拒绝,但经过一年的评估,团队认为其成熟的生态系统和安全性优势已足以弥补这一不足。

此次迁移的首个目标是 LibJS —— Ladybird 的 JavaScript 引擎。该模块包含词法分析、解析、抽象语法树( AST )和字节码生成器,具有良好的测试覆盖率,适合作为移植起点。

先祝大家新年快乐,开工大吉!(快不快乐另说,大吉是真得大吉)

为减少自己久坐,偶尔熬夜写代码的毛病,写了一款不能轻易跳过的 macOS 休息提醒工具:ForceBreak

**先丢 5 个终身码(国区¥18)**,手快有手慢无:

XA9K347K9F9N
M6X9XNM44JN3
PWF3E43YRXW6
MY7PHLHNHTPM
WMWER9MNAJT4

另外,欢迎回帖参与抽奖,48 小时后抽 15 个人发码

App Store: https://apps.apple.com/cn/app/forcebreak/id6758971359

1. 创建 Playwright 对象

方法1:上下文管理器(推荐)

from playwright.sync_api import sync_playwright

with sync_playwright() as pw:
    browser = pw.chromium.launch()
    page = browser.new_page()
    # 执行操作
    browser.close()

方法2:手动管理生命周期

from playwright.sync_api import sync_playwright

pw = sync_playwright().start()
browser = pw.chromium.launch()
page = browser.new_page()
# 执行操作
browser.close()
pw.stop()

2. 创建浏览器对象

# 有头浏览器(显示界面)
browser = pw.chromium.launch(headless=False)

# 无头浏览器(后台运行)
browser = pw.chromium.launch(headless=True)

# 其他浏览器
browser = pw.firefox.launch()
browser = pw.webkit.launch()

3. 创建 Page 对象

page = browser.new_page()

4. 打开网页

page.goto(url)
page.goto(url, wait_until="networkidle")  # 等待网络空闲
page.goto(url, timeout=30000)  # 设置超时时间(毫秒)

5. 获取网页信息

获取网页源代码

html_content = page.content()

获取网页 URL

current_url = page.url

获取网页标题

page_title = page.title()

6. 元素定位和交互

定位元素

# 返回 Locator 对象(懒加载)
locator = page.locator("xpath=//button[@id='submit']")
locator = page.locator("css=.button-class")
locator = page.locator("#element-id")
locator = page.locator(".element-class")

获取多个元素的操作

# 获取第一个元素
first_element = page.locator("//div[@class='item']").first

# 获取最后一个元素
last_element = page.locator("//div[@class='item']").last

# 获取指定索引的元素(0 开始)
nth_element = page.locator("//div[@class='item']").nth(2)

# 获取所有元素列表
all_elements = page.locator("//div[@class='item']").all()
for element in all_elements:
    print(element.inner_text())

7. 文本输入

使用 fill() - 清空后填充(推荐用于输入框)

page.locator("//input[@name='username']").fill("user123")

使用 type() - 逐字输入(模拟用户输入)

# 正常速度输入
page.locator("//input[@name='username']").type("user123")

# 添加延迟(每个字符之间延迟 100ms)
page.locator("//input[@name='username']").type("user123", delay=100)

8. 获取元素信息

获取元素文本内容

text = page.locator("//div[@class='content']").inner_text()

获取元素属性

# 获取指定属性值
href = page.locator("//a").get_attribute("href")
class_name = page.locator("//div").get_attribute("class")
id_value = page.locator("//button").get_attribute("id")

获取元素的 HTML 内容

html = page.locator("//div[@class='content']").inner_html()

9. 鼠标操作

点击元素

page.locator("//button[@id='submit']").click()

# 双击
page.locator("//button").dblclick()

# 右击
page.locator("//button").click(button="right")

鼠标低级操作

# 移动鼠标到指定坐标
page.mouse.move(x=100, y=200)

# 鼠标按下
page.mouse.down()

# 鼠标释放
page.mouse.up()

# 完整的拖拽操作示例
page.mouse.move(100, 100)
page.mouse.down()
page.mouse.move(200, 200)
page.mouse.up()

10. 拖拽操作

使用 drag_and_drop()(参数必须是选择器字符串)

# ❌ 错误做法(不能传 Locator 对象)
# source_locator = page.locator("//div[@id='source']")
# target_locator = page.locator("//div[@id='target']")
# page.drag_and_drop(source=source_locator, target=target_locator)  # 不行!

# ✅ 正确做法1:直接用选择器字符串
page.drag_and_drop(
    source="//div[@id='source']",
    target="//div[@id='target']"
)

使用 drag_to()(可以传 Locator 对象)⭐ 推荐

# ✅ 正确做法2:使用 Locator 对象的 drag_to 方法
source_locator = page.locator("//div[@id='source']")
target_locator = page.locator("//div[@id='target']")
source_locator.drag_to(target_locator)  # 可以传 Locator 对象!

关键区别:

  • page.drag_and_drop(source, target) → 参数必须是选择器字符串
  • locator.drag_to(target_locator) → 参数可以是 Locator 对象

11. 键盘操作

直接按键

# 单个按键
page.keyboard.press('Enter')
page.keyboard.press('Backspace')
page.keyboard.press('Delete')

# 组合键
page.keyboard.press('Control+a')  # 全选
page.keyboard.press('Control+c')  # 复制
page.keyboard.press('Control+v')  # 粘贴
page.keyboard.press('Control+z')  # 撤销
page.keyboard.press('Control+s')  # 保存
page.keyboard.press('Alt+Tab')    # 切换窗口

键盘输入

# 指定输入框中输入文本
page.keyboard.type("Hello World")

# 输入文本并带延迟(毫秒)
page.keyboard.type("Hello", delay=100)

12. 复制粘贴操作(重点解答)

⚠️ 常见误解

误解1: 能否先 Ctrl+A 复制,然后定位新标签,再 Ctrl+V 粘贴?

答案: 这取决于具体情况。复制的内容存储在「网页的虚拟剪贴板」中,只要页面没有刷新,虚拟剪贴板数据会保留。

可以工作的条件:

  • ✓ 源标签和目标标签在同一个页面
  • ✓ 中间没有刷新页面或导航
  • ✓ 没有 JavaScript 清空剪贴板
  • ✓ 两个标签都是可编辑的

会失败的情况:

  • ✗ 页面刷新了
  • ✗ 导航到其他页面
  • ✗ 目标标签是只读的(contentEditable=false)

⚠️ 常见误解2:能否用变量保存 press() 的返回值?

误解代码:

# ❌ 错误理解
data = page.keyboard.press('Control+c')  # 期望 data 里有复制的内容
print(data)  # 输出结果:None(空值)
# 然后页面刷新...
page.locator("//input[@id='target']").fill(data)  # ✗ 粘贴不了,data 是 None

为什么不行?

  1. press() 的返回值始终是 None

    def press(self, key: str) -> None:  # 返回值是 None
        # 只是发送一个"按键事件",不会返回任何数据
  2. Playwright 只是发送按键事件,不能拦截被复制的内容

    • page.keyboard.press('Control+c') 向浏览器发送"按 Ctrl+C"的信号
    • 网页的 JavaScript 接收这个事件,然后处理复制操作
    • 复制的内容存储在网页的虚拟剪贴板中,Playwright 无法读取
  3. 虚拟剪贴板在页面刷新时会被清空

    • 每个网页都有自己独立的虚拟剪贴板
    • 页面刷新 = 网页进程重启
    • 旧的虚拟剪贴板数据被清除
    • 新页面找不到这个数据

形象类比:

这就像你让朋友按下"复制"按钮,然后关闭了程序,
再打开时期望之前复制的东西还在一样 — 不可能的!

✅ 方案1:直接获取值再填充(最推荐,100% 可靠)

# 最简单、最稳定的方案!
source_text = page.locator("//input[@id='source']").input_value()
page.locator("//input[@id='target']").fill(source_text)

优点:

  • 直接从 DOM 获取值,100% 可靠
  • 无需依赖浏览器剪贴板
  • 速度快,代码简洁

✅ 方案2:使用键盘快捷键(需要谨慎)

# 点击源标签并复制
page.locator("//input[@id='source']").click()
page.keyboard.press('Control+a')
page.keyboard.press('Control+c')

# 立即点击目标标签并粘贴(中间不要有其他操作)
page.locator("//input[@id='target']").click()
page.keyboard.press('Control+v')

# 可选:等待粘贴完成
page.wait_for_timeout(500)

风险: 如果中间出现弹窗、页面更新等,可能导致粘贴失败

✅ 方案3:使用浏览器剪贴板 API(最稳定)

# 读取源标签的文本
source_text = page.locator("//input[@id='source']").input_value()

# 通过 JavaScript 将文本写入浏览器剪贴板
page.evaluate(f'navigator.clipboard.writeText("{source_text}")')

# 点击目标标签并粘贴
page.locator("//input[@id='target']").click()
page.keyboard.press('Control+v')

优点:

  • 利用浏览器原生剪贴板 API
  • 更稳定,跨页面有效
  • 可以和系统剪贴板交互

📊 三种方案对比

方案可靠性复杂度跨页面推荐度
方案1:直接填充⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
方案2:键盘快捷键⭐⭐⭐⭐⭐⭐⭐⭐
方案3:剪贴板 API⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

13. 其他常用操作

Frame(iframe)操作 ⭐ Playwright 的强项

获取 frame 对象

# 按 name 属性获取 frame
frame = page.frame(name="payment_frame")

# 按 url 获取 frame
frame = page.frame(url="https://example.com/iframe")

# 获取所有 frames
all_frames = page.frames

# 列出所有 frame 的信息
for frame in page.frames:
    print(f"Frame name: {frame.name}, URL: {frame.url}")

在 frame 中定位元素

# 方法1:先获取 frame,再定位(推荐用于多次操作)
frame = page.frame(name="payment_frame")
frame.locator("//input[@id='card_number']").fill("1234-5678-9012-3456")
frame.locator("//button[@id='submit']").click()

# 方法2:直接链式调用(推荐用于一次性操作)
page.frame(name="payment_frame") \
    .locator("//input[@id='card_number']") \
    .fill("1234-5678-9012-3456")

# 方法3:使用 frame_locator(按选择器查找 frame)
page.frame_locator("//iframe[@id='payment_frame']") \
    .locator("//input[@id='card_number']") \
    .fill("1234-5678-9012-3456")

嵌套 iframe 操作

# 多层 iframe 嵌套,直接链式调用
page.frame(name="outer") \
    .frame(name="inner") \
    .frame(name="deepest") \
    .locator("//button[@id='submit']") \
    .click()

# 或者分步骤写(更易读)
outer_frame = page.frame(name="outer")
inner_frame = outer_frame.frame(name="inner")
inner_frame.locator("//input[@id='nested_input']").fill("数据")

# 或者混合方式
outer = page.frame(name="outer")
outer.frame(name="inner") \
    .locator("//input[@id='nested_input']") \
    .fill("数据")
outer.locator("//button[@class='submit']").click()

获取 frame 中元素的信息

frame = page.frame(name="payment_frame")

# 获取元素文本
text = frame.locator("//span[@id='price']").inner_text()

# 获取元素属性
href = frame.locator("//a[@id='link']").get_attribute("href")

# 获取元素的 HTML
html = frame.locator("//div[@id='content']").inner_html()

# 获取输入框的值
value = frame.locator("//input[@id='username']").input_value()

在 frame 中执行 JavaScript

frame = page.frame(name="payment_frame")

# 执行 JavaScript 代码
result = frame.evaluate("1 + 1")
print(result)  # 2

# 获取 DOM 信息
count = frame.evaluate("document.querySelectorAll('input').length")

# 修改 DOM
frame.evaluate("document.getElementById('price').innerText = '¥99'")

✅ 关键优势:无需手动切换上下文

# Playwright 会自动管理 frame 上下文
# 这段代码在 frame 中操作...
page.frame(name="payment_frame") \
    .locator("//input[@id='card_number']") \
    .fill("1234-5678-9012-3456")

# ...然后直接回到主页面操作,无需手动切换!
page.locator("//h1").inner_text()  # ✓ 自动就在主页面

# Selenium 就需要这样做:
# browser.switch_to.frame("payment_frame")
# browser.find_element(By.ID, "card_number").send_keys("...")
# browser.switch_to.default_content()  # 必须手动切换回来!
# browser.find_element(By.TAG_NAME, "h1").text

等待操作

⭐ 方法1:等待元素状态变化(page.wait_for_selector)

这是你提到的方法!它有 4 个参数:

方法签名:

page.wait_for_selector(
    selector: str,              # 选择器(必填)
    state: str = None,          # 元素状态(可选)
    timeout: float = None,      # 超时时间(可选,单位:秒)
    strict: bool = None         # 严格模式(可选)
) -> Optional[ElementHandle]

参数详解:

参数说明默认值单位
selectorCSS 选择器或 XPath 表达式(必填)--
state元素状态,有 4 个值(见下表)"attached"-
timeout等待超时时间None(30秒) ⚠️
strict严格模式(选择器必须恰好匹配 1 个元素)None-

state 参数的 4 个值:

含义使用场景
"attached"元素在 DOM 中(默认)元素加载到页面
"visible"元素可见(附加且不隐藏)等待元素显示 ⭐ 最常用
"hidden"元素隐藏等待加载动画消失
"detached"元素从 DOM 完全移除等待弹窗关闭

返回值:

  • ElementHandle 对象(如果 state="visible" 或 "attached")
  • None(如果 state="hidden" 或 "detached")

使用示例:

# 最基础:等待元素出现
page.wait_for_selector("//button[@id='submit']")

# 等待元素可见(最常用)
element = page.wait_for_selector("//button[@id='submit']", state="visible")
element.click()  # 使用返回的 ElementHandle

# 等待元素消失
page.wait_for_selector("//loading", state="hidden")

# 自定义超时时间(单位:秒)⚠️  注意不是毫秒!
page.wait_for_selector("//button", state="visible", timeout=10)  # 10 秒

# 严格模式:选择器必须恰好匹配一个元素
page.wait_for_selector("//button[@class='submit']", strict=True)

# 获取返回的元素并操作
element = page.wait_for_selector("//input[@id='username']", state="visible")
element.fill("user123")
element.press("Enter")

方法2:等待元素状态变化(locator().wait_for)

# 等待元素在 DOM 中出现(默认)
page.locator("//button[@id='submit']").wait_for()

# 等待元素可见(推荐)
page.locator("//button[@id='submit']").wait_for(state="visible")

# 等待元素可见,自定义超时时间(10秒,单位:毫秒)⚠️  注意是毫秒!
page.locator("//button[@id='submit']").wait_for(state="visible", timeout=10000)

# 等待元素消失(隐藏)
page.locator("//loading-spinner").wait_for(state="hidden")

# 等待元素从 DOM 完全移除
page.locator("//modal").wait_for(state="detached")

注意区别:

  • page.wait_for_selector() 返回 ElementHandle,可以继续操作
  • locator().wait_for() 返回 None,不能链式调用
  • timeout 单位不同: wait_for_selector()locator().wait_for()毫秒

方法3:等待页面加载完成

# 等待页面加载完成(推荐)
page.wait_for_load_state("load")

# 等待 DOM 内容加载完成(较快)
page.wait_for_load_state("domcontentloaded")

# 等待网络空闲(所有网络请求完成)
page.wait_for_load_state("networkidle")

# 自定义超时时间
page.wait_for_load_state("load", timeout=10)

# 在 goto 中使用更方便(推荐)
page.goto(url, wait_until="networkidle")

方法4:等待指定时间(不推荐)

# 等待 2 秒(毫秒为单位)
page.wait_for_timeout(2000)

# ⚠️  尽量避免使用!应该用等待方法而不是固定延迟

方法5:等待 JavaScript 条件成立

# 等待直到元素文本变为特定值
page.wait_for_function(
    "document.getElementById('status').textContent === '加载完成'"
)

# 等待直到某个 JavaScript 变量存在
page.wait_for_function("typeof window.myVar !== 'undefined'")

# 等待直到满足条件(自定义超时)
page.wait_for_function(
    "document.querySelectorAll('.item').length > 10",
    timeout=5
)

方法6:等待页面导航完成

# 简单等待导航
page.locator("//a[@href='/next']").click()
page.wait_for_navigation()

# 等待导航到特定 URL
page.wait_for_navigation(url="**/success")

# 使用 with 语句(最推荐)
with page.expect_navigation():
    page.locator("//a").click()

📊 wait_for_selector vs locator().wait_for 对比

特性wait_for_selector()locator().wait_for()
返回值ElementHandleNoneNone
可以获取元素✅ 可以❌ 不能
可以继续操作返回值✅ 可以❌ 不能
参数:state✅ 支持✅ 支持
参数:timeout 单位 ⚠️毫秒
参数:strict✅ 支持❌ 不支持
推荐度⭐⭐⭐⭐⭐⭐⭐⭐⭐
使用场景需要获取元素时现代推荐方式

💡 深入理解:ElementHandle vs Locator(完全纠正)

重要纠正:我之前的说法完全错误!

根据 Playwright 源代码验证,ElementHandle 和 Locator 的操作方法都返回 None

实际的返回值(通过代码验证):

# Locator 的操作方法签名
Locator.click(...) -> None         # 返回 None!不是 Locator!
Locator.fill(...) -> None          # 返回 None!不是 Locator!
Locator.press(...) -> None         # 返回 None!不是 Locator!
Locator.type(...) -> None          # 返回 None!不是 Locator!

# ElementHandle 的操作方法
ElementHandle.click(...) -> None
ElementHandle.fill(...) -> None
ElementHandle.press(...) -> None

这意味着什么?

# ❌ Locator 不能链式调用操作方法
page.locator("//button").click().fill()  # 错误!click() 返回 None
page.locator("//button").click().is_visible()  # 错误!

# ❌ ElementHandle 也不能链式调用操作方法
element = page.wait_for_selector("//button")
element.click().fill()  # 错误!click() 返回 None

# ✅ 但单个操作是可以的
page.locator("//button").click()  # ✅ 可以
element.click()  # ✅ 可以

那 Locator 支持什么样的链式?

Locator 支持的是「定位器链式」(通过定位方法返回新的 Locator),不是「操作方法链式」:

# ✅ 定位器链式(返回新的 Locator 对象)
page.locator("div").first.click()              # first 返回 Locator
page.locator("div").last.click()               # last 返回 Locator
page.locator("div").nth(0).click()             # nth() 返回 Locator
page.locator("div").filter(has=...).click()    # filter() 返回 Locator

# ❌ 操作方法链式(都返回 None)
page.locator("div").click().fill()      # click() 返回 None,错误!
page.locator("div").fill().type()       # fill() 返回 None,错误!

ElementHandle 是什么?

  • ElementHandle 是一个对象,代表网页上的一个具体 DOM 元素
  • page.wait_for_selector() 找到元素时,就返回这个 ElementHandle
  • ElementHandle 和 Locator 的使用方式基本相同

ElementHandle 可以做的操作:

element = page.wait_for_selector("//input[@id='username']")

element.click()              # 点击(返回 None)
element.fill("user123")      # 填充文本(返回 None)
element.type("text")         # 输入文本(返回 None)
element.get_attribute("href")  # 获取属性(返回字符串)
element.inner_text()         # 获取文本(返回字符串)
element.press("Enter")       # 按键(返回 None)
element.hover()              # 悬停(返回 None)
element.screenshot()         # 截图(返回图片)

对比:wait_for_selector vs locator

特性wait_for_selectorlocator
返回值ElementHandleLocator
基本操作(click、fill)✅ 支持✅ 支持
操作方法链式❌ 不支持❌ 不支持
定位器链式(first、last、nth)❌ 不支持✅ 支持
自动等待❌ 需要手动 wait_for()✅ 内置自动等待
代码简洁度⭐⭐⭐⭐⭐⭐⭐⭐

为什么还是推荐用 Locator?

  1. 自动等待 - 不需要显式调用 wait_for_selector()
  2. 定位器灵活 - 支持 first、last、nth、filter 等来细化定位
  3. 代码简洁 - 一行代码 vs 两行代码
  4. 现代设计 - 这是 Playwright 官方推荐的 Web-First 方式

正确的使用方式:

# ✅ 推荐:用 Locator
page.locator("//button").click()
page.locator("//input").fill("text")

# ✅ Locator 定位器链式(unique feature)
page.locator("div").first.click()
page.locator("div").last.fill("text")
page.locator("table tr").nth(2).click()

# ✅ 可以用 wait_for_selector,但需要分开写
element = page.wait_for_selector("//button")
element.click()

💡 最直白的解释:element.click() vs page.wait_for_selector().click()

element = page.wait_for_selector("//button")
# element 就是一个 ElementHandle 对象
element.click()  # ✅ 这能工作

为什么能工作?

element.click()

执行过程:
1️⃣  element 已经是 ElementHandle 对象
2️⃣  ElementHandle.click() 执行点击动作
3️⃣  返回 None(但我们不继续使用它)
✅ 完成!

为什么 page.wait_for_selector().click().is_visible() 不行?

page.wait_for_selector("//button").click().is_visible()

执行过程:
1️⃣  page.wait_for_selector("//button") → 返回 ElementHandle
2️⃣  ElementHandle.click() → 返回 None
3️⃣  None.is_visible() → ❌ 错误!None 没有 .is_visible() 方法

对比 Locator 的定位器链式:

page.locator("div").first.click()

执行过程:
1️⃣  page.locator("div") → 返回 Locator
2️⃣  Locator.first → 返回 Locator(注意!这是属性,不是方法)
3️⃣  Locator.click() → 可以工作

总结:

  • page.wait_for_selector().click() 可以工作
  • page.wait_for_selector().click().is_visible() 不行(返回 None)
  • page.locator().click().fill() 也不行(返回 None)
  • page.locator().first.click() 可以工作(first 返回 Locator)

元素可见性判断

is_visible = page.locator("//button").is_visible()
is_enabled = page.locator("//button").is_enabled()

获取元素计数

count = page.locator("//div[@class='item']").count()

选择框操作

# 选择 select 框的选项
page.locator("//select[@name='country']").select_option("China")
page.locator("//select[@name='country']").select_option(value="cn")

复选框操作

# 勾选
page.locator("//input[@type='checkbox']").check()

# 取消勾选
page.locator("//input[@type='checkbox']").uncheck()

# 切换状态
page.locator("//input[@type='checkbox']").click()

14. 快速对比:Playwright vs Selenium

基础操作对比

操作PlaywrightSelenium
获取源代码page.content()driver.page_source
点击page.locator().click()element.click()
输入page.locator().fill()element.send_keys()
等待内置智能等待需要显式等待
速度⚡ 更快较慢
易用性⭐⭐⭐⭐⭐⭐⭐⭐⭐

Frame(iframe)操作对比 - Playwright 的巨大优势 ⭐

HTML 结构示例

<html>
  <body>
    <h1>主页面内容</h1>
    <iframe name="payment_frame" id="iframe-1">
      <form>
        <input id="card_number" placeholder="输入卡号">
        <button>提交</button>
      </form>
    </iframe>
  </body>
</html>

❌ Selenium 的做法(需要手动切换上下文,冗长且容易出错)

from selenium import webdriver
from selenium.webdriver.common.by import By

browser = webdriver.Chrome()
browser.get("http://example.com")

# ❌ 直接找不到,因为还在主页面
try:
    element = browser.find_element(By.ID, "card_number")
except:
    print("❌ 找不到!因为还没进入 iframe")

# 必须先切换到 iframe
browser.switch_to.frame("payment_frame")  # 按 name 切换
# 或者 browser.switch_to.frame(0)  # 按索引
# 或者 browser.switch_to.frame(browser.find_element(By.ID, "iframe-1"))

# 现在才能找到
element = browser.find_element(By.ID, "card_number")
element.send_keys("1234-5678-9012-3456")

# ⚠️ 必须手动切换回主页面
browser.switch_to.default_content()

# 现在回到了主页面
h1 = browser.find_element(By.TAG_NAME, "h1")
print(h1.text)  # "主页面内容"

Selenium 的问题:

  • ❌ 需要手动切换上下文(switch_to.frame()
  • ❌ 需要手动切换回来(switch_to.default_content()
  • ❌ 容易忘记切换,导致找不到元素
  • ❌ 代码不够流畅,上下文管理复杂
  • ❌ 嵌套 iframe 时特别麻烦

✅ Playwright 的做法(直接链式调用,优雅!)

from playwright.sync_api import sync_playwright

with sync_playwright() as pw:
    browser = pw.chromium.launch()
    page = browser.new_page()
    page.goto("http://example.com")
    
    # 🎯 方法1:按 name 进入 iframe(最常用)
    frame = page.frame(name="payment_frame")
    frame.locator("//input[@id='card_number']").fill("1234-5678-9012-3456")
    
    # 🎯 方法2:按选择器进入(用 frame_locator)
    frame = page.frame_locator("//iframe[@id='iframe-1']")
    frame.locator("//input[@id='card_number']").fill("1234-5678-9012-3456")
    
    # 🎯 方法3:直接链式调用(最推荐,一行代码搞定!)
    page.frame(name="payment_frame") \
        .locator("//input[@id='card_number']") \
        .fill("1234-5678-9012-3456")
    
    # 🎯 方法4:多层 iframe 嵌套也很简单
    page.frame(name="outer_frame") \
        .frame(name="inner_frame") \
        .locator("//button[@id='submit']") \
        .click()
    
    # ✅ 不需要切换回来!Playwright 自动管理上下文
    page.locator("//h1").inner_text()  # ✓ 自动就在主页面上

Playwright 的优势:

  • ✅ 无需手动切换上下文(自动管理)
  • ✅ 支持链式调用,代码优雅简洁
  • ✅ 自动管理 frame 生命周期
  • ✅ 嵌套 iframe 轻而易举
  • ✅ 更容易读和维护

🌲 复杂场景:嵌套 iframe 的对比

❌ Selenium(冗长且容易出错)

# 进入嵌套 iframe
browser.switch_to.frame("outer")
browser.switch_to.frame("inner")
element = browser.find_element(By.ID, "nested_input")
element.send_keys("数据")

# 需要逐层切回来,很容易出错
browser.switch_to.default_content()  # 回到主页面
browser.switch_to.frame("outer")  # 再进去
button = browser.find_element(By.XPATH, "//button[text()='按钮A']")
button.click()
browser.switch_to.default_content()  # 再切回来

✅ Playwright(简洁优雅)

# 进入嵌套 iframe 并输入(一行链式调用)
page.frame(name="outer") \
    .frame(name="inner") \
    .locator("//input[@id='nested_input']") \
    .fill("数据")

# 点击外层按钮(无需切换,自动在正确的上下文)
page.frame(name="outer") \
    .locator("//button[text()='按钮A']") \
    .click()

# 或者保存中间变量,更易读
outer = page.frame(name="outer")
outer.frame(name="inner").locator("//input[@id='nested_input']").fill("数据")
outer.locator("//button[text()='按钮A']").click()

Frame 操作详细对比表

维度SeleniumPlaywright
进入 iframeswitch_to.frame()page.frame()
切换回主页面switch_to.default_content()无需(自动)
链式调用❌ 不支持✅ 支持
多层 iframe麻烦,需要多次切换简洁,直接链式调用
代码行数更多(包含切换逻辑)更少(简洁优雅)
易错性高(容易忘记切换)低(自动管理)
可读性⭐⭐⭐⭐⭐⭐⭐⭐
维护性⭐⭐⭐⭐⭐⭐⭐⭐

15. 完整示例

from playwright.sync_api import sync_playwright

def test_web_automation():
    with sync_playwright() as pw:
        # 启动浏览器
        browser = pw.chromium.launch(headless=False)
        page = browser.new_page()
        
        # 打开网页
        page.goto("https://example.com")
        print(f"页面标题: {page.title()}")
        print(f"页面 URL: {page.url}")
        
        # 定位输入框并输入
        page.locator("//input[@name='search']").fill("Playwright")
        
        # 点击搜索按钮
        page.locator("//button[@type='submit']").click()
        
        # 等待结果加载
        page.wait_for_timeout(2000)
        
        # 获取搜索结果
        results = page.locator("//div[@class='result']").all()
        print(f"找到 {len(results)} 个结果")
        
        for result in results:
            print(result.inner_text())
        
        # 获取网页源代码
        html = page.content()
        print(f"源代码长度: {len(html)}")
        
        # 关闭浏览器
        browser.close()

if __name__ == "__main__":
    test_web_automation()

16. 常见错误排查

错误原因解决方案
TimeoutError元素加载超时增加 wait_for_timeout() 或检查选择器
选择器找不到元素XPath/CSS 语法错误用浏览器开发者工具验证选择器
鼠标操作失败元素可能不可见先滚动到元素位置或等待元素显示
拖拽不工作坐标不准确使用 drag_to() 而不是低级鼠标操作

核心概念

meta 是 Request 对象中的字典,用于在请求间传递数据和配置。它就像一个"信息包裹",跟随请求在整个爬虫流程中流动。


常用 meta 键分类

🌐 网络请求控制

类型说明示例
proxystr指定代理服务器http://proxy.example.com:8080
download_timeoutint/float下载超时时间(秒)10
dont_redirectbool禁止重定向True
dont_retrybool禁止重试True
dont_obey_robotstxtbool忽略 robots.txtTrue
download_slotstr控制并发槽"slow_site"

🍪 Cookie 和认证

类型说明
dont_merge_cookiesbool不合并 Cookie
cookiejarCookieJar指定 Cookie jar

📦 自定义数据传递

类型说明
custom_datadict自定义数据
itemItem/dict数据对象
user_dataany用户数据

⚙️ 其他配置

类型说明
dont_cachebool禁用缓存
priorityint请求优先级
errbackcallable错误回调

代码例子

例子 1: 基础 meta 使用

import scrapy

class MySpider(scrapy.Spider):
    name = 'example'
    start_urls = ['http://example.com']

    def start_requests(self):
        for page in range(1, 4):
            yield scrapy.Request(
                url=f'http://example.com/page/{page}',
                meta={
                    'page_num': page,
                    'download_timeout': 10
                },
                callback=self.parse
            )

    def parse(self, response):
        page_num = response.meta['page_num']
        print(f'正在爬取第 {page_num} 页')
        # 处理响应...

例子 2: 使用代理和自定义数据

import scrapy

class ProxySpider(scrapy.Spider):
    name = 'proxy_example'

    def start_requests(self):
        urls = ['http://example.com/item/1', 'http://example.com/item/2']
        
        for url in urls:
            yield scrapy.Request(
                url=url,
                meta={
                    'proxy': 'http://proxy.example.com:8080',
                    'item_id': url.split('/')[-1],
                    'source': 'example.com',
                    'download_timeout': 15
                },
                callback=self.parse
            )

    def parse(self, response):
        item_id = response.meta['item_id']
        source = response.meta['source']
        
        data = {
            'id': item_id,
            'source': source,
            'title': response.css('h1::text').get(),
            'content': response.css('.content::text').getall()
        }
        
        yield data

例子 3: 跨回调函数传递数据

import scrapy

class ListDetailSpider(scrapy.Spider):
    name = 'list_detail'
    start_urls = ['http://example.com/list']

    def parse(self, response):
        """解析列表页"""
        for item_url in response.css('a.item::attr(href)').getall():
            yield scrapy.Request(
                url=response.urljoin(item_url),
                meta={
                    'category': response.css('.category::text').get(),
                    'list_page': response.url,
                    'depth': response.meta.get('depth', 0) + 1
                },
                callback=self.parse_detail
            )

    def parse_detail(self, response):
        """解析详情页"""
        yield {
            'title': response.css('h1::text').get(),
            'category': response.meta['category'],
            'source_list': response.meta['list_page'],
            'depth': response.meta['depth'],
            'url': response.url
        }

例子 4: 处理错误和重试

import scrapy
from scrapy.http import Request

class ErrorHandlerSpider(scrapy.Spider):
    name = 'error_handler'
    
    def start_requests(self):
        urls = ['http://example.com/page1', 'http://example.com/page2']
        
        for url in urls:
            yield scrapy.Request(
                url=url,
                meta={
                    'retry_count': 0,
                    'max_retries': 3,
                    'dont_obey_robotstxt': False
                },
                callback=self.parse,
                errback=self.errback
            )

    def parse(self, response):
        if response.status == 200:
            yield {
                'url': response.url,
                'status': response.status,
                'data': response.css('body::text').get()
            }

    def errback(self, failure):
        """错误回调"""
        request = failure.request
        retry_count = request.meta['retry_count']
        max_retries = request.meta['max_retries']
        
        if retry_count < max_retries:
            self.logger.warning(f'重试 {request.url} (尝试 {retry_count + 1})')
            
            new_request = request.copy()
            new_request.meta['retry_count'] = retry_count + 1
            yield new_request
        else:
            self.logger.error(f'放弃 {request.url} - 重试次数超限')

例子 5: Item 对象传递

import scrapy
from scrapy import Item, Field

class ProductItem(Item):
    name = Field()
    price = Field()
    category = Field()
    url = Field()

class ItemPassSpider(scrapy.Spider):
    name = 'item_pass'
    start_urls = ['http://example.com/products']

    def parse(self, response):
        for product_url in response.css('a.product::attr(href)').getall():
            item = ProductItem()
            item['category'] = response.css('.cat-name::text').get()
            item['url'] = response.urljoin(product_url)
            
            yield scrapy.Request(
                url=item['url'],
                meta={'item': item},
                callback=self.parse_product
            )

    def parse_product(self, response):
        item = response.meta['item']
        item['name'] = response.css('h1::text').get()
        item['price'] = response.css('.price::text').get()
        
        yield item

例子 6: 控制并发和优先级

import scrapy

class ConcurrencySpider(scrapy.Spider):
    name = 'concurrency'
    
    def start_requests(self):
        # 快速页面
        for i in range(1, 6):
            yield scrapy.Request(
                url=f'http://fast-site.com/page/{i}',
                meta={
                    'download_slot': 'fast',
                    'priority': 10
                },
                callback=self.parse
            )
        
        # 慢速页面
        for i in range(1, 4):
            yield scrapy.Request(
                url=f'http://slow-site.com/page/{i}',
                meta={
                    'download_slot': 'slow',
                    'download_timeout': 20,
                    'priority': 5
                },
                callback=self.parse
            )

    def parse(self, response):
        yield {
            'url': response.url,
            'data': response.css('body::text').get()[:100]
        }

最佳实践

  1. 明确用途 - 为 meta 中的每个键赋予清晰的含义
  2. 避免冲突 - 不要使用 Scrapy 内置的键,除非有特定目的
  3. 数据序列化 - meta 中的数据应该可序列化
  4. 文档化 - 记录自定义 meta 键的含义
  5. 验证数据 - 在使用 meta 数据前验证其存在性
# 推荐做法
item_id = response.meta.get('item_id', 'unknown')

# 不推荐
item_id = response.meta['item_id']  # 可能报错

总结

meta 是 Scrapy 中强大的数据传递机制,可以:

  • 在请求间传递上下文信息
  • 控制网络请求行为
  • 处理复杂的爬虫流程
  • 简化中间件和 pipeline 的数据交互

前言

CompletableFuture是jdk8的新特性。CompletableFuture的实现与使用上,处处体现出了函数式异步编程的味道。一个CompletableFuture对象可以被一个环节接一个环节的处理、也可以对两个或者多个CompletableFuture进行组合处理或者等待结果完成。通过对CompletableFuture各种方法的合理使用与组合搭配,可以在很多的场景都可以应付自如。

CompletableFuture实现了CompletionStage接口和Future接口,前者是对后者的一个扩展,增加了异步会点、流式处理、多个Future组合处理的能力,使Java在处理多任务的协同工作时更加顺畅便利。

假设现在需求如下:
从网上查询某个产品的最低价格,例如可以从淘宝、京东、拼多多去获取某个商品的价格、优惠金额,并计算出实际的付款金额,最终返回价格最低的价格信息。

这里假设每个平台获取原价格与优惠券的接口已经实现、且都是需要调用HTTP接口查询的耗时操作,接口每个耗时1s左右。

根据需求理解,可以很自然的写出对应实现代码:

public int getCheapestPlatAndPrice(String product){
    int taoBaoPrice = computeRealPrice(HttpRequestMock.getTaoBaoPrice(product), HttpRequestMock.getTaoBaoDiscounts(product));
    int jingDongPrice = computeRealPrice(HttpRequestMock.getJingDongPrice(product), HttpRequestMock.getJingDongDiscounts(product));
    int pinDuoDuoPrice = computeRealPrice(HttpRequestMock.getPinDuoDuoPrice(product), HttpRequestMock.getPinDuoDuoDiscounts(product));

    // 计算并选出实际价格最低的平台
    return Stream.of(taoBaoPrice, jingDongPrice, pinDuoDuoPrice).min(Comparator.comparingInt(p - > p)).get();
}

运行测试下:

14:58:32.330228700[main]获取淘宝上iphone16的价格完成: 5199
14:58:33.351948100[main]获取淘宝上iphone16的折扣价格完成: 200
14:58:33.352933400[main]计算实际价格完成: 4999
14:58:34.364138900[main]获取京东上iphone16的价格完成: 5299
14:58:35.377258800[main]获取京东上iphone16的折扣价格完成: 150
14:58:35.378257300[main]计算实际价格完成: 5149
14:58:36.392813800[main]获取拼多多上iphone16的价格完成: 5399
14:58:37.405863200[main]获取拼多多上iphone16的折扣价格完成: 99
14:58:37.406712600[main]计算实际价格完成: 5300
4999
耗时:6142ms

结果符合预期,功能正常,但是耗时较长。试想一下,假如你在某个APP操作需要等待6s才返回最终计算结果,那不得直接摔手机?

梳理下代码的实现思路:

可以知道所有的环节都是串行实现的的,由于每个查询接口的耗时都是1s,因此每个环节耗时加到一起,接口总耗时超过6s。

但实际上,每个平台之间的操作是互不干扰的,那其实就可以通过多线程的方式,同时去分别执行各个平台的逻辑处理,最后将各个平台的结果汇总到一起比对得到最低价格。

所以整个执行过程会变成如下的效果:

因此为了提升性能,可以采用线程池来负责多线程的处理操作,因为需要得到各个子线程处理的结果,所以需要使用 Future来实现:

public Integer getCheapestPlatAndPrice2(String product) {
    Future <Integer> taoBaoFuture = threadPool.submit(() -> computeRealPrice(HttpRequestMock.getTaoBaoPrice(product), HttpRequestMock.getTaoBaoDiscounts(product)));
    Future <Integer> jingDongFuture = threadPool.submit(() -> computeRealPrice(HttpRequestMock.getJingDongPrice(product), HttpRequestMock.getJingDongDiscounts(product)));
    Future <Integer> pinDuoDuoFuture = threadPool.submit(() -> computeRealPrice(HttpRequestMock.getPinDuoDuoPrice(product), HttpRequestMock.getPinDuoDuoDiscounts(product)));

    // 等待所有线程结果都处理完成,然后从结果中计算出最低价
    return Stream.of(taoBaoFuture, jingDongFuture, pinDuoDuoFuture)
        .map(price - > {
            try {
                return price.get();
            } catch (Exception e) {
                return null;
            }
        })
        .min(Comparator.comparingInt(p - > p))
        .get();
}

上述代码中,将三个不同平台对应的Callable函数逻辑放入到ThreadPool中去执行,返回Future对象,然后再逐个通过Future.get()接口阻塞获取各自平台的结果,最后经比较处理后返回最低价信息。

执行代码,可以看到执行结果与过程如下:

15:19:25.793891500[pool-1-thread-3]获取拼多多上iphone16的价格完成: 5399
15:19:25.793891500[pool-1-thread-2]获取京东上iphone16的价格完成: 5299
15:19:25.794891500[pool-1-thread-1]获取淘宝上iphone16的价格完成: 5199
15:19:26.816140300[pool-1-thread-2]获取京东上iphone16的折扣价格完成: 150
15:19:26.816140300[pool-1-thread-3]获取拼多多上iphone16的折扣价格完成: 99
15:19:26.816923600[pool-1-thread-3]计算实际价格完成: 5300
15:19:26.816923600[pool-1-thread-2]计算实际价格完成: 5149
15:19:26.817921500[pool-1-thread-1]获取淘宝上iphone16的折扣价格完成: 200
15:19:26.820923400[pool-1-thread-1]计算实际价格完成: 4999
4999
耗时:2085ms

接口总耗时从6s下降到了2s,效果还是很显著的。但是,是否还能再压缩一些呢?

基于上面按照平台拆分并行处理的思路继续推进,我们可以看出每个平台内的处理逻辑其实可以分为3个主要步骤:

  1. 获取原始价格(耗时操作)
  2. 获取折扣优惠(耗时操作)
  3. 得到原始价格和折扣优惠之后,计算实付价格

这3个步骤中,其实第1、2两个耗时操作也是相对独立的,如果也能并行处理的话,响应时长上应该也能继续缩短,即如下的处理流程:

这里当然也可以继续使用上面提到的线程池+Future的方式,但Future在应对并行结果组合以及后续处理等方面显得力不从心,弊端明显:

代码写起来会非常拖沓:先封装Callable函数放到线程池中去执行查询操作,然后分三组阻塞等待结果并计算出各自结果,最后再阻塞等待价格计算完成后汇总得到最终结果。

说到这里呢,就需要CompletableFuture登场了,CompletableFuture可以很轻松的来完成任务的并行处理,以及各个并行任务结果之间的组合再处理等操作。使用CompletableFuture编写实现代码如下:

public Integer getCheapestPlatAndPrice3(String product) {
    CompletableFuture <Integer> taoBao = CompletableFuture.supplyAsync(() -> HttpRequestMock.getTaoBaoPrice(product)).thenCombine(CompletableFuture.supplyAsync(() -> HttpRequestMock.getTaoBaoDiscounts(product)), this::computeRealPrice);
    CompletableFuture <Integer> jingDong = CompletableFuture.supplyAsync(() -> HttpRequestMock.getJingDongPrice(product)).thenCombine(CompletableFuture.supplyAsync(() -> HttpRequestMock.getJingDongDiscounts(product)), this::computeRealPrice);
    CompletableFuture <Integer> pinDuoDuo = CompletableFuture.supplyAsync(() -> HttpRequestMock.getPinDuoDuoPrice(product)).thenCombine(CompletableFuture.supplyAsync(() -> HttpRequestMock.getPinDuoDuoDiscounts(product)), this::computeRealPrice);

    // 排序并获取最低价格
    return Stream.of(taoBao, jingDong, pinDuoDuo)
        .map(CompletableFuture::join)
        .min(Comparator.comparingInt(p - > p))
        .get();
}

看下执行结果符合预期,而接口耗时则降到了1s(因为依赖的每一个查询实际操作的接口耗时都是模拟的1s,所以这个结果已经算是此复合接口能达到的极限值了)。

15:29:04.911516600[ForkJoinPool.commonPool-worker-1]获取淘宝上iphone16的价格完成: 5199
15:29:04.911516600[ForkJoinPool.commonPool-worker-4]获取京东上iphone16的折扣价格完成: 150
15:29:04.911516600[ForkJoinPool.commonPool-worker-2]获取淘宝上iphone16的折扣价格完成: 200
15:29:04.911516600[ForkJoinPool.commonPool-worker-3]获取京东上iphone16的价格完成: 5299
15:29:04.911516600[ForkJoinPool.commonPool-worker-5]获取拼多多上iphone16的价格完成: 5399
15:29:04.911516600[ForkJoinPool.commonPool-worker-6]获取拼多多上iphone16的折扣价格完成: 99
15:29:04.924568[ForkJoinPool.commonPool-worker-2]计算实际价格完成: 4999
15:29:04.924568[ForkJoinPool.commonPool-worker-3]计算实际价格完成: 5149
15:29:04.924568[ForkJoinPool.commonPool-worker-6]计算实际价格完成: 5300
4999
耗时:1071ms

这里CompletableFuture执行时所使用的默认线程池是ForkJoinPool

Future与CompletableFuture

首先,先来理一下Future与CompletableFuture之间的关系。

Future

如果接触过多线程相关的概念,那Future应该不会陌生,早在Java5中就已经存在了。

该如何理解Future呢?举个生活中的例子:

你去咖啡店点了一杯咖啡,然后服务员会给你一个订单小票。 当服务员在后台制作咖啡的时候,你并没有在店里等待,而是出门到隔壁甜品店又买了个面包。 当面包买好之后,你回到咖啡店,拿着订单小票去取咖啡。 取到咖啡后,你边喝咖啡边把面包吃了……嗝~

是不是很熟悉的生活场景? 对比到我们多线程异步编程的场景中,咖啡店的订单小票其实就是Future,通过Future可以让稍后适当的时候可以获取到对应的异步执行线程中的执行结果。

上面的场景,我们翻译为代码实现逻辑:

public void buyCoffeeAndOthers() throws ExecutionException, InterruptedException {
    goShopping();
    // 子线程中去处理做咖啡这件事,返回future对象
    Future<Coffee> coffeeTicket = threadPool.submit(this::makeCoffee);
    // 主线程同步去做其他的事情
    Bread bread = buySomeBread();
    // 主线程其他事情并行处理完成,阻塞等待获取子线程执行结果
    Coffee coffee = coffeeTicket.get();
    // 子线程结果获取完成,主线程继续执行
    eatAndDrink(bread, coffee);
}

Future相关的了解可以看这篇文章:FutureTask是Future的基础实现

CompletableFuture

Future在应对一些简单且相互独立的异步执行场景很便捷,但是在一些复杂的场景,比如同时需要多个有依赖关系的异步独立处理的时候,或者是一些类似流水线的异步处理场景时,就显得力不从心了。比如:

  • 同时执行多个并行任务,等待最快的一个完成之后就可以继续往后处理
  • 多个异步任务,每个异步任务都需要依赖前一个异步任务执行的结果再去执行下一个异步任务,最后只需要一个最终的结果
  • 获取计算结果的 get() 方法为阻塞调用

Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。

可以看到,CompletableFuture 同时实现了 FutureCompletionStage 接口。

CompletableFuture使用方式

创建CompletableFuture并执行

当需要进行异步处理的时候,可以通过CompletableFuture.supplyAsync方法,传入一个具体的要执行的处理逻辑函数,这样就轻松的完成了CompletableFuture的创建与触发执行。

方法名称作用描述
supplyAsync静态方法,用于构建一个CompletableFuture<T>对象,并异步执行传入的函数,允许执行函数有返回值T
runAsync静态方法,用于构建一个CompletableFuture<Void>对象,并异步执行传入函数,与supplyAsync的区别在于此方法传入的是Callable类型,仅执行,没有返回值

使用示例:

public void testCreateFuture(String product) {
    // supplyAsync, 执行逻辑有返回值Integer
    CompletableFuture<Integer> supplyAsyncResult =
            CompletableFuture.supplyAsync(() -> HttpRequestMock.getTaoBaoPrice(product));
    
    // runAsync, 执行逻辑没有返回值
    CompletableFuture<Void> runAsyncResult =
            CompletableFuture.runAsync(() -> System.out.println(product));
}

特别补充:

supplyAsync或者runAsync创建后便会立即执行,无需手动调用触发。

线程串行化方法

使用方法

在流水线处理场景中,往往都是一个任务环节处理完成后,下一个任务环节接着上一环节处理结果继续处理。CompletableFuture用于这种流水线环节驱动类的方法有很多,相互之间主要是在返回值或者给到下一环节的入参上有些许差异,使用时需要注意区分:

具体的方法的描述归纳如下:

方法名称作用描述
thenApplyCompletableFuture的执行后的具体结果进行追加处理,并将当前的CompletableFuture泛型对象更改为处理后新的对象类型,返回当前CompletableFuture对象。
thenComposethenApply类似。区别点在于:此方法的入参函数是一个CompletableFuture类型对象,适用于回调函数需要启动另一个异步计算,并且想要一个扁平化的结果CompletableFuture,而不是嵌套的CompletableFuture<CompletableFuture<U>>
thenAcceptthenApply方法类似,区别点在于thenAccept返回void类型,没有具体结果输出,适合无需返回值的场景。
thenRunthenAccept类似,区别点在于thenAccept可以将前面CompletableFuture执行的实际结果作为入参进行传入并使用,但是thenRun方法没有任何入参,只能执行一个Runnable函数,并且返回void类型

因为上述thenApplythenCompose方法的输出仍然都是一个CompletableFuture对象,所以各个方法是可以一环接一环的进行调用,形成流水线式的处理逻辑:

thenApply

上面任务执行完执行 + 能获取上步返回值 + 自己有返回值

@Test
public void thenApplyAsync() throws ExecutionException, InterruptedException {
    CompletableFuture<String> thenApplyAsync = CompletableFuture.supplyAsync(() -> {
        System.out.println("thenApplyAsync当前线程:" + Thread.currentThread().getId());
        int i = 10 / 2;
        System.out.println("thenApplyAsync运行结果:" + i);
        return i;
    }, executor).thenApply(result -> {
        System.out.println("thenApplyAsync任务2启动了。。。。。上步结果:" + result);
        return "hello" + result * 2;
    });
    System.out.println("main.................end....." + thenApplyAsync.get());
}

结果:

thenApplyAsync当前线程:33
thenApplyAsync运行结果:5
thenApplyAsync任务2启动了。。。。。上步结果:5
main.................end.....hello10
thenAccept

上面任务执行完执行 + 能获取上步返回值

@Test
public void thenAcceptAsync() throws ExecutionException, InterruptedException {
    CompletableFuture<Void> thenAcceptAsync = CompletableFuture.supplyAsync(() -> {
        System.out.println("thenAcceptAsync当前线程:" + Thread.currentThread().getId());
        int i = 10 / 2;
        System.out.println("thenAcceptAsync运行结果:" + i);
        return i;
    }, executor).thenAccept(result -> {
        System.out.println("thenAcceptAsync任务2启动了。。。。。上步结果:" + result);
    });
}

结果:

thenAcceptAsync当前线程:33
thenAcceptAsync运行结果:5
thenAcceptAsync任务2启动了。。。。。上步结果:5
thenRun

上面任务执行完执行

@Test
public void thenRunAsync() throws ExecutionException, InterruptedException {
    System.out.println("main.................start.....");
    CompletableFuture<Void> voidCompletableFuture = CompletableFuture.supplyAsync(() -> {
        System.out.println("当前线程:" + Thread.currentThread().getId());
        int i = 10 / 2;
        System.out.println("运行结果:" + i);
        return i;
    }, executor).thenRun(() -> {
        System.out.println("任务2启动了。。。。。");
    });
}

结果:

main.................start.....
当前线程:33
运行结果:5
任务2启动了。。。。。
thenCompose

接收返回值并生成新的任务

@Test
public void thenCompose() {
    CompletableFuture cf = CompletableFuture.completedFuture("hello")
            .thenCompose(str -> CompletableFuture.supplyAsync(() -> {
                return str + ": thenCompose";
            },executor));
    System.out.println(cf.join());
}
  • thenApply():转换的是泛型中的类型,相当于将CompletableFuture 转换生成新的CompletableFuture
  • thenCompose():用来连接两个CompletableFuture,是生成一个新的CompletableFuture。

串联示例

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS);
    System.out.println("supplyAsync first");
    return "first";
}, fixedThreadPool).thenApply(s -> {
    Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS);
    System.out.println("supplyAsync second");
    return "second " + s;
}).whenComplete((s, t) -> {//s,是上面的返回值,t是上面可能会抛出的Throwable对象
    if (t == null) {
        System.out.println("whenComplete succeed:" + s);
    } else {
        System.out.println("whenComplete error");
    }
});
        
System.out.println(future.get());

//结果:
supplyAsync first
supplyAsync second
whenComplete succeed:secondfirst
second first

线程并联方法

很多时候为了提升并行效率,一些没有依赖的环节我们会让他们同时去执行,然后在某些环节需要依赖的时候,进行结果的依赖合并处理,类似如下图的效果。

CompletableFuture相比于Future的一大优势,就是可以方便的实现多个并行环节的合并处理。相关涉及方法介绍归纳如下:

方法名称作用描述
thenCombine将两个CompletableFuture对象组合起来进行下一步处理,可以拿到两个执行结果,并传给自己的执行函数进行下一步处理,最后返回一个新的CompletableFuture对象。
thenAcceptBoththenCombine类似,区别点在于thenAcceptBoth传入的执行函数没有返回值,即thenAcceptBoth返回值为CompletableFuture<Void>
runAfterBoth等待两个CompletableFuture都执行完成后再执行某个Runnable对象,再执行下一个的逻辑,类似thenRun。
applyToEither两个CompletableFuture中任意一个完成的时候,继续执行后面给定的新的函数处理。再执行后面给定函数的逻辑,类似thenApply。
acceptEither两个CompletableFuture中任意一个完成的时候,继续执行后面给定的新的函数处理。再执行后面给定函数的逻辑,类似thenAccept。
runAfterEither等待两个CompletableFuture中任意一个执行完成后再执行某个Runnable对象,可以理解为thenRun的升级版,注意与runAfterBoth对比理解。
allOf静态方法,阻塞等待所有给定的CompletableFuture执行结束后,返回一个CompletableFuture<Void>结果。
anyOf静态方法,阻塞等待任意一个给定的CompletableFuture对象执行结束后,返回一个CompletableFuture<Void>结果。

使用方法

thenCombine

消费两个结果 + 返回结果

@Test
public void thenCombine() throws ExecutionException, InterruptedException {
    CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务1线程:" + Thread.currentThread().getId());
        int i = 10 / 2;
        System.out.println("任务1运行结果:" + i);
        return i;
    }, executor);

    CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务2线程:" + Thread.currentThread().getId());
        System.out.println("任务2运行结果:");
        return "hello";
    }, executor);
    
    CompletableFuture<String> thenCombineAsync = future1.thenCombine(future2, (result1, result2) -> {
        System.out.println("任务5启动。。。结果1:" + result1 + "。。。结果2:" + result2);
        return result2 + "-->" + result1;
    });
    System.out.println("任务5结果" + thenCombineAsync.get());
}

结果:

任务1线程:33
任务1运行结果:5
任务2线程:34
任务2运行结果:
任务5启动。。。结果1:5。。。结果2:hello
任务5结果hello-->5
thenAcceptBoth

消费两个结果 + 无返回

@Test
public void thenAcceptBothAsync() throws ExecutionException, InterruptedException {
    CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务1线程:" + Thread.currentThread().getId());
        int i = 10 / 2;
        System.out.println("任务1运行结果:" + i);
        return i;
    }, executor);

    CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务2线程:" + Thread.currentThread().getId());
        System.out.println("任务2运行结果:");
        return "hello";
    }, executor);

    CompletableFuture<Void> thenAcceptBothAsync = future1.thenAcceptBoth(future2, (result1, result2) -> {
        System.out.println("任务4启动。。。结果1:" + result1 + "。。。结果2:" + result2);
    });

}

结果

任务1线程:33
任务1运行结果:5
任务2线程:34
任务2运行结果:
任务4启动。。。结果1:5。。。结果2:hello
runAfterBoth

两个任务都完成后,再接着运行

@Test
public void runAfterBothAsync() {
    CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务1线程:" + Thread.currentThread().getId());
        int i = 10 / 2;
        System.out.println("任务1运行结果:" + i);
        return i;
    }, executor);

    CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务2线程:" + Thread.currentThread().getId());
        System.out.println("任务2运行结果:");
        return "hello";
    }, executor);

    CompletableFuture<Void> runAfterBothAsync = future1.runAfterBoth(future2, () -> {
        System.out.println("任务3启动。。。");
    });

}

结果

任务1线程:33
任务1运行结果:5
任务2线程:34
任务2运行结果:
任务3启动。。。
applyToEither

只要有一个执行完就执行 + 获取返回值 + 有返回值

@Test
public void applyToEither() throws ExecutionException, InterruptedException {
    CompletableFuture<Object> future1 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务1线程:" + Thread.currentThread().getId());
        int i = 10 / 2;
        try {
            Thread.sleep(3000);
            System.out.println("任务1运行结果:" + i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return i;
    }, executor);

    CompletableFuture<Object> future2 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务2线程:" + Thread.currentThread().getId());
        System.out.println("任务2运行结果:");
        return "hello";
    }, executor);

    CompletableFuture<String> applyToEitherAsync = future1.applyToEither(future2, result -> {
        System.out.println("任务5开始执行。。。结果:" + result);
        return result.toString() + " world";
    });
    System.out.println("任务5结果:" + applyToEitherAsync.get());
}

结果

任务1线程:33
任务2线程:34
任务2运行结果:
任务5开始执行。。。结果:hello
任务5结果:hello world
acceptEither

只要有一个执行完就执行 + 获取返回值

@Test
public void acceptEither() {
    CompletableFuture<Object> future1 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务1线程:" + Thread.currentThread().getId());
        int i = 10 / 2;
        try {
            Thread.sleep(3000);
            System.out.println("任务1运行结果:" + i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return i;
    }, executor);

    CompletableFuture<Object> future2 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务2线程:" + Thread.currentThread().getId());
        System.out.println("任务2运行结果:");
        return "hello";
    }, executor);

    CompletableFuture<Void> acceptEitherAsync = future1.acceptEither(future2, result -> {
        System.out.println("任务4开始执行。。。结果:" + result);
    });

}

结果

任务1线程:33
任务2线程:34
任务2运行结果:
任务4开始执行。。。结果:hello
runAfterEither

只要有一个执行完就执行

@Test
public void runAfterEither() {
    CompletableFuture<Object> future1 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务1线程:" + Thread.currentThread().getId());
        int i = 10 / 2;
        try {
            Thread.sleep(3000);
            System.out.println("任务1运行结果:" + i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return i;
    }, executor);

    CompletableFuture<Object> future2 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务2线程:" + Thread.currentThread().getId());
        System.out.println("任务2运行结果:");
        return "hello";
    }, executor);

    CompletableFuture<Void> runAfterEitherAsync = future1.runAfterEither(future2, () -> {
        System.out.println("任务3开始执行。。。");
    });
}

结果

任务1线程:33
任务2线程:34
任务2运行结果:
任务3开始执行。。。
allOf

等待全部完成后才执行

@Test
public void allOf() throws ExecutionException, InterruptedException {
    CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务1");
        return "任务1";
    }, executor);
    CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
        try {
            Thread.sleep(2000);
            System.out.println("任务2");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "任务2";
    }, executor);
    CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务3");
        return "任务3";
    }, executor);

    CompletableFuture<Void> allOf = CompletableFuture.allOf(future1, future2, future3);
    //等待所有任务完成
    //allOf.get();
    allOf.join();
    System.out.println("allOf" + future1.get() + "-------" + future2.get() + "-------" + future3.get());

}

结果

任务1
任务3
任务2
allOf任务1-------任务2-------任务3
anyOf

等待其中之一完成后就执行

@Test
public void anyOf() throws ExecutionException, InterruptedException {
    CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务1");
        return "任务1";
    }, executor);
    CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
        try {
            Thread.sleep(2000);
            System.out.println("任务2");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "任务2";
    }, executor);

    CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> {
        System.out.println("任务3");
        return "任务3";
    }, executor);
    CompletableFuture<Object> anyOf = CompletableFuture.anyOf(future1, future2, future3);
    System.out.println("anyOf--最先完成的是" + anyOf.get());
    //等待future2打印
    System.out.println("等等任务2");
    Thread.sleep(3000);
}

结果

任务1
anyOf--最先完成的是任务1
任务3
等等任务2
任务2

并联示例

ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5);
CompletableFuture<String> firstfuture = CompletableFuture.supplyAsync(() -> {
    Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS);
    System.out.println("supplyAsync first");
    return "first";
}, fixedThreadPool);
CompletableFuture<String> secondfuture = CompletableFuture.supplyAsync(() -> {
    Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS);
    System.out.println("supplyAsync second");
    return "second";
}, fixedThreadPool);
CompletableFuture<String> thirdfuture = CompletableFuture.supplyAsync(() -> {
    Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS);
    System.out.println("supplyAsync third");
    return "third";
}, fixedThreadPool);

CompletableFuture.allOf(firstfuture, secondfuture, thirdfuture)
       .whenComplete((aVoid, t) -> {
             try {
                  System.out.println("whenComplete succeed:" + firstfuture.get() + secondfuture.get() + thirdfuture.get());
              } catch (Exception e) {
                  System.out.println("error");
              }
        });

结果等待与获取

在执行线程中将任务放到工作线程中进行处理的时候,执行线程与工作线程之间是异步执行的模式,如果执行线程需要获取到共工作线程的执行结果,则可以通过get或者join方法,阻塞等待并从CompletableFuture中获取对应的值。

getjoin的方法功能含义说明归纳如下:

方法名称作用描述
get()等待CompletableFuture执行完成并获取其具体执行结果,可能会抛出异常,需要代码调用的地方手动try...catch进行处理。
get(long, TimeUnit)与get()相同,只是允许设定阻塞等待超时时间,如果等待超过设定时间,则会抛出异常终止阻塞等待。
join()等待CompletableFuture执行完成并获取其具体执行结果,可能会抛出运行时异常,无需代码调用的地方手动try...catch进行处理。

从介绍上可以看出,两者的区别就在于是否需要调用方显式的进行try...catch处理逻辑,使用代码示例如下:

public void testGetAndJoin(String product) {
    // join无需显式try...catch...
    PriceResult joinResult = CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouXiXiPrice(product))
            .join();
    
    try {
        // get显式try...catch...
        PriceResult getResult = CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouXiXiPrice(product))
                .get(5L, TimeUnit.SECONDS);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

异常处理

在编排流水线的时候,如果某一个环节执行抛出异常了,会导致整个流水线后续的环节就没法再继续下去了,比如下面的例子:

public void testExceptionHandle() {
    CompletableFuture.supplyAsync(() -> {
        throw new RuntimeException("supplyAsync excetion occurred...");
    }).thenApply(obj -> {
        System.out.println("thenApply executed...");
        return obj;
    }).join();
}

执行之后会发现,supplyAsync抛出异常后,后面的thenApply并没有被执行。

那如果想要让流水线的每个环节处理失败之后都能让流水线继续往下面环节处理,让后续环节可以拿到前面环节的结果或者是抛出的异常并进行对应的应对处理,就需要用到handlewhenCompletable方法了。

先看下两个方法的作用描述:

方法名称作用描述
handlethenApply类似,区别点在于handle执行函数的入参有两个,一个是CompletableFuture执行的实际结果,一个是Throwable对象,这样如果前面执行出现异常的时候,可以通过handle获取到异常并进行处理。
whenCompletehandle类似,区别点在于whenComplete执行后无返回值
exceptionally捕获异常并返回指定值

handle

入参为 结果 或者 异常,返回新结果

@Test
public void handle() throws ExecutionException, InterruptedException {
    System.out.println("main.................start.....");
    final CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(() -> {
        System.out.println("当前线程:" + Thread.currentThread().getId());
        int i = 10 / 0;
        System.out.println("运行结果:" + i);
        return i;
    }, executor).handleAsync((in, throwable) -> {
        if (throwable != null) {
            return "报错返回";
        }
        return "正确了";
    });
    System.out.println("main.................end....." + completableFuture.get());

}

结果

main.................start.....
当前线程:33
main.................end.....报错返回

whenComplete

whenComplete虽然得到异常信息,但是不能修改返回信息

@Test
public void whenComplete() {
    System.out.println("main.................start.....");
    final CompletableFuture<Integer> completableFuture = CompletableFuture.supplyAsync(() -> {
        System.out.println("当前线程:" + Thread.currentThread().getId());
        int i = 10 / 0;
        System.out.println("运行结果:" + i);
        return i;
    }, executor).whenComplete((result, throwable) -> {
        //whenComplete虽然得到异常信息,但是不能修改返回信息
        System.out.println("异步完成。。。。结果是:" + result + "...异常是:" + throwable);
    });

    try {
        System.out.println("main.................end..T..." + completableFuture.get());
    } catch (InterruptedException e) {
        System.out.println("报错了1");
    } catch (ExecutionException e) {
        System.out.println("报错了2");
    }
}

结果

main.................start.....
当前线程:33
异步完成。。。。结果是:null...异常是:java.util.concurrent.CompletionException: java.lang.ArithmeticException: 除以零
报错了2

exceptionally

@Test
public void exceptionally() throws ExecutionException, InterruptedException {
    System.out.println("main.................start.....");
    CompletableFuture<Integer> completableFuture = CompletableFuture.supplyAsync(() -> {
        System.out.println("当前线程:" + Thread.currentThread().getId());
        int i = 10 / 0;
        System.out.println("运行结果:" + i);
        return i;
    }, executor).exceptionally(throwable -> {
        //R apply(T t);
        //exceptionally可以感知错误并返回指定值
        System.out.println("执行了exceptionally");
        return 0;
    });
    System.out.println("main.................end....." + completableFuture.get());
}

结果

main.................start.....
当前线程:33
执行了exceptionally
main.................end.....0

实现超时

由于网络波动或者连接节点下线等种种问题,对于大多数网络异步任务的执行常常会进行超时限制,在异步开发中可以看成是一个常见的问题。

在 Java 9 中,CompletableFuture 引入了支持超时和延迟执行的改进,这两个功能对于控制异步操作行为至关重要。

orTimeout()

允许为 CompletableFuture 设置一个超时时间。如果在指定的超时时间内未完成,CompletableFuture 将以 TimeoutException 完成

  • 示例
@Test
public void orTimeTest() {
    try {
        CompletableFuture completableFuture = CompletableFuture.runAsync(() - > {
            System.out.println("异步任务开始执行....");
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }).orTimeout(2, TimeUnit.SECONDS);

        completableFuture.join();
    } catch (Exception e) {
        System.out.println(e);
    }
}

completeOnTimeout()

允许在指定的超时时间内如果未完成,则用一个默认值来完成 CompletableFuture。该方法提供了一种优雅的回退机制,确保即使在超时的情况下也能保持异步流的连续性和完整性。

  • 示例
@Test
public void completeOnTimeoutTest() {
    CompletableFuture <String> completableFuture = CompletableFuture.supplyAsync(() - > {
        System.out.println("异步任务开始执行....");
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "死磕 Java 新特性";
    }).completeOnTimeout("死磕 Java", 2, TimeUnit.SECONDS);

    System.out.println("执行结果为:" + completableFuture.join());
}

延迟执行

CompletableFuture 提供了delayedExecutor() 来支持延迟执行,该方法创建一个延迟执行的 Executor,可以将任务的执行推迟到未来某个时间点。能够让我们更加精确地控制异步任务的执行时机,特别是在需要根据时间安排任务执行的场景中。

  • 示例
@Test
public void completeOnTimeoutTest() {
    // 创建一个延迟执行的Executor
    Executor delayedExecutor = CompletableFuture.delayedExecutor(3, TimeUnit.SECONDS);

    // 使用延迟的Executor执行一个简单任务
    CompletableFuture <Void> future = CompletableFuture.runAsync(() - > {
        System.out.println("任务延迟后执行...");
    }, delayedExecutor);

    // 等待异步任务完成
    future.join();
}

CompletableFuture的Async版本

在使用CompletableFuture的时候会发现,有很多的方法,都会同时有两个以Async命名结尾的方法版本。以thenCombine方法为例:

  1. thenCombine(CompletionStage, BiFunction)
  2. thenCombineAsync(CompletionStage, BiFunction)
  3. thenCombineAsync(CompletionStage, BiFunction, Executor)

从参数上看,区别并不大,仅第三个方法入参中多了线程池Executor对象。看下三个方法的源码实现,会发现其整体实现逻辑都是一致的,仅仅是使用线程池这个地方的逻辑有一点点的差异:

有兴趣的可以去翻一下此部分的源码实现,这里概括下三者的区别:

  1. thenCombine方法,沿用上一个执行任务所使用的线程池进行处理
  2. thenCombineAsync两个入参的方法,使用默认的ForkJoinPool线程池中的工作线程进行处理
  3. themCombineAsync三个入参的方法,支持自定义线程池并指定使用自定义线程池中的线程作为工作线程去处理待执行任务。

为了更好的理解下上述的三个差异点,通过下面的代码来演示下:

  • 用法1:其中thenCombineAsync指定使用自定义线程池,supplyAsync方法不指定线程池(使用默认线程池)
public PriceResult getCheapestPlatAndPrice4(String product) {
    // 构造自定义线程池
    ExecutorService executor = Executors.newFixedThreadPool(5);
    
    return
        CompletableFuture.supplyAsync(
            () -> HttpRequestMock.getPinDuoDuoPrice(product)
        ).thenCombineAsync(
            CompletableFuture.supplyAsync(() -> HttpRequestMock.getPinDuoDuoDiscounts(product)),
            this::computeRealPrice,
            executor
        ).join();
}

没有指定自定义线程池的supplyAsync方法,其使用了默认的ForkJoinPool工作线程来运行,而指定了自定义线程池的方法,则使用了自定义线程池来执行。

17:23:50.683636700[ForkJoinPool.commonPool-worker-1]获取拼多多上iphone16的价格完成: 5399
17:23:50.683636700[ForkJoinPool.commonPool-worker-2]获取拼多多上iphone16的折扣价格完成: 99
17:23:50.696637100[pool-2-thread-1]计算实际价格完成: 5300
5300
耗时:1079ms
  • 用法2: 不指定自定义线程池,使用默认线程池策略,使用thenCombine方法
public PriceResult getCheapestPlatAndPrice5(String product) {
    return
        CompletableFuture.supplyAsync(
            () -> HttpRequestMock.getPinDuoDuoPrice(product)
        ).thenCombine(
            CompletableFuture.supplyAsync(() -> HttpRequestMock.getPinDuoDuoDiscounts(product)),
            this::computeRealPrice
        ).join();
}

执行结果如下,可以看到执行线程名称与用法1示例相比发生了变化。因为没有指定线程池,所以两个supplyAsync方法都是用的默认的ForkJoinPool线程池,而thenCombine使用的是上一个任务所使用的线程池,所以也是用的ForkJoinPool

17:24:53.840945700[ForkJoinPool.commonPool-worker-2]获取拼多多上iphone16的折扣价格完成: 99
17:24:53.840945700[ForkJoinPool.commonPool-worker-1]获取拼多多上iphone16的价格完成: 5399
17:24:53.850944100[ForkJoinPool.commonPool-worker-1]计算实际价格完成: 5300
5300
耗时:1083ms

现在,我们知道了方法名称带有Async和不带Async的实现策略上的差异点就在于使用哪个线程池来执行而已。那么,对我们实际的指导意义是啥呢?实际使用的时候,应该怎么判断自己应该使用带Async结尾的方法、还是不带Async结尾的方法呢?

上面是Async结尾方法默认使用的ForkJoinPool创建的逻辑,这里可以看出,默认的线程池中的工作线程数是CPU核数 - 1,并且指定了默认的丢弃策略等,这就是一个主要关键点。所以说,符合以下几个条件的时候,可以考虑使用带有Async后缀的方法,指定自定义线程池:

  • 默认线程池的线程数满足不了实际诉求
  • 默认线程池的类型不符合自己业务诉求
  • 默认线程池的队列满处理策略不满足自己诉求

使用注意点

与Stream结合

在涉及批量进行并行处理的时候,通过StreamCompletableFuture结合使用,可以简化很多编码逻辑。但是在使用细节方面需要注意下,避免达不到使用CompletableFuture的预期效果。

需求场景: 在同一个平台内,传入多个商品,查询不同商品对应的价格与优惠信息,并选出实付价格最低的商品信息。

结合前面的介绍分析,我们应该知道最佳的方式,就是同时并行的方式去各自请求数据,最后合并处理即可。所以我们规划按照如下的策略来实现:

先看第一种编码实现:

public int comparePriceInOnePlat(List <String> products) {
    return products.stream()
        .map(product -> CompletableFuture.supplyAsync(() -> HttpRequestMock.getTaoBaoPrice(product))
            .thenCombine(CompletableFuture.supplyAsync(() -> HttpRequestMock.getTaoBaoDiscounts(product)),
                this::computeRealPrice))
        .map(CompletableFuture::join)
        .min(Comparator.comparingInt(p -> p))
        .get();
}

对于List的处理场景,这里采用了Stream方式来进行遍历与结果的收集、排序与返回。看似正常,但是执行的时候会发现,并没有达到我们预期的效果:

16:59:22.384338900[ForkJoinPool.commonPool-worker-2]获取淘宝上iphone16的折扣价格完成: 200
16:59:22.384338900[ForkJoinPool.commonPool-worker-1]获取淘宝上iphone16的价格完成: 5199
16:59:22.396881[ForkJoinPool.commonPool-worker-1]计算实际价格完成: 4999
16:59:23.404683800[ForkJoinPool.commonPool-worker-2]获取淘宝上iphone17的折扣价格完成: 200
16:59:23.404683800[ForkJoinPool.commonPool-worker-1]获取淘宝上iphone17的价格完成: 5199
16:59:23.404683800[ForkJoinPool.commonPool-worker-1]计算实际价格完成: 4999
16:59:24.416418500[ForkJoinPool.commonPool-worker-2]获取淘宝上iphone18的折扣价格完成: 200
16:59:24.417266700[ForkJoinPool.commonPool-worker-1]获取淘宝上iphone18的价格完成: 5199
16:59:24.417266700[ForkJoinPool.commonPool-worker-1]计算实际价格完成: 4999
4999
耗时:3116ms

从上述执行结果可以看出,其具体处理的时候,其实是按照下面的逻辑去处理了:

为什么会出现这种实际与预期的差异呢?原因就在于使用的Stream上面!虽然Stream中使用两个map方法,但Stream处理的时候并不会分别遍历两遍,其实写法等同于下面这种写到1个map中处理,改为下面这种写法,其实也就更容易明白为啥会没有达到我们预期的整体并行效果了:

public int comparePriceInOnePlat1(List < String > products) {
    return products.stream()
        .map(product -> CompletableFuture.supplyAsync(() -> HttpRequestMock.getTaoBaoPrice(product))
            .thenCombine(CompletableFuture.supplyAsync(() -> HttpRequestMock.getTaoBaoDiscounts(product)), this::computeRealPrice).join())
        .min(Comparator.comparingInt(p -> p))
        .get();
}

既然如此,这种场景是不是就不能使用Stream了呢?也不是,其实拆开成两个Stream分步操作下其实就可以了。

再看下面的第二种实现代码:

public int comparePriceInOnePlat2(List < String > products) {
    // 先触发各自平台的并行处理
    List <CompletableFuture <Integer>> completableFutures = products.stream()
        .map(product -> CompletableFuture.supplyAsync(() -> HttpRequestMock.getTaoBaoPrice(product))
            .thenCombine(CompletableFuture.supplyAsync(() -> HttpRequestMock.getTaoBaoDiscounts(product)), this::computeRealPrice))
        .collect(Collectors.toList());
    // 在独立的流中,等待所有并行处理结束,做最终结果处理
    return completableFutures.stream()
        .map(CompletableFuture::join)
        .min(Comparator.comparingInt(p -> p))
        .get();
}

执行结果:

17:08:00.052684200[ForkJoinPool.commonPool-worker-2]获取淘宝上iphone16的折扣价格完成: 200
17:08:00.051681700[ForkJoinPool.commonPool-worker-5]获取淘宝上iphone18的价格完成: 5199
17:08:00.051681700[ForkJoinPool.commonPool-worker-6]获取淘宝上iphone18的折扣价格完成: 200
17:08:00.052684200[ForkJoinPool.commonPool-worker-3]获取淘宝上iphone17的价格完成: 5199
17:08:00.051681700[ForkJoinPool.commonPool-worker-1]获取淘宝上iphone16的价格完成: 5199
17:08:00.051681700[ForkJoinPool.commonPool-worker-4]获取淘宝上iphone17的折扣价格完成: 200
17:08:00.064680500[ForkJoinPool.commonPool-worker-4]计算实际价格完成: 4999
17:08:00.064680500[ForkJoinPool.commonPool-worker-1]计算实际价格完成: 4999
17:08:00.063680100[ForkJoinPool.commonPool-worker-6]计算实际价格完成: 4999
4999
耗时:1083ms

从执行结果可以看出,三个商品并行处理,整体处理耗时相比前面编码方式有很大提升,达到了预期的效果。

归纳下:因为Stream的操作具有惰性执行的特点,且只有遇到终止操作(比如collect方法)的时候才会真正的执行。所以遇到这种需要并行处理且需要合并多个并行处理流程的情况下,需要将并行流程与合并逻辑放到两个Stream中,这样分别触发完成各自的处理逻辑,就可以了。

使用自定义线程池

CompletableFuture 默认使用ForkJoinPool.commonPool() 作为执行器,这个线程池是全局共享的,可能会被其他任务占用,导致性能下降或者饥饿。因此,建议使用自定义的线程池来执行 CompletableFuture 的异步任务,可以提高并发度和灵活性。

private ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10,
        0L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<Runnable>());

CompletableFuture.runAsync(() -> {
     //...
}, executor);

尽量避免使用get()

CompletableFutureget()方法是阻塞的,尽量避免使用。如果必须要使用的话,需要添加超时时间,否则可能会导致主线程一直等待,无法执行其他任务。

    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
        try {
            Thread.sleep(10_000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "Hello, world!";
    });

    // 获取异步任务的返回值,设置超时时间为 5 秒
    try {
        String result = future.get(5, TimeUnit.SECONDS);
        System.out.println(result);
    } catch (InterruptedException | ExecutionException | TimeoutException e) {
        // 处理异常
        e.printStackTrace();
    }
}

三星将 Perplexity AI 引入 Galaxy 手机

2 月 22 日,三星移动发布公告,宣布 Galaxy AI 进一步扩展其多 AI 代理生态,为用户带来更多样、更灵活的体验。旗舰级 Galaxy 设备中将引入 Perplexity AI,且三星称未来还将与更多伙伴合作,共同发展 Galaxy AI 这一具有包容性的 AI 生态。三星还为 Perplexity AI 适配了新的唤醒词「Hey Plex」,亦可长按侧边按键唤醒,与 Bixby 一样可与三星第一方和部分第三方应用互动,做系统级整合。该功能具体支持型号将于不久后公布。来源


Linux 7.0-rc1 系统内核已发布

Linux 7.0-rc1 系统内核于 2 月 22 日正式发布,包含多项更改和新功能。新硬件适配上,支持英特尔 Nova Lake 和 Diamond Rapids 处理器、AMD Zen 6 以及高通骁龙 X2。性能上提升也较为显著,包括在 AMD EPYC 上的 PostgreSQL 性能更好、exFAT 的顺序读取性能更好、各种 F2FS 文件系统增强功能、内存管理优化、EXT4 改进的并发直接 I/O 写入、英特尔 TSX 自动模式默认启用、调度器性能和可扩展性工作,以及为 Nouveau 带回大页内存支持以提升 NVK 性能。来源


Firefox 浏览器 115 ESR 将于本月内停止支持 Windows 7/8

Mozilla 于 2 月 18 日更新官方支持文档,确认将于本月(2026 年 2 月)底终止为 Windows 7、Windows 8、Windows 8.1 平台继续维护 Firefox 115 ESR(长期支持版)浏览器。而这也是继 Chrome 和 Edge 等其他主流浏览器放弃支持上述旧版系统后,最后一个放弃旧系统支持的主流浏览器厂商。Mozilla 原计划于 2024 年 9 月 结束 Firefox 115 ESR 的支持。来源


PayPal 确认旗下应用发生数据泄露

PayPal 日前确认其旗下应用 PayPal Working Capital 因代码漏洞引发数据泄露问题。自 2025 年 7 月 1 日至 2025 年 12 月 13 日,该漏洞持续存在六个月,PayPal 于公告时已联系了约一百名可能受波及的客户,他们的姓名、社保号码、生日、邮箱地址、电话号码、营业地址等暴露给了未经授权的访问。已有用户报告发现账户上出现了未经授权的转账记录。PayPal Working Capital 是 PayPal 旗下面向商业用户的借贷服务应用,可根据商户既有交易历史向其提供小额贷款。来源


Google 发布 AI 音乐生成模型 Lyria 3

Google 于 2 月 19 日发布了一款名为 Lyria 3 的全新 AI 音乐生成模型,该模型可根据文本、图片、音频或视频提示词,创作高保真、时长 30 秒的音轨,且风格多样,从短广告曲、低保真节拍,到更复杂的编曲与歌曲均可实现。用户可在提示词中控制曲风、人声和节奏。Lyria 3 还会根据提示词自动创作歌词,配合 Nano Banana 也能生成对应的专辑封面。该模型现支持英语、德语、西班牙语、法语、印地语、日语、韩语和葡萄牙语,未来还将支持更多的语言,并已接入 Gemini 应用,在「工具」下拉菜单中找到「音乐」选项即可使用。Google 还同步发布了 Lyria 3 提示词指南,帮助用户快速上手。来源


微软 Edge 浏览器将下线自定义主密码功能

据 Edge 浏览器 Beta 测试通道更新日志,自 Edge 146 起,Edge 密码管理器中的自定义主密码功能下线,用户将不可再创建新的自定义主密码作为密码管理器的安全保护措施。已经设置该功能的用户将收到警告提示,但功能将持续可用至 2026 年 6 月 4 日。此后,使用该功能的用户将自动迁移为 Windows Hello 等设备验证方式。

此外在该版本更新中,密码一项将从「删除浏览数据」选项卡中移除,以避免用户出现清楚浏览数据时误删已保存的账号密码的意外。来源


看看就行的小道消息

  • 当地时间 2 月 23 日,Anthropic 发布推文,指控 DeepSeek、Moonshot AI、MiniMax 使用共 24000 余个假账号对其模型进行产业级蒸馏攻击,与 Claude 分别产生 15 万次、340 万次、1300 万次交互,以提升自家模型表现。来源
  • 据 9to5Google,Google 正在调整 Android 内置 Google 天气的服务体验。此前点击 Google 天气小组件,会呈现出看似独立应用的全屏天气信息流,该界面实为 Google app 中的一个独立页面。已有用户在更新 Google app 后发现天气页面被取消,现在点击小组件会直接引导至 Google app 搜索页面,天气信息将以搜索结果的卡片形式呈现,且除了传统的天气预报、空气质量数据和详细天气指标外,还会引入 AI 生成的预报摘要,与常规的网页搜索体验基本一致。值得一提的是 Pixel 手机用户因为使用的是 Pixel Weather 应用,反而不受此影响。来源
  • 据 Game File,腾讯关闭了天美蒙特利尔工作室。除报道源自家信源外,有在该工作室员工发布的 LinkedIn 帖子印证了这一情况(现已删除)。该工作室成立于 2021 年 7 月,期望开发 3A 开放世界多平台游戏。来源
  • 2 月 23 日,Uber 宣布收购 Spot Hero,该 app 可以在拥挤场所提供预订停车位服务。具体交易细节未公开,Uber 表示希望将停车服务引入自家 app 内。来源
  • 据 Android Authority,Google 正在开发一套名为 Project Toscana 的人脸解锁系统,未来可用于 Android 手机与 Chromebook。据称该项目已有测试机,配备 Project Toscana 的 Pixel 手机只需一个圆形摄像头开孔。Project Toscana 可以与 Face ID 一样在不同光照环境下正常使用,尚不清楚其采用的具体原理,目前推测为红外摄像头。来源
  • 据彭博社 Mark Gurman,由于今年星宇橙色在全球范围内,尤其在中国市场,大受欢迎,Apple 正在考虑为 iPhone 18 Pro 引入深红色,此前出现的紫色或棕色传言可能均为该配色的调整测试。iPhone Fold 则不会采用大胆配色,仅提供深灰色或黑色,以及白色或亮银色可选。来源
  • 由于美国最高法院裁定去年的特朗普政府关税政策大部分违法,此前经游说使大部分产品免除高额税率的 Apple,目前要面对 10% 的统一关税,较之前反而更高。来源
  • 在 Windows 11 Insider 预览版 Build 26300.7877 中,版本号为 11.2601.391.0 的画图应用引入了自由旋转功能,任何形状、文字、选区等对象均可以任意角度旋转。来源


少数派的近期动态

  • 少数派年度征文来了,古法手搓大战人工智能,你会是哪条赛道的大赢家?参与一下
  • 重磅新片《寻源南疆》上线,我们在雪山上拍了一部「公路电影」。看看精彩画面
  • 将设计装进耳朵:少数派×飞傲联名 CD 机盖板设计大赛已经开始啦。了解详情
  • 没什么用,但就是好玩:盘点或恶搞或无聊的「神经病」应用。看看都有啥
  • Sonos × 少数派 × 暖风家联合打造:声音与视觉的沉浸体验空间正式上线啦。了解详情
  • 我们正在优化并改进新的首页版式,如果你在使用过程中发现了任何问题或者有改进建议,请通过反馈表单告知我们。首页反馈收集

你可能错过的文章

> 下载 少数派 2.0 客户端、关注 少数派公众号,解锁全新阅读体验 📰

> 实用、好用的 正版软件,少数派为你呈现 🚀

    感觉过年越来越没意思了,春晚也不好看,亲戚们的走动有时也觉得无聊,疲于应对,同时也会对新的一年自己的工作和未来产生迷茫吧

    用了两天,有一个非常致命的问题。
    大多数网页的文字编辑框都会在屏幕靠下的位置,用猴狐浏览器编辑文字,编辑框会被弹出的键盘遮挡。如果要浏览、修改已经输入的文字,我只能先隐藏键盘。
    我回去用 chrome 和 Firefox ,发现它们的编辑框会自动上移到键盘上方。
    所以这个怎么解决,感觉这个不解决,这浏览器就没法使用

    📰 今日新闻精选:

    • 史上最长春节假期收官:人均打卡 2.2 个城市,亲子票预订量增 76%,多代同游成新趋势
    • 微信马年数据报告:旅行、生活娱乐微信支付笔数大涨 20%;带封面红包发送 6 亿次,广东人最爱发红包
    • 2026 年春节档总票房突破 56 亿元,总场次超 435 万场创纪录,平均票价比去年便宜 3 元
    • 公安部:春节期间全国社会大局稳定治安秩序良好,刑事警情同比下降 12.1%
    • 我国城镇犬猫消费市场规模突破 3100 亿元,约七成养宠人愿意携宠出游
    • 广东一高速路段春运期间实施借用对向车道通行,有车主称节省 2 小时,网友建议推广
    • 国内油价 2 月 24 日 24 时起上调,迎来年内三连涨,92 号汽油将重回 7 元时代
    • 现货黄金重回 5200 美元,国内多家品牌足金饰品突破 1500 元每克,结婚五金起步价迈入 10 万元大关
    • 福布斯发布冬奥会运动员收入榜前五:谷爱凌 2300 万美元排第一
    • 德国总理默茨将于 2 月 25 日至 26 日访华,将讨论安全贸易等议题,30 位企业高管或随行
    • 印媒:“光辉” 战机再出事故,印度空军已停飞其全部约 30 架该型战机,以进行全面的技术检查
    • 外媒:墨西哥大毒枭被军方击毙,贩毒集团报复引发多地骚乱,墨总统最新回应称事态已平息
    • 美媒:暴风雪横扫美国东北部,近 40 万户停电、超 9000 架次航班取消,多州宣布紧急状态
    • 美媒:美国原定 3 月发射的 “阿耳忒弥斯 2 号” 载人绕月任务再次推迟,火箭和飞船需撤回装配大楼检修
    • 外媒:美国被曝已决定对伊朗发动军事打击,行动或于 23 至 24 日开始,第一轮打击目标或为伊朗军事或政府设施

    📅 今日信息:

    • 公历:2026-02-24 星期二 双鱼座
    • 农历:二〇二六年正月初八
    • 公历纪念日:第三世界青年日
    • 下一节气:2026-03-05,惊蛰
    • 今年进度:15.07%(已过 55 天,剩余 309 天)

    🌟 历史上的今天

    • 1582 年:教皇格里高利十三世颁布《格里高利历》,即现代公历,取代儒略历,调整了闰年规则,使日历更精确。
    • 1946 年:阿根廷前总统胡安·庇隆首次当选总统,开启庇隆主义时代,对阿根廷政治和社会产生深远影响。

    今天是 2026 年 2 月 24 日,星期二。春天悄悄靠近,阳光渐暖,或许可以计划一次户外散步,感受季节的微妙变化。

    📰 今日新闻精选:

    • 史上最长春节假期收官:人均打卡 2.2 个城市,亲子票预订量增 76%,多代同游成新趋势
    • 微信马年数据报告:旅行、生活娱乐微信支付笔数大涨 20%;带封面红包发送 6 亿次,广东人最爱发红包
    • 2026 年春节档总票房突破 56 亿元,总场次超 435 万场创纪录,平均票价比去年便宜 3 元
    • 公安部:春节期间全国社会大局稳定治安秩序良好,刑事警情同比下降 12.1%
    • 我国城镇犬猫消费市场规模突破 3100 亿元,约七成养宠人愿意携宠出游
    • 广东一高速路段春运期间实施借用对向车道通行,有车主称节省 2 小时,网友建议推广
    • 国内油价 2 月 24 日 24 时起上调,迎来年内三连涨,92 号汽油将重回 7 元时代
    • 现货黄金重回 5200 美元,国内多家品牌足金饰品突破 1500 元每克,结婚五金起步价迈入 10 万元大关
    • 福布斯发布冬奥会运动员收入榜前五:谷爱凌 2300 万美元排第一
    • 德国总理默茨将于 2 月 25 日至 26 日访华,将讨论安全贸易等议题,30 位企业高管或随行
    • 印媒:“光辉” 战机再出事故,印度空军已停飞其全部约 30 架该型战机,以进行全面的技术检查
    • 外媒:墨西哥大毒枭被军方击毙,贩毒集团报复引发多地骚乱,墨总统最新回应称事态已平息
    • 美媒:暴风雪横扫美国东北部,近 40 万户停电、超 9000 架次航班取消,多州宣布紧急状态
    • 美媒:美国原定 3 月发射的 “阿耳忒弥斯 2 号” 载人绕月任务再次推迟,火箭和飞船需撤回装配大楼检修
    • 外媒:美国被曝已决定对伊朗发动军事打击,行动或于 23 至 24 日开始,第一轮打击目标或为伊朗军事或政府设施

    📅 今日信息:

    • 公历:2026-02-24 星期二 双鱼座
    • 农历:二〇二六年正月初八
    • 公历纪念日:第三世界青年日
    • 下一节气:2026-03-05,惊蛰
    • 今年进度:15.07%(已过 55 天,剩余 309 天)

    🌟 历史上的今天

    • 1582 年:教皇格里高利十三世颁布《格里高利历》,即现代公历,取代儒略历,调整了闰年规则,使日历更精确。
    • 1946 年:阿根廷前总统胡安·庇隆首次当选总统,开启庇隆主义时代,对阿根廷政治和社会产生深远影响。

    今天是 2026 年 2 月 24 日,星期二。春天悄悄靠近,阳光渐暖,或许可以计划一次户外散步,感受季节的微妙变化。

    点赞 + 关注 + 收藏 = 学会了

    整理了一个 NAS 专属玩法专栏,感兴趣的工友可以戳这里👉 《NAS邪修》 关注,,更多干货持续更新~

    Solara 这款开源本地音乐播放器真的太香了,不仅能在线播放音乐,还能下载无损音质,亲测好用🐂🍺!

    本次实操以飞牛 NAS 为例,群晖、绿联、极空间等其他品牌 NAS 的操作逻辑基本一致,跟着步骤来就能搞定~

    打开 NAS 的「文件管理」,找到docker文件夹,在其内部新建solara-music文件夹。

    接着在solara-music文件夹中,再创建一个logs子文件夹,用于存放播放器日志文件。

    打开 NAS 的「Docker」应用,切换至「Compose」面板,点击「新增项目」。

    • 项目名称:Solara
    • 路径:选择第一步创建的docker/solara-music文件夹
    • 来源:选择创建 docker-compose.yml

    在编辑器输入以下代码:

    services:
      solara-music:
        image: aexus/solara-music:latest
        container_name: solara-music
        ports:
          - 3456:3001 # 项目打开端口,冒号前面请勿和本地冲突
        environment:
          - SOLARA_PASSWORD=123456 # 登录密码密码
        volumes:
          - /vol1/1000/docker/solara-music/logs:/app/logs
        restart: unless-stopped

    ⚠️ 关键配置注意事项

    • 端口:3456可自定义;3001为容器固定端口,禁止修改
    • 密码:SOLARA_PASSWORD后可替换为自己的专属密码,提升安全性。
    • 目录:在 volumes 里配置一下 /app/logs ,将其指向刚刚创建的 /docker/solara-music/logs ,具体位置目录根据你设备的来。

    代码填写保存后,等待项目自动构建完成即可。

    构建成功后,切换到 Docker 的「容器」面板,找到Solara容器,点击链接按钮,就能在浏览器中打开 Solara 播放器。

    首次打开会弹出密码输入框,输入刚才在 docker-compose.yml 中配置的SOLARA_PASSWORD值,即可登录。

    首次使用面板空空如也。

    在顶部搜索框输入歌曲名 / 歌手名,就能检索想听的音乐。

    没想到周杰伦的歌也可以播放😍

    除此之外,Solara 还有两大实用功能。

    ✅ 支持切换音乐源,多源检索不愁没歌听。

    ✅ 部分音乐提供无损音质

    而且还能直接下载到本地,听歌自由实现了!🐂🍺


    以上就是 NAS 部署 Solara 开源音乐播放器的全部实操步骤啦,有任何问题都可以在评论区留言讨论~

    想解锁更多 NAS 新奇玩法,记得关注《NAS 邪修》👏,后续持续更新干货教程!

    点赞 + 关注 + 收藏 = 学会了

    大部分 AI 产品要不然就是直接从 Idea 生成代码,要不然就是生成一个只能看不能改的花瓶设计稿。

    虽然有一些实验性的 Figma+AI 设计稿生成,但效果非常糟糕,跟投喂的 design system 不自洽,而且维护起来也是一坨(布局全乱,嵌套关系没有逻辑),边缘情形、空态、扩展性这种需要思考的环节更是强 AI 所难。

    现在观察到市场上有这片空白:

    1. 缺少 Figma 内按照现有设计(和设计习惯)学习、类推、减少重复劳动的 AI ;
    2. Vibe Coding 时不光提供代码结构,还实时提供一套(跟代码耦合的) Figma 原稿。

    AI 都直接炒设计师鱿鱼了,为什么还需要这些?
    设计师/PM 、PD 给超级大型产品出图时,不是单纯的秀外观,还要把一个产品里面的各种状态、子页面铺开。类似于一个 sitemap 那样的视觉地图,方便随时定位某个场景,做后续调试微调。

    现在的 AI 产品往往忽视了这个环节,但是 vibe coding 的产物又达不到直接部署的精度。

    里面的代码和布局免不了要修修改改,而且很多整改需求(尤其是视觉上的)非常难用自然语言描述,但又没法通过 Figma 这样直观可视化的工具去做人工干预,怎么用自然语言去下命令都是隔靴搔痒的感觉。

    最起码在 Vibe coding 能产出真正有 programmer discipline 的代码之前,你不能完全无视这个需求。

    日常开发用 win11 ,登录各种机器太麻烦了,周末 #VibeCoded 了个解决方案 👇

    w-ssh — 轻量桌面 SSH 管理器
    • 分组管理所有会话
    • xterm.js 完整终端模拟
    • 支持私钥认证
    • 数据本地存储( SQLite )

    github.com/enzyme2013/w-ssh

    其实是山寨版 terminus

    安卓安装包找不到
    ios 说是内侧中

    窝只是想在半夜让 openclaw 看看 iphone 今天各应用的耗电排行

    弗如豆包远甚 writing_hand.avif