背景:最近两个项目被应用市场查重导致其中一个项目下架,这里为避嫌定义为 “项目A”和“项目 B”,因为 项目B 确实是基于项目A 做的,所以有些基础的确实是一模一样的
查重了就得改掉相同的东西才行,但是这个前提是得快速找出一样的东西才行(这些东西主要包括代码片段和资源文件),找到后修改一波
主要需要执行如下这些操作:
1.利用静态代码检测工具Simian来寻找相似代码
2.找到的相似代码手动修改或AI修改成不同的代码结构
3.对比项目的资源文件(主要是 libs 和 res 目录下的文件)修改资源名字和内容
完整脚本放了个链接:https://github.com/woshiluoyong/simianDuplicateCheck
1.利用静态代码检测工具Simian来寻找相似代码
因为我是要寻找两个项目的相似代码,这一步也尤为重要,遍历百度谷歌及 GitHub,有类似需求的,基本都不满足,类似的一般只支持项目内部的代码查重,而且语言也仅支持有限的几种,不支持 java 和 kotlin,部署也比较笨重麻烦,最后还是得用大牛产品Simian来实现,只是需要自己解析Simian输出的重复行数数据然后分析出自己需要的内容,而且Simian实际也是搜寻的一个根目录下的文件来执行比较,所以为避免其他文件干扰,我这边写了两个python脚本来将两个项目的源码 copy 到一个目录下便于分析查重,因为有的文件在两个项目名字完全一样且便于区分两个项目,我还在 copy 的时候修改了文件名,加了特定的前缀好在判断及输出时区分,具体脚本如下:
# coding=utf-8
import os
import shutil
# 遍历源目录及其子目录,拷贝所有特定后缀文件到目标目录,排除特定目录(如build)
# 并为第一级目录和文件名添加特定前缀
def check_copy_files(src_dir, dst_dir, supported_extensions, exclude_dir, prefix):
# 确保目标目录存在,如果不存在则创建
if not os.path.exists(dst_dir):
os.makedirs(dst_dir)
print(f"Created destination directory: {dst_dir}")
# 遍历源目录及其子目录
for root, dirs, files in os.walk(src_dir):
# 检查当前路径是否包含排除目录(如build)
if exclude_dir in root.split(os.sep):
#print(f"Skipping directory: {root}")
continue # 跳过当前目录及其子目录
for file in files:
# 检查文件扩展名
if any(file.endswith(ext) for ext in supported_extensions):
# 构造源文件的完整路径
src_file_path = os.path.join(root, file)
# 构造目标文件的完整路径(保留源文件的相对路径结构)
relative_path = os.path.relpath(root, src_dir)
if relative_path == ".":
# 如果是第一级目录,添加前缀
relative_path = prefix
else:
# 如果是子目录,保留相对路径
relative_path = os.path.join(prefix, relative_path)
dst_subdir = os.path.join(dst_dir, relative_path)
# 如果目标子目录不存在,则创建
if not os.path.exists(dst_subdir):
os.makedirs(dst_subdir)
# 为文件名添加前缀
dst_file_name = f"{prefix}#@#{file}"
dst_file_path = os.path.join(dst_subdir, dst_file_name)
# 拷贝文件
shutil.copy2(src_file_path, dst_file_path)
#print(f"Copied: {src_file_path} -> {dst_file_path}")
print(f"{prefix} All files have been copied successfully.")
if __name__ == "__main__":
source_one_directory = "/Users/xxxx/Documents/AndroidProjects/xxxx_xxxx_xxxx_A"
source_two_directory = "/Users/xxxx/Documents/AndroidProjects/xxxx_xxxx_xxxx_B"
destination_directory = "sourceCode"
try:
if os.path.exists(destination_directory):
shutil.rmtree(destination_directory)
print(f"Delete destination directory: {destination_directory} Ok")
except Exception as e:
print(f"Delete destination directory: {destination_directory} Err: {e}")
check_copy_files(source_one_directory, destination_directory, [".java", ".kt"], "build", "A")
check_copy_files(source_two_directory, destination_directory, [".java", ".kt"], "build", "B")
copy 完成后就执行Simian分析 并按相似行数大小逆序排序,并可根据需要输出相似具体行位置 ,最终汇总并输出到一个log 文件下,具体脚本如下:
# coding=utf-8
import subprocess
import xml.etree.ElementTree as ET
from collections import defaultdict
import itertools
import os
import shutil
# 清理XML文件,删除文件开头的非XML内容
def clean_xml_file(input_file, output_file):
try:
with open(input_file, 'r', encoding='utf-8') as infile:
lines = infile.readlines()
# 找到XML声明的起始行
start_index = None
for i, line in enumerate(lines):
if line.strip().startswith('<?xml'):
start_index = i
break
if start_index is None:
raise ValueError("未找到有效的XML声明,文件可能不是有效的XML格式。")
# 从XML声明开始的内容保留下来
cleaned_lines = lines[start_index:]
with open(output_file, 'w', encoding='utf-8') as outfile:
outfile.writelines(cleaned_lines)
print(f"非XML内容清理完成,清理后的文件已保存到: {output_file}")
except Exception as e:
print(f"处理文件非XML内容时发生错误: {e}")
def parse_simian_xml(xml_file):
tree = ET.parse(xml_file)
root = tree.getroot()
# 提取所有文件对及其重复行数
file_pairs = defaultdict(lambda: defaultdict(int))
line_map = defaultdict(lambda: defaultdict(str))
for set_elem in root.find('check').findall('set'):
blocks = set_elem.findall('block')
if len(blocks) < 2:
continue # 跳过没有成对的块
for block in blocks:
sourceFile = block.get('sourceFile')
lineRangeStr = f"{block.get('startLineNumber')}:{block.get('endLineNumber')}"
isHasValue = True if sourceFile in line_map else False
#print(f"block: {sourceFile} = {isHasValue} = {lineRangeStr}")
line_map[sourceFile] = (line_map[sourceFile] +";"+ lineRangeStr) if isHasValue else lineRangeStr
line_count = int(set_elem.get('lineCount'))
file_paths = [block.get('sourceFile') for block in blocks]
# 生成所有可能的文件对组合
for file1, file2 in itertools.combinations(file_paths, 2):
file_pairs[file1][file2] += line_count
file_pairs[file2][file1] += line_count
#print(f"line_map: {line_map}")
return file_pairs, line_map
def truncate_filename(filename, max_length=20):
return filename.split("/")[-1] # 截断文件名,仅保留最后部分
def print_similarity(file_pairs, line_map, output_file, care_prefix):
unique_pairs = set()
similarities = [] # 用于存储排序后的相似性数据
for file1, pairs in file_pairs.items():
#print(f"print_similarity file1: {file1} {pairs}")
for file2, shared_lines in pairs.items():
# 排序文件名以避免重复输出
pair = tuple(sorted([file1, file2]))
if pair not in unique_pairs and file1 != file2:
unique_pairs.add(pair)
file1_truncated = truncate_filename(file1)
file2_truncated = truncate_filename(file2)
#print(f"print_similarity file1: {file1}")
#print(f"print_similarity file2: {file2}")
file1_prefix = file1_truncated.split("#@#")[0] # 提取前缀
file2_prefix = file2_truncated.split("#@#")[0] # 提取前缀
if file1_prefix != file2_prefix:# 如果前缀不同,则保留该行
similarities.append((file1_truncated, file2_truncated, shared_lines, file1, file2))
# 按 shared_lines 从大到小排序
similarities.sort(key=lambda x: x[2], reverse=True)
# 写入结果到文件
with open(output_file, 'w', encoding='utf-8') as outfile:
for index, (file1_truncated, file2_truncated, shared_lines, file1, file2) in enumerate(similarities, start=1):
outfile.write(f"{index}. {file1_truncated} 和 {file2_truncated} 之间的相似行数:「{shared_lines}」行 \n")
if care_prefix != None:
outfile.write(f"===行范围如下==>\n")
if care_prefix != None and care_prefix in file1:
outfile.write(f"{file1}:【{line_map[file1]}】\n")
elif care_prefix != None and care_prefix in file2:
outfile.write(f"{file2}:〖{line_map[file2]}〗\n")
else:
outfile.write(f"{file1}:【{line_map[file1]}】\n")
outfile.write(f"{file2}:〖{line_map[file2]}〗\n")
def run_simian_command(jar_path, threshold, formatter, source_paths, output_file):
# 构造命令
command = [
"java", "-jar", jar_path,
f"-threshold={threshold}", # 将参数和值写在一起
f"-formatter={formatter}" # 将参数和值写在一起
]
command.extend(source_paths) # 添加源代码路径
try:
# 打开输出文件,并将命令的输出重定向到该文件
with open(output_file, "w") as output:
subprocess.run(command, stdout=output, stderr=subprocess.PIPE, check=True)
print(f"Simian 命令执行成功,输出已保存到 {output_file}")
except subprocess.CalledProcessError as e:
retMsgVal = e.stderr.decode().strip()
if retMsgVal is not None and len(retMsgVal) > 0: print(f"Simian 命令执行失败: {retMsgVal}")
if __name__ == "__main__":
output_file = "check_output.xml" # 输出文件路径
result_file = "check_result.log"
result_backup_file = "check_result_backup.log"
jar_path = "simian-4.0.0.jar" # simian.jar 文件的路径
threshold = 10 # 相似性阈值
formatter = "xml" # 输出格式为 XML
source_paths = [
"sourceCode/**/*.java",
"sourceCode/**/*.kt"
]
# 调用函数执行命令
run_simian_command(jar_path, threshold, formatter, source_paths, output_file)
# 清理 XML 文件
clean_xml_file(output_file, output_file)
# 解析清理后的 XML 文件
file_pairs, line_map = parse_simian_xml(output_file)
#print(f"parseResult: {file_pairs}「」{line_map}")
# 输出相似性结果到文件, care_prefix 为 None 时,不输出行范围,否则根据关心前缀输出行范围
print_similarity(file_pairs, line_map, result_file, None)#"B#@#")
print(f"最终相似性结果已保存到: {result_file}")
try:
sureCopy = input('请问你是否要备份相似性结果?[y/n](直接回车默认不备份)')
except ValueError:
sureCopy = 'n'
if 'y' == sureCopy:
if os.path.exists(result_backup_file):
os.remove(result_backup_file)
shutil.copy2(result_file, result_backup_file)
print(f"已经备份相似性结果到: {result_backup_file}")
修改脚本对应本地文件路径参数后并用 python 依次执行上面两个脚本后,输出大概如下:
check_output.xml
check_result.log 不带行号范围
check_result.log 输出带关心项目的行号范围
输出的check_output.xml就是Simian生成的原始输出,check_result.log就是汇总check_output.xml得到的分析结果,就可以根据这个log 文件里面的,输出带关心项目的行号范围可以在脚本 2的print_similarity最后一个参数设置脚本 1 里面设定的文件名前缀,最后依次从重复度高的文件修改起走【简单的修改如交换变量、逻辑代码位置,switch、when 和 if 以及三元判断的互相转换、提出子方法等,如何更轻松的修改看下面↓】,修改完成后再依次重复脚本 1 和脚本 2,可以看到行号的变化,直到重复数降下去为止
2.利用Cursor编辑器来轻松修改相似代码
上面找到了要修改的代码文件及代码位置,如果重复度太高修改工作量是巨大的,而且还得保证功能逻辑不变,这个时候有个好用的 AI 助手就事半功倍了,As上可以用Copilot或Gemini,但我尝试多款之后首选推荐Cursor编辑器的composer,这个可以直接修改文件且应用到文件上,(虽然豆包也可以直接应用到文件上,但是用了感觉对代码方面很弱智,个人感觉,勿喷),Cursor各种折腾下来发现一般整个文件没法直接叫它改,因为首先可能出现误删方法还可能直接输出一般就停止了,应该是入参过大溢出了,尝试了很多次,最后圈定为改最多 8 个方法左右,而且只改方法内部逻辑,其他不要动,这个是最靠谱的,具体下面这个界面及操作
image.png
image.png
上图中的ai调教文字如下↓,一般只需要修改文件上下文和方法那坨的名字,然后 apply 后最好 as 同步打开文件,看修改的有不有报红,及时修改再改其他的
- 用于修改 java 文件的ai调教文字
在不改变 xxx、xxx、xxx、xxx、xxx 方法里面逻辑前提下,只修改方法内部的逻辑(比如将空实现的方法、将if块或者else块只包含了一句代码的简单代码块改成一行显示,不影响逻辑前提下也可交换方法内单行代码位置 等其他优化手段),每个方法必须要有变化,让修改后的方法和原来差异化大些,不影响原方法的传参和输出,不要误删除字段或方法,不要出现方法无法调用!不输出修改点,直接修改文件 - 用于修改 kotlin 文件的ai调教文字
在不改变 xxx、xxx、xxx、xxx、xxx 方法里面逻辑前提下,只修改方法内部的逻辑(比如switch、when 换成if else,if else换成switch、when,比如将空实现的方法、将if块或者else块只包含了一句代码的简单代码块改成一行显示,不影响逻辑前提下也可交换方法内单行代码位置,能用作用域函数run、let、apply、also这种就做更换,变量只有一个引用的就直接替换掉变量 等其他优化手段),每个方法必须要有变化,让修改后的方法和原来差异化大些,不影响原方法的传参和输出,不要误删除字段或方法,不要出现方法无法调用!不输出修改点,直接修改文件
然后针对有些类方法很多,不可能依次的 copy,也是很非精力,于是有了下面这个脚本,打印出方法且拼接好,只需 copy 到 cursor 上就行
# coding=utf-8
import re
def extract_methods_from_file(file_path):
try:
with open(file_path, 'r', encoding='utf-8') as file:
code_content = file.read()
# 正则表达式匹配方法定义(支持 Kotlin 和 Java)
if file_path.endswith('.java'):
method_pattern = re.compile(r'^\s*(public|private|protected|static|\s)*\s*[\w\<\>\[\]]+\s+(\w+)\s*\([^)]*\)\s*(\{|\:|\@Override|\@JvmStatic)?',re.MULTILINE)
else:
method_pattern = re.compile(r'^\s*(public\s+|private\s+|override\s+)?fun\s+([a-zA-Z0-9_]+)\s*\(', re.MULTILINE)
methods = method_pattern.findall(code_content)
# 提取方法名
method_names = [method[1] for method in methods]
# 排除接口中的方法(Kotlin 和 Java)
interface_pattern = re.compile(
r'(interface|abstract\s+class)\s+\w+\s*\{([^}]*)\}',
re.DOTALL
)
interface_methods = set()
for match in interface_pattern.finditer(code_content):
interface_code = match.group(2)
interface_method_pattern = re.compile(
r'\b(public|private|protected|static|\s)*\s*[\w\<\>\[\]]+\s+(\w+)\s*\([^)]*\)\s*(\{|\:)?',
re.MULTILINE
)
interface_methods.update([m[1] for m in interface_method_pattern.findall(interface_code)])
# 筛除接口中的方法并去重
unique_method_names = set(method_names) - interface_methods
print(f"共找到 {len(unique_method_names)} 个方法名:")
# 根据方法名数量决定是否换行
unique_method_list = list(unique_method_names)
if len(unique_method_list) < 10:
return '、'.join(unique_method_list)
else:
formatted_output = []
newLineNum = 8 if len(unique_method_list) > 16 else (5 if 10 == len(unique_method_list) else 6)
for i in range(0, len(unique_method_list), newLineNum):
formatted_output.append('、'.join(unique_method_list[i:i + newLineNum]))
return '\n'.join(formatted_output)
except FileNotFoundError:
return "文件未找到,请检查路径是否正确。"
except Exception as e:
return f"发生错误:{e}"
if __name__ == "__main__":
file_path = 'sourceCode/B/app/src/main/kotlin/com/xxx/xx/android/xx/tool/B#@#xxxManager.kt'
result = extract_methods_from_file(file_path)
print(result)
输出大概如下,每次 copy 单行使用即可
3_read_fun_name.py
关于Cursor次数用完的问题:可以闲鱼、多多上看哈Cursor激活的信息,目前我这边闲鱼一碗面拿下
3.使用项目对比工具kdiff3来找到并修改重复资源
kdiff3工具在 mac 上可以用 brew install kidff3安装,安装好后选择两个项目根目录,记住其中 A、B 代表的目录,后面要用
选择两个项目根目录对比
查看重复点
找到libs和res里面重复资源后,图片可以采取更换名字,xml 里面可以进行代码局部更换位置来解决,解决了后可以直接点击菜单上的 ReScan直接更新扫描看重复与否
rescan
具体就是这么多,借助这脚本和工具大体能解决项目重复的问题了,有相当部分还是些体力活,祝你鼠标右手健康,🙂!













网友评论