1. 场景与需求分析:为什么要在ABAP里做小游戏
先别笑,用ABAP写游戏这事听起来像“用拖拉机跑F1”,但我真不是来搞笑的。我在SAP项目里待了十多年,从ECC 6.0一路做到S/4HANA,见过太多顾问跟ABAP死磕报表、接口、增强的日常工作内容。但今年年初一个内部需求培训会下午,我突发奇想——为什么不用ABAP写个猜数字游戏,给团队里刚转FICO的同事演示一下“程序是怎么跑起来的”?结果一发不可收拾,陆续写了猜数字、井字棋、21点,最后连扫雷都上了。
其实仔细想想,ABAP做小游戏的需求场景非常多:
- 内部培训时,需要一个肉眼可见、逻辑清晰、能逐步调试的程序,讲清楚变量、循环、分支、事件处理这些基础概念,小游戏比几十页PPT好用太多了。
- 开发组团建或者客户沙龙时,做个简单的互动程序,比干讲PPT更能拉近关系。我见过有同行在客户月会上用ALV做个抽奖转盘,效果出奇地好。
- 自己练手学习ABAP新语法时,小游戏是个绝佳的试验场。新语法、内表操作、类和异常处理都能在一两百行代码里完整跑通,比啃一个上千行的接口程序有效率得多。
- 招聘面试时,让候选人现场写一个15分钟能完成的逻辑题,小游戏是最能暴露“会不会写代码、思路清不清楚”的试金石。
当然得承认,ABAP不是为游戏而生的。没有Canvas、没有SDL、没有实时的图形渲染,它最强的领域是“数据往里灌、逻辑往外吐”。但恰恰是这些限制,逼着我们把问题拆得足够简单——游戏本质上是“状态机+回合制+输入输出”,而ABAP在处理“状态迁移”和“批量输入输出”上一点都不弱。
这篇文章我就把我的整个设计和踩坑过程都摊开来讲。不绕弯子,直接把能跑的代码、能复现的思路、以及我踩过的那些坑全部写出来。这些内容适合所有做SAP开发的人,哪怕你刚学会SE38怎么用,也能跟着上手;也适合做非RICE模块想了解程序逻辑的朋友,看懂一个猜数字程序,你就看懂了ABAP八成的基础语法。
2. 游戏选型与整体方案设计
2.1 三个经典玩法,分梯度覆盖ABAP核心技术点
我筛选小游戏时,标准只有三个:逻辑要有代表性、控制在ABAP里能实现、代码量控制在两天以内写完。最终留在候选池里的有四个,下面把它们的核心机制和涉及的技术点写清楚。
猜数字游戏是初级玩家的入门之选,也是确定性最强的选择。计算机随机生成一个不重复的四位数,玩家输入猜测值,程序返回“数字和位置都对的有几个”和“数字对但位置不对的有几个”。这个游戏覆盖了随机数生成、字符串处理、循环输入、条件分支、异常捕捉这五个基础知识点,而且纯控制台就能跑,不需要任何界面组件。这算是我建议所有新手写的第一段完整ABAP程序。
井字棋则是中阶挑战,需要处理九宫格AI走棋的极小博弈树。它涉及ALV网格显示、内部表状态映射、两玩家回合轮转、最简单的剪枝策略。为什么选它?因为ALV是ABAP最常用的显示控件,能用游戏的方式把ALV的显示机制跑透,比在报表里看十遍数据更直观。
扫雷我放在最高难度档,本质是二维数组的BFS/DFS算法、右键标记事件和递归展开逻辑,代码量较大,但杀伤力最强——它考验的是“用结构化的方式描述空间关系”的能力,这对SAP的BOM展开、COGI分析逻辑都有迁移价值。21点我做过一版,它覆盖了扑克牌组的随机性、金额累计和庄家AI,但因为涉及较多“庄家何时补牌”的细节,代码量逼近600行,入门者容易写崩,所以这篇文章不展开。
2.2 ABAP小游戏的技术选型思路
选定游戏后,落地方式是最关键的决策点。ABAP没有独立的前端渲染层,我们在对话报表中能用的交互手段,基本就是“WRITE输出+屏幕输入框”和“ALV GRID”。
我先解释一下WRITE方案的核心机制。ABAP报表程序默认以WRITE输出文本,配合GET CURSOR可以进行简单的定位,但要实现“输入-回显-再输入”的循环效果,必须借助一个关键的会话机制:在列表处理器中,WRITE输出的内容会被暂存在列表栈里,每次输入后触发回车事件,代码重新执行时,从SY-UCOMM读取用户命令,再在原有列表基础上追加新的输出。这一步理解了,你就成功了一半。
ALV方案则是借助REUSE_ALV_GRID_DISPLAY将数据包输出到网格控件。它的优势是不用自己画HTML,网格自带排序、过滤、单元格编辑功能;劣势是事件注册稍显繁琐,需要自定义CONTROL_FLAG控制刷新点。具体到游戏场景,我后面会用井字棋讲清楚怎么把棋盘状态映射成显示行项目。
我最初试验过用SAP的WebDynpro开发游戏,能做导航效果,但配置太重,几乎没在传统ABAP开发者群体里铺开,所以果断放弃。也考虑过SAPUI5 Fiori——Fiori的图形表现力确实强,但本质是JavaScript技术栈,偏离了“纯ABAP实现”这个初衷。而且部署到Fiori Launchpad的过程本身,就把新手挡在了门外。
结论很直接:纯控制台+WRITE做猜数字,ALV GRID做井字棋,这是我不踩坑复盘后觉得最适合ABAP特性的组合。
2.3 工具准备与运行环境
跑这些代码的底座并不苛刻。传统的ECC 6.0 EHP8或者S/4HANA 1610以上版本都行,开发请用SE38或SE80,测试用SE38直接执行,激活也没额外讲究。我个人的开发习惯是用SE38先写一段最小原型跑通主流程,再把逻辑抽到独立类里,报表只做调用。原因后面专门讲。
SDN上很多同行问“ABAP小游戏能不能放到SAPUI5里”,我的看法是:如果目标是学习UI5,那就用JavaScript重写一个,别硬套ABAP的ABAP内核逻辑;如果目标是体现ABAP能力边界,那就老老实实走ABAP唯一擅长的文本流交互——用最小代价实现最大逻辑密度,这才是硬核的玩法。
3. 猜数字小游戏:完整实现与代码逐段解析
3.1 核心逻辑拆解
四位数猜数字的规则很经典:程序随机生成不重复的0-9四位数字,如“4832”。每轮玩家输入一个四位数,系统判断出A和B两个数值,A代表位置和数字都正确的个数,B代表数字正确但位置错误的个数。比如答案“4832”,如果猜测“4831”,则A=3(4、8、3位置全对),B=0;如果猜测“4382”,则A=2(4、2位置对),B=2(8和3位置对但互换)。直到A=4即猜中。
这个规则之所以经典,是因为它的判定逻辑里天然包含“查重”“逐位比对”“集合差集”三个ABAP里高频使用的操作。数据表去重比对、跨表字段校验、分状态汇总,在业务代码里到处都是,在这里反而以最简单直观的形式呈现。
3.2 老语法版本:完整可运行代码
先给出使用传统ABAP语法(ABAP 7.02之前)的原始版本。这个版本对老系统最友好,逻辑也直白:
REPORT z_game_guess_no. DATA: gv_answer TYPE string, gv_guess TYPE string, gv_a TYPE i, gv_b TYPE i, gv_msg TYPE string, gv_times TYPE i VALUE 0. * 生成四个不重复的数字 FORM f_create_answer. DATA: lv_tmp TYPE i. DATA: lt_digits TYPE TABLE OF i. DATA: lv_rand TYPE i. DATA: lv_idx TYPE i. CLEAR: gv_answer, lt_digits. WHILE lines( lt_digits ) < 4. CALL FUNCTION 'QF05_RANDOM_INTEGER' EXPORTING ran_int_max = 9 ran_int_min = 0 IMPORTING ran_int = lv_rand. READ TABLE lt_digits TRANSPORTING NO FIELDS WITH KEY table_line = lv_rand. IF sy-subrc <> 0. APPEND lv_rand TO lt_digits. ENDIF. ENDWHILE. LOOP AT lt_digits INTO lv_tmp. CONCATENATE gv_answer lv_tmp INTO gv_answer. ENDLOOP. ENDFORM. * 判断 A / B FORM f_check RETURNING VALUE(rv_a) TYPE i CHANGING VALUE(cv_b) TYPE i. DATA: lv_i TYPE i, lv_j TYPE i, lv_char1 TYPE c, lv_char2 TYPE c. rv_a = 0. cv_b = 0. DO 4 TIMES. lv_i = sy-index. lv_char1 = gv_guess+lv_i(1). DO 4 TIMES. lv_j = sy-index. lv_char2 = gv_answer+lv_j(1). IF lv_char1 = lv_char2. IF lv_i = lv_j. rv_a = rv_a + 1. ELSE. cv_b = cv_b + 1. ENDIF. ENDIF. ENDDO. ENDDO. ENDFORM. START-OF-SELECTION. PERFORM f_create_answer. WRITE: / '猜数字游戏开始!答案是一个四位不重复数字。'. WRITE: / '直接输入四位数字回车,退出请按 F2'. DO. gv_times = gv_times + 1. WRITE: / '请输入第', gv_times, '次猜测:'. READ LINE. IF sy-ucomm = 'F2'. LEAVE PROGRAM. ENDIF. gv_guess = sy-index_line. CONDENSE gv_guess NO-GAPS. IF strlen( gv_guess ) <> 4. WRITE: / '输入格式错误,请输入四位数。'. CONTINUE. ENDIF. PERFORM f_check CHANGING gv_a gv_b. WRITE: / '本次结果: A =', gv_a, ' B =', gv_b. IF gv_a = 4. WRITE: / '恭喜你猜中了!共用', gv_times, '次。'. EXIT. ENDIF. ENDDO.这段代码在SE38里直接激活即可运行。如果你用的是S/4HANA,建议把QF05_RANDOM_INTEGER换成CL_ABAP_RANDOM_INT类的静态方法,后面会说怎么用新语法优雅地写。
有几点必须提醒。
READ LINE是典型的事件驱动输入法。它的运行原理是在列表输出后,系统等待用户输入,按回车后SY-UCOMM被设置,sy-index_line记录光标所在行的首列内容。注意READ LINE必须在DO循环内配合列表栈才有意义——我见过有人把它放在普通INITIALIZATION事件里,输入永远拿不到值。
字符串偏移访问gv_answer+lv_i(1)在ABAP中是从0开始的偏移量,所以DO 4 TIMES里sy-index等于1时,取的就是第一位字符。这一点容易跟其他语言的字符串切片混淆,务必留意。
查重逻辑用了READ TABLE ... WITH KEY table_line =,这是标准表按表行内容查找最简单的方式。新语法里用LINE_EXISTS( )来实现等价逻辑,可读性提升不少。
QF05这个函数在较新的S/4版本里已经被标记为“use internal instead”,但依然能跑。如果系统禁止,写个简单的方法生成:( conv i( cl_abap_random_int=>create( seed = sy-uzeit )->intin( 10 ) ) )也行。不过对老系统而言,QF05是保留兼容性最稳妥的选择。
3.3 新语法重构:ABAP 7.40+推荐的写法
如果你当前项目已经有较新的底层版本,或者说你希望尽快熟悉新语法,我强烈建议把代码改成这种结构:
CLASS lcl_guess_number DEFINITION. PUBLIC SECTION. METHODS: start. PRIVATE SECTION. DATA: mv_answer TYPE string, mv_times TYPE i. METHODS: create_answer, check_guess IMPORTING iv_guess TYPE string RETURNING VALUE(rs_result) TYPE ty_result. ENDCLASS. CLASS lcl_guess_number IMPLEMENTATION. METHOD create_answer. DATA(lt_digits) = VALUE int4_table( ). WHILE lines( lt_digits ) < 4. DATA(lv_rand) = NEW cl_abap_random_int( seed = sy-uzeit )->intin( 10 ). IF lv_rand NOT IN lt_digits. APPEND lv_rand TO lt_digits. ENDIF. ENDWHILE. mv_answer = CONV string( REDUCE string( INIT txt = `` FOR wa IN lt_digits NEXT txt = |{ txt }{ wa }| ) ). ENDMETHOD. METHOD check_guess. DATA: lv_a TYPE i, lv_b TYPE i. DO 4 TIMES. DATA(lv_c1) = iv_guess+( sy-index - 1 )(1). DO 4 TIMES. DATA(lv_j) = sy-index. DATA(lv_c2) = mv_answer+( lv_j - 1 )(1). IF lv_c1 = lv_c2. IF ( sy-index - 1 ) = ( lv_j - 1 ). lv_a += 1. ELSE. lv_b += 1. ENDIF. ENDIF. ENDDO. ENDDO. rs_result = VALUE ty_result( a = lv_a b = lv_b ). ENDMETHOD. METHOD start. create_answer( ). cl_demo_output=>display( |猜数字开始!答案是一位四位数。| ). DO. mv_times += 1. cl_demo_output=>display( |第 { mv_times } 次:| )->line( ). “这里只是示例——实际要读取输入,可用SCREEN或SELECTION-SCREEN,控制台演示下一般通过调试变量方式” TRY. DATA(lv_input) = |1234|. DATA(ls_result) = check_guess( lv_input ). cl_demo_output=>display( |A = { ls_result-a } B = { ls_result-b }| ). IF ls_result-a = 4. cl_demo_output=>display( |你用了 { mv_times } 次猜中!| ). EXIT. ENDIF. CATCH cx_root. cl_demo_output=>display( |输入格式错误| ). ENDTRY. ENDDO. ENDMETHOD. ENDCLASS.新语法里最值得学习的是三个点。
字符串模板不再是CONCATENATE ... INTO ...,而是直接用竖线拼接,例如|{ txt }{ wa }|。这在少量文本拼接时的可读性好非常多,不用再数CONCATENATE的参数顺序。
VALUE int4_table( )直接构造一个整型标准表;IF lv_rand NOT IN lt_digits直接判断行是否存在,省掉READ TABLE+SY-SUBRC两步。这种IN判断在SAP Cloud Platform ABAP环境里照样支持。
CONV string( REDUCE string(...) )则是一个典型的用REDUCE把内表聚合为字符串的写法。REDUCE首次接触有点绕,但熟悉之后,你会发现它其实就是一个内置函数式的LOOP,非常适合跑这类聚合逻辑。
3.4 新语法和老语法,我建议这么选
这里说一个判断经验,这条经验我面试时经常问:如果系统底包在S/4HANA 2020以后,一律用新语法;如果客户还在维护ECC 6.0 EHP5及以下的老仓库,新语法可能不支持,那就保守一点用DATA: gv_xxx TYPE i这样的老式写法,但你个人项目可以用新语法练习,碰见新版本部署的时候就升级。
还有一件事我必须强调:不要在生产环境的真实系统里把这段游戏程序挂到生产作业里跑。它就是演示和教学用的,不同系统里打包、权限都可能有额外要求。放开发机或培训环境最合适。
4. 井字棋:把游戏搬进ALV GRID
4.1 ALV方案的设计动机
猜数字纯文本交互很典型,但它有个明显的局限:没有网格感,体现不了“位置关系”。所以第二款游戏我用井字棋来补足这个短板。
井字棋棋盘是3x3格子,两个玩家轮流出棋,X或O。我们作为SAP开发顾问,最直接的想法就是用REUSE_ALV_GRID_DISPLAY把3x3棋盘的当前状态显示在网格上,每个格子对应一行,列上放上“行坐标、列坐标、当前符号”,再加一个“操作”标志位,通过按钮或单元格点击触发落子。
这里正好应对很多同行问的一个问题:REUSE_ALV_GRID_DISPLAY能不能加F4帮助?答案是肯定的。ALV的F4本质上就是事件注册,通过注册“USER_COMMAND”和通过“ON_F4”事件回调,自定义F4搜索帮助逻辑。但要注意一个细节——REUSE_ALV_GRID_DISPLAY的F4回调使用ALV_EVENT维护码,而不是CALLBACK_USER_COMMAND的常规用法。很多人在这里踩坑,是因为把F4方法名当成了普通的事件处理直接注册,结果事件不触发。
井字棋画到ALV里以后,视觉结构清晰很多,棋盘不再是MEMORY中的字符串了,而是实实在在的一行行数据。
4.2 井字棋的ALV数据结构与显示方案
先定义棋盘显示要用的工作区结构:
TYPES: BEGIN OF ty_board_line, row TYPE i, " 行 1-3 col TYPE i, " 列 1-3 player TYPE c LENGTH 1, " X / O / 空 END OF ty_board_line.用一个内表gt_board来保存这9个格子,每次游戏动作更新内表,然后调用REUSE_ALV_GRID_DISPLAY刷新。
显示的关键配置点在fieldcat。我们要把列标题改成“行号”“列号”“玩家”,同时还要增强“玩家”这一列的可编辑属性、为格子设置下拉列表或单元格颜色提示。核心代码片段:
CALL FUNCTION 'REUSE_ALV_GRID_DISPLAY' EXPORTING i_structure_name = 'TY_BOARD_LINE' i_callback_program = sy-repid i_callback_user_command = 'ALV_USER_COMMAND' i_callback_f4 = 'ALV_ON_F4' it_fieldcat = lt_fieldcat i_save = 'A' TABLES t_outtab = gt_board EXCEPTIONS program_error = 1 OTHERS = 2.这里有一个细节:i_callback_f4 = 'ALV_ON_F4'响应的子程序必须实现为FORM alv_on_f4 USING p_event TYPE slis_event CHANGING p_selfield TYPE slis_selfield,才拿得到当前光标所在格子的坐标信息。
4.3 井字棋核心判断逻辑与回合控制
井字棋的胜负判断本质是判定8条连线(3横、3竖、2对角线)上是否出现相同符号。我复用了一个相当精简的规则:把所有X落子位置视作一个集合,把O落子位置视作一个集合;判断赢家,就是看某个集合里是否包含了一条完整连线的三个坐标。三行代码即可实现:
DEFINE mac_add_win. append &1 to lt_win_line. append &2 to lt_win_line. append &3 to lt_win_line. END-OF-DEFINITION.其实更简洁的成型方案是预定义所有胜利组合的坐标,遍历内表时只要遇到当前玩家的三个坐标全部命中,直接返回。
这里有个经验:井字棋AI的深度不需要太高。做一个能让你赢一个能让你平局的AI,反而比“永远最优解”的AI对玩家更有回馈感。我觉得游戏设计里最好玩的部分是“人工智能不那么聪明”时,你还能感受到策略空间。我的AI逻辑是:第一步随机下角;之后优先下中心;如果对手有两格连线且第三格为空,就立即阻止;否则下第一个合法空位。这套简单规则实现起来大约40行,已经能跟新手玩家打得有来有回。
完整实现里还有一个要点是回合控制状态。用全局变量gv_current_player来标记当前是谁走。每次落子后调用ALV_REFRESH_TABLE_DISPLAY做局部刷新,同时更新标题栏显示“现在是X回合”或“现在是O回合”。这个标题栏状态的更新我踩过一次坑,后面在问题排查里专门展开讲。
4.4 井字棋落子事件的捕获与校验
落子动作我需要两种交互方式:一种是直接点格子对应的行,另一种是通过F4弹出一个对话框选择放“X”还是“O”。前一种简单直接,后一种正好展示ALV的F4能力。
当用户点击行时,USER_COMMAND事件中SY-UCOMM通常是‘&REFRESH’或空值,我们需要通过SELFIELD-FIELDNAME来判断是哪个列被点击。这里必须注意SELFIELD-TABINDEX的值就是当前所在行的行号,利用它可以直接通过READ TABLE获取棋盘中的相应格子。落子的合法性校验包括:该格是否已空、当前是否为可操作状态。如果格子非空,必须输出提示信息并中止后续逻辑。
F4增强的实现里,如果你想让用户在选某一行后按F4看到“可输入的符号列表”,我建议在ALV_ON_F4里构造一个弹出对话框,推荐用F4IF_INT_TABLE_VALUE_REQUEST函数。这个函数可以传入候选值列表内部表,把'X'和'O'两个选项放进去。F4弹出的列表天生支持回车选中,选中后回调结果会自动回填到当前单元格。整个增强大约30行就能完成:
FORM alv_on_f4 USING p_event TYPE slis_event CHANGING p_selfield TYPE slis_selfield. DATA: lt_values TYPE TABLE OF ddbelp. DATA: ls_value LIKE LINE OF lt_values. IF p_selfield-fieldname = 'PLAYER'. ls_value-value = 'X'. APPEND ls_value TO lt_values. ls_value-value = 'O'. APPEND ls_value TO lt_values. CALL FUNCTION 'F4IF_INT_TABLE_VALUE_REQUEST' EXPORTING retfield = 'PLAYER' value_org = 'C' TABLES value_tab = lt_values EXCEPTIONS parameter_error = 1 OTHERS = 2. IF sy-subrc = 0. " 此时p_selfield-value已自动更新为用户选择的值 ENDIF. ENDIF. ENDFORM.这类F4增强在业务报表里同样非常实用,例如让用户用F4从参数列表里选状态字段的行项目,很多SAP顾问会去搜“reuse_alv_grid_display 可以加f4吗”,其实就是这个做法。
5. 工程化落地:从玩具代码到像样的程序结构
5.1 为什么要用类而不是全揉在一个REPORT里
猜数字和井字棋的第一版,我都直接写成了单报表程序。这种写法的好处是代码量少,一个文件从头看到尾,适合教学。但一旦游戏逻辑复杂到扫雷的程度,单报表就是死路。我举个例子:扫雷里既有行、列、雷状态三个维度的内表,也有递归展开、胜利判定、计时三个相对独立的逻辑块;如果全部用PERFORM子程序包裹,参数全靠全局变量传递,代码内部连“哪个全局变量在哪一步被改掉”都很难追。
所以第二版重构时,我的方案是:显示层保留一个REPORT,游戏逻辑放进全局类,数据存储全部私有化。REPORT里只做三件事——创建游戏对象、调用启动方法、给ALV事件回调转发用户的点击坐标。逻辑层内部的全局变量全部收进类的PRIVATE SECTION,通过公共方法暴露“落子”和“查询棋盘”的接口。这种分层模式看起来重了一些,但对后期维护、单测和代码阅读都有好处。
比如井字棋的类定义可以这样规划:
CLASS lcl_tic_tac_toe DEFINITION. PUBLIC SECTION. METHODS: constructor, new_game, make_move IMPORTING iv_row TYPE i iv_col TYPE i RETURNING VALUE(rs_state) TYPE ty_state, get_board RETURNING VALUE(rt_board) TYPE ty_board_tab. PRIVATE SECTION. DATA: mt_board TYPE ty_board_tab. DATA: mv_winner TYPE c LENGTH 1. METHODS: check_winner. ENDCLASS.这样在任何报表里,几行就能驱动一整局游戏:
DATA(lo_game) = NEW lcl_tic_tac_toe( ). DATA(ls_state) = lo_game->make_move( iv_row = 1 iv_col = 1 ).5.2 代码组织与可读性建议
关于代码风格,我特别想强调一件事:用符号常量代替硬编码。比如格子的空值状态不要直接写space,定义一个常量gc_player_empty = space;行坐标循环不要直接写DO 3 TIMES,定义一个gc_dim = 3。这样做不是为了显得代码规范,而是在扫雷级别里,如果你把尺寸常量写在每个DO循环里,后期把3改成9的难度会陡增。
程序界面标题和提示信息尽可能放到文本元素里。很多人觉得ABAP的文本元素老掉牙,但实际在ALV表头、F4对话框和列标题上,文本符号维护起来比在代码里硬编码字符串要干净得多,尤其是要给用户英文或中文界面切换的时候。
另外一个容易被忽略的工程点:异常处理。游戏代码不是业务代码,很多人会偷懒不做异常捕捉,但我的经验是预测到“用户可能把输入搞坏”,跟预测到“接口返回异常”同等重要。猜数字里输入非数字字符时,用老语法直接读取会DUMP,正确做法是包一层CX_SY_CONVERSION_ERROR的CATCH。我之前在客户项目里见过一个报表因为一笔数据里有数字格式异常直接ABEND,类似的问题在游戏里也一样常见。
5.3 性能问题的边界
有人说REUSE_ALV_GRID_DISPLAY刷新棋盘内表会不会性能很差。实际上井字棋最多9个格子,即使你每秒刷新一次,也毫无压力。我后来给一个内部培训工具加了个“自动走棋演示模式”,每秒走一步、刷新整个ALV,连续跑了一个小时也没见内存涨。
但如果你做的是扫雷级别,棋盘是16x30、480个格子,还有一堆标记状态,这时候再每次全量重建内表就有点吃力了。更合适的做法是只修改需要变更的行,然后用ALV的局部刷新接口,而不是重新调用REUSE_ALV_GRID_DISPLAY。局部刷新的实现方式我留在后面问题排查里说。
6. 常见问题与调试排查实录
6.1 READ LINE 拿不到输入值的几种情况
猜数字游戏最常见的问题就是运行后输入数字回车,程序无视输入、直接无限输出“请输入”。 这种情况十有八九是READ LINE用错了位置。READ LINE必须配合当前列表输出后的列表处理器事件。如果你的REPORT都还没任何WRITE,就在INITIALIZATION事件里写READ LINE,当然拿不到值。
标准流程是:在START-OF-SELECTION里先WRITE输出提示文字,然后再进行输入循环。同时记住回车后的屏幕事件与旧版“AT LINE-SELECTION”是不同的逻辑,别把两者混在一起,否则会重复打出同一行。
我用一个流程图思路解释(不画图,就文字描述):REPORT被运行时,ABAP把WRITE内容送进浏览器/列表控件并停住,此时用户输入回车,系统把输入送入SY-INDEX_LINE,同时STORE到内存里,程序继续执行下一语句。所以“READ LINE之后再WRITE结果”这个顺序极其重要,一旦你写反,比如先WRITE结果再去READ LINE,结果不会输出到用户正在看的那一屏,而是出现在下一次刷新时才看到。
6.2 ALV的列标题和单元格编辑不回写
井字棋集成ALV后,我遇到过列标题修改了但不生效的情况。这里有个经典坑:REUSE_ALV_GRID_DISPLAY的fieldcat里有个SELTEXT_L、SELTEXT_M和SELTEXT_S三个文本属性,很多人直接改SELTEXT_L,但网格默认显示的是列短文本或中文本,改L不生效。解决方法是三哥全设置,或者干脆用SET_TABLE_FOR_FIRST_DISPLAY的模式来一次性提供文本。
另一个问题是双击单元格想落子,却发现自己输入的X没写回内表。原因在于,REUSE_ALV_GRID_DISPLAY的默认编辑行为需要配合I_CALLBACK_USER_COMMAND内部用SELFIELD-VALUE捕获单元格新值。F4的选中值通常能自动回填,但F2手动输入的值必须通过“SELFIELD-VALUE”传回游戏层。如果你没在USER_COMMAND里主动处理这个值,落子就完全无反应。
这是我特别想提醒的一个细节:ALV所谓“单元格编辑自动写回内表”需要满足两个条件,一是fieldcat里该列EDIT标志为TRUE,二是在USER_COMMAND回调中显式读取SELFIELD-VALUE。如果不满足,改动的只是网格显示层,底层内表毫无变化。
6.3 刷新标题栏与棋盘状态不一致
井字棋里我需要在每次落子后更新标题栏提示“轮到X”,但刚开始标题栏一直不刷新。原因是我在USER_COMMAND回调里只是修改了自定义全局变量,没有调用ALV的刷新方法。REUSE_ALV_GRID_DISPLAY不支持原地动态刷新标题栏,你必须在回调里更新完变量后显示刷新一次网格,刷新时ALV才会重新读取标题栏字段和行数据。ALV刷新有个轻量级方式就是REUSE_ALV_GRID_DISPLAY后通过全局GRID对象调用REFRESH_TABLE_DISPLAY方法,它会重新取数、重新绘制的。
实际踩坑中,在这里我加上下面这段代码就正常了:
DATA: ls_stable TYPE lvc_s_stbl. ls_stable-row = '1'. ls_stable-col = '1'. CALL METHOD lo_grid->refresh_table_display EXPORTING is_stable = ls_stable.IS_STABLE是个很实用的参数,它告诉ALV尽量别调整布局,只更新数据,这样在游戏场景里视觉跳动小很多。如果你在对话框里清空整行或者改变了列宽,这个参数置为初始值会导致重置布局,反而影响体验。
6.4 回合制游戏中的按位计算精度问题
这里说的精度不是数学上的浮点精度,而是字符串截取时的偏移量精度。猜数字判断A/B时,我最初用strlen减去一个偏移量再去截取,结果发现当数字中有0时,会截到前导空格,导致判断错误。解决方式是统一用CONDENSE把字符串压缩后再截取,同时用sy-index - 1确保偏移量从0开始。
ABAP里还有个容易忽略的坑:如果用gv_guess+0(4)截取四位数字,在变长字符串上没问题,但如果是CHAR类型的定长变量,字符后面通常填充空格,直接比较会失败。所以变量定义时尽量用STRING,或者比较前统一加CONDENSE。
6.5 扫雷扩展里的递归展开死循环
我做扫雷的时候,递归展开一个空格子的区域,结果在REUSE_ALV_GRID_DISPLAY的来回刷新过程中,展开了两轮后直接栈溢出。原因很简单:我用的递归函数里没有把“已展开”的格子标记为展开态,导致ABAP里同一个格子被反复展开,无限来回跳转。
解决方法是在递归入参中传一个不可变的“展开标记表”,每次展开一个格子前先判断是否已经被标记,如果已经标记就跳过。这是最基础的回溯剪枝,但在游戏场景里,它尤其容易因为ABAP内表的传值/传引用语义而翻车。ABAP里内表传参默认是传值(COPY),除非你用CHANGING参数,不然你在递归里对表做的修改根本不会传导到外层。我建议这类场景统一用CHANGING传引用,或者用REF TO传递。
还有一点,SAP的会话上下文如果递归深度超过1000层会直接报“运行时堆栈不足”,但扫雷最多展开几百个格子,不会触发。真正要注意的是递归里的死循环,这个问题解决后扫雷的稳定性立刻起来了。
7. 从“小游戏”到真正的工作技能:可扩展玩法与技术延伸
7.1 把ALV F4增强迁移到业务报表
如果没有这次小游戏的实践,我真不一定会花时间把所谓的“REUSE_ALV_GRID_DISPLAY 加F4”这件事研究得这么透。后来我把这个F4模式用到一个真正的业务报表里:报表输出销售订单行项目明细,用户光标停在哪一行,按F4就能弹出“仅显示该行待处理状态”的筛选下拉。虽然可以用标准ALV的交互式搜索帮助实现,但自定义F4的好处是你可以根据当前行的值动态决定候选列表。如果当前行是已发货状态,F4列表多返回“查看发货单”;如果当前行是已开票状态,则多返回“查看发票清单”。这种按上下文动态变化的F4,是标准帮助替代不了的。
给个小建议:如果你要在生产系统用这个F4,别忘了权限检查。F4帮用户拉出来的选项如果涉及“要看到其他部门的单”,必须在F4回调里补上AUTHORITY-CHECK,否则就是越权。
7.2 新语法在生产代码里的落地经验
写游戏时大量接触的新语法,后来就直接迁移到我手头的接口类开发里。比如VALUE #( )构造内表、REDUCE做汇总、FILTER做筛选,在新开发的S/4项目里已经成为我的默认书写风格,代码行数平均可以压缩30%到40%。不要小看缩短的这几行,代码评审时候,新语法版本的逻辑更有结构感,一个方法一眼就能看懂在做什么。
但有个底线原则:再新的语法,也要坚持“一个方法只做一个事”。新语法简化了表达,但不会简化设计。游戏里的类和报表分层迁移到业务模块时间样适用——接口调用、BAPI封装和字段映射,各归各的类,绝不搅在一起。
7.3 如何继续扩展这个“ABAP小游戏”项目
那天我的最终版游戏合集包含五个游戏:猜数字、井字棋、21点、扫雷和“订单调度模拟器”——最后这个其实是披着游戏外衣的SAP PP模块调度逻辑练手,我用它模拟了产能有限条件下的排产逻辑,本质上就是把ALTVO生产版本和工艺路线选择逻辑转化成了一个可交互的桌游。
这种转化方式,推荐给那些想把游戏跟业务结合的人:选一个你熟悉但经常被人问懵的业务环节(比如生产版本选择ALTBO,或者物料可替代性),把其中决策链拆出来做成游戏,组件用SAP方式实现。玩的人一旦理解了这个决策链,对业务状态的理解就比看十页流程文档更深刻。
还有一个玩法是我一直想做的:用ABAP写一个极简版的“2048”。它的核心就是二维数组行/列变换、合并、随机生成数字,覆盖的逻辑点跟扫雷差不多,但视觉上更有吸引力。有空的话我会再做一版并补充Fiori前端,把ABAP后端的状态计算和Fiori UI的滑动交互串起来,那才是这个项目的终极形态。
8. 最终体会
写到这里,我不太想用“总结”那种正式收尾,更想聊聊我对“ABAP写小游戏”这件事的体会。刚开始真的只是图一乐,但代码写到第三个游戏时,我发现自己对ABAP基础机制的掌握,比过去几年做业务报表时更扎实。很多平时被封装好的细节——比如ALV事件到底怎么触发、READ LINE在列表处理器里的行为边界、字符串偏移量的左开右闭规则——全都在这个“看似没用”的项目里暴露出来。我太太说你这叫“用玩的方式练基本功”,我觉得这话挺准。
如果屏幕前的你正卡在“不知道拿一个什么练手ABAP新语法”的档口,我建议你别去抄大而全的接口程序,就写个猜数字。花一个下午把代码三版本写完,你对字符串、内表、类、异常处理的感受会立刻不一样。等到井字棋落地,你再回头看日常报表里的ALV那几个回调函数,会觉得它们不再是用“背下来的模式”拼出来的,而是真的能看懂每一行在做什么了。
最后分享一个小技巧:如果你也想在团队里安利这个玩法,可以在SE38里把程序分成两个加密版本——一个“纯算法版”和一个“BUG版”,留着让新人猜哪里错了。我实测过,这种互动方式的效果,比读十页代码规范要好得多。
祝你的ABAP小游戏顺利跑通,卡住了就在调试器里慢慢看,你会发现SAP给的调试器远远比你想的强大。