KOReader 事件系统完全指南:Event 分发、传播机制与页面绘制代码路径
【免费下载链接】koreaderAn ebook reader application supporting PDF, DjVu, EPUB, FB2 and many more formats, running on Cervantes, Kindle, Kobo, PocketBook and Android devices项目地址: https://gitcode.com/GitHub_Trending/ko/koreader
本篇技术指南围绕 KOReader(一款支持 PDF、DjVu、EPUB、FB2 等多种格式、运行于 Kindle/Kobo/PocketBook/Cervantes/Android 等设备上的开源电子书阅读器)的事件系统展开,核心讲解Event对象的构造与分发、EventListener与WidgetContainer的事件处理/传播规则、UIManager窗口栈上的全局事件路由,以及一条贯穿"脏标记→重绘→取页→渲染"的 Draw Page 完整代码路径。读完本文,你将掌握如何在自定义 Widget 中监听/消费事件、何时选用sendEvent与broadcastEvent、如何正确利用is_always_active与active_widgets机制,并能沿着源码逐行追踪一次页面重绘的完整调用链。
事件系统概览:一切皆 Event
KOReader 中,事件是贯穿整个 Widget 树的"消息"。frontend/ui/event.lua的注释将其定义得非常明确:
Events are messages that are passed through the widget tree. Events need a "name" attribute as minimal data.
每个事件是一个对象,包含两个属性(见 frontend/ui/event.lua):
handler:事件接收后将要被调用的方法名,按约定为"on"..Event.name;args:一个保存了所有待传递给事件处理函数参数的 table。
事件的构造由Event:new(name, ...)完成(frontend/ui/event.lua):
function Event:new(name, ...) local o = { handler = "on"..name, args = table.pack(...), } setmetatable(o, self) self.__index = self return o end也就是说,Event:new("GotoPage", 1)会生成一个handler = "onGotoPage"、args = {1}的事件对象;任何实现了onGotoPage方法的 Widget 都能响应它。
向一个 Widget 发送事件有三种途径,语义各不相同:
-- 1. 直接调用,事件仅发给这一个 widget(如果它在处理过程中可能被销毁,应改用下面两种) widget_foo:handleEvent(Event:new("Timeout")) -- 2. 从最顶层的 widget 开始向下传播(遇到第一个返回 true 的处理者即停止) UIManager:sendEvent(Event:new("Timeout")) -- 3. 广播给所有窗口级 widget,不因某个 handler 返回 true 而停止 UIManager:broadcastEvent(Event:new("Timeout"))关键区别在于:如果 widget 可能在事件处理过程中被销毁,就应通过UIManager:sendEvent从最顶层 widget 向下传播;如果需要所有 widget 都收到该事件(例如全局的 Close、Suspend 之类通知),则应使用UIManager:broadcastEvent。
EventListener:一切 Widget 的接收基类
所有 Widget 都是frontend/ui/widget/eventlistener.lua中EventListener的子类,因此天然继承其handleEvent方法(frontend/ui/widget/eventlistener.lua):
function EventListener:handleEvent(event) if self[event.handler] then return selfevent.handler) end end其逻辑非常朴素:
- 检查
self[event.handler]是否存在(即 widget 上是否定义了onXxx方法); - 若存在,则把
event.args解包后调用selfevent.handler; - 把处理函数的返回值原样返回给调用方(通常是 UIManager)。
由此引出事件系统中最重要的一个约定:如果某个 handler 不想让事件继续向下传播,就必须返回true。返回true表示"事件已被消费";返回nil/false则表示未消费,事件会继续传给其他 widget 的 handler,直到某个 handler 返回true为止。
例如一个文本输入 widget,只需实现输入相关的 handler,并在"光标位于文本末尾(右方向键不再消费)"或"光标位于文本开头(左方向键不再消费)"时返回nil,从而让焦点移动到其他 widget——这正是文档中描述的"让它根据光标位置决定是否消费按键"的典型用法。
事件传播:WidgetContainer 的子级优先策略
大多数 UI 组件(对话框、布局容器、ReaderUI 等)都是frontend/ui/widget/container/widgetcontainer.lua中WidgetContainer的子类。WidgetContainer本身是一个Lua 数组,以ipairs可遍历的方式存储子 widget 列表。
当一个事件到达WidgetContainer时,传播顺序是子级优先:事件先传给所有子 widget,全部未消费时才轮到容器自己处理。这一策略保证了子 widget(例如文本输入框)能比它的布局管理器更先看到输入事件。
propagateEvent与handleEvent的实现(frontend/ui/widget/container/widgetcontainer.lua)与文档中的伪代码完全对应:
function WidgetContainer:propagateEvent(event) -- 先向子 widget 传播 for _, widget in ipairs(self) do if widget:handleEvent(event) then -- 某个子 widget 的 handler 返回 true,立即停止传播 return true end end return false end function WidgetContainer:handleEvent(event) if not self:propagateEvent(event) then -- 子 widget 未消费,才由自己处理(即调用 self[event.handler]) return Widget.handleEvent(self, event) else return true end end文档中给出的等价伪代码同样直观:
-- First propagate event to its children for _, widget in ipairs(self) do if widget:handleEvent(event) then -- stop propagating when an event handler returns true return true end end -- If not consumed by children, consume it ourself return self"on"..event.name)内置事件:Reader 排版与滚动
文档列出了两个与阅读器排版、滚动紧密相关的内置事件:
UpdatePos
语义:由排版(typesetting)相关模块发出,通知其他模块:排版已发生变化,需要基于新的排版结果重新计算视图。
发出方(从源码统计,全部通过self.ui:handleEvent(Event:new("UpdatePos"))发出):
- readertypeset.lua(多处,如行号 125、140、148、157、171、355、364、375、439、552);
- readertypography.lua(290、323、398、412、451、470、508、592、614 等);
- readerfont.lua(字体切换、字号调整后大量发出,如 205、221、230、238、246、254、263、270、278、286、312、355、460、740、760、790);
- readercoptlistener.lua(CREngine 选项变更后);
- readeruserhyph.lua(用户连字符词典变化后)。
可以看到,凡是会改变页面排版结果的操作(改字体、改字号、切排版样式、换连字符词典、调整 CRE 选项),最后都会发出一次UpdatePos来驱动视图重算。
PosUpdate
语义:由readerrolling模块发出,表示当前阅读位置(pos)发生了变化。它通常携带位置参数:Event:new("PosUpdate", new_pos, self.current_page)(见 readerrolling.lua 等处的实际发出代码,如 L1093、L1199、L1688)。
接收方:ReaderRolling:onPosUpdate(new_pos)(readerrolling.lua),以及页脚、统计插件等关注"当前阅读位置"的模块,都会监听PosUpdate来刷新进度显示。
UIManager 与窗口栈:事件的全局路由
事件从 Widget 层进入全局层面后,由frontend/ui/uimanager.lua中的UIManager负责路由。
UIManager:show 与 _window_stack
调用UIManager:show(widget)时(uimanager.lua),该 widget 会被加入UIManager._window_stack的顶部(_window_stack在 uimanager.lua 初始化为空表)。插入位置遵循两条规则:
- toast 类窗口堆叠在其他 toast 之上;
- 非 modal 窗口不能压到 modal 窗口之上。
插入后还会执行self:setDirty(widget, ...)调度重绘,并向该 widget 发送一个Show事件(widget:handleEvent(Event:new("Show"))),通知它"你已被显示"。
sendEvent:自顶向下的消费式投递
UIManager:sendEvent的完整流程(uimanager.lua):
- 从
_window_stack顶部向下查找第一个非 toast 的 widget作为top_widget。toast(如顶部弹出的通知条)永远不会阻止事件传播,但依然会收到事件——例如为了让它在用户触摸时自动关闭; - 先调用
top_widget:handleEvent(event),返回true则结束; - 若未消费,则依次调用
top_widget.active_widgets中每个 active widget 的handleEvent(active_widgets是窗口可选注册的、拥有更高优先级的子模块,目前 ReaderUI 与 FileManager 主要为截图模块等注册); - 若仍未消费,则从顶到底遍历整个
_window_stack,把事件交给所有widget.is_always_active == true的窗口(含其active_widgets)处理。
源码中特别说明:is_always_active的 widget 目前主要指"希望显示虚拟键盘"或"监听 Dispatcher 事件"的窗口。由于事件处理过程中可能打开/关闭窗口导致窗口栈变化,遍历采用了"哈希去重 + 每次重置下标"的防御式写法(uimanager.lua 的注释详细解释了这一点)。
broadcastEvent:不中断的全局广播
与sendEvent不同,UIManager:broadcastEvent(uimanager.lua)把事件发送给所有窗口级 widget,即使某个 handler 返回了true也不会停止,最终返回是否有 handler 消费过该事件。它适用于"每个窗口都应该知道"的通知类事件。
事件路由小结
| 方法 | 投递范围 | 遇到返回 true 的处理者 |
|---|---|---|
widget:handleEvent(event) | 仅该 widget | 由该 widget 内部(容器则先子后己)决定 |
UIManager:sendEvent(event) | 顶层 widget → 其 active_widgets → 所有is_always_active窗口 | 立即停止 |
UIManager:broadcastEvent(event) | 所有窗口级 widget | 不停止,全部投递 |
Draw Page 代码路径:从脏标记到屏幕
文档用一条五步调用链清晰地概括了一次页面绘制从"标记脏"到"像素上屏"的完整路径。结合源码,这条路径的每一步都有精确的实现位置:
| 步骤 | 位置 | 行为 |
|---|---|---|
| 1 | readerview.luaReaderView:recalculate | 根据新排版/翻页状态重置自身(如清除 dithering 标记、重算page_area与visible_area),并把自己标记为 dirty,请求重绘 |
| 2 | uimanager.luaUIManager:_repaint | UI 主循环检测到 dirty 后调用widget:paintTo(Screen.bb, window.x, window.y, ...);被covers_fullscreen窗口完全遮挡的下层窗口会被跳过不绘制 |
| 3 | readerview.luaReaderView:paintTo | 绘制页面背景/环绕装饰后,按视图模式分发:paging 模式走drawSinglePage/drawScrollPages,滚动模式走drawPageView/drawScrollView,最终调用document:drawPage |
| 4 | document.luaDocument:drawPage | 将渲染得到的 tile 通过blitFrom(或开启软件抖动时的ditherblitFrom)拷贝到目标 framebuffer,并处理局部区域偏移 |
| 5 | document.luaDocument:renderPage | 先查缓存(DocCache:check(hash, TileCacheItem)),命中则直接返回缓存 tile;未命中则调用_document:openPage(pageno)打开页面、执行page:draw渲染,并把结果写入 DocCache |
缓存是绘制的第一道关卡
值得展开的是第 5 步:文档所述"check for cache, if found, return cache"实际发生在Document:renderPage(document.lua)中:
local hash = self:getFullPageHash(pageno, zoom, rotation, gamma, saturation) local tile = DocCache:check(hash, TileCacheItem) if tile then if self.tile_cache_validity_ts then if tile.created_ts and tile.created_ts >= self.tile_cache_validity_ts then return tile end logger.dbg("discarding stale cached tile") else return tile end end缓存 key 由页码、缩放、旋转、gamma、饱和度等参数联合哈希生成;命中且缓存未过期时直接复用,避免重复渲染。若整页太大放不进缓存,renderPage还会退化为只渲染rect指定的局部区域(并优先尝试缓存局部结果,见 L425-L428 的hash_excerpt分支)。缓存未命中时才真正执行_document:openPage(pageno)、page:draw并将渲染结果写入缓存——这就是文档中renderPage调openPage、page:draw并 put 进 cache 的底层含义。
写给模块/插件开发者的实践要点
基于上述机制,自定义 Widget 或插件模块时应当遵循以下实践:
- 定义处理器:在 widget 上实现
on+ 事件名的方法,例如要响应PosUpdate就写function MyWidget:onPosUpdate(new_pos) ... end; - 消费即返回 true:handler 处理完且不希望事件继续传播时务必
return true,否则事件会被继续传递给其他 widget 的 handler; - 选择发送方式:只通知单个对象用
handleEvent;需要从顶层窗口开始按优先级投递用sendEvent;需要全体知晓的通知用broadcastEvent; - 留意销毁窗口:在事件处理中可能被销毁的 widget,不要在裸
handleEvent中调用,应走UIManager:sendEvent路径; - 窗口级常驻监听:需要在整个会话期间响应事件的窗口,可把
is_always_active置为true,使sendEvent未消费的事件也能到达它(如虚拟键盘、Dispatcher 监听者);更高优先级的子模块则注册进active_widgets; - 自定义事件:直接
Event:new("MyEvent", arg1, arg2)即可,无需注册表——事件系统是鸭子类型分发,只要某个 widget 实现了onMyEvent就能收到; - 追溯调试:观察排版变化可重点搜索
Event:new("UpdatePos")的发出点(readertypeset.lua、readerfont.lua 等),观察阅读位置变化可跟踪Event:new("PosUpdate", ...)与onPosUpdate(readerrolling.lua)。
事件系统的完整官方说明见 doc/Events.md;事件对象的实现与文档注释见 frontend/ui/event.lua;基类与容器的分发逻辑分别位于 frontend/ui/widget/eventlistener.lua 与 frontend/ui/widget/container/widgetcontainer.lua;全局路由、窗口栈与重绘循环在 frontend/ui/uimanager.lua。
【免费下载链接】koreaderAn ebook reader application supporting PDF, DjVu, EPUB, FB2 and many more formats, running on Cervantes, Kindle, Kobo, PocketBook and Android devices项目地址: https://gitcode.com/GitHub_Trending/ko/koreader
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考