6.7.0 - Alpha19 - 修复代码编辑器加载大文件可能导致应用崩溃的问题, 一定程度提升大文件加载流畅度

This commit is contained in:
SuperMonster003
2026-02-08 13:22:15 +08:00
parent 7307c29711
commit 29fba208b0
11 changed files with 1256 additions and 48 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -54,6 +54,7 @@ import java.util.concurrent.CopyOnWriteArrayList
/**
* Created by Administrator on Feb 11, 2018.
* Modified by SuperMonster003 as of May 1, 2023.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026.
*/
class CodeEditText : AppCompatEditText {
@@ -63,6 +64,16 @@ class CodeEditText : AppCompatEditText {
@Volatile
private var mHighlightTokens: HighlightTokens? = null
// Loading state, used to suppress expensive callbacks during bulk text insertion.
// zh-CN: 加载状态, 用于在批量插入文本时抑制高开销回调.
@Volatile
private var mLoadingText = false
// Fixed gutter digits used during loading to avoid frequent requestLayout().
// zh-CN: 加载期间使用固定 gutter 位数, 避免频繁 requestLayout().
@Volatile
private var mLoadingGutterDigits: Int = 1
private var mTheme: Theme = Theme.getDefault(context)
private val mLineHighlightPaint = Paint().apply { style = Paint.Style.FILL }
private var mFirstLineForDraw = -1
@@ -118,6 +129,43 @@ class CodeEditText : AppCompatEditText {
isLongClickable = true
}
// Toggle loading state.
// zh-CN: 切换加载状态.
fun setLoadingText(loading: Boolean) {
mLoadingText = loading
if (loading) {
applyFixedGutterPaddingForLoading()
} else {
// Recompute gutter once after loading.
// zh-CN: 加载结束后重新计算 gutter(仅一次).
requestLayout()
invalidate()
}
}
// Expose loading state for outer components to suppress expensive UI updates.
// zh-CN: 对外暴露加载状态, 以便外部组件抑制高开销 UI 更新.
fun isLoadingText(): Boolean = mLoadingText
// Configure a fixed gutter width for loading.
// zh-CN: 配置加载期间的固定 gutter 宽度.
fun setLoadingGutterDigits(digits: Int) {
mLoadingGutterDigits = digits.coerceIn(2, 10)
if (mLoadingText) {
applyFixedGutterPaddingForLoading()
}
}
private fun applyFixedGutterPaddingForLoading() {
// Pre-allocate gutter width based on digits, e.g., "888888".
// zh-CN: 基于位数预分配 gutter 宽度, 例如 "888888".
val sample = "8".repeat(mLoadingGutterDigits)
val gutterWidth = paint.measureText(sample) + 20
if (paddingLeft.toFloat() != gutterWidth) {
setPadding(gutterWidth.toInt(), 0, 0, 0)
}
}
// Public API to toggle read-only mode.
// zh-CN: 切换只读模式的公开接口.
fun setReadOnly(readOnly: Boolean) {
@@ -173,7 +221,8 @@ class CodeEditText : AppCompatEditText {
android.R.id.cut,
android.R.id.paste,
android.R.id.pasteAsPlainText,
android.R.id.replaceText -> return false
android.R.id.replaceText,
-> return false
}
}
return super.onTextContextMenuItem(id)
@@ -279,6 +328,12 @@ class CodeEditText : AppCompatEditText {
}
private fun updatePaddingForGutter() {
// During loading, keep gutter fixed to avoid setPadding() loops and relayout storms.
// zh-CN: 加载期间保持 gutter 固定, 避免 setPadding() 循环与频繁 relayout.
if (mLoadingText) {
return
}
// 根据行号计算左边距 padding 留出绘制行号的空间
val max = lineCount.toString()
val gutterWidth = paint.measureText(max) + 20
@@ -292,7 +347,9 @@ class CodeEditText : AppCompatEditText {
private fun drawText(canvas: Canvas) {
if (mFirstLineForDraw < 0) return
val textLength = mHighlightTokens?.text?.length ?: 0
val highlightTokens = mHighlightTokens
val safeText = text ?: return
val textLength = highlightTokens?.text?.length ?: 0
val scrollX = (mParentScrollView!!.scrollX + scrollX - paddingLeft).coerceAtLeast(0)
for (line in mFirstLineForDraw..lineCount.coerceAtMost(mLastLineForDraw)) {
@@ -303,7 +360,6 @@ class CodeEditText : AppCompatEditText {
val lineBottom = layout.getLineTop(lineNumber)
val lineTop = layout.getLineTop(line)
val lineBaseline = lineBottom - layout.getLineDescent(line)
val highlightTokens = mHighlightTokens
// if there is a breakpoint at this line, draw a highlight background for line number
if (breakpoints.containsKey(line)) {
@@ -324,15 +380,10 @@ class CodeEditText : AppCompatEditText {
/* paint = */ paint.apply { color = mTheme.lineNumberColor },
)
if (highlightTokens == null) continue
// Draw code
val lineStart = layout.getLineStart(line)
// Never draw line-break control characters.
// zh-CN: 永远不要绘制换行等控制字符.
val safeText = text ?: continue
var lineEnd = layout.getLineEnd(line).coerceAtMost(safeText.length)
while (lineEnd > lineStart) {
val ch = safeText[lineEnd - 1]
@@ -343,11 +394,28 @@ class CodeEditText : AppCompatEditText {
}
}
// Fast path: no syntax highlighting, draw the line once with default color.
// zh-CN: 快速路径: 无语法高亮时, 使用默认颜色一次性绘制整行.
if (highlightTokens == null) {
val visibleCharStart = getVisibleCharIndex(paint, scrollX, lineStart, lineEnd)
val visibleCharEnd = (getVisibleCharIndex(paint, scrollX + mParentScrollView!!.width, lineStart, lineEnd) + 1)
.coerceAtMost(lineEnd)
if (visibleCharStart >= visibleCharEnd) continue
paint.color = mTheme.getColorForToken(Token.NAME)
runCatching {
val offsetX = paint.measureText(safeText, lineStart, visibleCharStart)
canvas.drawText(safeText, visibleCharStart, visibleCharEnd, paddingLeft + offsetX, lineBaseline.toFloat(), paint)
}.onFailure { it.printStackTrace() }
continue
}
if (lineStart >= textLength) continue
if (lineEnd > textLength) continue
// If this is an empty line (or the line only contains line-break chars), skip drawing.
// zh-CN: 如果这是空白行(或该行只包含换行字符), 则跳过绘制.
// zh-CN: 如果这是空白行 (或该行只包含换行字符), 则跳过绘制.
if (lineStart >= lineEnd) continue
val localColors = highlightTokens.colors
@@ -403,8 +471,8 @@ class CodeEditText : AppCompatEditText {
}
runCatching {
val offsetX = paint.measureText(currentText, lineStart, previousColorPos)
canvas.drawText(currentText, previousColorPos, visibleCharEnd, paddingLeft + offsetX, lineBaseline.toFloat(), paint)
val offsetX = paint.measureText(safeText, lineStart, previousColorPos)
canvas.drawText(safeText, previousColorPos, visibleCharEnd, paddingLeft + offsetX, lineBaseline.toFloat(), paint)
}.onFailure {
it.printStackTrace()
runCatching {
@@ -460,6 +528,13 @@ class CodeEditText : AppCompatEditText {
}
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
// Skip selection callbacks during bulk loading to keep UI responsive.
// zh-CN: 批量加载期间跳过 selection 回调, 以保持 UI 响应.
if (mLoadingText) {
super.onSelectionChanged(selStart, selEnd)
return
}
// 调用父类的 onSelectionChanged 时会发送一个 AccessibilityEvent, 当文本过大时造成异常
// super.onSelectionChanged(selStart, selEnd);
// 父类构造函数会调用 onSelectionChanged, 此时 mCursorChangeCallbacks 还没有初始化
@@ -524,6 +599,13 @@ class CodeEditText : AppCompatEditText {
fun removeCursorChangeCallback(callback: CursorChangeCallback) = mCursorChangeCallbacks!!.remove(callback)
// Clear syntax highlight tokens and redraw with plain text.
// zh-CN: 清空语法高亮 tokens, 并用纯文本方式重绘.
fun clearHighlightTokens() {
mHighlightTokens = null
postInvalidate()
}
fun updateHighlightTokens(highlightTokens: HighlightTokens) {
if (mHighlightTokens != null && mHighlightTokens!!.id >= highlightTokens.id) {
return

View File

@@ -3,11 +3,15 @@ package org.autojs.autojs.ui.edit.editor
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.ScaleGestureDetector.SimpleOnScaleGestureListener
import android.view.WindowManager
import com.afollestad.materialdialogs.MaterialDialog
import io.reactivex.Observable
import org.autojs.autojs.core.pref.Pref.getEditorTextSize
@@ -53,7 +57,7 @@ import kotlin.math.floor
/**
* Transformed by SuperMonster003 on Jul 16, 2023.
* Modified by SuperMonster003 as of Jul 16, 2023.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 6, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026.
*/
class CodeEditor : HVScrollView {
@@ -65,6 +69,15 @@ class CodeEditor : HVScrollView {
ThemeColorHelper.setThemeColorPrimary(it, true)
}
// Whether the user is interacting with the editor via touch.
// zh-CN: 用户是否正在通过触摸与编辑器交互.
@Volatile
private var mUserTouching = false
// Public getter for streaming loader to prioritize scroll responsiveness.
// zh-CN: 给流式加载器使用的公开读取口, 用于优先保障滚动响应性.
fun isUserTouching(): Boolean = mUserTouching
val lineCount
get() = Observable.just(codeEditText.layout.lineCount)
@@ -146,6 +159,46 @@ class CodeEditor : HVScrollView {
private var mFoundIndex = -1
private var mLastScaleFactor = 1.0
private val mUiHandler = Handler(Looper.getMainLooper())
// Delay showing the loading dialog to avoid flicker for fast operations.
// zh-CN: 延迟显示加载对话框, 避免快速操作产生闪烁.
private val mProgressShowDelayMs = 1500L
// Once shown, keep the dialog visible for at least this duration to avoid flash.
// zh-CN: 对话框一旦出现, 至少显示一段时间, 避免一闪而过.
private val mProgressMinShowMs = 500L
private var mProgressRequested = false
private var mProgressShownAtMs = 0L
private var mProgressInteractive = false
private val mShowProgressRunnable = Runnable {
if (!mProgressRequested) return@Runnable
if (mProcessDialog?.isShowing == true) return@Runnable
mProcessDialog = MaterialDialog.Builder(context)
.content(R.string.text_processing)
// Text only, no progress spinner.
// zh-CN: 仅显示文字, 不使用进度圆圈动画.
.cancelable(false)
.canceledOnTouchOutside(false)
.show()
mProgressShownAtMs = SystemClock.uptimeMillis()
// Make it non-modal (optional) so user can scroll/view during loading.
// zh-CN: 可选地设置为非模态, 使用户在加载时仍可滚动/查看.
if (mProgressInteractive) {
mProcessDialog?.window?.let { w ->
w.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
w.setDimAmount(0f)
w.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
w.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH)
}
}
}
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
@@ -154,6 +207,92 @@ class CodeEditor : HVScrollView {
applyScaleGesture()
}
fun refreshHighlightTokensIfAllowed() {
// Force a highlight refresh after bulk loading, when loading flags are cleared.
// zh-CN: 在批量加载结束且 loading 标记已清除后, 主动触发一次高亮刷新.
val t = codeEditText.text?.toString() ?: return
mJavaScriptHighlighter.updateTokens(t)
}
/**
* Show or hide a "processing" indicator.
*
* Behavior:
* - Delayed show to avoid flicker.
* - Minimum show time once visible.
* - Optional interactive mode: don't block touches and don't dim background.
*
* zh-CN:
* 显示或隐藏 "处理中" 提示.
*
* 行为:
* - 延迟显示以避免闪烁.
* - 一旦出现则保证最短展示时间.
* - 可选交互模式: 不拦截触摸/不压暗背景.
*/
/**
* Show or hide a "processing" indicator.
*
* Behavior:
* - Delayed show to avoid flicker.
* - Minimum show time once visible.
* - Optional interactive mode: don't block touches and don't dim background.
*
* zh-CN:
* 显示或隐藏 "处理中" 提示.
*
* 行为:
* - 延迟显示以避免闪烁.
* - 一旦出现则保证最短展示时间.
* - 可选交互模式: 不拦截触摸/不压暗背景.
*/
fun setProgress(progress: Boolean, interactive: Boolean = false) {
mProgressInteractive = interactive
if (progress) {
mProgressRequested = true
// If already showing, keep it.
// zh-CN: 若已显示则保持不变.
if (mProcessDialog?.isShowing == true) return
// Schedule delayed show.
// zh-CN: 延迟调度显示.
mUiHandler.removeCallbacks(mShowProgressRunnable)
mUiHandler.postDelayed(mShowProgressRunnable, mProgressShowDelayMs)
return
}
// Hide requested.
// zh-CN: 请求隐藏.
mProgressRequested = false
mUiHandler.removeCallbacks(mShowProgressRunnable)
val dlg = mProcessDialog
if (dlg == null || dlg.isShowing != true) {
mProcessDialog = null
return
}
val elapsed = SystemClock.uptimeMillis() - mProgressShownAtMs
val remain = (mProgressMinShowMs - elapsed).coerceAtLeast(0L)
if (remain == 0L) {
dlg.dismiss()
mProcessDialog = null
return
}
mUiHandler.postDelayed({
// Only dismiss if no new show request came in.
// zh-CN: 仅当没有新的显示请求时才 dismiss.
if (!mProgressRequested) {
runCatching { dlg.dismiss() }
mProcessDialog = null
}
}, remain)
}
private fun applyScaleGesture(key: String? = null) {
var niceKey = key
if (niceKey == null) {
@@ -174,6 +313,23 @@ class CodeEditor : HVScrollView {
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_POINTER_DOWN,
MotionEvent.ACTION_MOVE -> {
// Mark touching as early as possible.
// zh-CN: 尽可能早地标记触摸中状态.
mUserTouching = true
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_POINTER_UP -> {
// Clear touching flag when gesture ends.
// zh-CN: 手势结束时清除触摸中标记.
mUserTouching = false
}
}
return mScaleGestureDetector?.let {
it.onTouchEvent(ev)
!it.isInProgress && super.onTouchEvent(ev)
@@ -182,6 +338,14 @@ class CodeEditor : HVScrollView {
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt)
// Avoid scheduling extra invalidations during bulk loading.
// zh-CN: 批量加载期间避免额外调度 invalidate, 降低重绘压力.
if (codeEditText.isLoadingText()) {
codeEditText.invalidate()
return
}
codeEditText.postInvalidate()
}
@@ -301,16 +465,10 @@ class CodeEditor : HVScrollView {
mTextViewRedoUndo.isEnabled = enabled
}
fun setProgress(progress: Boolean) {
mProcessDialog?.dismiss()
mProcessDialog = when {
!progress -> null
else -> MaterialDialog.Builder(context)
.content(R.string.text_processing)
.progress(true, 0)
.cancelable(false)
.show()
}
fun markUndoRedoBaselineAsUnchanged() {
// Reset undo/redo history but keep current text intact.
// zh-CN: 重置 undo/redo 历史但保持当前文本不变.
mTextViewRedoUndo.resetHistoryAsUnchanged()
}
fun addCursorChangeCallback(callback: CursorChangeCallback?) {

View File

@@ -21,6 +21,8 @@ import java.util.List;
/**
* Reference to ScrollView and HorizontalScrollView
*
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026.
*/
public class HVScrollView extends FrameLayout {
static final int ANIMATED_SCROLL_GAP = 250;
@@ -385,6 +387,20 @@ public class HVScrollView extends FrameLayout {
final int scrollX = getScrollX();
final int scrollY = getScrollY();
final View child = getChildAt(0);
// Fallback for transient layout states during progressive loading.
// zh-CN: 渐进式加载期间可能出现的短暂布局状态回退处理.
//
// When the child is not measured/layouted yet (width/height == 0), the original
// bounds check may reject all ACTION_DOWN events, causing scrolling to be stuck.
// zh-CN: 当子 View 尚未完成测量/布局时(width/height == 0), 原边界判断可能拒绝所有 ACTION_DOWN,
// zh-CN: 从而导致滚动完全失效.
final int childWidth = child.getWidth();
final int childHeight = child.getHeight();
if (childWidth <= 0 || childHeight <= 0) {
return x >= 0 && x < getWidth() && y >= 0 && y < getHeight();
}
return !(y < child.getTop() - scrollY
|| y >= child.getBottom() - scrollY
|| x < child.getLeft() - scrollX

View File

@@ -17,8 +17,15 @@ import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026.
*/
public class JavaScriptHighlighter implements SimpleTextWatcher.AfterTextChangedListener {
// Syntax highlight hard limit for performance and memory.
// zh-CN: 为性能和内存设置的语法高亮硬限制.
public static final int MAX_HIGHLIGHT_CHARS = 512 * 1024;
public static class HighlightTokens {
public final int[] colors;
@@ -81,6 +88,28 @@ public class JavaScriptHighlighter implements SimpleTextWatcher.AfterTextChanged
@Override
public void afterTextChanged(Editable s) {
if (mTheme == null) {
return;
}
// Skip highlighting during progressive loading to avoid UI-thread allocations and CPU contention.
// zh-CN: 渐进式加载期间跳过高亮, 避免 UI 线程分配与 CPU 争用.
if (mCodeEditText != null && mCodeEditText.isLoadingText()) {
mRunningHighlighterId.incrementAndGet();
mCodeEditText.clearHighlightTokens();
return;
}
// Avoid calling toString() on huge Editable, which allocates a large String on UI thread.
// zh-CN: 避免对超大的 Editable 调用 toString(), 否则会在 UI 线程分配巨大的 String.
if (s != null && s.length() > MAX_HIGHLIGHT_CHARS) {
mRunningHighlighterId.incrementAndGet();
if (mCodeEditText != null) {
mCodeEditText.clearHighlightTokens();
}
return;
}
updateTokens(s.toString());
}
@@ -92,10 +121,24 @@ public class JavaScriptHighlighter implements SimpleTextWatcher.AfterTextChanged
if (mTheme == null) {
return;
}
// Disable highlighting for very large text to avoid ANR/OOM on low-end devices.
// zh-CN: 对超大文本禁用高亮, 避免低端设备出现 ANR/OOM.
if (sourceString.length() > MAX_HIGHLIGHT_CHARS) {
mRunningHighlighterId.incrementAndGet();
mCodeEditText.clearHighlightTokens();
return;
}
final int id = mRunningHighlighterId.incrementAndGet();
if (mExecutorService.isShutdown() || mExecutorService.isTerminated() || mExecutorService.isTerminating()) {
return;
}
// Keep only the latest highlight task.
// zh-CN: 仅保留最新的高亮任务, 丢弃过期任务以避免队列堆积.
mExecutorService.getQueue().clear();
mExecutorService.execute(() -> {
try {
updateTokens(sourceString, id);
@@ -106,14 +149,27 @@ public class JavaScriptHighlighter implements SimpleTextWatcher.AfterTextChanged
}
private void updateTokens(String sourceString, int id) throws IOException {
// Drop stale tasks early.
// zh-CN: 尽早丢弃过期任务.
if (id != mRunningHighlighterId.get()) {
return;
}
TokenStream ts = new TokenStream(null, sourceString, 0);
HighlightTokens highlightTokens = new HighlightTokens(sourceString, id);
int token;
int color = mTheme.getColorForToken(Token.NAME);
while ((token = ts.getToken()) != Token.EOF) {
// Abort quickly if a newer task arrives.
// zh-CN: 如果有更新任务到来, 则尽快中止.
if (id != mRunningHighlighterId.get()) {
return;
}
color = mTheme.getColorForToken(token);
highlightTokens.addToken(ts.getTokenBeg(), ts.getTokenEnd(), color);
}
if (highlightTokens.getCharCount() < sourceString.length()) {
highlightTokens.addToken(highlightTokens.getCharCount(), sourceString.length(), color);
}

View File

@@ -17,6 +17,8 @@ import android.widget.TextView;
/**
* A generic undo/redo implementation for TextViews.
*
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026.
*/
public class TextViewUndoRedo {
@@ -72,10 +74,39 @@ public class TextViewUndoRedo {
mEnabled = enabled;
}
/**
* Reset undo/redo history without modifying the current text.
*
* This is useful after bulk loading text progressively, where the text is already in the TextView
* and we only want to treat it as the "baseline" (unchanged) state.
*
* zh-CN:
*
* 在不修改当前文本的前提下重置 undo/redo 历史.
*
* 适用于渐进式批量加载文本后: 文本已经在 TextView 中, 此时只需要把它视为 "基线" (未修改) 状态.
*/
public final void resetHistoryAsUnchanged() {
clearHistory();
markTextAsUnchanged();
}
// public final void setDefaultText(CharSequence text) {
// clearHistory();
// mIsUndoOrRedo = true;
// ((Editable) mTextView.getText()).replace(0, text.length(), text);
// mIsUndoOrRedo = false;
// }
public final void setDefaultText(CharSequence text) {
clearHistory();
mIsUndoOrRedo = true;
((Editable) mTextView.getText()).replace(0, text.length(), text);
Editable editable = (Editable) mTextView.getText();
// Replace the entire old content, not based on the new text length.
// zh-CN: 替换整个旧内容, 而不是按新文本长度截断替换范围.
editable.replace(0, editable.length(), text);
mIsUndoOrRedo = false;
}

View File

@@ -83,6 +83,29 @@
android:layout_alignParentTop="true"
android:layout_toEndOf="@+id/functions" />
<FrameLayout
android:id="@+id/loading_bar_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="false"
android:focusable="false"
android:visibility="gone"
tools:visibility="visible"
tools:background="#A0000000">
<TextView
android:id="@+id/loading_bar_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textColor="@color/night_full"
android:textSize="14sp"
tools:text="@string/text_loading_with_dots" />
</FrameLayout>
</RelativeLayout>
<org.autojs.autojs.ui.edit.keyboard.FunctionsKeyboardView

View File

@@ -1286,4 +1286,5 @@
<string name="text_write_secure_settings">修改安全设置</string>
<string name="text_write_system_settings">修改系统设置</string>
<string name="text_xiaomi_background_popup_permission">后台弹出界面</string>
<string name="text_loading_completed">加载完毕</string>
</resources>

View File

@@ -1561,4 +1561,5 @@
<string name="text_write_secure_settings">Write security settings</string>
<string name="text_write_system_settings">Write system settings</string>
<string name="text_xiaomi_background_popup_permission">Display pop-up windows while running in the background</string>
<string name="text_loading_completed">Loading completed</string>
</resources>